diff --git a/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt b/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt index 82e987e3f6f..dca115b97c0 100644 --- a/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt +++ b/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt @@ -59,6 +59,7 @@ import com.android.settings.applications.manageapplications.ManageApplications.L import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_WIFI_ACCESS import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_WRITE_SETTINGS import com.android.settings.spa.app.AllAppListPageProvider +import com.android.settings.spa.app.battery.BatteryOptimizationModeAppListPageProvider import com.android.settings.spa.app.appcompat.UserAspectRatioAppsPageProvider import com.android.settings.spa.app.specialaccess.AlarmsAndRemindersAppListProvider import com.android.settings.spa.app.specialaccess.AllFilesAccessAppListProvider @@ -70,7 +71,6 @@ import com.android.settings.spa.app.specialaccess.ModifySystemSettingsAppListPro import com.android.settings.spa.app.specialaccess.NfcTagAppsSettingsProvider import com.android.settings.spa.app.specialaccess.TurnScreenOnAppsAppListProvider import com.android.settings.spa.app.specialaccess.WifiControlAppListProvider -import com.android.settings.spa.app.storage.StorageAppListPageProvider import com.android.settings.spa.notification.AppListNotificationsPageProvider import com.android.settings.spa.system.AppLanguagesPageProvider @@ -127,6 +127,7 @@ object ManageApplicationsUtil { // TODO(b/292165031) enable once sorting is supported //LIST_TYPE_STORAGE -> StorageAppListPageProvider.Apps.name //LIST_TYPE_GAMES -> StorageAppListPageProvider.Games.name + LIST_TYPE_BATTERY_OPTIMIZATION -> BatteryOptimizationModeAppListPageProvider.name else -> null } } diff --git a/src/com/android/settings/fuelgauge/BatteryOptimizeUtils.java b/src/com/android/settings/fuelgauge/BatteryOptimizeUtils.java index 6d3bd6bf771..dc4aade4545 100644 --- a/src/com/android/settings/fuelgauge/BatteryOptimizeUtils.java +++ b/src/com/android/settings/fuelgauge/BatteryOptimizeUtils.java @@ -112,11 +112,28 @@ public class BatteryOptimizeUtils { /** Gets the {@link OptimizationMode} for associated app. */ @OptimizationMode - public int getAppOptimizationMode() { - refreshState(); + public int getAppOptimizationMode(boolean refreshList) { + if (refreshList) { + mPowerAllowListBackend.refreshList(); + } + mAllowListed = mPowerAllowListBackend.isAllowlisted(mPackageName, mUid); + mMode = + mAppOpsManager.checkOpNoThrow( + AppOpsManager.OP_RUN_ANY_IN_BACKGROUND, mUid, mPackageName); + Log.d( + TAG, + String.format( + "refresh %s state, allowlisted = %s, mode = %d", + mPackageName, mAllowListed, mMode)); return getAppOptimizationMode(mMode, mAllowListed); } + /** Gets the {@link OptimizationMode} for associated app. */ + @OptimizationMode + public int getAppOptimizationMode() { + return getAppOptimizationMode(true); + } + /** Resets optimization mode for all applications. */ public static void resetAppOptimizationMode( Context context, IPackageManager ipm, AppOpsManager aom) { @@ -336,19 +353,6 @@ public class BatteryOptimizeUtils { context, action, packageNameKey, createLogEvent(appStandbyMode, allowListed)); } - private void refreshState() { - mPowerAllowListBackend.refreshList(); - mAllowListed = mPowerAllowListBackend.isAllowlisted(mPackageName, mUid); - mMode = - mAppOpsManager.checkOpNoThrow( - AppOpsManager.OP_RUN_ANY_IN_BACKGROUND, mUid, mPackageName); - Log.d( - TAG, - String.format( - "refresh %s state, allowlisted = %s, mode = %d", - mPackageName, mAllowListed, mMode)); - } - private static String createLogEvent(int appStandbyMode, boolean allowListed) { return appStandbyMode < 0 ? "Apply optimize setting ERROR" diff --git a/src/com/android/settings/spa/SettingsSpaEnvironment.kt b/src/com/android/settings/spa/SettingsSpaEnvironment.kt index d94e8618082..ac1af804549 100644 --- a/src/com/android/settings/spa/SettingsSpaEnvironment.kt +++ b/src/com/android/settings/spa/SettingsSpaEnvironment.kt @@ -22,6 +22,7 @@ import com.android.settings.network.apn.ApnEditPageProvider import com.android.settings.spa.about.AboutPhonePageProvider import com.android.settings.spa.app.AllAppListPageProvider import com.android.settings.spa.app.AppsMainPageProvider +import com.android.settings.spa.app.battery.BatteryOptimizationModeAppListPageProvider import com.android.settings.spa.app.appcompat.UserAspectRatioAppsPageProvider import com.android.settings.spa.app.appinfo.AppInfoSettingsProvider import com.android.settings.spa.app.appinfo.CloneAppInfoSettingsProvider @@ -116,6 +117,7 @@ open class SettingsSpaEnvironment(context: Context) : SpaEnvironment(context) { StorageAppListPageProvider.Games, ApnEditPageProvider, SimOnboardingPageProvider, + BatteryOptimizationModeAppListPageProvider, ) override val logger = if (FeatureFlagUtils.isEnabled( diff --git a/src/com/android/settings/spa/app/battery/BatteryOptimizationModeAppListPageProvider.kt b/src/com/android/settings/spa/app/battery/BatteryOptimizationModeAppListPageProvider.kt new file mode 100644 index 00000000000..f077506bc3c --- /dev/null +++ b/src/com/android/settings/spa/app/battery/BatteryOptimizationModeAppListPageProvider.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024 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.battery + +import android.app.AppOpsManager +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.os.bundleOf +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.core.SubSettingLauncher +import com.android.settings.fuelgauge.AdvancedPowerUsageDetail +import com.android.settings.fuelgauge.BatteryOptimizeUtils +import com.android.settings.spa.app.AppRecordWithSize +import com.android.settings.spa.app.appinfo.AppInfoSettingsProvider +import com.android.settings.spa.app.rememberResetAppDialogPresenter +import com.android.settingslib.fuelgauge.PowerAllowlistBackend +import com.android.settingslib.spa.framework.common.SettingsEntryBuilder +import com.android.settingslib.spa.framework.common.SettingsPageProvider +import com.android.settingslib.spa.framework.common.createSettingsPage +import com.android.settingslib.spa.framework.compose.navigator +import com.android.settingslib.spa.framework.compose.rememberContext +import com.android.settingslib.spa.framework.util.filterItem +import com.android.settingslib.spa.framework.util.mapItem +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spa.widget.ui.SpinnerOption +import com.android.settingslib.spaprivileged.framework.compose.getPlaceholder +import com.android.settingslib.spaprivileged.model.app.AppListModel +import com.android.settingslib.spaprivileged.model.app.installed +import com.android.settingslib.spaprivileged.model.app.userHandle +import com.android.settingslib.spaprivileged.template.app.AppList +import com.android.settingslib.spaprivileged.template.app.AppListInput +import com.android.settingslib.spaprivileged.template.app.AppListItem +import com.android.settingslib.spaprivileged.template.app.AppListItemModel +import com.android.settingslib.spaprivileged.template.app.AppListPage +import kotlinx.coroutines.flow.Flow + +object BatteryOptimizationModeAppListPageProvider : SettingsPageProvider { + override val name = "BatteryOptimizationModeAppList" + private val owner = createSettingsPage() + + @Composable + override fun Page(arguments: Bundle?) { + BatteryOptimizationModeAppList() + } + + fun buildInjectEntry() = SettingsEntryBuilder + .createInject(owner) + .setSearchDataFn { null } + .setUiLayoutFn { + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_battery_usage_title) + override val onClick = navigator(name) + }) + } +} + +@Composable +fun BatteryOptimizationModeAppList( + appList: @Composable AppListInput.() -> Unit = { AppList() }, +) { + AppListPage( + title = stringResource(R.string.app_battery_usage_title), + listModel = rememberContext(::BatteryOptimizationModeAppListModel), + appList = appList, + ) +} + +class BatteryOptimizationModeAppListModel( + private val context: Context, +) : AppListModel { + + override fun getSpinnerOptions(recordList: List): List = + OptimizationModeSpinnerItem.entries.map { + SpinnerOption( + id = it.ordinal, + text = context.getString(it.stringResId), + ) + } + + override fun transform(userIdFlow: Flow, appListFlow: Flow>) = + appListFlow.mapItem(::AppRecordWithSize) + + override fun filter( + userIdFlow: Flow, + option: Int, + recordListFlow: Flow>, + ): Flow> { + PowerAllowlistBackend.getInstance(context).refreshList() + return recordListFlow.filterItem { + val appOptimizationMode = BatteryOptimizeUtils(context, it.app.uid, it.app.packageName) + .getAppOptimizationMode(/* refreshList */ false); + when (OptimizationModeSpinnerItem.entries.getOrNull(option)) { + OptimizationModeSpinnerItem.Restricted -> + appOptimizationMode == BatteryOptimizeUtils.MODE_RESTRICTED + OptimizationModeSpinnerItem.Optimized -> + appOptimizationMode == BatteryOptimizeUtils.MODE_OPTIMIZED + OptimizationModeSpinnerItem.Unrestricted -> + appOptimizationMode == BatteryOptimizeUtils.MODE_UNRESTRICTED + else -> (true) + } + } + } + + @Composable + override fun getSummary(option: Int, record: AppRecordWithSize): () -> String = { + var summary = String() + val app = record.app + when { + !app.installed && !app.isArchived -> { + summary += context.getString(R.string.not_installed) + } + + !app.enabled -> { + summary += context.getString(com.android.settingslib.R.string.disabled) + } + } + summary + } + + @Composable + override fun AppListItemModel.AppItem() { + AppListItem(onClick = { + val args = bundleOf( + AdvancedPowerUsageDetail.EXTRA_PACKAGE_NAME to record.app.packageName, + AdvancedPowerUsageDetail.EXTRA_POWER_USAGE_PERCENT to Utils.formatPercentage(0), + AdvancedPowerUsageDetail.EXTRA_UID to record.app.uid, + ) + SubSettingLauncher(context) + .setDestination(AdvancedPowerUsageDetail::class.java.name) + .setTitleRes(R.string.battery_details_title) + .setArguments(args) + .setUserHandle(record.app.userHandle) + .setSourceMetricsCategory(AppInfoSettingsProvider.METRICS_CATEGORY) + .launch() + }) + } +} + +private enum class OptimizationModeSpinnerItem(val stringResId: Int) { + All(R.string.filter_all_apps), + Restricted(R.string.filter_battery_restricted_title), + Optimized(R.string.filter_battery_optimized_title), + Unrestricted(R.string.filter_battery_unrestricted_title); +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/battery/BatteryOptimizationModeAppListPageProviderTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/battery/BatteryOptimizationModeAppListPageProviderTest.kt new file mode 100644 index 00000000000..da1e94c1422 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/battery/BatteryOptimizationModeAppListPageProviderTest.kt @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2024 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 + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.DisplaySettings +import com.android.settings.R +import com.android.settings.SettingsActivity +import com.android.settings.fuelgauge.AdvancedPowerUsageDetail +import com.android.settings.spa.app.battery.BatteryOptimizationModeAppList +import com.android.settings.spa.app.battery.BatteryOptimizationModeAppListModel +import com.android.settings.spa.app.battery.BatteryOptimizationModeAppListPageProvider +import com.android.settingslib.spa.testutils.FakeNavControllerWrapper +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.android.settingslib.spaprivileged.framework.compose.getPlaceholder +import com.android.settingslib.spaprivileged.template.app.AppListInput +import com.android.settingslib.spaprivileged.template.app.AppListItemModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class BatteryOptimizationModeAppListPageProviderTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val fakeNavControllerWrapper = FakeNavControllerWrapper() + + private val packageManager = mock { + on { getPackagesForUid(USER_ID) } doReturn arrayOf(PACKAGE_NAME) + } + + private val context: Context = spy(ApplicationProvider.getApplicationContext()) { + on { packageManager } doReturn packageManager + } + + @Test + fun batteryOptimizationModeAppListPageProvider_name() { + assertThat(BatteryOptimizationModeAppListPageProvider.name) + .isEqualTo("BatteryOptimizationModeAppList") + } + + @Test + fun injectEntry_title() { + setInjectEntry() + + composeTestRule.onNodeWithText(context.getString(R.string.app_battery_usage_title)) + .assertIsDisplayed() + } + + @Test + fun injectEntry_onClick_navigate() { + setInjectEntry() + + composeTestRule.onNodeWithText(context.getString(R.string.app_battery_usage_title)) + .performClick() + + assertThat(fakeNavControllerWrapper.navigateCalledWith) + .isEqualTo("BatteryOptimizationModeAppList") + } + + @Test + fun title_displayed() { + composeTestRule.setContent { + BatteryOptimizationModeAppList {} + } + + composeTestRule.onNodeWithText(context.getString(R.string.app_battery_usage_title)) + .assertIsDisplayed() + } + + @Test + fun showInstantApps_isFalse() { + val input = getAppListInput() + + assertThat(input.config.showInstantApps).isFalse() + } + + @Test + fun item_labelDisplayed() { + setItemContent() + + composeTestRule.onNodeWithText(LABEL).assertIsDisplayed() + } + + @Test + fun item_summaryDisplayed() { + setItemContent() + + composeTestRule.onNodeWithText(SUMMARY).assertIsDisplayed() + } + + @Test + fun item_onClick_navigate() { + setItemContent() + doNothing().whenever(context).startActivity(any()) + + composeTestRule.onNodeWithText(LABEL).performClick() + + val intent = argumentCaptor { + verify(context).startActivity(capture()) + }.firstValue + + assertThat(intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT))!! + .isEqualTo(AdvancedPowerUsageDetail::class.java.name) + val arguments = intent.getBundleExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS)!! + assertThat(arguments.getString(AdvancedPowerUsageDetail.EXTRA_PACKAGE_NAME)) + .isEqualTo(PACKAGE_NAME) + } + + @Test + fun BatteryOptimizationModeAppListModel_transform() = runTest { + val listModel = BatteryOptimizationModeAppListModel(context) + + val recordListFlow = listModel.transform(flowOf(USER_ID), flowOf(listOf(APP))) + + val recordList = recordListFlow.firstWithTimeoutOrNull()!! + assertThat(recordList).hasSize(1) + assertThat(recordList[0].app).isSameInstanceAs(APP) + } + + @Test + fun listModelGetSummary_regular() { + val listModel = BatteryOptimizationModeAppListModel(context) + + lateinit var summary: () -> String + composeTestRule.setContent { + summary = listModel.getSummary(option = 0, record = AppRecordWithSize(app = APP)) + } + + assertThat(summary()).isEmpty() + } + + @Test + fun listModelGetSummary_disabled() { + val listModel = BatteryOptimizationModeAppListModel(context) + val disabledApp = ApplicationInfo().apply { + packageName = PACKAGE_NAME + flags = ApplicationInfo.FLAG_INSTALLED + enabled = false + } + + lateinit var summary: () -> String + composeTestRule.setContent { + summary = + listModel.getSummary(option = 0, record = AppRecordWithSize(app = disabledApp)) + } + + assertThat(summary()) + .isEqualTo(context.getString(com.android.settingslib.R.string.disabled)) + } + + @Test + fun listModelGetSummary_notInstalled() { + val listModel = BatteryOptimizationModeAppListModel(context) + val notInstalledApp = ApplicationInfo().apply { + packageName = PACKAGE_NAME + } + + lateinit var summary: () -> String + composeTestRule.setContent { + summary = + listModel.getSummary(option = 0, record = AppRecordWithSize(app = notInstalledApp)) + } + + assertThat(summary()).isEqualTo(context.getString(R.string.not_installed)) + } + + @Test + fun batteryOptimizationModeAppListModel_archivedApp() { + val app = mock { + on { loadUnbadgedIcon(any()) } doReturn UNBADGED_ICON + on { loadLabel(any()) } doReturn LABEL + } + app.isArchived = true + packageManager.stub { + on { + getApplicationInfoAsUser(PACKAGE_NAME, 0, USER_ID) + } doReturn app + } + composeTestRule.setContent { + fakeNavControllerWrapper.Wrapper { + with(BatteryOptimizationModeAppListModel(context)) { + AppListItemModel( + record = AppRecordWithSize(app = app), + label = LABEL, + summary = { SUMMARY }, + ).AppItem() + } + } + } + + composeTestRule.onNodeWithText(LABEL).assertIsDisplayed() + } + + @Test + fun batteryOptimizationModeAppListModel_NoStorageSummary() { + val listModel = BatteryOptimizationModeAppListModel(context) + val archivedApp = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = true + } + + lateinit var summary: () -> String + composeTestRule.setContent { + summary = + listModel.getSummary(option = 0, record = AppRecordWithSize(app = archivedApp)) + } + + assertThat(summary()).isEmpty() + } + + private fun setInjectEntry() { + composeTestRule.setContent { + fakeNavControllerWrapper.Wrapper { + BatteryOptimizationModeAppListPageProvider.buildInjectEntry().build().UiLayout() + } + } + } + + private fun getAppListInput(): AppListInput { + lateinit var input: AppListInput + composeTestRule.setContent { + BatteryOptimizationModeAppList { + SideEffect { + input = this + } + } + } + return input + } + + private fun setItemContent() { + composeTestRule.setContent { + fakeNavControllerWrapper.Wrapper { + with(BatteryOptimizationModeAppListModel(context)) { + AppListItemModel( + record = AppRecordWithSize(app = APP), + label = LABEL, + summary = { SUMMARY }, + ).AppItem() + } + } + } + } + + private companion object { + const val USER_ID = 0 + const val PACKAGE_NAME = "package.name" + const val LABEL = "Label" + const val SUMMARY = "Summary" + val UNBADGED_ICON = mock() + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + flags = ApplicationInfo.FLAG_INSTALLED + } + } +}