From dfaa6c62fc3819ff1ba653f398e2719dc2d46d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 22 Jun 2026 14:41:29 +0100 Subject: [PATCH 1/2] compose: Focus the message composer on entry for screen readers When a screen reader is active, move the reading cursor to the message composer input as a message view opens, without moving input focus, so the keyboard stays closed and the user double-taps to type. This uses ACTION_ACCESSIBILITY_FOCUS instead of FocusRequester (which would open the keyboard), and re-applies focus while the message list loads so the screen reader's own initial placement does not override it. Gated on touch exploration, so sighted users are unaffected. Compose exposes no public API to move accessibility focus, so the node is resolved reflectively via AndroidComposeView.getSemanticsOwner; a consumer ProGuard rule keeps that method for minified release builds, and the lookup fails safe otherwise. Share the composer input test tag as a single constant and reuse the touch-exploration helper across the composer and the Giphy preview. --- .../consumer-rules.pro | 7 ++ .../messages/GiphyMessageContent.kt | 25 +--- .../compose/ui/messages/ChannelScreen.kt | 46 +++++++ .../ui/messages/composer/ComposerTestTags.kt | 24 ++++ .../MessageComposerInputCenterContent.kt | 3 +- .../compose/ui/util/AccessibilityUtils.kt | 94 +++++++++++++++ .../composer/MessageComposerScreenTest.kt | 4 +- .../compose/ui/util/AccessibilityUtilsTest.kt | 113 ++++++++++++++++++ 8 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/ComposerTestTags.kt create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AccessibilityUtils.kt create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt diff --git a/stream-chat-android-compose/consumer-rules.pro b/stream-chat-android-compose/consumer-rules.pro index e69de29bb2d..409cf5f3a90 100644 --- a/stream-chat-android-compose/consumer-rules.pro +++ b/stream-chat-android-compose/consumer-rules.pro @@ -0,0 +1,7 @@ +# Compose does not expose a public API to move screen-reader (accessibility) focus, so the SDK +# resolves it reflectively via AndroidComposeView.getSemanticsOwner() when placing the screen +# reader on the composer as a message view opens. Keep that method so the lookup keeps working in +# R8-minified release builds; without it the call fails safe (focus is simply not moved). +-keepclassmembers class androidx.compose.ui.platform.AndroidComposeView { + androidx.compose.ui.semantics.SemanticsOwner getSemanticsOwner(); +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/GiphyMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/GiphyMessageContent.kt index fdc63f07b2c..3daaa31e785 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/GiphyMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/GiphyMessageContent.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.compose.ui.components.messages -import android.view.accessibility.AccessibilityManager import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -30,7 +29,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,7 +41,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -52,7 +49,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.getSystemService import coil3.ColorImage import coil3.compose.LocalAsyncImagePreviewHandler import io.getstream.chat.android.compose.R @@ -65,6 +61,7 @@ import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.ui.util.AsyncImagePreviewHandler import io.getstream.chat.android.compose.ui.util.applyIf +import io.getstream.chat.android.compose.ui.util.rememberIsTouchExplorationEnabled import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Message @@ -208,26 +205,6 @@ public fun GiphyMessageContent( private const val PreviewFocusRequestDelayMs = 100L -/** - * Observes [AccessibilityManager.isTouchExplorationEnabled] and recomposes when it toggles. Used - * to gate focus-stealing behaviour so we only request TalkBack focus when an explore-by-touch - * service (e.g. TalkBack) is active — otherwise we would yank Compose focus away from the - * composer's text field for sighted users and dismiss the IME. - */ -@Composable -private fun rememberIsTouchExplorationEnabled(): Boolean { - val context = LocalContext.current - val manager = remember(context) { context.getSystemService() } ?: return false - var enabled by remember(manager) { mutableStateOf(manager.isTouchExplorationEnabled) } - DisposableEffect(manager) { - val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled = it } - manager.addTouchExplorationStateChangeListener(listener) - enabled = manager.isTouchExplorationEnabled - onDispose { manager.removeTouchExplorationStateChangeListener(listener) } - } - return enabled -} - @Composable internal fun GiphyMessageContent() { val previewHandler = AsyncImagePreviewHandler { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/ChannelScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/ChannelScreen.kt index f757ba0a4f0..9e60ac92e22 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/ChannelScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/ChannelScreen.kt @@ -43,9 +43,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -59,6 +62,7 @@ import io.getstream.chat.android.compose.ui.components.moderatedmessage.Moderate import io.getstream.chat.android.compose.ui.components.poll.PollAnswersDialog import io.getstream.chat.android.compose.ui.components.poll.PollMoreOptionsDialog import io.getstream.chat.android.compose.ui.components.poll.PollViewResultDialog +import io.getstream.chat.android.compose.ui.messages.composer.ComposerInputTestTag import io.getstream.chat.android.compose.ui.messages.composer.MessageComposer import io.getstream.chat.android.compose.ui.messages.list.LocalSelectedMessageSnapshot import io.getstream.chat.android.compose.ui.messages.list.MessageList @@ -70,7 +74,9 @@ import io.getstream.chat.android.compose.ui.theme.MessageActionsParams import io.getstream.chat.android.compose.ui.theme.MessageReactionPickerParams import io.getstream.chat.android.compose.ui.theme.ReactionsMenuParams import io.getstream.chat.android.compose.ui.util.StreamSnackbarHost +import io.getstream.chat.android.compose.ui.util.rememberIsTouchExplorationEnabled import io.getstream.chat.android.compose.ui.util.rememberMessageListState +import io.getstream.chat.android.compose.ui.util.requestAccessibilityFocusForTestTag import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.compose.viewmodel.messages.ChannelViewModelFactory import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel @@ -96,6 +102,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.SelectedMessageSt import io.getstream.chat.android.ui.common.state.messages.list.SendAnyway import io.getstream.chat.android.ui.common.state.messages.poll.PollSelectionType import io.getstream.chat.android.ui.common.state.messages.updateMessage +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -193,6 +200,8 @@ public fun ChannelScreen( BackHandler(enabled = true, onBack = backAction) + ComposerScreenReaderEntryFocus(listViewModel) + ChannelScreenContentBox { Scaffold( modifier = Modifier.fillMaxSize(), @@ -275,6 +284,43 @@ public fun ChannelScreen( } } +/** + * When a screen reader is active, moves the reading cursor onto the message composer input as a + * message view opens, without moving input focus, so the keyboard stays closed and the user + * double-taps to type. A thread is also a message view, so this re-arms when the view switches + * between the channel and a thread. + * + * The message list loads asynchronously and the screen reader re-asserts its own initial focus when + * content appears, so the cursor is re-applied while the list settles (keyed on the item count) and + * for a short window, then it stops so later incoming messages do not pull focus back. + * + * @param listViewModel The [MessageListViewModel] whose loading state drives the re-apply. + */ +@Composable +private fun ComposerScreenReaderEntryFocus(listViewModel: MessageListViewModel) { + val isTouchExplorationEnabled = rememberIsTouchExplorationEnabled() + if (!isTouchExplorationEnabled) return + + val view = LocalView.current + val messagesState by listViewModel.currentMessagesState + // Null in the channel, the parent message id in a thread; switching either way is a new entry. + val viewKey = messagesState.parentMessageId + var entryFocusSettled by rememberSaveable(viewKey) { mutableStateOf(false) } + + LaunchedEffect(viewKey, messagesState.messageItems.size) { + if (entryFocusSettled) return@LaunchedEffect + // Defer a frame so the composer is laid out before moving the cursor onto it. + withFrameNanos {} + view.requestAccessibilityFocusForTestTag(ComposerInputTestTag) + } + LaunchedEffect(viewKey) { + delay(ComposerEntryFocusWindowMs) + entryFocusSettled = true + } +} + +private const val ComposerEntryFocusWindowMs = 2000L + @Composable private fun ChannelScreenContentBox(content: @Composable BoxScope.() -> Unit) { val selectedMessageSnapshot = remember { mutableStateOf(null) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/ComposerTestTags.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/ComposerTestTags.kt new file mode 100644 index 00000000000..d213ebc21c4 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/ComposerTestTags.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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 io.getstream.chat.android.compose.ui.messages.composer + +/** + * Test tag of the message composer text input. Single source of truth so the tag the composer sets + * stays in sync with consumers that look the node up by tag (e.g. moving screen-reader focus to it + * on screen entry). + */ +internal const val ComposerInputTestTag: String = "Stream_ComposerInputField" diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerInputCenterContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerInputCenterContent.kt index 14624effcbe..3f27d2afdf9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerInputCenterContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerInputCenterContent.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.messages.composer.ComposerInputTestTag import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamDesign import io.getstream.chat.android.compose.ui.theme.StreamTokens @@ -85,7 +86,7 @@ internal fun MessageComposerInputCenterContent( BasicTextField( modifier = modifier .fillMaxWidth() - .testTag("Stream_ComposerInputField") + .testTag(ComposerInputTestTag) .heightIn(min = 48.dp), value = textState, onValueChange = { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AccessibilityUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AccessibilityUtils.kt new file mode 100644 index 00000000000..602df479f7f --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/AccessibilityUtils.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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 io.getstream.chat.android.compose.ui.util + +import android.view.View +import android.view.accessibility.AccessibilityManager +import android.view.accessibility.AccessibilityNodeInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.core.content.getSystemService + +/** + * Observes [AccessibilityManager.isTouchExplorationEnabled] and recomposes when it toggles. + * + * Used to gate behaviour that should only apply when an explore-by-touch service (e.g. TalkBack) + * is active. + * + * @return `true` when an explore-by-touch service is active, `false` otherwise. + */ +@Composable +internal fun rememberIsTouchExplorationEnabled(): Boolean { + val context = LocalContext.current + val manager = remember(context) { context.getSystemService() } ?: return false + var enabled by remember(manager) { mutableStateOf(manager.isTouchExplorationEnabled) } + DisposableEffect(manager) { + val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled = it } + manager.addTouchExplorationStateChangeListener(listener) + enabled = manager.isTouchExplorationEnabled + onDispose { manager.removeTouchExplorationStateChangeListener(listener) } + } + return enabled +} + +/** + * Moves the screen-reader (accessibility) cursor to the composable tagged with [testTag] by + * dispatching [AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS]. It moves only the reading cursor, + * not input focus, so no keyboard opens and the field does not become the input target. + * + * Compose exposes no public API for this, so the node id is resolved from the [SemanticsOwner] + * (the only non-public step). Fails safe: returns `false` and leaves focus untouched if anything + * cannot be resolved. + * + * @param testTag The test tag of the node to focus. + * @return `true` if the accessibility focus action was dispatched, `false` otherwise. + */ +internal fun View.requestAccessibilityFocusForTestTag(testTag: String): Boolean = runCatching { + val nodeId = semanticsNodeIdForTestTag(testTag) ?: return@runCatching false + val provider = accessibilityNodeProvider ?: return@runCatching false + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) +}.getOrDefault(false) + +/** + * Resolves the accessibility virtual-view id (the Compose semantics node id) of the node tagged with + * [testTag] under this view's [SemanticsOwner]. Returns `null` if this view is not a Compose host, + * the owner cannot be resolved, or no node carries the tag. Fails safe (never throws). + * + * @param testTag The test tag to look up. + * @return The semantics node id, or `null` if it cannot be resolved. + */ +internal fun View.semanticsNodeIdForTestTag(testTag: String): Int? = runCatching { + val owner = javaClass.getMethod("getSemanticsOwner").invoke(this) as? SemanticsOwner + ?: return@runCatching null + owner.unmergedRootSemanticsNode.findByTestTag(testTag)?.id +}.getOrNull() + +private fun SemanticsNode.findByTestTag(testTag: String): SemanticsNode? = + if (config.getOrNull(SemanticsProperties.TestTag) == testTag) { + this + } else { + children.firstNotNullOfOrNull { it.findByTestTag(testTag) } + } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt index aebebb6499e..51b33940765 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt @@ -121,7 +121,7 @@ internal class MessageComposerScreenTest : MockedChatClientTest { composeTestRule.onNodeWithText("9").assertExists() composeTestRule.onNodeWithText("Slow mode, wait 9s…").assertExists() - composeTestRule.onNodeWithTag("Stream_ComposerInputField").assertIsNotEnabled() + composeTestRule.onNodeWithTag(ComposerInputTestTag).assertIsNotEnabled() composeTestRule.onNodeWithTag("Stream_ComposerAttachmentsButton").assertIsNotEnabled() } @@ -138,7 +138,7 @@ internal class MessageComposerScreenTest : MockedChatClientTest { } } - composeTestRule.onNodeWithTag("Stream_ComposerInputField").assertIsEnabled() + composeTestRule.onNodeWithTag(ComposerInputTestTag).assertIsEnabled() composeTestRule.onNodeWithTag("Stream_ComposerAttachmentsButton").assertIsEnabled() } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt new file mode 100644 index 00000000000..3ef20e5285b --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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 io.getstream.chat.android.compose.ui.util + +import android.content.Context +import android.view.View +import android.view.accessibility.AccessibilityManager +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class AccessibilityUtilsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun setTouchExplorationEnabled(enabled: Boolean) { + val manager = ApplicationProvider.getApplicationContext() + .getSystemService()!! + Shadows.shadowOf(manager).setTouchExplorationEnabled(enabled) + } + + @Test + fun `rememberIsTouchExplorationEnabled is true when touch exploration is enabled`() { + setTouchExplorationEnabled(true) + var enabled = false + composeTestRule.setContent { enabled = rememberIsTouchExplorationEnabled() } + composeTestRule.runOnIdle { assertTrue(enabled) } + } + + @Test + fun `rememberIsTouchExplorationEnabled is false when touch exploration is disabled`() { + setTouchExplorationEnabled(false) + var enabled = true + composeTestRule.setContent { enabled = rememberIsTouchExplorationEnabled() } + composeTestRule.runOnIdle { assertFalse(enabled) } + } + + @Test + fun `semanticsNodeIdForTestTag resolves the id of the tagged node`() { + lateinit var view: View + composeTestRule.setContent { + view = LocalView.current + TaggedNode(tag = "present") + } + composeTestRule.runOnIdle { + assertNotNull(view.semanticsNodeIdForTestTag("present")) + } + } + + @Test + fun `semanticsNodeIdForTestTag returns null when no node has the tag`() { + lateinit var view: View + composeTestRule.setContent { + view = LocalView.current + TaggedNode(tag = "present") + } + composeTestRule.runOnIdle { + assertNull(view.semanticsNodeIdForTestTag("absent")) + } + } + + @Test + fun `requestAccessibilityFocusForTestTag fails safe on a non-Compose view`() { + val plainView = View(ApplicationProvider.getApplicationContext()) + assertFalse(plainView.requestAccessibilityFocusForTestTag("present")) + } +} + +@Composable +private fun TaggedNode(tag: String) { + Box( + modifier = Modifier + .size(48.dp) + .testTag(tag) + .semantics { contentDescription = "tagged" }, + ) +} From 6960eca853d030c40cfb4141ca0e2b41817f7b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 22 Jun 2026 16:55:47 +0100 Subject: [PATCH 2/2] compose: Cover screen-reader composer entry focus with unit tests --- .../compose/ui/messages/ChannelScreenTest.kt | 28 +++++++++++++++++++ .../compose/ui/util/AccessibilityUtilsTest.kt | 27 ++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/ChannelScreenTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/ChannelScreenTest.kt index 61299e449dd..d1295c1f80c 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/ChannelScreenTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/ChannelScreenTest.kt @@ -16,10 +16,13 @@ package io.getstream.chat.android.compose.ui.messages +import android.content.Context +import android.view.accessibility.AccessibilityManager import androidx.annotation.UiThread import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.core.content.getSystemService import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.getstream.chat.android.client.test.MockedChatClientTest @@ -35,6 +38,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -62,8 +66,32 @@ internal class ChannelScreenTest : MockedChatClientTest { composeTestRule.onNodeWithText("Channel without name").assertExists() composeTestRule.onNodeWithText("0 Members").assertExists() } + + @Test + @UiThread + fun `renders when a screen reader moves entry focus to the composer`() { + setTouchExplorationEnabled(true) + + composeTestRule.setContent { + ChatTheme { + ChannelScreen() + } + } + // Advance past the entry-focus window so the re-apply loop settles. + composeTestRule.mainClock.advanceTimeBy(EntryFocusWindowElapsedMs) + + composeTestRule.onNodeWithText("Channel without name").assertExists() + } + + private fun setTouchExplorationEnabled(enabled: Boolean) { + val manager = ApplicationProvider.getApplicationContext() + .getSystemService()!! + Shadows.shadowOf(manager).setTouchExplorationEnabled(enabled) + } } +private const val EntryFocusWindowElapsedMs = 2_100L + @Composable private fun ChannelScreen() { ChannelScreen( diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt index 3ef20e5285b..0437192c0ed 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt @@ -100,6 +100,33 @@ internal class AccessibilityUtilsTest { val plainView = View(ApplicationProvider.getApplicationContext()) assertFalse(plainView.requestAccessibilityFocusForTestTag("present")) } + + @Test + fun `requestAccessibilityFocusForTestTag resolves and dispatches for a present tag`() { + lateinit var view: View + composeTestRule.setContent { + view = LocalView.current + TaggedNode(tag = "present") + } + composeTestRule.runOnIdle { + // The node resolves, so the action is dispatched to the provider (the host's shadow does + // not actually move focus, so the dispatched result is not asserted here). + assertNotNull(view.semanticsNodeIdForTestTag("present")) + view.requestAccessibilityFocusForTestTag("present") + } + } + + @Test + fun `requestAccessibilityFocusForTestTag fails safe for an absent tag`() { + lateinit var view: View + composeTestRule.setContent { + view = LocalView.current + TaggedNode(tag = "present") + } + composeTestRule.runOnIdle { + assertFalse(view.requestAccessibilityFocusForTestTag("absent")) + } + } } @Composable