From dbead03b6ab7d9f13e8455f6c60519fc22656b0e Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Wed, 26 Oct 2022 17:00:13 +0800 Subject: [PATCH] Add AppBatteryPreference for Spa This is used in new App Info page. To try: 1. adb shell am start -n com.android.settings/.spa.SpaActivity 2. Go to Apps -> All apps -> [One App] -> App battery usage Bug: 236346018 Test: Unit test & Manual with Settings App Change-Id: I4784e42f230534d8d843ec00de51032baffcb7e2 --- .../fuelgauge/AdvancedPowerUsageDetail.java | 20 +- .../spa/app/appinfo/AppBatteryPreference.kt | 159 +++++++++++++++ .../spa/app/appinfo/AppInfoSettings.kt | 2 +- .../app/appinfo/AppBatteryPreferenceTest.kt | 187 ++++++++++++++++++ 4 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt diff --git a/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java b/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java index 32e2e2f0b54..e3919b07ebf 100644 --- a/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java +++ b/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java @@ -134,6 +134,14 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements public static void startBatteryDetailPage( Activity caller, InstrumentedPreferenceFragment fragment, BatteryDiffEntry diffEntry, String usagePercent, String slotInformation) { + startBatteryDetailPage( + caller, fragment.getMetricsCategory(), diffEntry, usagePercent, slotInformation); + } + + /** Launches battery details page for an individual battery consumer fragment. */ + public static void startBatteryDetailPage( + Context context, int sourceMetricsCategory, + BatteryDiffEntry diffEntry, String usagePercent, String slotInformation) { final BatteryHistEntry histEntry = diffEntry.mBatteryHistEntry; final LaunchBatteryDetailPageArgs launchArgs = new LaunchBatteryDetailPageArgs(); // configure the launch argument. @@ -147,7 +155,7 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements launchArgs.mForegroundTimeMs = diffEntry.mForegroundUsageTimeInMs; launchArgs.mBackgroundTimeMs = diffEntry.mBackgroundUsageTimeInMs; launchArgs.mIsUserEntry = histEntry.isUserEntry(); - startBatteryDetailPage(caller, fragment, launchArgs); + startBatteryDetailPage(context, sourceMetricsCategory, launchArgs); } /** Launches battery details page for an individual battery consumer. */ @@ -165,11 +173,11 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements launchArgs.mForegroundTimeMs = isValidToShowSummary ? entry.getTimeInForegroundMs() : 0; launchArgs.mBackgroundTimeMs = isValidToShowSummary ? entry.getTimeInBackgroundMs() : 0; launchArgs.mIsUserEntry = entry.isUserEntry(); - startBatteryDetailPage(caller, fragment, launchArgs); + startBatteryDetailPage(caller, fragment.getMetricsCategory(), launchArgs); } - private static void startBatteryDetailPage(Activity caller, - InstrumentedPreferenceFragment fragment, LaunchBatteryDetailPageArgs launchArgs) { + private static void startBatteryDetailPage( + Context context, int sourceMetricsCategory, LaunchBatteryDetailPageArgs launchArgs) { final Bundle args = new Bundle(); if (launchArgs.mPackageName == null) { // populate data for system app @@ -190,11 +198,11 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements final int userId = launchArgs.mIsUserEntry ? ActivityManager.getCurrentUser() : UserHandle.getUserId(launchArgs.mUid); - new SubSettingLauncher(caller) + new SubSettingLauncher(context) .setDestination(AdvancedPowerUsageDetail.class.getName()) .setTitleRes(R.string.battery_details_title) .setArguments(args) - .setSourceMetricsCategory(fragment.getMetricsCategory()) + .setSourceMetricsCategory(sourceMetricsCategory) .setUserHandle(new UserHandle(userId)) .launch(); } diff --git a/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt b/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt new file mode 100644 index 00000000000..a2164b20f37 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt @@ -0,0 +1,159 @@ +/* + * 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.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.core.os.bundleOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +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.batteryusage.BatteryChartPreferenceController +import com.android.settings.fuelgauge.batteryusage.BatteryDiffEntry +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.installed +import com.android.settingslib.spaprivileged.model.app.userId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun AppBatteryPreference(app: ApplicationInfo) { + val context = LocalContext.current + val presenter = remember { AppBatteryPresenter(context, app) } + if (!presenter.isAvailable()) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_battery_usage_title) + override val summary = presenter.summary + override val enabled = presenter.enabled + override val onClick = presenter::startActivity + }) + + presenter.Updater() +} + +private class AppBatteryPresenter(private val context: Context, private val app: ApplicationInfo) { + private var batteryDiffEntryState: LoadingState + by mutableStateOf(LoadingState.Loading) + + @Composable + fun isAvailable() = remember { + context.resources.getBoolean(R.bool.config_show_app_info_settings_battery) + } + + @Composable + fun Updater() { + if (!app.installed) return + val current = LocalLifecycleOwner.current + LaunchedEffect(app) { + current.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { batteryDiffEntryState = LoadingState.Done(getBatteryDiffEntry()) } + } + } + } + + private suspend fun getBatteryDiffEntry(): BatteryDiffEntry? = withContext(Dispatchers.IO) { + BatteryChartPreferenceController.getAppBatteryUsageData( + context, app.packageName, app.userId + ).also { + Log.d(TAG, "loadBatteryDiffEntries():\n$it") + } + } + + val enabled = derivedStateOf { batteryDiffEntryState is LoadingState.Done } + + val summary = derivedStateOf { + if (!app.installed) return@derivedStateOf "" + batteryDiffEntryState.let { batteryDiffEntryState -> + when (batteryDiffEntryState) { + is LoadingState.Loading -> context.getString(R.string.summary_placeholder) + is LoadingState.Done -> batteryDiffEntryState.result.getSummary() + } + } + } + + private fun BatteryDiffEntry?.getSummary(): String = + this?.takeIf { mConsumePower > 0 }?.let { + context.getString( + R.string.battery_summary, Utils.formatPercentage(percentOfTotal, true) + ) + } ?: context.getString(R.string.no_battery_summary) + + fun startActivity() { + batteryDiffEntryState.resultOrNull?.run { + startBatteryDetailPage() + return + } + + fallbackStartBatteryDetailPage() + } + + private fun BatteryDiffEntry.startBatteryDetailPage() { + Log.i(TAG, "handlePreferenceTreeClick():\n$this") + AdvancedPowerUsageDetail.startBatteryDetailPage( + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + this, + Utils.formatPercentage(percentOfTotal, true), + null, + ) + } + + private fun fallbackStartBatteryDetailPage() { + Log.i(TAG, "Launch : ${app.packageName} with package name") + val args = bundleOf( + AdvancedPowerUsageDetail.EXTRA_PACKAGE_NAME to app.packageName, + AdvancedPowerUsageDetail.EXTRA_POWER_USAGE_PERCENT to Utils.formatPercentage(0), + AdvancedPowerUsageDetail.EXTRA_UID to app.uid, + ) + SubSettingLauncher(context) + .setDestination(AdvancedPowerUsageDetail::class.java.name) + .setTitleRes(R.string.battery_details_title) + .setArguments(args) + .setSourceMetricsCategory(SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS) + .launch() + } + + companion object { + private const val TAG = "AppBatteryPresenter" + } +} + +private sealed class LoadingState { + object Loading : LoadingState() + + data class Done(val result: T) : LoadingState() + + val resultOrNull: T? get() = if (this is Done) result else null +} diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 9a286c712e7..b4b6945ca1a 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -95,7 +95,7 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { // TODO: instant_app_launch_supported_domain_urls // TODO: data_settings AppTimeSpentPreference(app) - // TODO: battery + AppBatteryPreference(app) AppLocalePreference(app) AppOpenByDefaultPreference(app) DefaultAppShortcuts(app) diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt new file mode 100644 index 00000000000..06574352f3f --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt @@ -0,0 +1,187 @@ +/* + * 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 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.hasTextExactly +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.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.dx.mockito.inline.extended.ExtendedMockito +import com.android.settings.R +import com.android.settings.fuelgauge.AdvancedPowerUsageDetail +import com.android.settings.fuelgauge.batteryusage.BatteryChartPreferenceController +import com.android.settings.fuelgauge.batteryusage.BatteryDiffEntry +import com.android.settingslib.spaprivileged.model.app.userId +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.MockitoSession +import org.mockito.Spy +import org.mockito.quality.Strictness +import org.mockito.Mockito.`when` as whenever + +@RunWith(AndroidJUnit4::class) +class AppBatteryPreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var mockSession: MockitoSession + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Spy + private val resources = context.resources + + @Before + fun setUp() { + mockSession = ExtendedMockito.mockitoSession() + .initMocks(this) + .mockStatic(BatteryChartPreferenceController::class.java) + .mockStatic(AdvancedPowerUsageDetail::class.java) + .strictness(Strictness.LENIENT) + .startMocking() + whenever(context.resources).thenReturn(resources) + whenever(resources.getBoolean(R.bool.config_show_app_info_settings_battery)) + .thenReturn(true) + } + + private fun mockBatteryDiffEntry(batteryDiffEntry: BatteryDiffEntry?) { + whenever(BatteryChartPreferenceController.getAppBatteryUsageData( + context, PACKAGE_NAME, APP.userId + )).thenReturn(batteryDiffEntry) + } + + @After + fun tearDown() { + mockSession.finishMocking() + } + + @Test + fun whenConfigIsFalse_notDisplayed() { + whenever(resources.getBoolean(R.bool.config_show_app_info_settings_battery)) + .thenReturn(false) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenAppNotInstalled_noSummary() { + val notInstalledApp = ApplicationInfo() + + setContent(notInstalledApp) + + composeTestRule.onNode(hasTextExactly(context.getString(R.string.app_battery_usage_title))) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun batteryDiffEntryIsNull() { + mockBatteryDiffEntry(null) + + setContent() + + composeTestRule.onNode( + hasTextExactly( + context.getString(R.string.app_battery_usage_title), + context.getString(R.string.no_battery_summary), + ), + ).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun noConsumePower() { + val batteryDiffEntry = mock(BatteryDiffEntry::class.java).apply { + mConsumePower = 0.0 + } + mockBatteryDiffEntry(batteryDiffEntry) + + setContent() + + composeTestRule.onNodeWithText(context.getString(R.string.no_battery_summary)) + .assertIsDisplayed() + } + + @Test + fun hasConsumePower() { + val batteryDiffEntry = mock(BatteryDiffEntry::class.java).apply { + mConsumePower = 12.3 + } + whenever(batteryDiffEntry.percentOfTotal).thenReturn(45.6) + mockBatteryDiffEntry(batteryDiffEntry) + + setContent() + + composeTestRule.onNodeWithText("46% use since last full charge").assertIsDisplayed() + } + + @Test + fun whenClick_openDetailsPage() { + val batteryDiffEntry = mock(BatteryDiffEntry::class.java) + whenever(batteryDiffEntry.percentOfTotal).thenReturn(10.0) + mockBatteryDiffEntry(batteryDiffEntry) + + setContent() + composeTestRule.onRoot().performClick() + + ExtendedMockito.verify { + AdvancedPowerUsageDetail.startBatteryDetailPage( + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + batteryDiffEntry, + "10%", + null, + ) + } + } + + private fun setContent(app: ApplicationInfo = APP) { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppBatteryPreference(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 + } + } +}