Add 'Archive' button to AppInfo screen

Disable 'Archive' button whenever 'Uninstall' button is disabled.

Test: AppArchiveButtonTest, AppButtonsTest

Bug: 304256700
Change-Id: I9671905eca2cb71a5bf30bf29be83e5305a48ef4
This commit is contained in:
Mark Kim
2023-10-30 20:56:44 +00:00
parent 62702e3c47
commit 63f48ad2c6
9 changed files with 363 additions and 66 deletions

View File

@@ -0,0 +1,130 @@
/*
* Copyright (C) 2023 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.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInstaller
import android.os.UserHandle
import android.util.Log
import android.widget.Toast
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.settings.R
import com.android.settingslib.spa.widget.button.ActionButton
import com.android.settingslib.spaprivileged.framework.compose.DisposableBroadcastReceiverAsUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
class AppArchiveButton(packageInfoPresenter: PackageInfoPresenter) {
private companion object {
private const val LOG_TAG = "AppArchiveButton"
private const val INTENT_ACTION = "com.android.settings.archive.action"
}
private val context = packageInfoPresenter.context
private val appButtonRepository = AppButtonRepository(context)
private val userPackageManager = packageInfoPresenter.userPackageManager
private val packageInstaller = userPackageManager.packageInstaller
private val packageName = packageInfoPresenter.packageName
private val userHandle = UserHandle.of(packageInfoPresenter.userId)
private var broadcastReceiverIsCreated = false
@Composable
fun getActionButton(app: ApplicationInfo): ActionButton {
if (!broadcastReceiverIsCreated) {
val intentFilter = IntentFilter(INTENT_ACTION)
DisposableBroadcastReceiverAsUser(intentFilter, userHandle) { intent ->
if (app.packageName == intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)) {
onReceive(intent, app)
}
}
broadcastReceiverIsCreated = true
}
return ActionButton(
text = context.getString(R.string.archive),
imageVector = Icons.Outlined.CloudUpload,
enabled = remember(app) {
flow {
emit(
app.isActionButtonEnabled() && appButtonRepository.isAllowUninstallOrArchive(
context,
app
)
)
}.flowOn(Dispatchers.Default)
}.collectAsStateWithLifecycle(false).value
) { onArchiveClicked(app) }
}
private fun ApplicationInfo.isActionButtonEnabled(): Boolean {
return !isArchived
&& userPackageManager.isAppArchivable(packageName)
// We apply the same device policy for both the uninstallation and archive
// button.
&& !appButtonRepository.isUninstallBlockedByAdmin(this)
}
private fun onArchiveClicked(app: ApplicationInfo) {
val intent = Intent(INTENT_ACTION)
intent.setPackage(context.packageName)
val pendingIntent = PendingIntent.getBroadcastAsUser(
context, 0, intent,
// FLAG_MUTABLE is required by PackageInstaller#requestArchive
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_MUTABLE,
userHandle
)
try {
packageInstaller.requestArchive(app.packageName, pendingIntent.intentSender, 0)
} catch (e: Exception) {
Log.e(LOG_TAG, "Request archive failed", e)
Toast.makeText(
context,
context.getString(R.string.archiving_failed),
Toast.LENGTH_SHORT
).show()
}
}
private fun onReceive(intent: Intent, app: ApplicationInfo) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE)) {
PackageInstaller.STATUS_SUCCESS -> {
val appLabel = userPackageManager.getApplicationLabel(app)
Toast.makeText(
context,
context.getString(R.string.archiving_succeeded, appLabel),
Toast.LENGTH_SHORT
).show()
}
else -> {
Log.e(LOG_TAG, "Request archiving failed for $packageName with code $status")
Toast.makeText(
context,
context.getString(R.string.archiving_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
}

View File

@@ -19,6 +19,7 @@ package com.android.settings.spa.app.appinfo
import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.om.OverlayManager
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
@@ -26,7 +27,9 @@ import com.android.settingslib.RestrictedLockUtils
import com.android.settingslib.RestrictedLockUtilsInternal
import com.android.settingslib.Utils
import com.android.settingslib.spaprivileged.framework.common.devicePolicyManager
import com.android.settingslib.spaprivileged.model.app.hasFlag
import com.android.settingslib.spaprivileged.model.app.isDisallowControl
import com.android.settingslib.spaprivileged.model.app.userHandle
import com.android.settingslib.spaprivileged.model.app.userId
class AppButtonRepository(private val context: Context) {
@@ -77,6 +80,55 @@ class AppButtonRepository(private val context: Context) {
false
}
/** Gets whether a package can be uninstalled or archived. */
fun isAllowUninstallOrArchive(
context: Context, app: ApplicationInfo
): Boolean {
val overlayManager = checkNotNull(context.getSystemService(OverlayManager::class.java))
when {
!app.hasFlag(ApplicationInfo.FLAG_INSTALLED) && !app.isArchived -> return false
com.android.settings.Utils.isProfileOrDeviceOwner(
context.devicePolicyManager, app.packageName, app.userId
) -> return false
isDisallowControl(app) -> return false
uninstallDisallowedDueToHomeApp(app.packageName) -> return false
// Resource overlays can be uninstalled iff they are public (installed on /data) and
// disabled. ("Enabled" means they are in use by resource management.)
app.isEnabledResourceOverlay(overlayManager) -> return false
else -> return true
}
}
/**
* Checks whether the given package cannot be uninstalled due to home app restrictions.
*
* Home launcher apps need special handling, we can't allow uninstallation of the only home
* app, and we don't want to allow uninstallation of an explicitly preferred one -- the user
* can go to Home settings and pick a different one, after which we'll permit uninstallation
* of the now-not-default one.
*/
private fun uninstallDisallowedDueToHomeApp(packageName: String): Boolean {
val homePackageInfo = getHomePackageInfo()
return when {
packageName !in homePackageInfo.homePackages -> false
// Disallow uninstall when this is the only home app.
homePackageInfo.homePackages.size == 1 -> true
// Disallow if this is the explicit default home app.
else -> packageName == homePackageInfo.currentDefaultHome?.packageName
}
}
private fun ApplicationInfo.isEnabledResourceOverlay(overlayManager: OverlayManager): Boolean =
isResourceOverlay &&
overlayManager.getOverlayInfo(packageName, userHandle)?.isEnabled == true
data class HomePackages(
val homePackages: Set<String>,
val currentDefaultHome: ComponentName?,

View File

@@ -30,7 +30,10 @@ import com.android.settingslib.spa.widget.button.ActionButtons
/**
* @param featureFlags can be overridden in tests
*/
fun AppButtons(packageInfoPresenter: PackageInfoPresenter, featureFlags: FeatureFlags = FeatureFlagsImpl()) {
fun AppButtons(
packageInfoPresenter: PackageInfoPresenter,
featureFlags: FeatureFlags = FeatureFlagsImpl()
) {
if (remember(packageInfoPresenter) { packageInfoPresenter.isMainlineModule() }) return
val presenter = remember { AppButtonsPresenter(packageInfoPresenter, featureFlags) }
ActionButtons(actionButtons = presenter.getActionButtons())
@@ -49,6 +52,7 @@ private class AppButtonsPresenter(
private val appUninstallButton = AppUninstallButton(packageInfoPresenter)
private val appClearButton = AppClearButton(packageInfoPresenter)
private val appForceStopButton = AppForceStopButton(packageInfoPresenter)
private val appArchiveButton = AppArchiveButton(packageInfoPresenter)
@Composable
fun getActionButtons() =
@@ -58,7 +62,11 @@ private class AppButtonsPresenter(
@Composable
private fun getActionButtons(app: ApplicationInfo): List<ActionButton> = listOfNotNull(
if (featureFlags.archiving()) null else appLaunchButton.getActionButton(app),
if (featureFlags.archiving()) {
appArchiveButton.getActionButton(app)
} else {
appLaunchButton.getActionButton(app)
},
appInstallButton.getActionButton(app),
appDisableButton.getActionButton(app),
appUninstallButton.getActionButton(app),

View File

@@ -18,7 +18,6 @@ package com.android.settings.spa.app.appinfo
import android.app.settings.SettingsEnums
import android.content.Intent
import android.content.om.OverlayManager
import android.content.pm.ApplicationInfo
import android.os.UserHandle
import android.os.UserManager
@@ -28,11 +27,8 @@ 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
import com.android.settingslib.spa.widget.button.ActionButton
import com.android.settingslib.spaprivileged.framework.common.devicePolicyManager
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
@@ -42,7 +38,6 @@ import kotlinx.coroutines.flow.flowOn
class AppUninstallButton(private val packageInfoPresenter: PackageInfoPresenter) {
private val context = packageInfoPresenter.context
private val appButtonRepository = AppButtonRepository(context)
private val overlayManager = context.getSystemService(OverlayManager::class.java)!!
private val userManager = context.getSystemService(UserManager::class.java)!!
@Composable
@@ -51,49 +46,6 @@ class AppUninstallButton(private val packageInfoPresenter: PackageInfoPresenter)
return uninstallButton(app)
}
/** Gets whether a package can be uninstalled. */
private fun isUninstallButtonEnabled(app: ApplicationInfo): Boolean = when {
!app.hasFlag(ApplicationInfo.FLAG_INSTALLED) -> false
Utils.isProfileOrDeviceOwner(
context.devicePolicyManager, app.packageName, packageInfoPresenter.userId) -> false
appButtonRepository.isDisallowControl(app) -> false
uninstallDisallowedDueToHomeApp(app.packageName) -> false
// Resource overlays can be uninstalled iff they are public (installed on /data) and
// disabled. ("Enabled" means they are in use by resource management.)
app.isEnabledResourceOverlay() -> false
else -> true
}
/**
* Checks whether the given package cannot be uninstalled due to home app restrictions.
*
* Home launcher apps need special handling, we can't allow uninstallation of the only home
* app, and we don't want to allow uninstallation of an explicitly preferred one -- the user
* can go to Home settings and pick a different one, after which we'll permit uninstallation
* of the now-not-default one.
*/
private fun uninstallDisallowedDueToHomeApp(packageName: String): Boolean {
val homePackageInfo = appButtonRepository.getHomePackageInfo()
return when {
packageName !in homePackageInfo.homePackages -> false
// Disallow uninstall when this is the only home app.
homePackageInfo.homePackages.size == 1 -> true
// Disallow if this is the explicit default home app.
else -> packageName == homePackageInfo.currentDefaultHome?.packageName
}
}
private fun ApplicationInfo.isEnabledResourceOverlay(): Boolean =
isResourceOverlay &&
overlayManager.getOverlayInfo(packageName, userHandle)?.isEnabled == true
@Composable
private fun uninstallButton(app: ApplicationInfo) = ActionButton(
text = if (isCloneApp(app)) context.getString(R.string.delete) else
@@ -101,7 +53,7 @@ class AppUninstallButton(private val packageInfoPresenter: PackageInfoPresenter)
imageVector = ImageVector.vectorResource(R.drawable.ic_settings_delete),
enabled = remember(app) {
flow {
emit(isUninstallButtonEnabled(app))
emit(appButtonRepository.isAllowUninstallOrArchive(context, app))
}.flowOn(Dispatchers.Default)
}.collectAsStateWithLifecycle(false).value,
) { onUninstallClicked(app) }

View File

@@ -87,19 +87,9 @@ class PackageInfoPresenter(
).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
}
fun isInterestedAppChange(intent: Intent) =
intent.action != Intent.ACTION_PACKAGE_REMOVED ||
intent.getBooleanExtra(Intent.EXTRA_ARCHIVAL, false)
val flow: StateFlow<PackageInfo?> = merge(flowOf(null), appChangeFlow)
.map { getPackageInfo() }