From 2899664af74f8841ae18e976827b783c8b455681 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 5 Aug 2026 15:12:56 +0000 Subject: [PATCH] Add missing content descriptions to media-send capture and image editor toolbars. --- .../mediasend/screens/capture/TextStoryBar.kt | 3 +- .../screens/edit/image/ImageEditorToolbar.kt | 17 +++- .../src/main/res/values/strings.xml | 8 ++ .../screens/capture/TextStoryBarTest.kt | 54 ++++++++++++ .../edit/image/ImageEditorCropToolbarTest.kt | 88 +++++++++++++++++++ 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt index 892aa96d3a..3689830497 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt @@ -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) ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt index 6c35d7e785..5733107559 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt @@ -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::class.java + ).apply { + put(CropLock.LOCKED, cropLockContentDescription) + put(CropLock.UNLOCKED, cropUnlockContentDescription) + } } ) } @@ -231,7 +243,8 @@ private fun DiscardButton(imageEditorController: ImageController) { private inline fun > IconCrossfadeToggleButton( target: E, crossinline setTarget: (E) -> Unit, - targetToImageMap: EnumMap + targetToImageMap: EnumMap, + targetToContentDescriptionMap: EnumMap ) { IconButtons.IconButton( onClick = { setTarget(target.next()) } @@ -239,7 +252,7 @@ private inline fun > IconCrossfadeToggleButton( Crossfade(target) { enumValue -> Icon( imageVector = targetToImageMap[enumValue]!!, - contentDescription = null, // TODO + contentDescription = targetToContentDescriptionMap[enumValue], modifier = Modifier.size(24.dp) ) } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index e619082791..45b516d25c 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -89,6 +89,9 @@ %1$d items + + Add link + Unnamed file @@ -97,6 +100,11 @@ Brush + + Aspect ratio locked + + Aspect ratio unlocked + Blur faces diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt new file mode 100644 index 0000000000..8c2e7241bb --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt @@ -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() + + @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()) + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt new file mode 100644 index 0000000000..169dc8cf63 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt @@ -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) + } +}