From e3b456ef3efb2246548891f180efe0d0dff9beb4 Mon Sep 17 00:00:00 2001 From: Mark Kim Date: Sun, 3 Dec 2023 16:26:55 +0000 Subject: [PATCH] Add 'Restore' button to AppInfo screen Test: AppButtonsTest, AppRestoreButtonTest Bug: 304255818 Change-Id: Ica9055d8ee5603e4bb682e9b5d90a225c839002a --- AndroidManifest.xml | 1 + res/values/strings.xml | 8 ++ .../settings/spa/app/appinfo/AppButtons.kt | 7 +- .../spa/app/appinfo/AppRestoreButton.kt | 135 ++++++++++++++++++ .../spa/app/appinfo/AppButtonsTest.kt | 32 +++++ .../spa/app/appinfo/AppRestoreButtonTest.kt | 132 +++++++++++++++++ 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppRestoreButton.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppRestoreButtonTest.kt diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 2b491480b5f..1312de58297 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -138,6 +138,7 @@ + Force stop Archive + + Restore Total @@ -4012,6 +4014,12 @@ Archiving failed Archived %1$s + + Restoring failed + + Restored %1$s + + Restoring %1$s diff --git a/src/com/android/settings/spa/app/appinfo/AppButtons.kt b/src/com/android/settings/spa/app/appinfo/AppButtons.kt index f6fafd7f0bf..403263cd559 100644 --- a/src/com/android/settings/spa/app/appinfo/AppButtons.kt +++ b/src/com/android/settings/spa/app/appinfo/AppButtons.kt @@ -53,6 +53,7 @@ private class AppButtonsPresenter( private val appClearButton = AppClearButton(packageInfoPresenter) private val appForceStopButton = AppForceStopButton(packageInfoPresenter) private val appArchiveButton = AppArchiveButton(packageInfoPresenter) + private val appRestoreButton = AppRestoreButton(packageInfoPresenter) @Composable fun getActionButtons() = @@ -63,7 +64,11 @@ private class AppButtonsPresenter( @Composable private fun getActionButtons(app: ApplicationInfo): List = listOfNotNull( if (featureFlags.archiving()) { - appArchiveButton.getActionButton(app) + if (app.isArchived) { + appRestoreButton.getActionButton(app) + } else { + appArchiveButton.getActionButton(app) + } } else { appLaunchButton.getActionButton(app) }, diff --git a/src/com/android/settings/spa/app/appinfo/AppRestoreButton.kt b/src/com/android/settings/spa/app/appinfo/AppRestoreButton.kt new file mode 100644 index 00000000000..c47fdac7630 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppRestoreButton.kt @@ -0,0 +1,135 @@ +/* + * 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.CloudDownload +import androidx.compose.runtime.Composable +import com.android.settings.R +import com.android.settingslib.spa.widget.button.ActionButton +import com.android.settingslib.spaprivileged.framework.compose.DisposableBroadcastReceiverAsUser + +class AppRestoreButton(packageInfoPresenter: PackageInfoPresenter) { + private companion object { + private const val LOG_TAG = "AppRestoreButton" + private const val INTENT_ACTION = "com.android.settings.unarchive.action" + } + + private val context = packageInfoPresenter.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.restore), + imageVector = Icons.Outlined.CloudDownload, + enabled = app.isArchived + ) { onRestoreClicked(app) } + } + + private fun onRestoreClicked(app: ApplicationInfo) { + val intent = Intent(INTENT_ACTION) + intent.setPackage(context.packageName) + val pendingIntent = PendingIntent.getBroadcastAsUser( + context, 0, intent, + // FLAG_MUTABLE is required by PackageInstaller#requestUnarchive + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_MUTABLE, + userHandle + ) + try { + packageInstaller.requestUnarchive(app.packageName, pendingIntent.intentSender) + val appLabel = userPackageManager.getApplicationLabel(app) + Toast.makeText( + context, + context.getString(R.string.restoring_in_progress, appLabel), + Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Log.e(LOG_TAG, "Request unarchive failed", e) + Toast.makeText( + context, + context.getString(R.string.restoring_failed), + Toast.LENGTH_SHORT + ).show() + } + } + + private fun onReceive(intent: Intent, app: ApplicationInfo) { + when (val unarchiveStatus = + intent.getIntExtra(PackageInstaller.EXTRA_UNARCHIVE_STATUS, Int.MIN_VALUE)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + Log.e( + LOG_TAG, + "Request unarchiving failed for $packageName with code $unarchiveStatus" + ) + Toast.makeText( + context, + context.getString(R.string.restoring_failed), + Toast.LENGTH_SHORT + ).show() + } + + PackageInstaller.STATUS_SUCCESS -> { + val appLabel = userPackageManager.getApplicationLabel(app) + Toast.makeText( + context, + context.getString(R.string.restoring_succeeded, appLabel), + Toast.LENGTH_SHORT + ).show() + } + + else -> { + Log.e( + LOG_TAG, + "Request unarchiving failed for $packageName with code $unarchiveStatus" + ) + val errorDialogIntent = + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + if (errorDialogIntent != null) { + context.startActivityAsUser(errorDialogIntent, userHandle) + } else { + Toast.makeText( + context, + context.getString(R.string.restoring_failed), + Toast.LENGTH_SHORT + ).show() + } + } + } + } +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppButtonsTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppButtonsTest.kt index 50094f25f46..6d22c92d98d 100644 --- a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppButtonsTest.kt +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppButtonsTest.kt @@ -140,6 +140,38 @@ class AppButtonsTest { composeTestRule.onNodeWithText(context.getString(R.string.uninstall_text)).assertIsEnabled() } + @Test + fun archiveButton_displayed_whenAppIsNotArchived() { + featureFlags.setFlag(Flags.FLAG_ARCHIVING, true) + val packageInfo = PackageInfo().apply { + applicationInfo = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = false + } + packageName = PACKAGE_NAME + } + setContent(packageInfo) + + composeTestRule.onNodeWithText(context.getString(R.string.archive)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.restore)).assertIsNotDisplayed() + } + + @Test + fun restoreButton_displayed_whenAppIsArchived() { + featureFlags.setFlag(Flags.FLAG_ARCHIVING, true) + val packageInfo = PackageInfo().apply { + applicationInfo = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = true + } + packageName = PACKAGE_NAME + } + setContent(packageInfo) + + composeTestRule.onNodeWithText(context.getString(R.string.restore)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.archive)).assertIsNotDisplayed() + } + private fun setContent(packageInfo: PackageInfo = PACKAGE_INFO) { whenever(packageInfoPresenter.flow).thenReturn(MutableStateFlow(packageInfo)) composeTestRule.setContent { diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppRestoreButtonTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppRestoreButtonTest.kt new file mode 100644 index 00000000000..9d305219570 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppRestoreButtonTest.kt @@ -0,0 +1,132 @@ +/* + * 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.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.annotation.UiThreadTest +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.R +import com.android.settingslib.spa.widget.button.ActionButton +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.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class AppRestoreButtonTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context = spy(ApplicationProvider.getApplicationContext()) {} + + private val packageInfoPresenter = mock() + + private val userPackageManager = mock() + + private val packageInstaller = mock() + + private lateinit var appRestoreButton: AppRestoreButton + + @Before + fun setUp() { + whenever(packageInfoPresenter.context).thenReturn(context) + whenever(packageInfoPresenter.userPackageManager).thenReturn(userPackageManager) + whenever(userPackageManager.packageInstaller).thenReturn(packageInstaller) + whenever(packageInfoPresenter.packageName).thenReturn(PACKAGE_NAME) + appRestoreButton = AppRestoreButton(packageInfoPresenter) + } + + @Test + fun appRestoreButton_whenIsNotArchived_isDisabled() { + val app = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = false + } + + val actionButton = setContent(app) + + assertThat(actionButton.enabled).isFalse() + } + + @Test + fun appRestoreButton_whenIsArchived_isEnabled() { + val app = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = true + } + + val actionButton = setContent(app) + + assertThat(actionButton.enabled).isTrue() + } + + @Test + fun appRestoreButton_displaysRightTextAndIcon() { + val app = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = false + } + + val actionButton = setContent(app) + + assertThat(actionButton.text).isEqualTo(context.getString(R.string.restore)) + assertThat(actionButton.imageVector).isEqualTo(Icons.Outlined.CloudDownload) + } + + @Test + @UiThreadTest + fun appRestoreButton_clicked() { + val app = ApplicationInfo().apply { + packageName = PACKAGE_NAME + isArchived = true + } + + val actionButton = setContent(app) + actionButton.onClick() + + verify(packageInstaller).requestUnarchive( + eq(PACKAGE_NAME), + any() + ) + } + + private fun setContent(app: ApplicationInfo): ActionButton { + lateinit var actionButton: ActionButton + composeTestRule.setContent { + actionButton = appRestoreButton.getActionButton(app) + } + return actionButton + } + + private companion object { + const val PACKAGE_NAME = "package.name" + } +}