Add HibernationSwitchPreference for Spa
Bug: 236346018 Test: Manual with App Info page Test: Settings Unit tests Change-Id: I23140a2a16b3b5a4b569623504b1838a641611fe
This commit is contained in:
@@ -100,6 +100,10 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) {
|
||||
AppOpenByDefaultPreference(app)
|
||||
DefaultAppShortcuts(app)
|
||||
|
||||
Category(title = stringResource(R.string.unused_apps_category)) {
|
||||
HibernationSwitchPreference(app)
|
||||
}
|
||||
|
||||
Category(title = stringResource(R.string.advanced_apps)) {
|
||||
DisplayOverOtherAppsAppListProvider.InfoPageEntryItem(app)
|
||||
ModifySystemSettingsAppListProvider.InfoPageEntryItem(app)
|
||||
|
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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.AppOpsManager.MODE_ALLOWED
|
||||
import android.app.AppOpsManager.MODE_DEFAULT
|
||||
import android.app.AppOpsManager.MODE_IGNORED
|
||||
import android.app.AppOpsManager.OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Build
|
||||
import android.permission.PermissionControllerManager.HIBERNATION_ELIGIBILITY_EXEMPT_BY_SYSTEM
|
||||
import android.permission.PermissionControllerManager.HIBERNATION_ELIGIBILITY_UNKNOWN
|
||||
import android.provider.DeviceConfig
|
||||
import android.provider.DeviceConfig.NAMESPACE_APP_HIBERNATION
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.android.settings.R
|
||||
import com.android.settings.Utils.PROPERTY_APP_HIBERNATION_ENABLED
|
||||
import com.android.settings.Utils.PROPERTY_HIBERNATION_TARGETS_PRE_S_APPS
|
||||
import com.android.settingslib.spa.framework.compose.OverridableFlow
|
||||
import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle
|
||||
import com.android.settingslib.spa.framework.compose.stateOf
|
||||
import com.android.settingslib.spa.widget.preference.SwitchPreference
|
||||
import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel
|
||||
import com.android.settingslib.spaprivileged.framework.common.appHibernationManager
|
||||
import com.android.settingslib.spaprivileged.framework.common.appOpsManager
|
||||
import com.android.settingslib.spaprivileged.framework.common.asUser
|
||||
import com.android.settingslib.spaprivileged.framework.common.permissionControllerManager
|
||||
import com.android.settingslib.spaprivileged.model.app.userHandle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Composable
|
||||
fun HibernationSwitchPreference(app: ApplicationInfo) {
|
||||
val context = LocalContext.current
|
||||
val presenter = remember { HibernationSwitchPresenter(context, app) }
|
||||
if (!presenter.isAvailable()) return
|
||||
|
||||
val isEligibleState = presenter.isEligibleFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isCheckedState = presenter.isCheckedFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
SwitchPreference(remember {
|
||||
object : SwitchPreferenceModel {
|
||||
override val title = context.getString(R.string.unused_apps_switch)
|
||||
override val summary = stateOf(context.getString(R.string.unused_apps_switch_summary))
|
||||
override val changeable = isEligibleState
|
||||
|
||||
override val checked = derivedStateOf {
|
||||
if (!changeable.value) false else isCheckedState.value
|
||||
}
|
||||
|
||||
override val onCheckedChange = presenter::onCheckedChange
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private class HibernationSwitchPresenter(context: Context, private val app: ApplicationInfo) {
|
||||
private val appOpsManager = context.appOpsManager
|
||||
private val permissionControllerManager =
|
||||
context.asUser(app.userHandle).permissionControllerManager
|
||||
private val appHibernationManager = context.appHibernationManager
|
||||
private val executor = Dispatchers.IO.asExecutor()
|
||||
|
||||
fun isAvailable() =
|
||||
DeviceConfig.getBoolean(NAMESPACE_APP_HIBERNATION, PROPERTY_APP_HIBERNATION_ENABLED, true)
|
||||
|
||||
val isEligibleFlow = flow {
|
||||
val eligibility = getEligibility()
|
||||
emit(
|
||||
eligibility != HIBERNATION_ELIGIBILITY_EXEMPT_BY_SYSTEM &&
|
||||
eligibility != HIBERNATION_ELIGIBILITY_UNKNOWN
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getEligibility(): Int = suspendCoroutine { continuation ->
|
||||
permissionControllerManager.getHibernationEligibility(app.packageName, executor) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
|
||||
private val isChecked = OverridableFlow(flow {
|
||||
emit(!isExempt())
|
||||
})
|
||||
|
||||
val isCheckedFlow = isChecked.flow
|
||||
|
||||
private suspend fun isExempt(): Boolean = withContext(Dispatchers.IO) {
|
||||
val mode = appOpsManager.checkOpNoThrow(
|
||||
OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED, app.uid, app.packageName
|
||||
)
|
||||
if (mode == MODE_DEFAULT) isExemptByDefault() else mode != MODE_ALLOWED
|
||||
}
|
||||
|
||||
private fun isExemptByDefault() =
|
||||
!hibernationTargetsPreSApps() && app.targetSdkVersion <= Build.VERSION_CODES.Q
|
||||
|
||||
private fun hibernationTargetsPreSApps() = DeviceConfig.getBoolean(
|
||||
NAMESPACE_APP_HIBERNATION, PROPERTY_HIBERNATION_TARGETS_PRE_S_APPS, false
|
||||
)
|
||||
|
||||
fun onCheckedChange(newChecked: Boolean) {
|
||||
try {
|
||||
appOpsManager.setUidMode(
|
||||
OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED,
|
||||
app.uid,
|
||||
if (newChecked) MODE_ALLOWED else MODE_IGNORED,
|
||||
)
|
||||
if (!newChecked) {
|
||||
appHibernationManager.setHibernatingForUser(app.packageName, false)
|
||||
appHibernationManager.setHibernatingGlobally(app.packageName, false)
|
||||
}
|
||||
isChecked.override(newChecked)
|
||||
} catch (_: RuntimeException) {
|
||||
}
|
||||
}
|
||||
}
|
@@ -16,7 +16,6 @@
|
||||
|
||||
package com.android.settings.spa.app.specialaccess
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.AppOpsManager
|
||||
import android.app.AppOpsManager.MODE_ALLOWED
|
||||
import android.app.AppOpsManager.MODE_ERRORED
|
||||
@@ -24,21 +23,23 @@ import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.android.settingslib.spaprivileged.framework.common.alarmManager
|
||||
import com.android.settingslib.spaprivileged.framework.common.appOpsManager
|
||||
import com.android.settingslib.spaprivileged.model.app.userId
|
||||
|
||||
class AlarmsAndRemindersController(
|
||||
context: Context,
|
||||
private val app: ApplicationInfo,
|
||||
) {
|
||||
private val alarmManager = context.getSystemService(AlarmManager::class.java)!!
|
||||
private val appOpsManager = context.getSystemService(AppOpsManager::class.java)!!
|
||||
private val alarmManager = context.alarmManager
|
||||
private val appOpsManager = context.appOpsManager
|
||||
|
||||
val isAllowed: LiveData<Boolean>
|
||||
get() = _allowed
|
||||
|
||||
fun setAllowed(allowed: Boolean) {
|
||||
val mode = if (allowed) MODE_ALLOWED else MODE_ERRORED
|
||||
appOpsManager.setUidMode(AppOpsManager.OPSTR_SCHEDULE_EXACT_ALARM, app.uid, mode)
|
||||
appOpsManager.setUidMode(AppOpsManager.OP_SCHEDULE_EXACT_ALARM, app.uid, mode)
|
||||
_allowed.postValue(allowed)
|
||||
}
|
||||
|
||||
@@ -46,8 +47,5 @@ class AlarmsAndRemindersController(
|
||||
override fun onActive() {
|
||||
postValue(alarmManager.hasScheduleExactAlarm(app.packageName, app.userId))
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -19,6 +19,8 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.android.settings.tests.spa_unit">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG" />
|
||||
|
||||
<application android:debuggable="true">
|
||||
<provider android:name="com.android.settings.slices.SettingsSliceProvider"
|
||||
android:authorities="${applicationId}.slices"
|
||||
|
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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.AppOpsManager
|
||||
import android.app.AppOpsManager.MODE_ALLOWED
|
||||
import android.app.AppOpsManager.MODE_DEFAULT
|
||||
import android.app.AppOpsManager.MODE_IGNORED
|
||||
import android.app.AppOpsManager.OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED
|
||||
import android.apphibernation.AppHibernationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Build
|
||||
import android.os.UserHandle
|
||||
import android.permission.PermissionControllerManager
|
||||
import android.permission.PermissionControllerManager.HIBERNATION_ELIGIBILITY_ELIGIBLE
|
||||
import android.permission.PermissionControllerManager.HIBERNATION_ELIGIBILITY_EXEMPT_BY_SYSTEM
|
||||
import android.provider.DeviceConfig.NAMESPACE_APP_HIBERNATION
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.settings.R
|
||||
import com.android.settings.Utils.PROPERTY_APP_HIBERNATION_ENABLED
|
||||
import com.android.settings.Utils.PROPERTY_HIBERNATION_TARGETS_PRE_S_APPS
|
||||
import com.android.settings.testutils.TestDeviceConfig
|
||||
import com.android.settingslib.spaprivileged.framework.common.appHibernationManager
|
||||
import com.android.settingslib.spaprivileged.framework.common.appOpsManager
|
||||
import com.android.settingslib.spaprivileged.framework.common.permissionControllerManager
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.any
|
||||
import org.mockito.Mockito.anyBoolean
|
||||
import org.mockito.Mockito.anyString
|
||||
import org.mockito.Mockito.doAnswer
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.eq
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Spy
|
||||
import org.mockito.junit.MockitoJUnit
|
||||
import org.mockito.junit.MockitoRule
|
||||
import java.util.function.IntConsumer
|
||||
import org.mockito.Mockito.`when` as whenever
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HibernationSwitchPreferenceTest {
|
||||
|
||||
@JvmField
|
||||
@Rule
|
||||
val mockito: MockitoRule = MockitoJUnit.rule()
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Spy
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Mock
|
||||
private lateinit var permissionControllerManager: PermissionControllerManager
|
||||
|
||||
@Mock
|
||||
private lateinit var appOpsManager: AppOpsManager
|
||||
|
||||
@Mock
|
||||
private lateinit var appHibernationManager: AppHibernationManager
|
||||
|
||||
private val hibernationEnabledConfig =
|
||||
TestDeviceConfig(NAMESPACE_APP_HIBERNATION, PROPERTY_APP_HIBERNATION_ENABLED)
|
||||
|
||||
private val hibernationTargetsPreSConfig =
|
||||
TestDeviceConfig(NAMESPACE_APP_HIBERNATION, PROPERTY_HIBERNATION_TARGETS_PRE_S_APPS)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hibernationEnabledConfig.override(true)
|
||||
hibernationTargetsPreSConfig.override(false)
|
||||
doReturn(context)
|
||||
.`when`(context).createContextAsUser(UserHandle.getUserHandleForUid(UID), 0)
|
||||
whenever(context.permissionControllerManager).thenReturn(permissionControllerManager)
|
||||
whenever(context.appOpsManager).thenReturn(appOpsManager)
|
||||
whenever(context.appHibernationManager).thenReturn(appHibernationManager)
|
||||
mockHibernationEligibility(HIBERNATION_ELIGIBILITY_ELIGIBLE)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
hibernationEnabledConfig.reset()
|
||||
hibernationTargetsPreSConfig.reset()
|
||||
}
|
||||
|
||||
private fun mockHibernationEligibility(eligibility: Int) {
|
||||
doAnswer {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(it.arguments[2] as IntConsumer).accept(eligibility)
|
||||
}.`when`(permissionControllerManager).getHibernationEligibility(
|
||||
eq(PACKAGE_NAME), any(), any()
|
||||
)
|
||||
}
|
||||
|
||||
private fun mockOpsMode(mode: Int) {
|
||||
whenever(
|
||||
appOpsManager.checkOpNoThrow(OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED, UID, PACKAGE_NAME)
|
||||
).thenReturn(mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Hibernation disabled - not display`() {
|
||||
hibernationEnabledConfig.override(false)
|
||||
|
||||
setContent()
|
||||
|
||||
composeTestRule.onRoot().assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Not eligible - displayed but disabled`() {
|
||||
mockHibernationEligibility(HIBERNATION_ELIGIBILITY_EXEMPT_BY_SYSTEM)
|
||||
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.unused_apps_switch))
|
||||
.assertIsDisplayed()
|
||||
.assertIsNotEnabled()
|
||||
.assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app targets Q with ops mode default when hibernation targets pre S - not exempted`() {
|
||||
mockOpsMode(MODE_DEFAULT)
|
||||
hibernationTargetsPreSConfig.override(true)
|
||||
|
||||
setContent(TARGET_Q_APP)
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled().assertIsOn()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app targets Q with ops mode default when hibernation targets R - exempted`() {
|
||||
mockOpsMode(MODE_DEFAULT)
|
||||
hibernationTargetsPreSConfig.override(false)
|
||||
|
||||
setContent(TARGET_Q_APP)
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled().assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app targets R with ops mode default - not exempted`() {
|
||||
mockOpsMode(MODE_DEFAULT)
|
||||
|
||||
setContent(TARGET_R_APP)
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled().assertIsOn()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app with ops mode allowed - not exempted`() {
|
||||
mockOpsMode(MODE_ALLOWED)
|
||||
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled().assertIsOn()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app with ops mode ignored - exempted`() {
|
||||
mockOpsMode(MODE_IGNORED)
|
||||
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled().assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app is exempted - on click`() {
|
||||
mockOpsMode(MODE_IGNORED)
|
||||
|
||||
setContent()
|
||||
composeTestRule.onRoot().performClick()
|
||||
|
||||
verify(appOpsManager).setUidMode(OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED, UID, MODE_ALLOWED)
|
||||
verify(appHibernationManager, never()).setHibernatingForUser(anyString(), anyBoolean())
|
||||
verify(appHibernationManager, never()).setHibernatingGlobally(anyString(), anyBoolean())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `An app is not exempted - on click`() {
|
||||
mockOpsMode(MODE_ALLOWED)
|
||||
|
||||
setContent()
|
||||
composeTestRule.onRoot().performClick()
|
||||
|
||||
verify(appOpsManager).setUidMode(OP_AUTO_REVOKE_PERMISSIONS_IF_UNUSED, UID, MODE_IGNORED)
|
||||
verify(appHibernationManager).setHibernatingForUser(PACKAGE_NAME, false)
|
||||
verify(appHibernationManager).setHibernatingGlobally(PACKAGE_NAME, false)
|
||||
}
|
||||
|
||||
private fun setContent(app: ApplicationInfo = TARGET_R_APP) {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalContext provides context) {
|
||||
HibernationSwitchPreference(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PACKAGE_NAME = "package name"
|
||||
const val UID = 123
|
||||
|
||||
val TARGET_R_APP = ApplicationInfo().apply {
|
||||
packageName = PACKAGE_NAME
|
||||
uid = UID
|
||||
targetSdkVersion = Build.VERSION_CODES.R
|
||||
}
|
||||
val TARGET_Q_APP = ApplicationInfo().apply {
|
||||
packageName = PACKAGE_NAME
|
||||
uid = UID
|
||||
targetSdkVersion = Build.VERSION_CODES.Q
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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.testutils
|
||||
|
||||
import android.provider.DeviceConfig
|
||||
|
||||
/**
|
||||
* A util class used to override [DeviceConfig] value for testing purpose.
|
||||
*/
|
||||
class TestDeviceConfig(private val namespace: String, private val name: String) {
|
||||
private val initialValue = DeviceConfig.getProperty(namespace, name)
|
||||
|
||||
/** Overrides the property value. */
|
||||
fun override(value: Boolean) {
|
||||
DeviceConfig.setProperty(namespace, name, value.toString(), false)
|
||||
}
|
||||
|
||||
/** Resets the property to its initial value before the testing. */
|
||||
fun reset() {
|
||||
DeviceConfig.setProperty(namespace, name, initialValue, false)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user