From daa98eabe141b2b22ecbd9049f88984e97ce5dff Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Fri, 25 Nov 2022 13:29:35 +0800 Subject: Add semantics for RestrictedSwitchPreference Add ToggleableState when BaseUserRestricted or BlockedByAdmin. Bug: 235727273 Test: Unit test Test: Manually when Talkback is on Change-Id: Iac68c0c726add6a1e57910f0953dde7805d23e71 --- packages/SettingsLib/Spa/spa/Android.bp | 1 + .../model/enterprise/RestrictionsProvider.kt | 62 +++++--- .../template/app/TogglePermissionAppListPage.kt | 17 +- .../preference/RestrictedSwitchPreference.kt | 70 ++++++--- .../preference/RestrictedSwitchPreferenceTest.kt | 175 +++++++++++++++++++++ 5 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreferenceTest.kt diff --git a/packages/SettingsLib/Spa/spa/Android.bp b/packages/SettingsLib/Spa/spa/Android.bp index eb7aaa71fc7b..3ea3b5ccc4f5 100644 --- a/packages/SettingsLib/Spa/spa/Android.bp +++ b/packages/SettingsLib/Spa/spa/Android.bp @@ -33,6 +33,7 @@ android_library { "androidx.compose.runtime_runtime-livedata", "androidx.compose.ui_ui-tooling-preview", "androidx.lifecycle_lifecycle-livedata-ktx", + "androidx.lifecycle_lifecycle-runtime-compose", "androidx.navigation_navigation-compose", "com.google.android.material_material", "lottie_compose", diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/enterprise/RestrictionsProvider.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/enterprise/RestrictionsProvider.kt index b1adc9d27a88..a618c3d0575b 100644 --- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/enterprise/RestrictionsProvider.kt +++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/enterprise/RestrictionsProvider.kt @@ -20,27 +20,41 @@ import android.app.admin.DevicePolicyResources.Strings.Settings import android.content.Context import android.os.UserHandle import android.os.UserManager -import androidx.lifecycle.liveData +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.settingslib.RestrictedLockUtils import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin import com.android.settingslib.RestrictedLockUtilsInternal import com.android.settingslib.spaprivileged.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn data class Restrictions( val userId: Int, val keys: List, ) -sealed class RestrictedMode +sealed interface RestrictedMode -object NoRestricted : RestrictedMode() +object NoRestricted : RestrictedMode -object BaseUserRestricted : RestrictedMode() +object BaseUserRestricted : RestrictedMode -data class BlockedByAdmin( - val enterpriseRepository: EnterpriseRepository, - val enforcedAdmin: EnforcedAdmin, -) : RestrictedMode() { - fun getSummary(checked: Boolean?): String = when (checked) { +interface BlockedByAdmin : RestrictedMode { + fun getSummary(checked: Boolean?): String + fun sendShowAdminSupportDetailsIntent() +} + +private data class BlockedByAdminImpl( + private val context: Context, + private val enforcedAdmin: EnforcedAdmin, +) : BlockedByAdmin { + private val enterpriseRepository by lazy { EnterpriseRepository(context) } + + override fun getSummary(checked: Boolean?) = when (checked) { true -> enterpriseRepository.getEnterpriseString( Settings.ENABLED_BY_ADMIN_SWITCH_SUMMARY, R.string.enabled_by_admin ) @@ -49,18 +63,31 @@ data class BlockedByAdmin( ) else -> "" } + + override fun sendShowAdminSupportDetailsIntent() { + RestrictedLockUtils.sendShowAdminSupportDetailsIntent(context, enforcedAdmin) + } +} + +interface RestrictionsProvider { + @Composable + fun restrictedModeState(): State } -class RestrictionsProvider( +internal class RestrictionsProviderImpl( private val context: Context, private val restrictions: Restrictions, -) { +) : RestrictionsProvider { private val userManager by lazy { UserManager.get(context) } - private val enterpriseRepository by lazy { EnterpriseRepository(context) } - val restrictedMode = liveData { + private val restrictedMode = flow { emit(getRestrictedMode()) - } + }.flowOn(Dispatchers.IO) + + @OptIn(ExperimentalLifecycleComposeApi::class) + @Composable + override fun restrictedModeState() = + restrictedMode.collectAsStateWithLifecycle(initialValue = null) private fun getRestrictedMode(): RestrictedMode { for (key in restrictions.keys) { @@ -71,12 +98,7 @@ class RestrictionsProvider( for (key in restrictions.keys) { RestrictedLockUtilsInternal .checkIfRestrictionEnforced(context, key, restrictions.userId) - ?.let { - return BlockedByAdmin( - enterpriseRepository = enterpriseRepository, - enforcedAdmin = it, - ) - } + ?.let { return BlockedByAdminImpl(context = context, enforcedAdmin = it) } } return NoRestricted } diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/TogglePermissionAppListPage.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/TogglePermissionAppListPage.kt index ec7d75e969df..6db27336b67f 100644 --- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/TogglePermissionAppListPage.kt +++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/TogglePermissionAppListPage.kt @@ -22,7 +22,6 @@ import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -43,7 +42,7 @@ import com.android.settingslib.spaprivileged.model.app.AppListModel import com.android.settingslib.spaprivileged.model.app.AppRecord import com.android.settingslib.spaprivileged.model.app.userId import com.android.settingslib.spaprivileged.model.enterprise.Restrictions -import com.android.settingslib.spaprivileged.model.enterprise.RestrictionsProvider +import com.android.settingslib.spaprivileged.model.enterprise.RestrictionsProviderImpl import com.android.settingslib.spaprivileged.template.preference.RestrictedSwitchPreference import kotlinx.coroutines.flow.Flow @@ -146,9 +145,7 @@ internal class TogglePermissionInternalAppListModel( listModel.filter(userIdFlow, recordListFlow) @Composable - override fun getSummary(option: Int, record: T): State { - return getSummary(record) - } + override fun getSummary(option: Int, record: T) = getSummary(record) @Composable fun getSummary(record: T): State { @@ -157,27 +154,27 @@ internal class TogglePermissionInternalAppListModel( userId = record.app.userId, keys = listModel.switchRestrictionKeys, ) - RestrictionsProvider(context, restrictions) + RestrictionsProviderImpl(context, restrictions) } - val restrictedMode = restrictionsProvider.restrictedMode.observeAsState() + val restrictedMode = restrictionsProvider.restrictedModeState() val allowed = listModel.isAllowed(record) return remember { derivedStateOf { RestrictedSwitchPreference.getSummary( context = context, restrictedMode = restrictedMode.value, - noRestrictedSummary = getNoRestrictedSummary(allowed), + summaryIfNoRestricted = getSummaryIfNoRestricted(allowed), checked = allowed, ).value } } } - private fun getNoRestrictedSummary(allowed: State) = derivedStateOf { + private fun getSummaryIfNoRestricted(allowed: State) = derivedStateOf { when (allowed.value) { true -> context.getString(R.string.app_permission_summary_allowed) false -> context.getString(R.string.app_permission_summary_not_allowed) - else -> context.getString(R.string.summary_placeholder) + null -> context.getString(R.string.summary_placeholder) } } } diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreference.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreference.kt index 31fd3ad6d521..a003da81e06d 100644 --- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreference.kt +++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreference.kt @@ -22,12 +22,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role -import com.android.settingslib.RestrictedLockUtils +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import com.android.settingslib.spa.framework.compose.stateOf import com.android.settingslib.spa.widget.preference.SwitchPreference import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel @@ -38,32 +39,44 @@ import com.android.settingslib.spaprivileged.model.enterprise.NoRestricted import com.android.settingslib.spaprivileged.model.enterprise.RestrictedMode import com.android.settingslib.spaprivileged.model.enterprise.Restrictions import com.android.settingslib.spaprivileged.model.enterprise.RestrictionsProvider +import com.android.settingslib.spaprivileged.model.enterprise.RestrictionsProviderImpl @Composable fun RestrictedSwitchPreference(model: SwitchPreferenceModel, restrictions: Restrictions) { + RestrictedSwitchPreferenceImpl(model, restrictions, ::RestrictionsProviderImpl) +} + +@Composable +internal fun RestrictedSwitchPreferenceImpl( + model: SwitchPreferenceModel, + restrictions: Restrictions, + restrictionsProviderFactory: (Context, Restrictions) -> RestrictionsProvider, +) { if (restrictions.keys.isEmpty()) { SwitchPreference(model) return } val context = LocalContext.current - val restrictionsProvider = remember { RestrictionsProvider(context, restrictions) } - val restrictedMode = restrictionsProvider.restrictedMode.observeAsState().value ?: return + val restrictionsProvider = remember(restrictions) { + restrictionsProviderFactory(context, restrictions) + } + val restrictedMode = restrictionsProvider.restrictedModeState().value val restrictedSwitchModel = remember(restrictedMode) { RestrictedSwitchPreferenceModel(context, model, restrictedMode) } - Box(remember { restrictedSwitchModel.getModifier() }) { + restrictedSwitchModel.RestrictionWrapper { SwitchPreference(restrictedSwitchModel) } } -object RestrictedSwitchPreference { +internal object RestrictedSwitchPreference { fun getSummary( context: Context, restrictedMode: RestrictedMode?, - noRestrictedSummary: State, + summaryIfNoRestricted: State, checked: State, ): State = when (restrictedMode) { - is NoRestricted -> noRestrictedSummary + is NoRestricted -> summaryIfNoRestricted is BaseUserRestricted -> stateOf(context.getString(R.string.disabled)) is BlockedByAdmin -> derivedStateOf { restrictedMode.getSummary(checked.value) } null -> stateOf(context.getString(R.string.summary_placeholder)) @@ -71,43 +84,64 @@ object RestrictedSwitchPreference { } private class RestrictedSwitchPreferenceModel( - private val context: Context, + context: Context, model: SwitchPreferenceModel, - private val restrictedMode: RestrictedMode, + private val restrictedMode: RestrictedMode?, ) : SwitchPreferenceModel { override val title = model.title override val summary = RestrictedSwitchPreference.getSummary( context = context, restrictedMode = restrictedMode, - noRestrictedSummary = model.summary, + summaryIfNoRestricted = model.summary, checked = model.checked, ) override val checked = when (restrictedMode) { + null -> stateOf(null) is NoRestricted -> model.checked is BaseUserRestricted -> stateOf(false) is BlockedByAdmin -> model.checked } override val changeable = when (restrictedMode) { + null -> stateOf(false) is NoRestricted -> model.changeable is BaseUserRestricted -> stateOf(false) is BlockedByAdmin -> stateOf(false) } override val onCheckedChange = when (restrictedMode) { + null -> null is NoRestricted -> model.onCheckedChange - is BaseUserRestricted -> null + // Need to pass a non null onCheckedChange to enable semantics ToggleableState, although + // since changeable is false this will not be called. + is BaseUserRestricted -> model.onCheckedChange + // Pass null since semantics ToggleableState is provided in RestrictionWrapper. is BlockedByAdmin -> null } - fun getModifier(): Modifier = when (restrictedMode) { - is BlockedByAdmin -> Modifier.clickable(role = Role.Switch) { - RestrictedLockUtils.sendShowAdminSupportDetailsIntent( - context, restrictedMode.enforcedAdmin - ) + @Composable + fun RestrictionWrapper(content: @Composable () -> Unit) { + if (restrictedMode !is BlockedByAdmin) { + content() + return } - else -> Modifier + Box( + Modifier + .clickable( + role = Role.Switch, + onClick = { restrictedMode.sendShowAdminSupportDetailsIntent() }, + ) + .semantics { + this.toggleableState = ToggleableState(checked.value) + }, + ) { content() } + } + + private fun ToggleableState(value: Boolean?) = when (value) { + true -> ToggleableState.On + false -> ToggleableState.Off + null -> ToggleableState.Indeterminate } } diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreferenceTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreferenceTest.kt new file mode 100644 index 000000000000..a5352b2f575d --- /dev/null +++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/preference/RestrictedSwitchPreferenceTest.kt @@ -0,0 +1,175 @@ +/* + * 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.settingslib.spaprivileged.template.preference + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.isOff +import androidx.compose.ui.test.isOn +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.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spa.framework.compose.stateOf +import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel +import com.android.settingslib.spaprivileged.model.enterprise.BaseUserRestricted +import com.android.settingslib.spaprivileged.model.enterprise.BlockedByAdmin +import com.android.settingslib.spaprivileged.model.enterprise.NoRestricted +import com.android.settingslib.spaprivileged.model.enterprise.RestrictedMode +import com.android.settingslib.spaprivileged.model.enterprise.Restrictions +import com.android.settingslib.spaprivileged.model.enterprise.RestrictionsProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RestrictedSwitchPreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val fakeBlockedByAdmin = object : BlockedByAdmin { + var sendShowAdminSupportDetailsIntentIsCalled = false + + override fun getSummary(checked: Boolean?) = BLOCKED_BY_ADMIN_SUMMARY + + override fun sendShowAdminSupportDetailsIntent() { + sendShowAdminSupportDetailsIntentIsCalled = true + } + } + + private val fakeRestrictionsProvider = FakeRestrictionsProvider() + + private val switchPreferenceModel = object : SwitchPreferenceModel { + override val title = TITLE + override val checked = mutableStateOf(true) + override val onCheckedChange: (Boolean) -> Unit = { checked.value = it } + } + + @Test + fun whenRestrictionsKeysIsEmpty_enabled() { + val restrictions = Restrictions(userId = USER_ID, keys = emptyList()) + + setContent(restrictions) + + composeTestRule.onNodeWithText(TITLE).assertIsDisplayed().assertIsEnabled() + composeTestRule.onNode(isOn()).assertIsDisplayed() + } + + @Test + fun whenRestrictionsKeysIsEmpty_toggleable() { + val restrictions = Restrictions(userId = USER_ID, keys = emptyList()) + + setContent(restrictions) + composeTestRule.onRoot().performClick() + + composeTestRule.onNode(isOff()).assertIsDisplayed() + } + + @Test + fun whenNoRestricted_enabled() { + val restrictions = Restrictions(userId = USER_ID, keys = listOf(RESTRICTION_KEY)) + fakeRestrictionsProvider.restrictedMode = NoRestricted + + setContent(restrictions) + + composeTestRule.onNodeWithText(TITLE).assertIsDisplayed().assertIsEnabled() + composeTestRule.onNode(isOn()).assertIsDisplayed() + } + + @Test + fun whenNoRestricted_toggleable() { + val restrictions = Restrictions(userId = USER_ID, keys = listOf(RESTRICTION_KEY)) + fakeRestrictionsProvider.restrictedMode = NoRestricted + + setContent(restrictions) + composeTestRule.onRoot().performClick() + + composeTestRule.onNode(isOff()).assertIsDisplayed() + } + + @Test + fun whenBaseUserRestricted_disabled() { + val restrictions = Restrictions(userId = USER_ID, keys = listOf(RESTRICTION_KEY)) + fakeRestrictionsProvider.restrictedMode = BaseUserRestricted + + setContent(restrictions) + + composeTestRule.onNodeWithText(TITLE).assertIsDisplayed().assertIsNotEnabled() + composeTestRule.onNode(isOff()).assertIsDisplayed() + } + + @Test + fun whenBaseUserRestricted_notToggleable() { + val restrictions = Restrictions(userId = USER_ID, keys = listOf(RESTRICTION_KEY)) + fakeRestrictionsProvider.restrictedMode = BaseUserRestricted + + setContent(restrictions) + composeTestRule.onRoot().performClick() + + composeTestRule.onNode(isOff()).assertIsDisplayed() + } + + @Test + fun whenBlockedByAdmin_disabled() { + val restrictions = Restrictions(userId = USER_ID, keys = listOf(RESTRICTION_KEY)) + fakeRestrictionsProvider.restrictedMode = fakeBlockedByAdmin + + setContent(restrictions) + + composeTestRule.onNodeWithText(TITLE).assertIsDisplayed().assertIsEnabled() + composeTestRule.onNodeWithText(BLOCKED_BY_ADMIN_SUMMARY).assertIsDisplayed() + composeTestRule.onNode(isOn()).assertIsDisplayed() + } + + @Test + fun whenBlockedByAdmin_click() { + val restrictions = Restrictions(userId = USER_ID, keys = listOf(RESTRICTION_KEY)) + fakeRestrictionsProvider.restrictedMode = fakeBlockedByAdmin + + setContent(restrictions) + composeTestRule.onRoot().performClick() + + assertThat(fakeBlockedByAdmin.sendShowAdminSupportDetailsIntentIsCalled).isTrue() + } + + private fun setContent(restrictions: Restrictions) { + composeTestRule.setContent { + RestrictedSwitchPreferenceImpl(switchPreferenceModel, restrictions) { _, _ -> + fakeRestrictionsProvider + } + } + } + + private companion object { + const val TITLE = "Title" + const val USER_ID = 0 + const val RESTRICTION_KEY = "restriction_key" + const val BLOCKED_BY_ADMIN_SUMMARY = "Blocked by admin" + } +} + +private class FakeRestrictionsProvider : RestrictionsProvider { + var restrictedMode: RestrictedMode? = null + + @Composable + override fun restrictedModeState() = stateOf(restrictedMode) +} -- cgit v1.2.3-59-g8ed1b