diff --git a/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt b/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt new file mode 100644 index 00000000000..d13d108e408 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.spa.app.appinfo + +import android.app.settings.SettingsEnums +import android.content.Context +import android.content.pm.ApplicationInfo +import android.net.NetworkStats +import android.net.NetworkTemplate +import android.os.Process +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.applications.appinfo.AppInfoDashboardFragment +import com.android.settings.datausage.AppDataUsage +import com.android.settings.datausage.DataUsageUtils +import com.android.settingslib.net.NetworkCycleDataForUid +import com.android.settingslib.net.NetworkCycleDataForUidLoader +import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle +import com.android.settingslib.spa.framework.compose.toState +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.hasFlag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +@Composable +fun AppDataUsagePreference(app: ApplicationInfo) { + val context = LocalContext.current + val presenter = remember { AppDataUsagePresenter(context, app) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.data_usage_app_summary_title) + override val summary = presenter.summaryFlow.collectAsStateWithLifecycle( + initialValue = stringResource(R.string.computing_size), + ) + override val enabled = presenter.isEnabled().toState() + override val onClick = presenter::startActivity + }) +} + +private class AppDataUsagePresenter( + private val context: Context, + private val app: ApplicationInfo, +) { + val isAvailableFlow = flow { emit(isAvailable()) } + + private suspend fun isAvailable(): Boolean = withContext(Dispatchers.IO) { + Utils.isBandwidthControlEnabled() + } + + fun isEnabled() = app.hasFlag(ApplicationInfo.FLAG_INSTALLED) + + val summaryFlow = flow { emit(getSummary()) } + + private suspend fun getSummary() = withContext(Dispatchers.IO) { + val appUsageData = getAppUsageData() + val totalBytes = appUsageData.sumOf { it.totalUsage } + if (totalBytes == 0L) { + context.getString(R.string.no_data_usage) + } else { + val startTime = appUsageData.minOfOrNull { it.startTime } ?: System.currentTimeMillis() + context.getString( + R.string.data_summary_format, + Formatter.formatFileSize(context, totalBytes, Formatter.FLAG_IEC_UNITS), + DateUtils.formatDateTime(context, startTime, DATE_FORMAT), + ) + } + } + + private suspend fun getAppUsageData(): List = + withContext(Dispatchers.IO) { + createLoader().loadInBackground() ?: emptyList() + } + + private fun createLoader(): NetworkCycleDataForUidLoader = + NetworkCycleDataForUidLoader.builder(context).apply { + setRetrieveDetail(false) + setNetworkTemplate(getTemplate()) + addUid(app.uid) + if (Process.isApplicationUid(app.uid)) { + // Also add in network usage for the app's SDK sandbox + addUid(Process.toSdkSandboxUid(app.uid)) + } + }.build() + + private fun getTemplate(): NetworkTemplate = when { + DataUsageUtils.hasReadyMobileRadio(context) -> { + NetworkTemplate.Builder(NetworkTemplate.MATCH_MOBILE) + .setMeteredness(NetworkStats.METERED_YES) + .build() + } + DataUsageUtils.hasWifiRadio(context) -> { + NetworkTemplate.Builder(NetworkTemplate.MATCH_WIFI).build() + } + else -> NetworkTemplate.Builder(NetworkTemplate.MATCH_ETHERNET).build() + } + + fun startActivity() { + AppInfoDashboardFragment.startAppInfoFragment( + AppDataUsage::class.java, + app, + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + ) + } + + private companion object { + const val DATE_FORMAT = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_MONTH + } +} diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 2e3e45f4f8f..315953d733c 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -93,7 +93,7 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { AppPermissionPreference(app) AppStoragePreference(app) // TODO: instant_app_launch_supported_domain_urls - // TODO: data_settings + AppDataUsagePreference(app) AppTimeSpentPreference(app) // TODO: battery AppLocalePreference(app) diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt new file mode 100644 index 00000000000..22876d19464 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.spa.app.appinfo + +import android.app.settings.SettingsEnums +import android.content.Context +import android.content.pm.ApplicationInfo +import android.net.NetworkTemplate +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.printToLog +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.dx.mockito.inline.extended.ExtendedMockito +import com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.applications.appinfo.AppInfoDashboardFragment +import com.android.settings.datausage.AppDataUsage +import com.android.settings.testutils.waitUntilExists +import com.android.settingslib.net.NetworkCycleDataForUid +import com.android.settingslib.net.NetworkCycleDataForUidLoader +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.MockitoSession +import org.mockito.Spy +import org.mockito.quality.Strictness +import org.mockito.Mockito.`when` as whenever + +@RunWith(AndroidJUnit4::class) +class AppDataUsagePreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var mockSession: MockitoSession + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var builder: NetworkCycleDataForUidLoader.Builder + + @Mock + private lateinit var loader: NetworkCycleDataForUidLoader + + @Before + fun setUp() { + mockSession = mockitoSession() + .initMocks(this) + .mockStatic(Utils::class.java) + .mockStatic(NetworkCycleDataForUidLoader::class.java) + .mockStatic(NetworkTemplate::class.java) + .mockStatic(AppInfoDashboardFragment::class.java) + .strictness(Strictness.LENIENT) + .startMocking() + whenever(Utils.isBandwidthControlEnabled()).thenReturn(true) + whenever(NetworkCycleDataForUidLoader.builder(context)).thenReturn(builder) + whenever(builder.build()).thenReturn(loader) + } + + @After + fun tearDown() { + mockSession.finishMocking() + } + + @Test + fun whenBandwidthControlDisabled_notDisplayed() { + whenever(Utils.isBandwidthControlEnabled()).thenReturn(false) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenAppNotInstalled_disabled() { + val notInstalledApp = ApplicationInfo() + + setContent(notInstalledApp) + + composeTestRule.onNodeWithText(context.getString(R.string.data_usage_app_summary_title)) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun whenAppInstalled_enabled() { + setContent(APP) + + composeTestRule.onNodeWithText(context.getString(R.string.data_usage_app_summary_title)) + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun setCorrectValuesForBuilder() { + setContent() + + verify(builder).setRetrieveDetail(false) + verify(builder).addUid(UID) + } + + @Test + fun whenNoDataUsage() { + whenever(loader.loadInBackground()).thenReturn(emptyList()) + + setContent() + + composeTestRule.onRoot().printToLog("AAA") + composeTestRule.onNodeWithText(context.getString(R.string.no_data_usage)) + .assertIsDisplayed() + } + + @Test + fun whenHasDataUsage() { + val cycleData = mock(NetworkCycleDataForUid::class.java) + whenever(cycleData.totalUsage).thenReturn(123) + whenever(cycleData.startTime).thenReturn(1666666666666) + whenever(loader.loadInBackground()).thenReturn(listOf(cycleData)) + + setContent() + + composeTestRule.waitUntilExists(hasText("123 B used since Oct 25")) + } + + @Test + fun whenClick_startActivity() { + whenever(loader.loadInBackground()).thenReturn(emptyList()) + + setContent() + composeTestRule.onRoot().performClick() + + ExtendedMockito.verify { + AppInfoDashboardFragment.startAppInfoFragment( + AppDataUsage::class.java, + APP, + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + ) + } + } + + private fun setContent(app: ApplicationInfo = APP) { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppDataUsagePreference(app) + } + } + } + + private companion object { + const val PACKAGE_NAME = "packageName" + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + flags = ApplicationInfo.FLAG_INSTALLED + } + } +} diff --git a/tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt b/tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt new file mode 100644 index 00000000000..f3eb52957d4 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.testutils + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.ComposeContentTestRule + +/** Blocks until the found a semantics node that match the given condition. */ +fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { + onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() +}