From 9065049733e0446945a793ce7f68cae56366d66b Mon Sep 17 00:00:00 2001 From: Tsung-Mao Fang Date: Thu, 27 Oct 2022 17:58:32 +0800 Subject: [PATCH 1/9] Remove "Always show icon when in vibrate mode" If device doesn't support vibrator, then disable this feature. Test: No this setting Fix: 253165108 Change-Id: I58e2bf7b36a3af61d458fc2cb8c92c18219d6684 --- .../notification/VibrateIconPreferenceController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/com/android/settings/notification/VibrateIconPreferenceController.java b/src/com/android/settings/notification/VibrateIconPreferenceController.java index 25d23264131..d772b47d59e 100644 --- a/src/com/android/settings/notification/VibrateIconPreferenceController.java +++ b/src/com/android/settings/notification/VibrateIconPreferenceController.java @@ -19,6 +19,7 @@ package com.android.settings.notification; import static com.android.settings.notification.SettingPref.TYPE_SECURE; import android.content.Context; +import android.os.Vibrator; import android.provider.Settings.Secure; import com.android.settings.SettingsPreferenceFragment; @@ -27,16 +28,18 @@ import com.android.settingslib.core.lifecycle.Lifecycle; public class VibrateIconPreferenceController extends SettingPrefController { private static final String KEY_VIBRATE_ICON = "vibrate_icon"; + private final boolean mHasVibrator; public VibrateIconPreferenceController(Context context, SettingsPreferenceFragment parent, Lifecycle lifecycle) { super(context, parent, lifecycle); + mHasVibrator = context.getSystemService(Vibrator.class).hasVibrator(); mPreference = new SettingPref( TYPE_SECURE, KEY_VIBRATE_ICON, Secure.STATUS_BAR_SHOW_VIBRATE_ICON, 0 /*default off*/); } @Override public boolean isAvailable() { - return true; + return mHasVibrator; } } From 9ee43c96d3fb7fc04abf63b5312ecf474be33ce5 Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Thu, 27 Oct 2022 16:24:00 +0800 Subject: [PATCH 2/9] Add AppDataUsagePreference 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] -> Mobile data & Wi-Fi Bug: 236346018 Test: Unit test & Manual with Settings App Change-Id: I1ebcc2c5197eef0c35a2b188b7edb3594fa4ae2a --- .../spa/app/appinfo/AppDataUsagePreference.kt | 132 ++++++++++++ .../spa/app/appinfo/AppInfoSettings.kt | 2 +- .../app/appinfo/AppDataUsagePreferenceTest.kt | 189 ++++++++++++++++++ .../testutils/ComposeContentTestRuleExt.kt | 25 +++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt create mode 100644 tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt 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() +} From 601124517a6eaddc57aae41b600311b73537892f Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Thu, 27 Oct 2022 18:38:39 +0800 Subject: [PATCH 3/9] Add AppInstallerInfoPreference 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 details Bug: 236346018 Test: Unit test Change-Id: Ibd1ae27c60a096b7f12ca6640a58b099dcfb0b6b --- src/com/android/settings/Utils.java | 6 +- .../settings/applications/AppStoreUtil.java | 16 +- .../spa/app/appinfo/AppInfoSettings.kt | 4 +- .../app/appinfo/AppInstallerInfoPreference.kt | 123 +++++++++++ .../appinfo/AppInstallerInfoPreferenceTest.kt | 208 ++++++++++++++++++ .../testutils/ComposeContentTestRuleExt.kt | 25 +++ 6 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt create mode 100644 tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt diff --git a/src/com/android/settings/Utils.java b/src/com/android/settings/Utils.java index 0fcf4a3e7d3..b2de0041b1b 100644 --- a/src/com/android/settings/Utils.java +++ b/src/com/android/settings/Utils.java @@ -21,7 +21,6 @@ import static android.content.Intent.EXTRA_USER_ID; import static android.text.format.DateUtils.FORMAT_ABBREV_MONTH; import static android.text.format.DateUtils.FORMAT_SHOW_DATE; -import android.annotation.Nullable; import android.app.ActionBar; import android.app.Activity; import android.app.ActivityManager; @@ -96,6 +95,7 @@ import android.widget.TabWidget; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.RoundedBitmapDrawable; @@ -799,7 +799,9 @@ public final class Utils extends com.android.settingslib.Utils { } } - public static CharSequence getApplicationLabel(Context context, String packageName) { + /** Gets the application label of the given package name. */ + @Nullable + public static CharSequence getApplicationLabel(Context context, @NonNull String packageName) { try { final ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo( packageName, diff --git a/src/com/android/settings/applications/AppStoreUtil.java b/src/com/android/settings/applications/AppStoreUtil.java index 79a4f35e19f..b18a68fd7db 100644 --- a/src/com/android/settings/applications/AppStoreUtil.java +++ b/src/com/android/settings/applications/AppStoreUtil.java @@ -24,7 +24,9 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.util.Log; -// This class provides methods that help dealing with app stores. +import androidx.annotation.Nullable; + +/** This class provides methods that help dealing with app stores. */ public class AppStoreUtil { private static final String LOG_TAG = "AppStoreUtil"; @@ -34,8 +36,11 @@ public class AppStoreUtil { .setClassName(result.activityInfo.packageName, result.activityInfo.name) : null; } - // Returns the package name of the app that we consider to be the user-visible 'installer' - // of given packageName, if one is available. + /** + * Returns the package name of the app that we consider to be the user-visible 'installer' + * of given packageName, if one is available. + */ + @Nullable public static String getInstallerPackageName(Context context, String packageName) { String installerPackageName; try { @@ -62,7 +67,8 @@ public class AppStoreUtil { return installerPackageName; } - // Returns a link to the installer app store for a given package name. + /** Returns a link to the installer app store for a given package name. */ + @Nullable public static Intent getAppStoreLink(Context context, String installerPackageName, String packageName) { Intent intent = new Intent(Intent.ACTION_SHOW_APP_INFO) @@ -75,7 +81,7 @@ public class AppStoreUtil { return null; } - // Convenience method that looks up the installerPackageName for you. + /** Convenience method that looks up the installerPackageName for you. */ public static Intent getAppStoreLink(Context context, String packageName) { String installerPackageName = getInstallerPackageName(context, packageName); return getAppStoreLink(context, installerPackageName, packageName); diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 2e3e45f4f8f..9a286c712e7 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -113,7 +113,9 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { AlarmsAndRemindersAppListProvider.InfoPageEntryItem(app) } - // TODO: app_installer + Category(title = stringResource(R.string.app_install_details_group_title)) { + AppInstallerInfoPreference(app) + } appInfoProvider.FooterAppVersion() } } diff --git a/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt b/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt new file mode 100644 index 00000000000..8d9c98ab35f --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt @@ -0,0 +1,123 @@ +/* + * 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.content.Context +import android.content.pm.ApplicationInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.AppStoreUtil +import com.android.settingslib.applications.AppUtils +import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.framework.common.asUser +import com.android.settingslib.spaprivileged.framework.common.userManager +import com.android.settingslib.spaprivileged.model.app.userHandle +import com.android.settingslib.spaprivileged.model.app.userId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun AppInstallerInfoPreference(app: ApplicationInfo) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val presenter = remember { AppInstallerInfoPresenter(context, app, coroutineScope) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_install_details_title) + override val summary = presenter.summaryFlow.collectAsStateWithLifecycle( + initialValue = stringResource(R.string.summary_placeholder), + ) + override val enabled = + presenter.enabledFlow.collectAsStateWithLifecycle(initialValue = false) + override val onClick = presenter::startActivity + }) +} + +private class AppInstallerInfoPresenter( + private val context: Context, + private val app: ApplicationInfo, + private val coroutineScope: CoroutineScope, +) { + private val userContext = context.asUser(app.userHandle) + private val packageManager = userContext.packageManager + private val userManager = context.userManager + + private val installerPackageFlow = flow { + emit(withContext(Dispatchers.IO) { + AppStoreUtil.getInstallerPackageName(userContext, app.packageName) + }) + }.sharedFlow() + + private val installerLabelFlow = installerPackageFlow.map { installerPackage -> + installerPackage ?: return@map null + withContext(Dispatchers.IO) { + Utils.getApplicationLabel(context, installerPackage) + } + }.sharedFlow() + + val isAvailableFlow = installerLabelFlow.map { installerLabel -> + withContext(Dispatchers.IO) { + !userManager.isManagedProfile(app.userId) && + !AppUtils.isMainlineModule(packageManager, app.packageName) && + installerLabel != null + } + } + + val summaryFlow = installerLabelFlow.map { installerLabel -> + val detailsStringId = when { + app.isInstantApp -> R.string.instant_app_details_summary + else -> R.string.app_install_details_summary + } + context.getString(detailsStringId, installerLabel) + } + + private val intentFlow = installerPackageFlow.map { installerPackage -> + withContext(Dispatchers.IO) { + AppStoreUtil.getAppStoreLink(context, installerPackage, app.packageName) + } + }.sharedFlow() + + val enabledFlow = intentFlow.map { it != null } + + fun startActivity() { + coroutineScope.launch { + intentFlow.collect { intent -> + if (intent != null) { + context.startActivityAsUser(intent, app.userHandle) + } + } + } + } + + private fun Flow.sharedFlow() = + shareIn(coroutineScope, SharingStarted.WhileSubscribed(), 1) +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt new file mode 100644 index 00000000000..b66967a96c2 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt @@ -0,0 +1,208 @@ +/* + * 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.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.os.UserManager +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.mockitoSession +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.applications.AppStoreUtil +import com.android.settings.testutils.waitUntilExists +import com.android.settingslib.applications.AppUtils +import com.android.settingslib.spaprivileged.framework.common.userManager +import com.android.settingslib.spaprivileged.model.app.userHandle +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.any +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.eq +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 AppInstallerInfoPreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var mockSession: MockitoSession + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var userManager: UserManager + + @Before + fun setUp() { + mockSession = mockitoSession() + .initMocks(this) + .mockStatic(AppStoreUtil::class.java) + .mockStatic(Utils::class.java) + .mockStatic(AppUtils::class.java) + .strictness(Strictness.LENIENT) + .startMocking() + whenever(context.userManager).thenReturn(userManager) + whenever(userManager.isManagedProfile(anyInt())).thenReturn(false) + whenever(AppStoreUtil.getInstallerPackageName(any(), eq(PACKAGE_NAME))) + .thenReturn(INSTALLER_PACKAGE_NAME) + whenever(AppStoreUtil.getAppStoreLink(context, INSTALLER_PACKAGE_NAME, PACKAGE_NAME)) + .thenReturn(STORE_LINK) + whenever(Utils.getApplicationLabel(context, INSTALLER_PACKAGE_NAME)) + .thenReturn(INSTALLER_PACKAGE_LABEL) + whenever(AppUtils.isMainlineModule(any(), eq(PACKAGE_NAME))) + .thenReturn(false) + } + + @After + fun tearDown() { + mockSession.finishMocking() + } + + @Test + fun whenNoInstaller_notDisplayed() { + whenever(AppStoreUtil.getInstallerPackageName(any(), eq(PACKAGE_NAME))).thenReturn(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenInstallerLabelIsNull_notDisplayed() { + whenever(Utils.getApplicationLabel(context, INSTALLER_PACKAGE_NAME)).thenReturn(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenIsManagedProfile_notDisplayed() { + whenever(userManager.isManagedProfile(anyInt())).thenReturn(true) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenIsMainlineModule_notDisplayed() { + whenever(AppUtils.isMainlineModule(any(), eq(PACKAGE_NAME))).thenReturn(true) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenStoreLinkIsNull_disabled() { + whenever(AppStoreUtil.getAppStoreLink(context, INSTALLER_PACKAGE_NAME, PACKAGE_NAME)) + .thenReturn(null) + + setContent() + waitUntilDisplayed() + + composeTestRule.onNode(preferenceNode).assertIsNotEnabled() + } + + @Test + fun whenIsInstantApp_hasSummaryForInstant() { + val instantApp = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + privateFlags = ApplicationInfo.PRIVATE_FLAG_INSTANT + } + + setContent(instantApp) + waitUntilDisplayed() + + composeTestRule.onRoot().printToLog("AAA") + composeTestRule.onNodeWithText("More info on installer label") + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun whenNotInstantApp() { + setContent() + waitUntilDisplayed() + + composeTestRule.onRoot().printToLog("AAA") + composeTestRule.onNodeWithText("App installed from installer label") + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun whenClick_startActivity() { + setContent() + waitUntilDisplayed() + composeTestRule.onRoot().performClick() + + verify(context).startActivityAsUser(STORE_LINK, APP.userHandle) + } + + private fun setContent(app: ApplicationInfo = APP) { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppInstallerInfoPreference(app) + } + } + } + + private fun waitUntilDisplayed() { + composeTestRule.waitUntilExists(preferenceNode) + } + + private val preferenceNode = hasText(context.getString(R.string.app_install_details_title)) + + private companion object { + const val PACKAGE_NAME = "packageName" + const val INSTALLER_PACKAGE_NAME = "installer" + const val INSTALLER_PACKAGE_LABEL = "installer label" + val STORE_LINK = Intent("store/link") + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + } + } +} 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() +} From e899b4c96c1bbf54602be7b288837707456efab6 Mon Sep 17 00:00:00 2001 From: Yining Liu Date: Tue, 25 Oct 2022 20:22:12 +0000 Subject: [PATCH 4/9] Fix text in notification settings for apps that do not send notifications Added a new string to show for apps apps that target T, but do not declare the POST_NOTIFICATIONS permission in notification settings. Bug: 229108007 Test: `croot` `make -j64 RunSettingsRoboTests ROBOTEST_FILTER="com.android.settings.notification.app.NotificationsOffPreferenceControllerTest"` and manual: open Settings-Notifications-App Settings, check the text for apps that does not post notifications with toggle disabled. Text should be: "This app does not send notifications" when system language is English. Change-Id: I9822b6f983e00c5982522099e339d86fae45aefe --- res/values/strings.xml | 3 +++ .../notification/NotificationBackend.java | 4 ++++ .../NotificationsOffPreferenceController.java | 2 ++ ...ificationsOffPreferenceControllerTest.java | 21 +++++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/res/values/strings.xml b/res/values/strings.xml index 42f8e4c0811..dfb99647f81 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -9895,6 +9895,9 @@ At your request, Android is blocking this group of notifications from appearing on this device + + This app does not send notifications + Categories diff --git a/src/com/android/settings/notification/NotificationBackend.java b/src/com/android/settings/notification/NotificationBackend.java index 150dbe0483d..68f5d081d11 100644 --- a/src/com/android/settings/notification/NotificationBackend.java +++ b/src/com/android/settings/notification/NotificationBackend.java @@ -126,6 +126,7 @@ public class NotificationBackend { if (app.requestedPermissions == null || Arrays.stream(app.requestedPermissions) .noneMatch(p -> p.equals(android.Manifest.permission.POST_NOTIFICATIONS))) { row.lockedImportance = true; + row.permissionStateLocked = true; } } } @@ -684,6 +685,9 @@ public class NotificationBackend { public boolean systemApp; public boolean lockedImportance; public boolean showBadge; + // For apps target T but have not but has not requested the permission + // we cannot change the permission state + public boolean permissionStateLocked; public int bubblePreference = NotificationManager.BUBBLE_PREFERENCE_NONE; public int userId; public int blockedChannelCount; diff --git a/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java b/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java index 0c7cd2361e2..04e3f0e311b 100644 --- a/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java +++ b/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java @@ -62,6 +62,8 @@ public class NotificationsOffPreferenceController extends NotificationPreference preference.setTitle(R.string.channel_notifications_off_desc); } else if (mChannelGroup != null) { preference.setTitle(R.string.channel_group_notifications_off_desc); + } else if (mAppRow.permissionStateLocked) { + preference.setTitle(R.string.app_notifications_not_send_desc); } else { preference.setTitle(R.string.app_notifications_off_desc); } diff --git a/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java index 34e94a042f9..2eebd3a2eab 100644 --- a/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java @@ -20,6 +20,7 @@ import static android.app.NotificationManager.IMPORTANCE_NONE; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @@ -32,6 +33,7 @@ import android.os.UserManager; import androidx.preference.Preference; +import com.android.settings.R; import com.android.settings.notification.NotificationBackend; import com.google.common.collect.ImmutableList; @@ -157,4 +159,23 @@ public class NotificationsOffPreferenceControllerTest { assertThat(pref.getTitle().toString()).contains("app"); assertThat(pref.isSelectable()).isFalse(); } + + @Test + public void testUpdateState_whenToggleDisabled() { + // Given: the app does not request to post notifications + // and it's preference toggle is disabled + NotificationBackend.AppRow appRow = new NotificationBackend.AppRow(); + appRow.banned = true; + appRow.permissionStateLocked = true; + mController.onResume(appRow, null, null, null, null, null, null); + Preference pref = new Preference(RuntimeEnvironment.application); + + // When: updateState(Preference preference) is called + mController.updateState(pref); + + // Then: title of pref should be app_notifications_not_send_desc + assertEquals( + RuntimeEnvironment.application.getString(R.string.app_notifications_not_send_desc), + pref.getTitle().toString()); + } } From dbead03b6ab7d9f13e8455f6c60519fc22656b0e Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Wed, 26 Oct 2022 17:00:13 +0800 Subject: [PATCH 5/9] 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 + } + } +} From 22d1cdb3bf6ddccd750ea7b21e346b508a7b88f9 Mon Sep 17 00:00:00 2001 From: Stanley Wang Date: Thu, 1 Sep 2022 17:33:17 +0800 Subject: [PATCH 6/9] Add new api for search pre-index Add sendPreIndexIntent api. Bug: 244521263 Test: manually test and review UI Change-Id: Ibbe88eeb114b453886693ecb92ebd8ee287f9581 --- src/com/android/settings/search/SearchFeatureProvider.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/com/android/settings/search/SearchFeatureProvider.java b/src/com/android/settings/search/SearchFeatureProvider.java index 1785361d3b2..b14a4d7fbf5 100644 --- a/src/com/android/settings/search/SearchFeatureProvider.java +++ b/src/com/android/settings/search/SearchFeatureProvider.java @@ -71,6 +71,12 @@ public interface SearchFeatureProvider { return context.getString(R.string.config_settingsintelligence_package_name); } + /** + * Send the pre-index intent. + */ + default void sendPreIndexIntent(Context context){ + } + /** * Initializes the search toolbar. */ From 0a9a9d34a731cf359420c7794f68893c40838e4f Mon Sep 17 00:00:00 2001 From: Stanley Wang Date: Mon, 5 Sep 2022 18:22:25 +0800 Subject: [PATCH 7/9] Send pre-index intent at Settings first started Bug: 244521263 Test: manually test and review UI Change-Id: I654a562b62256fd673f65949dd264f75574444c0 (cherry picked from commit 59446252d76e6dca399a75047b0bd9e20aea6ccd) --- src/com/android/settings/homepage/TopLevelSettings.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/com/android/settings/homepage/TopLevelSettings.java b/src/com/android/settings/homepage/TopLevelSettings.java index 70530fc8cd3..8c122ef2ad6 100644 --- a/src/com/android/settings/homepage/TopLevelSettings.java +++ b/src/com/android/settings/homepage/TopLevelSettings.java @@ -43,6 +43,7 @@ import com.android.settings.activityembedding.ActivityEmbeddingRulesController; import com.android.settings.activityembedding.ActivityEmbeddingUtils; import com.android.settings.core.SubSettingLauncher; import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.overlay.FeatureFactory; import com.android.settings.search.BaseSearchIndexProvider; import com.android.settings.support.SupportPreferenceController; import com.android.settings.widget.HomepagePreference; @@ -157,6 +158,8 @@ public class TopLevelSettings extends DashboardFragment implements SplitLayoutLi public void onStart() { if (mFirstStarted) { mFirstStarted = false; + FeatureFactory.getFactory(getContext()).getSearchFeatureProvider().sendPreIndexIntent( + getContext()); } else if (mIsEmbeddingActivityEnabled && isOnlyOneActivityInTask() && !SplitController.getInstance().isActivityEmbedded(getActivity())) { // Set default highlight menu key for 1-pane homepage since it will show the placeholder @@ -346,7 +349,9 @@ public class TopLevelSettings extends DashboardFragment implements SplitLayoutLi } private interface PreferenceJob { - default void init() {} + default void init() { + } + void doForEach(Preference preference); } From c0458b8a1568261f3ee2bdfa2be39ea9d2c57385 Mon Sep 17 00:00:00 2001 From: Stanley Wang Date: Thu, 8 Sep 2022 17:00:24 +0800 Subject: [PATCH 8/9] Send pre-index intent when developer options changed Bug: 244521263 Test: manually test and review UI Change-Id: I410dcc8ac8a9e131357a3e186e17b0d6363ad247 (cherry picked from commit 0b72d620a3979950b2a7c67db9cf99cb9f59328c) --- .../DevelopmentSettingsDashboardFragment.java | 11 +++++++---- .../deviceinfo/BuildNumberPreferenceController.java | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java b/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java index d92fb7fd99b..70fbefc36f4 100644 --- a/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java +++ b/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java @@ -60,6 +60,7 @@ import com.android.settings.development.bluetooth.BluetoothQualityDialogPreferen import com.android.settings.development.bluetooth.BluetoothSampleRateDialogPreferenceController; import com.android.settings.development.qstile.DevelopmentTiles; import com.android.settings.development.storage.SharedDataPreferenceController; +import com.android.settings.overlay.FeatureFactory; import com.android.settings.search.BaseSearchIndexProvider; import com.android.settings.search.actionbar.SearchMenuController; import com.android.settings.widget.SettingsMainSwitchBar; @@ -308,6 +309,8 @@ public class DevelopmentSettingsDashboardFragment extends RestrictedDashboardFra DisableDevSettingsDialogFragment.show(this /* host */); } } + FeatureFactory.getFactory( + getContext()).getSearchFeatureProvider().sendPreIndexIntent(getContext()); } } @@ -369,12 +372,12 @@ public class DevelopmentSettingsDashboardFragment extends RestrictedDashboardFra final BluetoothLeAudioHwOffloadPreferenceController leAudioController = getDevelopmentOptionsController( - BluetoothLeAudioHwOffloadPreferenceController.class); + BluetoothLeAudioHwOffloadPreferenceController.class); leAudioController.onRebootDialogConfirmed(); final BluetoothLeAudioPreferenceController leAudioFeatureController = getDevelopmentOptionsController( - BluetoothLeAudioPreferenceController.class); + BluetoothLeAudioPreferenceController.class); leAudioFeatureController.onRebootDialogConfirmed(); } @@ -386,12 +389,12 @@ public class DevelopmentSettingsDashboardFragment extends RestrictedDashboardFra final BluetoothLeAudioHwOffloadPreferenceController leAudioController = getDevelopmentOptionsController( - BluetoothLeAudioHwOffloadPreferenceController.class); + BluetoothLeAudioHwOffloadPreferenceController.class); leAudioController.onRebootDialogCanceled(); final BluetoothLeAudioPreferenceController leAudioFeatureController = getDevelopmentOptionsController( - BluetoothLeAudioPreferenceController.class); + BluetoothLeAudioPreferenceController.class); leAudioFeatureController.onRebootDialogCanceled(); } diff --git a/src/com/android/settings/deviceinfo/BuildNumberPreferenceController.java b/src/com/android/settings/deviceinfo/BuildNumberPreferenceController.java index 7ddca793f59..6af58b5bc6b 100644 --- a/src/com/android/settings/deviceinfo/BuildNumberPreferenceController.java +++ b/src/com/android/settings/deviceinfo/BuildNumberPreferenceController.java @@ -240,6 +240,8 @@ public class BuildNumberPreferenceController extends BasePreferenceController im mDevHitToast = Toast.makeText(mContext, R.string.show_dev_on, Toast.LENGTH_LONG); mDevHitToast.show(); + + FeatureFactory.getFactory(mContext).getSearchFeatureProvider().sendPreIndexIntent(mContext); } @VisibleForTesting From aafdb3bfbc85c80e914fd2fd6945da4e21abc648 Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Fri, 28 Oct 2022 10:31:49 +0800 Subject: [PATCH 9/9] Add AppSettingsPreference 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] -> Additional settings in the app Bug: 236346018 Test: Unit test & Manual with App Info page Change-Id: I40a175d771c4c0f13986f8c615169c99ce1b0623 --- .../spa/app/appinfo/AppInfoSettings.kt | 6 + .../app/appinfo/AppOpenByDefaultPreference.kt | 3 +- .../spa/app/appinfo/AppSettingsPreference.kt | 101 +++++++++++ .../spa/app/appinfo/AppStoragePreference.kt | 3 +- .../app/appinfo/AppSettingsPreferenceTest.kt | 169 ++++++++++++++++++ 5 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index b4b6945ca1a..32b09bf1180 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -16,6 +16,7 @@ package com.android.settings.spa.app.appinfo +import android.app.settings.SettingsEnums import android.content.pm.ApplicationInfo import android.os.Bundle import androidx.compose.runtime.Composable @@ -50,6 +51,8 @@ object AppInfoSettingsProvider : SettingsPageProvider { navArgument(USER_ID) { type = NavType.IntType }, ) + const val METRICS_CATEGORY = SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS + @Composable override fun Page(arguments: Bundle?) { val packageName = arguments!!.getString(PACKAGE_NAME)!! @@ -90,6 +93,9 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { AppButtons(packageInfoPresenter) + AppSettingsPreference(app) + // TODO: all_services_settings + // TODO: notification_settings AppPermissionPreference(app) AppStoragePreference(app) // TODO: instant_app_launch_supported_domain_urls diff --git a/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt b/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt index 936dee61d9a..4cc24b36a87 100644 --- a/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt @@ -16,7 +16,6 @@ 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.Composable @@ -86,7 +85,7 @@ private class AppOpenByDefaultPresenter( AppLaunchSettings::class.java, app, context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, ) } } diff --git a/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt b/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt new file mode 100644 index 00000000000..babd6070080 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt @@ -0,0 +1,101 @@ +/* + * 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.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager.ResolveInfoFlags +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settings.overlay.FeatureFactory +import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.userHandle +import com.android.settingslib.spaprivileged.model.app.userId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun AppSettingsPreference(app: ApplicationInfo) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val presenter = remember { AppSettingsPresenter(context, app, coroutineScope) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_settings_link) + override val onClick = presenter::startActivity + }) +} + +private class AppSettingsPresenter( + private val context: Context, + private val app: ApplicationInfo, + private val coroutineScope: CoroutineScope, +) { + private val packageManager = context.packageManager + + private val intentFlow = flow { + emit(resolveIntent()) + }.shareIn(coroutineScope, SharingStarted.WhileSubscribed(), 1) + + val isAvailableFlow = intentFlow.map { it != null } + + fun startActivity() { + coroutineScope.launch { + intentFlow.collect { intent -> + if (intent != null) { + FeatureFactory.getFactory(context).metricsFeatureProvider + .action( + SettingsEnums.PAGE_UNKNOWN, + SettingsEnums.ACTION_OPEN_APP_SETTING, + AppInfoSettingsProvider.METRICS_CATEGORY, + null, + 0, + ) + context.startActivityAsUser(intent, app.userHandle) + } + } + } + } + + private suspend fun resolveIntent(): Intent? = withContext(Dispatchers.IO) { + val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES).apply { + `package` = app.packageName + } + packageManager.resolveActivityAsUser(intent, ResolveInfoFlags.of(0), app.userId) + ?.activityInfo + ?.let { activityInfo -> + Intent(intent.action).apply { + setClassName(activityInfo.packageName, activityInfo.name) + } + } + } +} diff --git a/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt b/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt index 265f88299f3..e8b1018acfd 100644 --- a/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt @@ -16,7 +16,6 @@ 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.Composable @@ -70,6 +69,6 @@ private fun startStorageSettingsActivity(context: Context, app: ApplicationInfo) AppStorageSettings::class.java, app, context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, ) } diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt new file mode 100644 index 00000000000..1184ee7270a --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt @@ -0,0 +1,169 @@ +/* + * 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.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.content.pm.ResolveInfo +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.settings.R +import com.android.settings.testutils.waitUntilExists +import com.android.settingslib.applications.AppUtils +import com.android.settingslib.spaprivileged.model.app.userHandle +import com.android.settingslib.spaprivileged.model.app.userId +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.eq +import org.mockito.Mockito.verify +import org.mockito.Spy +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.mockito.Mockito.`when` as whenever + +@RunWith(AndroidJUnit4::class) +class AppSettingsPreferenceTest { + @JvmField + @Rule + val mockito: MockitoRule = MockitoJUnit.rule() + + @get:Rule + val composeTestRule = createComposeRule() + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var packageManager: PackageManager + + @Before + fun setUp() { + whenever(context.packageManager).thenReturn(packageManager) + } + + private fun mockResolveActivityAsUser(resolveInfo: ResolveInfo?) { + whenever( + packageManager.resolveActivityAsUser(any(), any(), eq(APP.userId)) + ).thenReturn(resolveInfo) + } + + @Test + fun callResolveActivityAsUser_withIntent() { + mockResolveActivityAsUser(null) + + setContent() + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + verify(packageManager).resolveActivityAsUser( + intentCaptor.capture(), any(), eq(APP.userId) + ) + val intent = intentCaptor.value + assertThat(intent.action).isEqualTo(Intent.ACTION_APPLICATION_PREFERENCES) + assertThat(intent.`package`).isEqualTo(PACKAGE_NAME) + } + + @Test + fun noResolveInfo_notDisplayed() { + mockResolveActivityAsUser(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun noSettingsActivity_notDisplayed() { + mockResolveActivityAsUser(ResolveInfo()) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun hasSettingsActivity_displayed() { + mockResolveActivityAsUser(RESOLVE_INFO) + + setContent() + + composeTestRule.onNodeWithText(context.getString(R.string.app_settings_link)) + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun whenClick_startActivity() { + mockResolveActivityAsUser(RESOLVE_INFO) + + setContent() + composeTestRule.onRoot().performClick() + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + verify(context).startActivityAsUser(intentCaptor.capture(), eq(APP.userHandle)) + val intent = intentCaptor.value + assertThat(intent.action).isEqualTo(Intent.ACTION_APPLICATION_PREFERENCES) + assertThat(intent.component).isEqualTo(ComponentName(PACKAGE_NAME, ACTIVITY_NAME)) + } + + private fun setContent() { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppSettingsPreference(APP) + } + } + } + + private companion object { + const val PACKAGE_NAME = "packageName" + const val ACTIVITY_NAME = "activityName" + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + } + val RESOLVE_INFO = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = PACKAGE_NAME + name = ACTIVITY_NAME + } + } + } +}