Merge "Add accessibility for new tiles." into main
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiStateTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiStateTest.kt
new file mode 100644
index 0000000..b144f06
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiStateTest.kt
@@ -0,0 +1,270 @@
+/*
+ * 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
diff --git a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
index c2f1c3d..af167d4 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
@@ -158,7 +158,7 @@
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
- savedInstanceState: Bundle?
+ savedInstanceState: Bundle?,
): View {
val context = inflater.context
return ComposeView(context).apply {
@@ -181,7 +181,7 @@
notificationScrimClippingParams.bottom,
notificationScrimClippingParams.radius,
)
- }
+ },
) {
AnimatedContent(targetState = qsState) {
when (it) {
@@ -272,7 +272,7 @@
qsExpansionFraction: Float,
panelExpansionFraction: Float,
headerTranslation: Float,
- squishinessFraction: Float
+ squishinessFraction: Float,
) {
viewModel.qsExpansionValue = qsExpansionFraction
viewModel.panelExpansionFractionValue = panelExpansionFraction
@@ -318,12 +318,12 @@
override fun setTransitionToFullShadeProgress(
isTransitioningToFullShade: Boolean,
qsTransitionFraction: Float,
- qsSquishinessFraction: Float
+ qsSquishinessFraction: Float,
) {
super.setTransitionToFullShadeProgress(
isTransitioningToFullShade,
qsTransitionFraction,
- qsSquishinessFraction
+ qsSquishinessFraction,
)
}
@@ -334,7 +334,7 @@
bottom: Int,
cornerRadius: Int,
visible: Boolean,
- fullWidth: Boolean
+ fullWidth: Boolean,
) {
notificationScrimClippingParams.isEnabled = visible
notificationScrimClippingParams.top = top
@@ -402,7 +402,7 @@
launch {
setListenerJob(
heightListener,
- viewModel.containerViewModel.editModeViewModel.isEditing
+ viewModel.containerViewModel.editModeViewModel.isEditing,
) {
onQsHeightChanged()
}
@@ -410,7 +410,7 @@
launch {
setListenerJob(
qsContainerController,
- viewModel.containerViewModel.editModeViewModel.isEditing
+ viewModel.containerViewModel.editModeViewModel.isEditing,
) {
setCustomizerShowing(it)
}
@@ -422,6 +422,7 @@
@Composable
private fun QuickQuickSettingsElement() {
val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
+ val bottomPadding = dimensionResource(id = R.dimen.qqs_layout_padding_bottom)
DisposableEffect(Unit) {
qqsVisible.value = true
@@ -441,7 +442,7 @@
)
}
.onSizeChanged { size -> qqsHeight.value = size.height }
- .padding(top = { qqsPadding })
+ .padding(top = { qqsPadding }, bottom = { bottomPadding.roundToPx() })
) {
val qsEnabled by viewModel.qsEnabled.collectAsStateWithLifecycle()
if (qsEnabled) {
@@ -450,7 +451,7 @@
modifier =
Modifier.collapseExpandSemanticAction(
stringResource(id = R.string.accessibility_quick_settings_expand)
- )
+ ),
)
}
}
@@ -482,7 +483,7 @@
FooterActions(
viewModel = viewModel.footerActionsViewModel,
qsVisibilityLifecycleOwner = this@QSFragmentCompose,
- modifier = Modifier.sysuiResTag("qs_footer_actions")
+ modifier = Modifier.sysuiResTag("qs_footer_actions"),
)
}
}
@@ -562,7 +563,7 @@
private suspend inline fun <Listener : Any, Data> setListenerJob(
listenerFlow: MutableStateFlow<Listener?>,
dataFlow: Flow<Data>,
- crossinline onCollect: suspend Listener.(Data) -> Unit
+ crossinline onCollect: suspend Listener.(Data) -> Unit,
) {
coroutineScope {
try {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt
index eeb55ca..fde40da 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt
@@ -22,18 +22,16 @@
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.util.fastMap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.qs.panels.ui.viewmodel.QuickQuickSettingsViewModel
@Composable
-fun QuickQuickSettings(
- viewModel: QuickQuickSettingsViewModel,
- modifier: Modifier = Modifier,
-) {
+fun QuickQuickSettings(viewModel: QuickQuickSettingsViewModel, modifier: Modifier = Modifier) {
val sizedTiles by
viewModel.tileViewModels.collectAsStateWithLifecycle(initialValue = emptyList())
- val tiles = sizedTiles.map { it.tile }
+ val tiles = sizedTiles.fastMap { it.tile }
DisposableEffect(tiles) {
val token = Any()
@@ -44,14 +42,18 @@
TileLazyGrid(
modifier = modifier.sysuiResTag("qqs_tile_layout"),
- columns = GridCells.Fixed(columns)
+ columns = GridCells.Fixed(columns),
) {
items(
- tiles.size,
+ sizedTiles.size,
key = { index -> sizedTiles[index].tile.spec.spec },
- span = { index -> GridItemSpan(sizedTiles[index].width) }
+ span = { index -> GridItemSpan(sizedTiles[index].width) },
) { index ->
- Tile(tile = tiles[index], iconOnly = sizedTiles[index].isIcon, modifier = Modifier)
+ Tile(
+ tile = sizedTiles[index].tile,
+ iconOnly = sizedTiles[index].isIcon,
+ modifier = Modifier,
+ )
}
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt
index 93037d1..afd47a7 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt
@@ -18,6 +18,7 @@
package com.android.systemui.qs.panels.ui.compose
+import android.content.res.Resources
import android.graphics.drawable.Animatable
import android.service.quicksettings.Tile.STATE_ACTIVE
import android.service.quicksettings.Tile.STATE_INACTIVE
@@ -71,6 +72,7 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -85,13 +87,19 @@
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -106,12 +114,15 @@
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.common.ui.compose.load
+import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.qs.panels.shared.model.SizedTile
import com.android.systemui.qs.panels.shared.model.SizedTileImpl
+import com.android.systemui.qs.panels.ui.compose.TileDefaults.longPressLabel
import com.android.systemui.qs.panels.ui.model.GridCell
import com.android.systemui.qs.panels.ui.model.SpacerGridCell
import com.android.systemui.qs.panels.ui.model.TileGridCell
+import com.android.systemui.qs.panels.ui.viewmodel.AccessibilityUiState
import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
import com.android.systemui.qs.panels.ui.viewmodel.TileUiState
import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
@@ -126,15 +137,15 @@
object TileType
+private const val TEST_TAG_SMALL = "qs_tile_small"
+private const val TEST_TAG_LARGE = "qs_tile_large"
+private const val TEST_TAG_TOGGLE = "qs_tile_toggle_target"
+
@Composable
-fun Tile(
- tile: TileViewModel,
- iconOnly: Boolean,
- showLabels: Boolean = false,
- modifier: Modifier,
-) {
+fun Tile(tile: TileViewModel, iconOnly: Boolean, showLabels: Boolean = false, modifier: Modifier) {
val state by tile.state.collectAsStateWithLifecycle(tile.currentState)
- val uiState = remember(state) { state.toUiState() }
+ val resources = resources()
+ val uiState = remember(state, resources) { state.toUiState(resources) }
val colors = TileDefaults.getColorForState(uiState)
// TODO(b/361789146): Draw the shapes instead of clipping
@@ -150,6 +161,7 @@
onClick = tile::onClick,
onLongClick = tile::onLongClick,
modifier = modifier.height(tileHeight()),
+ uiState = uiState,
) {
val icon = getTileIcon(icon = uiState.icon)
if (iconOnly) {
@@ -169,6 +181,7 @@
}
},
onLongClick = { tile.onLongClick(it) },
+ accessibilityUiState = uiState.accessibilityUiState,
)
}
}
@@ -185,6 +198,7 @@
onClick: (Expandable) -> Unit = {},
onLongClick: (Expandable) -> Unit = {},
modifier: Modifier = Modifier,
+ uiState: TileUiState? = null,
content: @Composable BoxScope.(Expandable) -> Unit,
) {
Column(
@@ -194,7 +208,7 @@
modifier = modifier,
) {
val backgroundColor =
- if (iconOnly) {
+ if (iconOnly || uiState?.handlesSecondaryClick != true) {
colors.iconBackground
} else {
colors.background
@@ -202,18 +216,43 @@
Expandable(
color = backgroundColor,
shape = shape,
- modifier = Modifier.height(tileHeight()).clip(shape)
+ modifier = Modifier.height(tileHeight()).clip(shape),
) {
+ val longPressLabel = longPressLabel()
Box(
modifier =
Modifier.fillMaxSize()
.thenIf(clickEnabled) {
Modifier.combinedClickable(
onClick = { onClick(it) },
- onLongClick = { onLongClick(it) }
+ onLongClick = { onLongClick(it) },
+ onClickLabel = uiState?.accessibilityUiState?.clickLabel,
+ onLongClickLabel = longPressLabel,
)
}
- .tilePadding(),
+ .thenIf(uiState != null) {
+ uiState as TileUiState
+ Modifier.semantics {
+ role = uiState.accessibilityUiState.accessibilityRole
+ if (
+ uiState.accessibilityUiState.accessibilityRole ==
+ Role.Switch
+ ) {
+ uiState.accessibilityUiState.toggleableState?.let {
+ toggleableState = it
+ }
+ }
+ stateDescription = uiState.accessibilityUiState.stateDescription
+ }
+ .sysuiResTag(if (iconOnly) TEST_TAG_SMALL else TEST_TAG_LARGE)
+ .thenIf(iconOnly) {
+ Modifier.semantics {
+ contentDescription =
+ uiState.accessibilityUiState.contentDescription
+ }
+ }
+ }
+ .tilePadding()
) {
content(it)
}
@@ -238,21 +277,39 @@
icon: Icon,
colors: TileColors,
iconShape: Shape,
+ accessibilityUiState: AccessibilityUiState? = null,
toggleClickSupported: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = tileHorizontalArrangement()
+ horizontalArrangement = tileHorizontalArrangement(),
) {
// Icon
+ val longPressLabel = longPressLabel()
Box(
modifier =
Modifier.size(TileDefaults.ToggleTargetSize).thenIf(toggleClickSupported) {
Modifier.clip(iconShape)
.background(colors.iconBackground, { 1f })
- .combinedClickable(onClick = onClick, onLongClick = onLongClick)
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ onLongClickLabel = longPressLabel,
+ )
+ .thenIf(accessibilityUiState != null) {
+ accessibilityUiState as AccessibilityUiState
+ Modifier.semantics {
+ contentDescription = accessibilityUiState.contentDescription
+ stateDescription = accessibilityUiState.stateDescription
+ accessibilityUiState.toggleableState?.let {
+ toggleableState = it
+ }
+ role = Role.Switch
+ }
+ .sysuiResTag(TEST_TAG_TOGGLE)
+ }
}
) {
TileIcon(icon = icon, color = colors.icon, modifier = Modifier.align(Alignment.Center))
@@ -260,16 +317,19 @@
// Labels
Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) {
- Text(
- label,
- color = colors.label,
- modifier = Modifier.tileMarquee(),
- )
+ Text(label, color = colors.label, modifier = Modifier.tileMarquee())
if (!TextUtils.isEmpty(secondaryLabel)) {
Text(
secondaryLabel ?: "",
color = colors.secondaryLabel,
- modifier = Modifier.tileMarquee(),
+ modifier =
+ Modifier.tileMarquee().thenIf(
+ accessibilityUiState
+ ?.stateDescription
+ ?.contains(secondaryLabel ?: "") == true
+ ) {
+ Modifier.clearAndSetSemantics {}
+ },
)
}
}
@@ -277,10 +337,7 @@
}
private fun Modifier.tileMarquee(): Modifier {
- return basicMarquee(
- iterations = 1,
- initialDelayMillis = 200,
- )
+ return basicMarquee(iterations = 1, initialDelayMillis = 200)
}
@Composable
@@ -320,11 +377,11 @@
Column(
verticalArrangement =
spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin)),
- modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState())
+ modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()),
) {
AnimatedContent(
targetState = currentListState.dragInProgress,
- modifier = Modifier.wrapContentSize()
+ modifier = Modifier.wrapContentSize(),
) { dragIsInProgress ->
EditGridHeader(Modifier.dragAndDropRemoveZone(currentListState, onRemoveTile)) {
if (dragIsInProgress) {
@@ -348,12 +405,12 @@
AnimatedVisibility(
visible = !currentListState.dragInProgress,
enter = fadeIn(),
- exit = fadeOut()
+ exit = fadeOut(),
) {
Column(
verticalArrangement =
spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin)),
- modifier = modifier.fillMaxSize()
+ modifier = modifier.fillMaxSize(),
) {
EditGridHeader { Text(text = "Hold and drag to add tiles.") }
@@ -381,14 +438,14 @@
@Composable
private fun EditGridHeader(
modifier: Modifier = Modifier,
- content: @Composable BoxScope.() -> Unit
+ content: @Composable BoxScope.() -> Unit,
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onBackground.copy(alpha = .5f)
) {
Box(
contentAlignment = Alignment.Center,
- modifier = modifier.fillMaxWidth().height(EditModeTileDefaults.EditGridHeaderHeight)
+ modifier = modifier.fillMaxWidth().height(EditModeTileDefaults.EditGridHeaderHeight),
) {
content()
}
@@ -403,7 +460,7 @@
modifier =
Modifier.fillMaxHeight()
.border(1.dp, LocalContentColor.current, shape = CircleShape)
- .padding(10.dp)
+ .padding(10.dp),
) {
Icon(imageVector = Icons.Default.Clear, contentDescription = null)
Text(text = "Remove")
@@ -454,7 +511,7 @@
gridContentOffset = coordinates.positionInRoot()
}
.testTag(CURRENT_TILES_GRID_TEST_TAG),
- columns = GridCells.Fixed(columns)
+ columns = GridCells.Fixed(columns),
) {
editTiles(
listState.tiles,
@@ -488,7 +545,7 @@
// Available tiles
TileLazyGrid(
modifier = Modifier.height(availableGridHeight).testTag(AVAILABLE_TILES_GRID_TEST_TAG),
- columns = GridCells.Fixed(columns)
+ columns = GridCells.Fixed(columns),
) {
groupedTiles.forEach { category, tiles ->
stickyHeader {
@@ -498,7 +555,7 @@
color = labelColors.label,
modifier =
Modifier.background(Color.Black)
- .padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
+ .padding(start = 16.dp, bottom = 8.dp, top = 8.dp),
)
}
editTiles(
@@ -542,7 +599,7 @@
count = cells.size,
key = { cells[it].key(it, dragAndDropState) },
span = { cells[it].span },
- contentType = { TileType }
+ contentType = { TileType },
) { index ->
when (val cell = cells[index]) {
is TileGridCell ->
@@ -552,7 +609,7 @@
Modifier.background(
color = MaterialTheme.colorScheme.secondary,
alpha = { EditModeTileDefaults.PLACEHOLDER_ALPHA },
- shape = RoundedCornerShape(TileDefaults.InactiveCornerRadius)
+ shape = RoundedCornerShape(TileDefaults.InactiveCornerRadius),
)
.animateItem()
)
@@ -565,7 +622,7 @@
onClick = onClick,
onResize = onResize,
showLabels = showLabels,
- indicatePosition = indicatePosition
+ indicatePosition = indicatePosition,
)
}
is SpacerGridCell -> SpacerGridCell()
@@ -604,7 +661,7 @@
modifier =
Modifier.height(tileHeight)
.animateItem()
- .semantics {
+ .semantics(mergeDescendants = true) {
onClick(onClickActionName) { false }
this.stateDescription = stateDescription
}
@@ -613,7 +670,7 @@
onClick,
onResize,
dragAndDropState,
- )
+ ),
)
}
@@ -645,7 +702,7 @@
TileIcon(
icon = tileViewModel.icon,
color = colors.icon,
- modifier = Modifier.align(Alignment.Center)
+ modifier = Modifier.align(Alignment.Center),
)
} else {
LargeTileContent(
@@ -694,11 +751,7 @@
}
}
if (loadedDrawable !is Animatable) {
- Icon(
- icon = icon,
- tint = color,
- modifier = iconModifier,
- )
+ Icon(icon = icon, tint = color, modifier = iconModifier)
} else if (icon is Icon.Resource) {
val image = AnimatedImageVector.animatedVectorResource(id = icon.res)
val painter =
@@ -716,7 +769,7 @@
painter = painter,
contentDescription = icon.contentDescription?.load(),
colorFilter = ColorFilter.tint(color = color),
- modifier = iconModifier
+ modifier = iconModifier,
)
}
}
@@ -765,6 +818,8 @@
val TileHeight = 72.dp
val IconTileWithLabelHeight = 140.dp
+ @Composable fun longPressLabel() = stringResource(id = R.string.accessibility_long_click_tile)
+
/** An active tile without dual target uses the active color as background */
@Composable
fun activeTileColors(): TileColors =
@@ -850,7 +905,7 @@
} else {
InactiveCornerRadius
},
- label = label
+ label = label,
)
return RoundedCornerShape(animatedCornerRadius)
}
@@ -858,3 +913,14 @@
private const val CURRENT_TILES_GRID_TEST_TAG = "CurrentTilesGrid"
private const val AVAILABLE_TILES_GRID_TEST_TAG = "AvailableTilesGrid"
+
+/**
+ * A composable function that returns the [Resources]. It will be recomposed when [Configuration]
+ * gets updated.
+ */
+@Composable
+@ReadOnlyComposable
+private fun resources(): Resources {
+ LocalConfiguration.current
+ return LocalContext.current.resources
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt
index 45051fe..aa42080 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt
@@ -16,8 +16,16 @@
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.ui.semantics.Role
+import androidx.compose.ui.state.ToggleableState
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
@Immutable
@@ -27,14 +35,78 @@
val state: Int,
val handlesSecondaryClick: Boolean,
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(
- label?.toString() ?: "",
- secondaryLabel?.toString() ?: "",
- state,
- handlesSecondaryClick,
- icon?.let { Supplier { icon } } ?: iconSupplier ?: Supplier { null },
+ label = label?.toString() ?: "",
+ secondaryLabel = secondaryLabel?.toString() ?: "",
+ state = if (disabledByPolicy) Tile.STATE_UNAVAILABLE else state,
+ handlesSecondaryClick = handlesSecondaryClick,
+ 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]
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java b/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
index 7ceb786..7bff827 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
@@ -32,7 +32,7 @@
import android.service.quicksettings.Tile;
import android.text.TextUtils;
import android.util.Log;
-import android.widget.Switch;
+import android.widget.Button;
import androidx.annotation.VisibleForTesting;
@@ -59,13 +59,13 @@
import com.android.systemui.res.R;
import com.android.systemui.statusbar.policy.BluetoothController;
-import kotlinx.coroutines.Job;
-
import java.util.List;
import java.util.concurrent.Executor;
import javax.inject.Inject;
+import kotlinx.coroutines.Job;
+
/** Quick settings tile: Bluetooth **/
public class BluetoothTile extends QSTileImpl<BooleanState> {
@@ -147,6 +147,8 @@
}
}
+
+
@Override
public Intent getLongClickIntent() {
return new Intent(Settings.ACTION_BLUETOOTH_SETTINGS);
@@ -221,7 +223,7 @@
state.state = Tile.STATE_INACTIVE;
}
- state.expandedAccessibilityClassName = Switch.class.getName();
+ state.expandedAccessibilityClassName = Button.class.getName();
state.forceExpandIcon = mFeatureFlags.isEnabled(Flags.BLUETOOTH_QS_TILE_DIALOG);
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt
index 078698c..7606293 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt
@@ -55,7 +55,7 @@
qsLogger: QSLogger,
private val keyguardStateController: KeyguardStateController,
private val dialogTransitionAnimator: DialogTransitionAnimator,
- private val fontScalingDialogDelegateProvider: Provider<FontScalingDialogDelegate>
+ private val fontScalingDialogDelegateProvider: Provider<FontScalingDialogDelegate>,
) :
QSTileImpl<QSTile.State?>(
host,
@@ -66,7 +66,7 @@
metricsLogger,
statusBarStateController,
activityStarter,
- qsLogger
+ qsLogger,
) {
private val icon = ResourceIcon.get(R.drawable.ic_qs_font_scaling)
@@ -86,7 +86,7 @@
expandable?.dialogTransitionController(
DialogCuj(
InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
- INTERACTION_JANK_TAG
+ INTERACTION_JANK_TAG,
)
)
controller?.let { dialogTransitionAnimator.show(dialog, controller) }
@@ -102,7 +102,7 @@
/* cancelAction= */ null,
/* dismissShade= */ true,
/* afterKeyguardGone= */ true,
- /* deferred= */ false
+ /* deferred= */ false,
)
}
}
@@ -110,6 +110,7 @@
override fun handleUpdateState(state: QSTile.State?, arg: Any?) {
state?.label = mContext.getString(R.string.quick_settings_font_scaling_label)
state?.icon = icon
+ state?.contentDescription = state?.label
}
override fun getLongClickIntent(): Intent? {