Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit daa98eab authored by Chaohui Wang's avatar Chaohui Wang
Browse files

Add semantics for RestrictedSwitchPreference

Add ToggleableState when BaseUserRestricted or BlockedByAdmin.

Bug: 235727273
Test: Unit test
Test: Manually when Talkback is on
Change-Id: Iac68c0c726add6a1e57910f0953dde7805d23e71
parent 5cf37839
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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",
+42 −20
Original line number Diff line number Diff line
@@ -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<String>,
)

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)
    }
}

class RestrictionsProvider(
interface RestrictionsProvider {
    @Composable
    fun restrictedModeState(): State<RestrictedMode?>
}

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
    }
+7 −10
Original line number Diff line number Diff line
@@ -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<T : AppRecord>(
        listModel.filter(userIdFlow, recordListFlow)

    @Composable
    override fun getSummary(option: Int, record: T): State<String> {
        return getSummary(record)
    }
    override fun getSummary(option: Int, record: T) = getSummary(record)

    @Composable
    fun getSummary(record: T): State<String> {
@@ -157,27 +154,27 @@ internal class TogglePermissionInternalAppListModel<T : AppRecord>(
                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<Boolean?>) = derivedStateOf {
    private fun getSummaryIfNoRestricted(allowed: State<Boolean?>) = 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)
        }
    }
}
+52 −18
Original line number Diff line number Diff line
@@ -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<String>,
        summaryIfNoRestricted: State<String>,
        checked: State<Boolean?>,
    ): State<String> = 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
        }
        Box(
            Modifier
                .clickable(
                    role = Role.Switch,
                    onClick = { restrictedMode.sendShowAdminSupportDetailsIntent() },
                )
                .semantics {
                    this.toggleableState = ToggleableState(checked.value)
                },
        ) { content() }
    }
        else -> Modifier

    private fun ToggleableState(value: Boolean?) = when (value) {
        true -> ToggleableState.On
        false -> ToggleableState.Off
        null -> ToggleableState.Indeterminate
    }
}
+175 −0
Original line number Diff line number Diff line
/*
 * 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)
}