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
diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt
index 6882963..85e59de 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 @@
 
 @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 6b3535b..5f6f097 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.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.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 @@
     @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 @@
             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 8d0f0bb..a6bd8f0 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.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.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 @@
     val userContext by lazy { context.asUser(userHandle) }
     val userPackageManager: PackageManager by lazy { userContext.packageManager }
 
-    val flow: StateFlow<PackageInfo?> = 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<PackageInfo?> = merge(flowOf(null), appChangeFlow)
+        .map { getPackageInfo() }
         .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, null)
 
     /**
@@ -89,12 +115,14 @@
         }
         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 ecb540c..d81bb1a 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.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 @@
     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 @@
 
     @Test
     fun disable() = runBlocking {
-        val packageInfoPresenter =
-            PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers)
-
         packageInfoPresenter.disable()
         delay(100)
 
@@ -91,9 +136,6 @@
 
     @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 @@
 
     @Test
     fun clearInstantApp() = runBlocking {
-        val packageInfoPresenter =
-            PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers)
-
         packageInfoPresenter.clearInstantApp()
         delay(100)
 
@@ -121,9 +160,6 @@
 
     @Test
     fun forceStop() = runBlocking {
-        val packageInfoPresenter =
-            PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers)
-
         packageInfoPresenter.forceStop()
         delay(100)
 
@@ -133,9 +169,6 @@
 
     @Test
     fun logAction() = runBlocking {
-        val packageInfoPresenter =
-            PackageInfoPresenter(context, PACKAGE_NAME, USER_ID, TestScope(), packageManagers)
-
         packageInfoPresenter.logAction(123)
 
         verifyAction(123)
@@ -148,5 +181,6 @@
     private companion object {
         const val PACKAGE_NAME = "package.name"
         const val USER_ID = 0
+        val PACKAGE_INFO = PackageInfo()
     }
 }