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

Commit 33866840 authored by Fabián Kozynski's avatar Fabián Kozynski Committed by Fabian Kozynski
Browse files

Add accessibility for new tiles.

Accessibility is as follows:

* For small tiles, we use the content description and the state
  description to indicate the current name and state. If the tile is a
  toggle (as determined by its accessibility class name) we add
  ToggleableState (and the corresponding role).
* For large tiles, we use the text in the label, as well as the state
  description. If the secondary label content is contained in the state
  description, we ignore it for a11y as it will be read
  (stateDescription has precedence because it will be read on state
  changes).
* For single target large tiles, their role and ToggleableState is the
  same as for small tiles.
* For dual target large tiles, they should always be a Button, with the
  dual target being a Toggle. The content description and state
  description is also applied to the toggle button.
* Long press actions have the correct description label.

Test: atest PlatformScenarioTests
Test: atest TileUiStateTest
Bug: 359523013
Flag: com.android.systemui.qs_ui_refactor_compose_fragment

Change-Id: I178873fb813e4986e88c71f19be319d7fbb7edd8
parent 0d2313c2
Loading
Loading
Loading
Loading
+270 −0
Original line number Original line Diff line number Diff line
/*
 * 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.systemui.qs.panels.ui.viewmodel

import android.content.res.Resources
import android.content.res.mainResources
import android.service.quicksettings.Tile
import android.widget.Button
import android.widget.Switch
import androidx.compose.ui.semantics.Role
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class TileUiStateTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val resources: Resources
        get() = kosmos.mainResources

    @Test
    fun stateUnavailable_secondaryLabelNotmodified() {
        val testString = "TEST STRING"
        val state =
            QSTile.State().apply {
                state = Tile.STATE_UNAVAILABLE
                secondaryLabel = testString
            }

        val uiState = state.toUiState()

        assertThat(uiState.state).isEqualTo(Tile.STATE_UNAVAILABLE)
    }

    @Test
    fun accessibilityRole_switch() {
        val stateSwitch =
            QSTile.State().apply { expandedAccessibilityClassName = Switch::class.java.name }
        val uiState = stateSwitch.toUiState()
        assertThat(uiState.accessibilityRole).isEqualTo(Role.Switch)
    }

    @Test
    fun accessibilityRole_button() {
        val stateButton =
            QSTile.State().apply { expandedAccessibilityClassName = Button::class.java.name }
        val uiState = stateButton.toUiState()
        assertThat(uiState.accessibilityRole).isEqualTo(Role.Button)
    }

    @Test
    fun accessibilityRole_switchWithSecondaryClick() {
        val stateSwitchWithSecondaryClick =
            QSTile.State().apply {
                expandedAccessibilityClassName = Switch::class.java.name
                handlesSecondaryClick = true
            }
        val uiState = stateSwitchWithSecondaryClick.toUiState()
        assertThat(uiState.accessibilityRole).isEqualTo(Role.Button)
    }

    @Test
    fun switchInactive_secondaryLabelNotModified() {
        val testString = "TEST STRING"
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Switch::class.java.name
                state = Tile.STATE_INACTIVE
                secondaryLabel = testString
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEqualTo(testString)
    }

    @Test
    fun switchActive_secondaryLabelNotModified() {
        val testString = "TEST STRING"
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Switch::class.java.name
                state = Tile.STATE_ACTIVE
                secondaryLabel = testString
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEqualTo(testString)
    }

    @Test
    fun buttonInactive_secondaryLabelNotModifiedWhenEmpty() {
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Button::class.java.name
                state = Tile.STATE_INACTIVE
                secondaryLabel = ""
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEmpty()
    }

    @Test
    fun buttonActive_secondaryLabelNotModifiedWhenEmpty() {
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Button::class.java.name
                state = Tile.STATE_ACTIVE
                secondaryLabel = ""
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEmpty()
    }

    @Test
    fun buttonUnavailable_emptySecondaryLabel_default() {
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Button::class.java.name
                state = Tile.STATE_UNAVAILABLE
                secondaryLabel = ""
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEqualTo(resources.getString(R.string.tile_unavailable))
    }

    @Test
    fun switchUnavailable_emptySecondaryLabel_defaultUnavailable() {
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Switch::class.java.name
                state = Tile.STATE_UNAVAILABLE
                secondaryLabel = ""
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEqualTo(resources.getString(R.string.tile_unavailable))
    }

    @Test
    fun switchInactive_emptySecondaryLabel_defaultOff() {
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Switch::class.java.name
                state = Tile.STATE_INACTIVE
                secondaryLabel = ""
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEqualTo(resources.getString(R.string.switch_bar_off))
    }

    @Test
    fun switchActive_emptySecondaryLabel_defaultOn() {
        val state =
            QSTile.State().apply {
                expandedAccessibilityClassName = Switch::class.java.name
                state = Tile.STATE_ACTIVE
                secondaryLabel = ""
            }

        val uiState = state.toUiState()

        assertThat(uiState.secondaryLabel).isEqualTo(resources.getString(R.string.switch_bar_on))
    }

    @Test
    fun disabledByPolicy_inactive_appearsAsUnavailable() {
        val stateDisabledByPolicy =
            QSTile.State().apply {
                state = Tile.STATE_INACTIVE
                disabledByPolicy = true
            }

        val uiState = stateDisabledByPolicy.toUiState()

        assertThat(uiState.state).isEqualTo(Tile.STATE_UNAVAILABLE)
    }

    @Test
    fun disabledByPolicy_active_appearsAsUnavailable() {
        val stateDisabledByPolicy =
            QSTile.State().apply {
                state = Tile.STATE_ACTIVE
                disabledByPolicy = true
            }

        val uiState = stateDisabledByPolicy.toUiState()

        assertThat(uiState.state).isEqualTo(Tile.STATE_UNAVAILABLE)
    }

    @Test
    fun disabledByPolicy_clickLabel() {
        val stateDisabledByPolicy =
            QSTile.State().apply {
                state = Tile.STATE_INACTIVE
                disabledByPolicy = true
            }

        val uiState = stateDisabledByPolicy.toUiState()
        assertThat(uiState.accessibilityUiState.clickLabel)
            .isEqualTo(
                resources.getString(
                    R.string.accessibility_tile_disabled_by_policy_action_description
                )
            )
    }

    @Test
    fun notDisabledByPolicy_clickLabel_null() {
        val stateDisabledByPolicy =
            QSTile.State().apply {
                state = Tile.STATE_INACTIVE
                disabledByPolicy = false
            }

        val uiState = stateDisabledByPolicy.toUiState()
        assertThat(uiState.accessibilityUiState.clickLabel).isNull()
    }

    @Test
    fun disabledByPolicy_unavailableInStateDescription() {
        val state =
            QSTile.State().apply {
                disabledByPolicy = true
                state = Tile.STATE_INACTIVE
            }

        val uiState = state.toUiState()
        assertThat(uiState.accessibilityUiState.stateDescription)
            .contains(resources.getString(R.string.tile_unavailable))
    }

    private fun QSTile.State.toUiState() = toUiState(resources)
}

private val TileUiState.accessibilityRole: Role
    get() = accessibilityUiState.accessibilityRole
+13 −12
Original line number Original line Diff line number Diff line
@@ -158,7 +158,7 @@ constructor(
    override fun onCreateView(
    override fun onCreateView(
        inflater: LayoutInflater,
        inflater: LayoutInflater,
        container: ViewGroup?,
        container: ViewGroup?,
        savedInstanceState: Bundle?
        savedInstanceState: Bundle?,
    ): View {
    ): View {
        val context = inflater.context
        val context = inflater.context
        return ComposeView(context).apply {
        return ComposeView(context).apply {
@@ -181,7 +181,7 @@ constructor(
                                    notificationScrimClippingParams.bottom,
                                    notificationScrimClippingParams.bottom,
                                    notificationScrimClippingParams.radius,
                                    notificationScrimClippingParams.radius,
                                )
                                )
                            }
                            },
                    ) {
                    ) {
                        AnimatedContent(targetState = qsState) {
                        AnimatedContent(targetState = qsState) {
                            when (it) {
                            when (it) {
@@ -272,7 +272,7 @@ constructor(
        qsExpansionFraction: Float,
        qsExpansionFraction: Float,
        panelExpansionFraction: Float,
        panelExpansionFraction: Float,
        headerTranslation: Float,
        headerTranslation: Float,
        squishinessFraction: Float
        squishinessFraction: Float,
    ) {
    ) {
        viewModel.qsExpansionValue = qsExpansionFraction
        viewModel.qsExpansionValue = qsExpansionFraction
        viewModel.panelExpansionFractionValue = panelExpansionFraction
        viewModel.panelExpansionFractionValue = panelExpansionFraction
@@ -318,12 +318,12 @@ constructor(
    override fun setTransitionToFullShadeProgress(
    override fun setTransitionToFullShadeProgress(
        isTransitioningToFullShade: Boolean,
        isTransitioningToFullShade: Boolean,
        qsTransitionFraction: Float,
        qsTransitionFraction: Float,
        qsSquishinessFraction: Float
        qsSquishinessFraction: Float,
    ) {
    ) {
        super.setTransitionToFullShadeProgress(
        super.setTransitionToFullShadeProgress(
            isTransitioningToFullShade,
            isTransitioningToFullShade,
            qsTransitionFraction,
            qsTransitionFraction,
            qsSquishinessFraction
            qsSquishinessFraction,
        )
        )
    }
    }


@@ -334,7 +334,7 @@ constructor(
        bottom: Int,
        bottom: Int,
        cornerRadius: Int,
        cornerRadius: Int,
        visible: Boolean,
        visible: Boolean,
        fullWidth: Boolean
        fullWidth: Boolean,
    ) {
    ) {
        notificationScrimClippingParams.isEnabled = visible
        notificationScrimClippingParams.isEnabled = visible
        notificationScrimClippingParams.top = top
        notificationScrimClippingParams.top = top
@@ -402,7 +402,7 @@ constructor(
                launch {
                launch {
                    setListenerJob(
                    setListenerJob(
                        heightListener,
                        heightListener,
                        viewModel.containerViewModel.editModeViewModel.isEditing
                        viewModel.containerViewModel.editModeViewModel.isEditing,
                    ) {
                    ) {
                        onQsHeightChanged()
                        onQsHeightChanged()
                    }
                    }
@@ -410,7 +410,7 @@ constructor(
                launch {
                launch {
                    setListenerJob(
                    setListenerJob(
                        qsContainerController,
                        qsContainerController,
                        viewModel.containerViewModel.editModeViewModel.isEditing
                        viewModel.containerViewModel.editModeViewModel.isEditing,
                    ) {
                    ) {
                        setCustomizerShowing(it)
                        setCustomizerShowing(it)
                    }
                    }
@@ -422,6 +422,7 @@ constructor(
    @Composable
    @Composable
    private fun QuickQuickSettingsElement() {
    private fun QuickQuickSettingsElement() {
        val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
        val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
        val bottomPadding = dimensionResource(id = R.dimen.qqs_layout_padding_bottom)
        DisposableEffect(Unit) {
        DisposableEffect(Unit) {
            qqsVisible.value = true
            qqsVisible.value = true


@@ -441,7 +442,7 @@ constructor(
                            )
                            )
                        }
                        }
                        .onSizeChanged { size -> qqsHeight.value = size.height }
                        .onSizeChanged { size -> qqsHeight.value = size.height }
                        .padding(top = { qqsPadding })
                        .padding(top = { qqsPadding }, bottom = { bottomPadding.roundToPx() })
            ) {
            ) {
                val qsEnabled by viewModel.qsEnabled.collectAsStateWithLifecycle()
                val qsEnabled by viewModel.qsEnabled.collectAsStateWithLifecycle()
                if (qsEnabled) {
                if (qsEnabled) {
@@ -450,7 +451,7 @@ constructor(
                        modifier =
                        modifier =
                            Modifier.collapseExpandSemanticAction(
                            Modifier.collapseExpandSemanticAction(
                                stringResource(id = R.string.accessibility_quick_settings_expand)
                                stringResource(id = R.string.accessibility_quick_settings_expand)
                            )
                            ),
                    )
                    )
                }
                }
            }
            }
@@ -482,7 +483,7 @@ constructor(
                    FooterActions(
                    FooterActions(
                        viewModel = viewModel.footerActionsViewModel,
                        viewModel = viewModel.footerActionsViewModel,
                        qsVisibilityLifecycleOwner = this@QSFragmentCompose,
                        qsVisibilityLifecycleOwner = this@QSFragmentCompose,
                        modifier = Modifier.sysuiResTag("qs_footer_actions")
                        modifier = Modifier.sysuiResTag("qs_footer_actions"),
                    )
                    )
                }
                }
            }
            }
@@ -562,7 +563,7 @@ private fun View.setBackPressedDispatcher() {
private suspend inline fun <Listener : Any, Data> setListenerJob(
private suspend inline fun <Listener : Any, Data> setListenerJob(
    listenerFlow: MutableStateFlow<Listener?>,
    listenerFlow: MutableStateFlow<Listener?>,
    dataFlow: Flow<Data>,
    dataFlow: Flow<Data>,
    crossinline onCollect: suspend Listener.(Data) -> Unit
    crossinline onCollect: suspend Listener.(Data) -> Unit,
) {
) {
    coroutineScope {
    coroutineScope {
        try {
        try {
+11 −9
Original line number Original line Diff line number Diff line
@@ -22,18 +22,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastMap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.qs.panels.ui.viewmodel.QuickQuickSettingsViewModel
import com.android.systemui.qs.panels.ui.viewmodel.QuickQuickSettingsViewModel


@Composable
@Composable
fun QuickQuickSettings(
fun QuickQuickSettings(viewModel: QuickQuickSettingsViewModel, modifier: Modifier = Modifier) {
    viewModel: QuickQuickSettingsViewModel,
    modifier: Modifier = Modifier,
) {
    val sizedTiles by
    val sizedTiles by
        viewModel.tileViewModels.collectAsStateWithLifecycle(initialValue = emptyList())
        viewModel.tileViewModels.collectAsStateWithLifecycle(initialValue = emptyList())
    val tiles = sizedTiles.map { it.tile }
    val tiles = sizedTiles.fastMap { it.tile }


    DisposableEffect(tiles) {
    DisposableEffect(tiles) {
        val token = Any()
        val token = Any()
@@ -44,14 +42,18 @@ fun QuickQuickSettings(


    TileLazyGrid(
    TileLazyGrid(
        modifier = modifier.sysuiResTag("qqs_tile_layout"),
        modifier = modifier.sysuiResTag("qqs_tile_layout"),
        columns = GridCells.Fixed(columns)
        columns = GridCells.Fixed(columns),
    ) {
    ) {
        items(
        items(
            tiles.size,
            sizedTiles.size,
            key = { index -> sizedTiles[index].tile.spec.spec },
            key = { index -> sizedTiles[index].tile.spec.spec },
            span = { index -> GridItemSpan(sizedTiles[index].width) }
            span = { index -> GridItemSpan(sizedTiles[index].width) },
        ) { index ->
        ) { index ->
            Tile(tile = tiles[index], iconOnly = sizedTiles[index].isIcon, modifier = Modifier)
            Tile(
                tile = sizedTiles[index].tile,
                iconOnly = sizedTiles[index].isIcon,
                modifier = Modifier,
            )
        }
        }
    }
    }
}
}
+112 −46

File changed.

Preview size limit exceeded, changes collapsed.

+78 −6
Original line number Original line Diff line number Diff line
@@ -16,8 +16,16 @@


package com.android.systemui.qs.panels.ui.viewmodel
package com.android.systemui.qs.panels.ui.viewmodel


import android.content.res.Resources
import android.service.quicksettings.Tile
import android.text.TextUtils
import android.widget.Switch
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.state.ToggleableState
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.qs.tileimpl.SubtitleArrayMapping
import com.android.systemui.res.R
import java.util.function.Supplier
import java.util.function.Supplier


@Immutable
@Immutable
@@ -27,14 +35,78 @@ data class TileUiState(
    val state: Int,
    val state: Int,
    val handlesSecondaryClick: Boolean,
    val handlesSecondaryClick: Boolean,
    val icon: Supplier<QSTile.Icon?>,
    val icon: Supplier<QSTile.Icon?>,
    val accessibilityUiState: AccessibilityUiState,
)
)


fun QSTile.State.toUiState(): TileUiState {
data class AccessibilityUiState(
    val contentDescription: String,
    val stateDescription: String,
    val accessibilityRole: Role,
    val toggleableState: ToggleableState? = null,
    val clickLabel: String? = null,
)

fun QSTile.State.toUiState(resources: Resources): TileUiState {
    val accessibilityRole =
        if (expandedAccessibilityClassName == Switch::class.java.name && !handlesSecondaryClick) {
            Role.Switch
        } else {
            Role.Button
        }
    // State handling and description
    val stateDescription = StringBuilder()
    val stateText =
        if (accessibilityRole == Role.Switch || state == Tile.STATE_UNAVAILABLE) {
            getStateText(resources)
        } else {
            ""
        }
    val secondaryLabel = getSecondaryLabel(stateText)
    if (!TextUtils.isEmpty(stateText)) {
        stateDescription.append(stateText)
    }
    if (disabledByPolicy && state != Tile.STATE_UNAVAILABLE) {
        stateDescription.append(", ")
        stateDescription.append(getUnavailableText(spec, resources))
    }
    if (
        !TextUtils.isEmpty(this.stateDescription) &&
            !stateDescription.contains(this.stateDescription!!)
    ) {
        stateDescription.append(", ")
        stateDescription.append(this.stateDescription)
    }
    val toggleableState =
        if (accessibilityRole == Role.Switch || handlesSecondaryClick) {
            ToggleableState(state == Tile.STATE_ACTIVE)
        } else {
            null
        }
    return TileUiState(
    return TileUiState(
        label?.toString() ?: "",
        label = label?.toString() ?: "",
        secondaryLabel?.toString() ?: "",
        secondaryLabel = secondaryLabel?.toString() ?: "",
        state,
        state = if (disabledByPolicy) Tile.STATE_UNAVAILABLE else state,
        handlesSecondaryClick,
        handlesSecondaryClick = handlesSecondaryClick,
        icon?.let { Supplier { icon } } ?: iconSupplier ?: Supplier { null },
        icon = icon?.let { Supplier { icon } } ?: iconSupplier ?: Supplier { null },
        AccessibilityUiState(
            contentDescription?.toString() ?: "",
            stateDescription.toString(),
            accessibilityRole,
            toggleableState,
            resources
                .getString(R.string.accessibility_tile_disabled_by_policy_action_description)
                .takeIf { disabledByPolicy },
        ),
    )
    )
}
}

private fun QSTile.State.getStateText(resources: Resources): CharSequence {
    val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
    val array = resources.getStringArray(arrayResId)
    return array[state]
}

private fun getUnavailableText(spec: String?, resources: Resources): String {
    val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
    return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE]
}
Loading