From b0cf27abbd5e28d0bdaa58dde9be8f90c571d05c Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Wed, 6 Dec 2023 15:30:47 +0800 Subject: [PATCH] Update the App Info Settings when package archived Listen to the following actions, - Intent.ACTION_PACKAGE_CHANGED for App enabled / disabled - Intent.ACTION_PACKAGE_REMOVED for App archived - Intent.ACTION_PACKAGE_REPLACED for App updated App updates are uninstalled - Intent.ACTION_PACKAGE_RESTARTED for App force-stopped Also, - Prevent AppInfoSettings flaky, by moving package info null into RegularScaffold. - Offload uninstallButton's enabled from main thread. Bug: 314562958 Test: manual - All apps > app detail Change-Id: Iaf210eb9e9b4ace1aa9079cdbb2d7430de4dd75f --- .../spa/app/appinfo/AppInfoSettings.kt | 11 +-- .../spa/app/appinfo/AppUninstallButton.kt | 15 +++- .../spa/app/appinfo/PackageInfoPresenter.kt | 54 ++++++++++---- .../app/appinfo/PackageInfoPresenterTest.kt | 70 ++++++++++++++----- 4 files changed, 112 insertions(+), 38 deletions(-) diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 6882963d7ce..85e59de9617 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -119,16 +119,19 @@ object AppInfoSettingsProvider : SettingsPageProvider { @Composable private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { - val packageInfo = packageInfoPresenter.flow.collectAsStateWithLifecycle().value ?:return - val app = checkNotNull(packageInfo.applicationInfo) + val packageInfoState = packageInfoPresenter.flow.collectAsStateWithLifecycle() val featureFlags: FeatureFlags = FeatureFlagsImpl() RegularScaffold( title = stringResource(R.string.application_info_label), actions = { - if (featureFlags.archiving()) TopBarAppLaunchButton(packageInfoPresenter, app) - AppInfoSettingsMoreOptions(packageInfoPresenter, app) + packageInfoState.value?.applicationInfo?.let { app -> + if (featureFlags.archiving()) TopBarAppLaunchButton(packageInfoPresenter, app) + AppInfoSettingsMoreOptions(packageInfoPresenter, app) + } } ) { + val packageInfo = packageInfoState.value ?: return@RegularScaffold + val app = packageInfo.applicationInfo ?: return@RegularScaffold val appInfoProvider = remember(packageInfo) { AppInfoProvider(packageInfo) } appInfoProvider.AppInfo() diff --git a/src/com/android/settings/spa/app/appinfo/AppUninstallButton.kt b/src/com/android/settings/spa/app/appinfo/AppUninstallButton.kt index 6b3535be353..5f6f097116a 100644 --- a/src/com/android/settings/spa/app/appinfo/AppUninstallButton.kt +++ b/src/com/android/settings/spa/app/appinfo/AppUninstallButton.kt @@ -23,8 +23,10 @@ import android.content.pm.ApplicationInfo import android.os.UserHandle import android.os.UserManager import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.settings.R import com.android.settings.Utils import com.android.settings.applications.specialaccess.deviceadmin.DeviceAdminAdd @@ -33,6 +35,9 @@ import com.android.settingslib.spaprivileged.framework.common.devicePolicyManage import com.android.settingslib.spaprivileged.model.app.hasFlag import com.android.settingslib.spaprivileged.model.app.isActiveAdmin import com.android.settingslib.spaprivileged.model.app.userHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn class AppUninstallButton(private val packageInfoPresenter: PackageInfoPresenter) { private val context = packageInfoPresenter.context @@ -43,7 +48,7 @@ class AppUninstallButton(private val packageInfoPresenter: PackageInfoPresenter) @Composable fun getActionButton(app: ApplicationInfo): ActionButton? { if (app.isSystemApp || app.isInstantApp) return null - return uninstallButton(app = app, enabled = isUninstallButtonEnabled(app)) + return uninstallButton(app) } /** Gets whether a package can be uninstalled. */ @@ -90,11 +95,15 @@ class AppUninstallButton(private val packageInfoPresenter: PackageInfoPresenter) overlayManager.getOverlayInfo(packageName, userHandle)?.isEnabled == true @Composable - private fun uninstallButton(app: ApplicationInfo, enabled: Boolean) = ActionButton( + private fun uninstallButton(app: ApplicationInfo) = ActionButton( text = if (isCloneApp(app)) context.getString(R.string.delete) else context.getString(R.string.uninstall_text), imageVector = ImageVector.vectorResource(R.drawable.ic_settings_delete), - enabled = enabled, + enabled = remember(app) { + flow { + emit(isUninstallButtonEnabled(app)) + }.flowOn(Dispatchers.Default) + }.collectAsStateWithLifecycle(false).value, ) { onUninstallClicked(app) } private fun onUninstallClicked(app: ApplicationInfo) { diff --git a/src/com/android/settings/spa/app/appinfo/PackageInfoPresenter.kt b/src/com/android/settings/spa/app/appinfo/PackageInfoPresenter.kt index 8d0f0bbeee7..a6bd8f0e64b 100644 --- a/src/com/android/settings/spa/app/appinfo/PackageInfoPresenter.kt +++ b/src/com/android/settings/spa/app/appinfo/PackageInfoPresenter.kt @@ -26,6 +26,7 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.UserHandle import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import com.android.settings.overlay.FeatureFactory.Companion.featureFactory import com.android.settings.spa.app.startUninstallActivity @@ -40,6 +41,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -65,18 +67,42 @@ class PackageInfoPresenter( val userContext by lazy { context.asUser(userHandle) } val userPackageManager: PackageManager by lazy { userContext.packageManager } - val flow: StateFlow = merge( - flowOf(null), // kick an initial value - context.broadcastReceiverAsUserFlow( - intentFilter = IntentFilter().apply { - addAction(Intent.ACTION_PACKAGE_CHANGED) - addAction(Intent.ACTION_PACKAGE_REPLACED) - addAction(Intent.ACTION_PACKAGE_RESTARTED) - addDataScheme("package") - }, - userHandle = userHandle, - ), - ).map { getPackageInfo() } + private val appChangeFlow = context.broadcastReceiverAsUserFlow( + intentFilter = IntentFilter().apply { + // App enabled / disabled + addAction(Intent.ACTION_PACKAGE_CHANGED) + + // App archived + addAction(Intent.ACTION_PACKAGE_REMOVED) + + // App updated / the updates are uninstalled (system app) + addAction(Intent.ACTION_PACKAGE_REPLACED) + + // App force-stopped + addAction(Intent.ACTION_PACKAGE_RESTARTED) + + addDataScheme("package") + }, + userHandle = userHandle, + ).filter(::isInterestedAppChange).filter(::isForThisApp) + + @VisibleForTesting + fun isInterestedAppChange(intent: Intent) = when { + intent.action != Intent.ACTION_PACKAGE_REMOVED -> true + + // filter out the fully removed case, in which the page will be closed, so no need to + // refresh + intent.getBooleanExtra(Intent.EXTRA_DATA_REMOVED, false) -> false + + // filter out the updates are uninstalled (system app), which will followed by a replacing + // broadcast, we can refresh at that time + intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) -> false + + else -> true // App archived + } + + val flow: StateFlow = merge(flowOf(null), appChangeFlow) + .map { getPackageInfo() } .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, null) /** @@ -89,12 +115,14 @@ class PackageInfoPresenter( } val navController = LocalNavController.current DisposableBroadcastReceiverAsUser(intentFilter, userHandle) { intent -> - if (packageName == intent.data?.schemeSpecificPart) { + if (isForThisApp(intent)) { navController.navigateBack() } } } + private fun isForThisApp(intent: Intent) = packageName == intent.data?.schemeSpecificPart + /** Enables this package. */ fun enable() { logAction(SettingsEnums.ACTION_SETTINGS_ENABLE_APP) diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/PackageInfoPresenterTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/PackageInfoPresenterTest.kt index ecb540cd84a..d81bb1a0bea 100644 --- a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/PackageInfoPresenterTest.kt +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/PackageInfoPresenterTest.kt @@ -20,7 +20,9 @@ import android.app.ActivityManager import android.app.settings.SettingsEnums import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.net.Uri import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settings.testutils.FakeFeatureFactory @@ -61,11 +63,57 @@ class PackageInfoPresenterTest { private val fakeFeatureFactory = FakeFeatureFactory() private val metricsFeatureProvider = fakeFeatureFactory.metricsFeatureProvider + private val packageInfoPresenter = + PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) + + @Test + fun isInterestedAppChange_packageChanged_isInterested() { + val intent = Intent(Intent.ACTION_PACKAGE_CHANGED).apply { + data = Uri.parse("package:$PACKAGE_NAME") + } + + val isInterestedAppChange = packageInfoPresenter.isInterestedAppChange(intent) + + assertThat(isInterestedAppChange).isTrue() + } + + @Test + fun isInterestedAppChange_fullyRemoved_notInterested() { + val intent = Intent(Intent.ACTION_PACKAGE_REMOVED).apply { + data = Uri.parse("package:$PACKAGE_NAME") + putExtra(Intent.EXTRA_DATA_REMOVED, true) + } + + val isInterestedAppChange = packageInfoPresenter.isInterestedAppChange(intent) + + assertThat(isInterestedAppChange).isFalse() + } + + @Test + fun isInterestedAppChange_removedBeforeReplacing_notInterested() { + val intent = Intent(Intent.ACTION_PACKAGE_REMOVED).apply { + data = Uri.parse("package:$PACKAGE_NAME") + putExtra(Intent.EXTRA_REPLACING, true) + } + + val isInterestedAppChange = packageInfoPresenter.isInterestedAppChange(intent) + + assertThat(isInterestedAppChange).isFalse() + } + + @Test + fun isInterestedAppChange_archived_interested() { + val intent = Intent(Intent.ACTION_PACKAGE_REMOVED).apply { + data = Uri.parse("package:$PACKAGE_NAME") + } + + val isInterestedAppChange = packageInfoPresenter.isInterestedAppChange(intent) + + assertThat(isInterestedAppChange).isTrue() + } + @Test fun enable() = runBlocking { - val packageInfoPresenter = - PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) - packageInfoPresenter.enable() delay(100) @@ -77,9 +125,6 @@ class PackageInfoPresenterTest { @Test fun disable() = runBlocking { - val packageInfoPresenter = - PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) - packageInfoPresenter.disable() delay(100) @@ -91,9 +136,6 @@ class PackageInfoPresenterTest { @Test fun startUninstallActivity() = runBlocking { - val packageInfoPresenter = - PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) - packageInfoPresenter.startUninstallActivity() verifyAction(SettingsEnums.ACTION_SETTINGS_UNINSTALL_APP) @@ -109,9 +151,6 @@ class PackageInfoPresenterTest { @Test fun clearInstantApp() = runBlocking { - val packageInfoPresenter = - PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) - packageInfoPresenter.clearInstantApp() delay(100) @@ -121,9 +160,6 @@ class PackageInfoPresenterTest { @Test fun forceStop() = runBlocking { - val packageInfoPresenter = - PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) - packageInfoPresenter.forceStop() delay(100) @@ -133,9 +169,6 @@ class PackageInfoPresenterTest { @Test fun logAction() = runBlocking { - val packageInfoPresenter = - PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers) - packageInfoPresenter.logAction(123) verifyAction(123) @@ -148,5 +181,6 @@ class PackageInfoPresenterTest { private companion object { const val PACKAGE_NAME = "package.name" const val USER_ID = 0 + val PACKAGE_INFO = PackageInfo() } }