From 298b738171e32ffac98e7ac3568269e41b55a7ae Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Mon, 13 May 2024 16:06:19 +0800 Subject: [PATCH] Create AppForceStopRepository Use Intent.ACTION_QUERY_PACKAGE_RESTART for more accurate app state. Fix: 340150349 Test: manual - on App info Test: unit test Change-Id: I1fb634b3fbee200f2251afa37ef2cba88ff58fe0 --- .../spa/app/appinfo/AppForceStopButton.kt | 28 +--- .../spa/app/appinfo/AppForceStopRepository.kt | 115 +++++++++++++ .../spa/app/appinfo/AppForceStopButtonTest.kt | 45 ++--- .../app/appinfo/AppForceStopRepositoryTest.kt | 155 ++++++++++++++++++ 4 files changed, 288 insertions(+), 55 deletions(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppForceStopRepository.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopRepositoryTest.kt diff --git a/src/com/android/settings/spa/app/appinfo/AppForceStopButton.kt b/src/com/android/settings/spa/app/appinfo/AppForceStopButton.kt index c3183a7494f..e612d1d2c8c 100644 --- a/src/com/android/settings/spa/app/appinfo/AppForceStopButton.kt +++ b/src/com/android/settings/spa/app/appinfo/AppForceStopButton.kt @@ -35,18 +35,14 @@ import com.android.settingslib.spa.widget.button.ActionButton import com.android.settingslib.spa.widget.dialog.AlertDialogButton import com.android.settingslib.spa.widget.dialog.AlertDialogPresenter import com.android.settingslib.spa.widget.dialog.rememberAlertDialogPresenter -import com.android.settingslib.spaprivileged.model.app.hasFlag -import com.android.settingslib.spaprivileged.model.app.isActiveAdmin import com.android.settingslib.spaprivileged.model.app.userId -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn class AppForceStopButton( private val packageInfoPresenter: PackageInfoPresenter, + private val appForceStopRepository: AppForceStopRepository = + AppForceStopRepository(packageInfoPresenter), ) { private val context = packageInfoPresenter.context - private val appButtonRepository = AppButtonRepository(context) private val packageManager = context.packageManager @Composable @@ -55,27 +51,11 @@ class AppForceStopButton( return ActionButton( text = stringResource(R.string.force_stop), imageVector = Icons.Outlined.Report, - enabled = remember(app) { - flow { - emit(isForceStopButtonEnable(app)) - }.flowOn(Dispatchers.Default) - }.collectAsStateWithLifecycle(false).value, + enabled = remember(app) { appForceStopRepository.canForceStopFlow() } + .collectAsStateWithLifecycle(false).value, ) { onForceStopButtonClicked(app, dialogPresenter) } } - /** - * Gets whether a package can be force stopped. - */ - private fun isForceStopButtonEnable(app: ApplicationInfo): Boolean = when { - // User can't force stop device admin. - app.isActiveAdmin(context) -> false - - appButtonRepository.isDisallowControl(app) -> false - - // If the app isn't explicitly stopped, then always show the force stop button. - else -> !app.hasFlag(ApplicationInfo.FLAG_STOPPED) - } - private fun onForceStopButtonClicked( app: ApplicationInfo, dialogPresenter: AlertDialogPresenter, diff --git a/src/com/android/settings/spa/app/appinfo/AppForceStopRepository.kt b/src/com/android/settings/spa/app/appinfo/AppForceStopRepository.kt new file mode 100644 index 00000000000..e929df1b976 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppForceStopRepository.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 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.Manifest +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.net.Uri +import android.os.UserHandle +import android.util.Log +import com.android.settingslib.spaprivileged.model.app.hasFlag +import com.android.settingslib.spaprivileged.model.app.isActiveAdmin +import com.android.settingslib.spaprivileged.model.app.userId +import kotlin.coroutines.resume +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.suspendCancellableCoroutine + +class AppForceStopRepository( + private val packageInfoPresenter: PackageInfoPresenter, + private val appButtonRepository: AppButtonRepository = + AppButtonRepository(packageInfoPresenter.context), +) { + private val context = packageInfoPresenter.context + + /** + * Flow of whether a package can be force stopped. + */ + fun canForceStopFlow(): Flow = packageInfoPresenter.flow + .map { packageInfo -> + val app = packageInfo?.applicationInfo ?: return@map false + canForceStop(app) + } + .conflate() + .onEach { Log.d(TAG, "canForceStopFlow: $it") } + .flowOn(Dispatchers.Default) + + /** + * Gets whether a package can be force stopped. + */ + private suspend fun canForceStop(app: ApplicationInfo): Boolean = when { + // User can't force stop device admin. + app.isActiveAdmin(context) -> false + + appButtonRepository.isDisallowControl(app) -> false + + // If the app isn't explicitly stopped, then always show the force stop button. + !app.hasFlag(ApplicationInfo.FLAG_STOPPED) -> true + + else -> queryAppRestart(app) + } + + /** + * Queries if app has restarted. + * + * @return true means app can be force stop again. + */ + private suspend fun queryAppRestart(app: ApplicationInfo): Boolean { + val packageName = app.packageName + val intent = Intent( + Intent.ACTION_QUERY_PACKAGE_RESTART, + Uri.fromParts("package", packageName, null) + ).apply { + putExtra(Intent.EXTRA_PACKAGES, arrayOf(packageName)) + putExtra(Intent.EXTRA_UID, app.uid) + putExtra(Intent.EXTRA_USER_HANDLE, app.userId) + } + Log.d(TAG, "Sending broadcast to query restart status for $packageName") + + return suspendCancellableCoroutine { continuation -> + val receiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val enabled = resultCode != Activity.RESULT_CANCELED + Log.d(TAG, "Got broadcast response: Restart status for $packageName $enabled") + continuation.resume(enabled) + } + } + context.sendOrderedBroadcastAsUser( + intent, + UserHandle.CURRENT, + Manifest.permission.HANDLE_QUERY_PACKAGE_RESTART, + receiver, + null, + Activity.RESULT_CANCELED, + null, + null, + ) + } + } + + private companion object { + private const val TAG = "AppForceStopRepository" + } +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopButtonTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopButtonTest.kt index 84d6651c396..186acd941d8 100644 --- a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopButtonTest.kt +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopButtonTest.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.util.trace import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settings.R @@ -36,10 +37,10 @@ import com.android.settingslib.spa.testutils.delay import com.android.settingslib.spaprivileged.framework.common.devicePolicyManager import com.android.settingslib.spaprivileged.model.app.userId import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.flowOf import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.spy @@ -54,51 +55,30 @@ class AppForceStopButtonTest { private val mockDevicePolicyManager = mock() - private val mockUserManager = mock { - on { getUserRestrictionSources(any(), any()) } doReturn emptyList() - } - private val context: Context = spy(ApplicationProvider.getApplicationContext()) { on { packageManager } doReturn mockPackageManager on { devicePolicyManager } doReturn mockDevicePolicyManager on { getSystemService(Context.DEVICE_POLICY_SERVICE) } doReturn mockDevicePolicyManager - on { getSystemService(Context.USER_SERVICE) } doReturn mockUserManager } private val packageInfoPresenter = mock { on { context } doReturn context } - private val appForceStopButton = AppForceStopButton(packageInfoPresenter) - - @Test - fun getActionButton_isActiveAdmin_buttonDisabled() { - val app = createApp() - mockDevicePolicyManager.stub { - on { packageHasActiveAdmins(PACKAGE_NAME, app.userId) } doReturn true - } - - setForceStopButton(app) - - composeTestRule.onNodeWithText(context.getString(R.string.force_stop)).assertIsNotEnabled() + private val mockAppForceStopRepository = mock { + on { canForceStopFlow() } doReturn flowOf(false) } - @Test - fun getActionButton_isUninstallInQueue_buttonDisabled() { - val app = createApp() - mockDevicePolicyManager.stub { - on { isUninstallInQueue(PACKAGE_NAME) } doReturn true - } - - setForceStopButton(app) - - composeTestRule.onNodeWithText(context.getString(R.string.force_stop)).assertIsNotEnabled() - } + private val appForceStopButton = AppForceStopButton( + packageInfoPresenter = packageInfoPresenter, + appForceStopRepository = mockAppForceStopRepository, + ) @Test fun getActionButton_isStopped_buttonDisabled() { - val app = createApp { - flags = ApplicationInfo.FLAG_STOPPED + val app = createApp() + mockAppForceStopRepository.stub { + on { canForceStopFlow() } doReturn flowOf(false) } setForceStopButton(app) @@ -109,6 +89,9 @@ class AppForceStopButtonTest { @Test fun getActionButton_regularApp_buttonEnabled() { val app = createApp() + mockAppForceStopRepository.stub { + on { canForceStopFlow() } doReturn flowOf(true) + } setForceStopButton(app) diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopRepositoryTest.kt new file mode 100644 index 00000000000..0bcd2490ea9 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppForceStopRepositoryTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2024 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.Manifest +import android.app.Activity +import android.app.admin.DevicePolicyManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.os.UserHandle +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.android.settingslib.spaprivileged.framework.common.devicePolicyManager +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.stub + +@RunWith(AndroidJUnit4::class) +class AppForceStopRepositoryTest { + + private val mockDevicePolicyManager = mock() + + private var resultCode = Activity.RESULT_CANCELED + + private val context: Context = spy(ApplicationProvider.getApplicationContext()) { + on { devicePolicyManager } doReturn mockDevicePolicyManager + onGeneric { + sendOrderedBroadcastAsUser( + argThat { action == Intent.ACTION_QUERY_PACKAGE_RESTART }, + eq(UserHandle.CURRENT), + eq(Manifest.permission.HANDLE_QUERY_PACKAGE_RESTART), + any(), + isNull(), + eq(Activity.RESULT_CANCELED), + isNull(), + isNull(), + ) + } doAnswer { + val broadcastReceiver = spy(it.arguments[3] as BroadcastReceiver) { + on { resultCode } doReturn resultCode + } + broadcastReceiver.onReceive(mock, it.arguments[0] as Intent) + } + } + + private val packageInfoPresenter = mock { + on { context } doReturn context + } + + private val repository = AppForceStopRepository(packageInfoPresenter) + + @Test + fun getActionButton_isActiveAdmin_returnFalse() = runBlocking { + val app = mockApp {} + mockDevicePolicyManager.stub { + on { packageHasActiveAdmins(PACKAGE_NAME, app.userId) } doReturn true + } + + val canForceStop = repository.canForceStopFlow().firstWithTimeoutOrNull() + + assertThat(canForceStop).isFalse() + } + + @Test + fun getActionButton_isUninstallInQueue_returnFalse() = runBlocking { + mockApp {} + mockDevicePolicyManager.stub { + on { isUninstallInQueue(PACKAGE_NAME) } doReturn true + } + + val canForceStop = repository.canForceStopFlow().firstWithTimeoutOrNull() + + assertThat(canForceStop).isFalse() + } + + @Test + fun canForceStopFlow_notStopped_returnTrue() = runBlocking { + mockApp { flags = 0 } + + val canForceStop = repository.canForceStopFlow().firstWithTimeoutOrNull() + + assertThat(canForceStop).isTrue() + } + + @Test + fun canForceStopFlow_isStoppedAndQueryReturnCancel_returnFalse() = runBlocking { + mockApp { + flags = ApplicationInfo.FLAG_STOPPED + } + resultCode = Activity.RESULT_CANCELED + + val canForceStop = repository.canForceStopFlow().firstWithTimeoutOrNull() + + assertThat(canForceStop).isFalse() + } + + @Test + fun canForceStopFlow_isStoppedAndQueryReturnOk_returnTrue() = runBlocking { + mockApp { + flags = ApplicationInfo.FLAG_STOPPED + } + resultCode = Activity.RESULT_OK + + val canForceStop = repository.canForceStopFlow().firstWithTimeoutOrNull() + + assertThat(canForceStop).isTrue() + } + + private fun mockApp(builder: ApplicationInfo.() -> Unit = {}) = packageInfoPresenter.stub { + on { flow } doReturn MutableStateFlow(PackageInfo().apply { + applicationInfo = createApp(builder) + }) + } + + private fun createApp(builder: ApplicationInfo.() -> Unit = {}) = + ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + enabled = true + }.apply(builder) + + private companion object { + const val PACKAGE_NAME = "package.name" + const val UID = 10000 + } +}