Add missing content descriptions to media-send capture and image editor toolbars.

This commit is contained in:
Greyson Parrelli
2026-08-10 16:32:55 -04:00
parent 86993d1cdc
commit 2899664af7
5 changed files with 167 additions and 3 deletions
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.NightPreview
@@ -96,7 +97,7 @@ private fun LinkButton(
Icon(
imageVector = SignalIcons.Link.imageVector,
tint = SignalTheme.colors.colorOnCustom,
contentDescription = null // TODO [media-send]
contentDescription = stringResource(R.string.TextStoryBar__add_link)
)
}
}
@@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.FoldablePortraitDayPreview
import org.signal.core.ui.compose.FoldablePortraitNightPreview
@@ -27,6 +28,7 @@ import org.signal.core.util.next
import org.signal.imageeditor.core.model.EditorModel
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.R
import org.signal.mediasend.rememberPreviewState
import org.signal.mediasend.screens.edit.ImageController
import org.signal.mediasend.screens.edit.MediaEditScreenDialogs
@@ -177,6 +179,8 @@ private fun ImageEditorCropAndResizeToolbar(
val cropLockImageVector = SignalIcons.CropLock.imageVector
val cropUnlockImageVector = SignalIcons.CropUnlock.imageVector
val cropLockContentDescription = stringResource(R.string.ImageEditorToolbar__aspect_ratio_locked)
val cropUnlockContentDescription = stringResource(R.string.ImageEditorToolbar__aspect_ratio_unlocked)
IconCrossfadeToggleButton(
target = if (imageEditorController.isCropAspectRatioLocked) CropLock.LOCKED else CropLock.UNLOCKED,
@@ -193,6 +197,14 @@ private fun ImageEditorCropAndResizeToolbar(
put(CropLock.LOCKED, cropLockImageVector)
put(CropLock.UNLOCKED, cropUnlockImageVector)
}
},
targetToContentDescriptionMap = remember(cropLockContentDescription, cropUnlockContentDescription) {
EnumMap<CropLock, String>(
CropLock::class.java
).apply {
put(CropLock.LOCKED, cropLockContentDescription)
put(CropLock.UNLOCKED, cropUnlockContentDescription)
}
}
)
}
@@ -231,7 +243,8 @@ private fun DiscardButton(imageEditorController: ImageController) {
private inline fun <reified E : Enum<E>> IconCrossfadeToggleButton(
target: E,
crossinline setTarget: (E) -> Unit,
targetToImageMap: EnumMap<E, ImageVector>
targetToImageMap: EnumMap<E, ImageVector>,
targetToContentDescriptionMap: EnumMap<E, String>
) {
IconButtons.IconButton(
onClick = { setTarget(target.next()) }
@@ -239,7 +252,7 @@ private inline fun <reified E : Enum<E>> IconCrossfadeToggleButton(
Crossfade(target) { enumValue ->
Icon(
imageVector = targetToImageMap[enumValue]!!,
contentDescription = null, // TODO
contentDescription = targetToContentDescriptionMap[enumValue],
modifier = Modifier.size(24.dp)
)
}
@@ -89,6 +89,9 @@
<item quantity="other">%1$d items</item>
</plurals>
<!-- Content description for the button that attaches a link to the text story being created -->
<string name="TextStoryBar__add_link">Add link</string>
<!-- Name shown on the document preview when we cannot read the file\'s name. -->
<string name="DocumentPage__unnamed_file">Unnamed file</string>
@@ -97,6 +100,11 @@
<!-- Content description for button to enable brush mode -->
<string name="DrawModeColorBar__brush">Brush</string>
<!-- Content description for the crop toggle when the crop area is currently constrained to a fixed aspect ratio. Tapping it removes the constraint. -->
<string name="ImageEditorToolbar__aspect_ratio_locked">Aspect ratio locked</string>
<!-- Content description for the crop toggle when the crop area can currently be resized freely. Tapping it constrains the crop area to a fixed aspect ratio. -->
<string name="ImageEditorToolbar__aspect_ratio_unlocked">Aspect ratio unlocked</string>
<!-- Label for the toggle that blurs every face in the image being edited -->
<string name="BlurFacesBar__blur_faces">Blur faces</string>
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
import android.app.Application
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
/**
* The add link affordance is an icon-only button, so TalkBack has nothing but its content description to go on.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class TextStoryBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<MediaCaptureScreenEvents>()
@Test
fun `Given the text story bar, when the labelled add link button is tapped, then a link is requested`() {
composeTestRule.setContent {
SignalTheme {
TextStoryHorizontalBar(
background = Brush.linearGradient(listOf(Color.Red, Color.Green)),
onEvent = { events += it }
)
}
}
composeTestRule.onNodeWithContentDescription("Add link").performClick()
assertEquals(MediaCaptureScreenEvents.AddLinkToTextStory, events.single())
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.edit.image
import android.app.Application
import androidx.compose.runtime.remember
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.imageeditor.core.model.EditorModel
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendDependenciesRule
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.screens.edit.ImageController
/**
* The aspect ratio toggle is an icon-only button whose icon is the only thing that says whether the crop is locked, so the
* content description has to carry that state for TalkBack.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class ImageEditorCropToolbarTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
@get:Rule
val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext())
@Test
fun `Given an unlocked crop, when rendering the toolbar, then the aspect ratio toggle announces that it is unlocked`() {
setContent()
composeTestRule.onNodeWithContentDescription(UNLOCKED).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(LOCKED).assertDoesNotExist()
}
@Test
fun `Given an unlocked crop, when the aspect ratio toggle is tapped, then it announces that it is locked`() {
setContent()
composeTestRule.onNodeWithContentDescription(UNLOCKED).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(LOCKED).assertIsDisplayed()
}
private fun setContent() {
composeTestRule.setContent {
SignalTheme {
val controller = remember {
ImageController(EDITOR_MODEL).apply { enterCropMode() }
}
ImageEditorToolbar(
imageEditorController = controller,
state = MediaSendFlowState(),
editorState = EditorState.Image(EDITOR_MODEL),
onEvent = {}
)
}
}
}
companion object {
private const val LOCKED = "Aspect ratio locked"
private const val UNLOCKED = "Aspect ratio unlocked"
/** The editor model is never read by the toolbar, and a real one cannot be built under Robolectric's legacy graphics. */
private val EDITOR_MODEL: EditorModel = mockk(relaxed = true)
}
}