Skip to content

Commit eea0942

Browse files
committed
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.
1 parent 7fb7a97 commit eea0942

8 files changed

Lines changed: 289 additions & 27 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Compose does not expose a public API to move screen-reader (accessibility) focus, so the SDK
2+
# resolves it reflectively via AndroidComposeView.getSemanticsOwner() when placing the screen
3+
# reader on the composer as a message view opens. Keep that method so the lookup keeps working in
4+
# R8-minified release builds; without it the call fails safe (focus is simply not moved).
5+
-keepclassmembers class androidx.compose.ui.platform.AndroidComposeView {
6+
androidx.compose.ui.semantics.SemanticsOwner getSemanticsOwner();
7+
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/GiphyMessageContent.kt

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package io.getstream.chat.android.compose.ui.components.messages
1818

19-
import android.view.accessibility.AccessibilityManager
2019
import androidx.compose.foundation.background
2120
import androidx.compose.foundation.focusable
2221
import androidx.compose.foundation.layout.Arrangement
@@ -30,7 +29,6 @@ import androidx.compose.material3.Icon
3029
import androidx.compose.material3.Text
3130
import androidx.compose.runtime.Composable
3231
import androidx.compose.runtime.CompositionLocalProvider
33-
import androidx.compose.runtime.DisposableEffect
3432
import androidx.compose.runtime.LaunchedEffect
3533
import androidx.compose.runtime.getValue
3634
import androidx.compose.runtime.mutableStateOf
@@ -43,7 +41,6 @@ import androidx.compose.ui.focus.FocusRequester
4341
import androidx.compose.ui.focus.focusRequester
4442
import androidx.compose.ui.graphics.Color
4543
import androidx.compose.ui.graphics.toArgb
46-
import androidx.compose.ui.platform.LocalContext
4744
import androidx.compose.ui.platform.LocalView
4845
import androidx.compose.ui.platform.testTag
4946
import androidx.compose.ui.res.painterResource
@@ -52,7 +49,6 @@ import androidx.compose.ui.semantics.contentDescription
5249
import androidx.compose.ui.semantics.semantics
5350
import androidx.compose.ui.tooling.preview.Preview
5451
import androidx.compose.ui.unit.dp
55-
import androidx.core.content.getSystemService
5652
import coil3.ColorImage
5753
import coil3.compose.LocalAsyncImagePreviewHandler
5854
import io.getstream.chat.android.compose.R
@@ -65,6 +61,7 @@ import io.getstream.chat.android.compose.ui.theme.MessageStyling
6561
import io.getstream.chat.android.compose.ui.theme.StreamTokens
6662
import io.getstream.chat.android.compose.ui.util.AsyncImagePreviewHandler
6763
import io.getstream.chat.android.compose.ui.util.applyIf
64+
import io.getstream.chat.android.compose.ui.util.rememberIsTouchExplorationEnabled
6865
import io.getstream.chat.android.models.Attachment
6966
import io.getstream.chat.android.models.AttachmentType
7067
import io.getstream.chat.android.models.Message
@@ -208,26 +205,6 @@ public fun GiphyMessageContent(
208205

209206
private const val PreviewFocusRequestDelayMs = 100L
210207

211-
/**
212-
* Observes [AccessibilityManager.isTouchExplorationEnabled] and recomposes when it toggles. Used
213-
* to gate focus-stealing behaviour so we only request TalkBack focus when an explore-by-touch
214-
* service (e.g. TalkBack) is active — otherwise we would yank Compose focus away from the
215-
* composer's text field for sighted users and dismiss the IME.
216-
*/
217-
@Composable
218-
private fun rememberIsTouchExplorationEnabled(): Boolean {
219-
val context = LocalContext.current
220-
val manager = remember(context) { context.getSystemService<AccessibilityManager>() } ?: return false
221-
var enabled by remember(manager) { mutableStateOf(manager.isTouchExplorationEnabled) }
222-
DisposableEffect(manager) {
223-
val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled = it }
224-
manager.addTouchExplorationStateChangeListener(listener)
225-
enabled = manager.isTouchExplorationEnabled
226-
onDispose { manager.removeTouchExplorationStateChangeListener(listener) }
227-
}
228-
return enabled
229-
}
230-
231208
@Composable
232209
internal fun GiphyMessageContent() {
233210
val previewHandler = AsyncImagePreviewHandler {

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/ChannelScreen.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ import androidx.compose.runtime.getValue
4343
import androidx.compose.runtime.mutableStateOf
4444
import androidx.compose.runtime.remember
4545
import androidx.compose.runtime.rememberCoroutineScope
46+
import androidx.compose.runtime.saveable.rememberSaveable
4647
import androidx.compose.runtime.setValue
48+
import androidx.compose.runtime.withFrameNanos
4749
import androidx.compose.ui.Modifier
4850
import androidx.compose.ui.platform.LocalResources
51+
import androidx.compose.ui.platform.LocalView
4952
import androidx.compose.ui.platform.testTag
5053
import androidx.compose.ui.res.stringResource
5154
import androidx.compose.ui.unit.dp
@@ -59,6 +62,7 @@ import io.getstream.chat.android.compose.ui.components.moderatedmessage.Moderate
5962
import io.getstream.chat.android.compose.ui.components.poll.PollAnswersDialog
6063
import io.getstream.chat.android.compose.ui.components.poll.PollMoreOptionsDialog
6164
import io.getstream.chat.android.compose.ui.components.poll.PollViewResultDialog
65+
import io.getstream.chat.android.compose.ui.messages.composer.ComposerInputTestTag
6266
import io.getstream.chat.android.compose.ui.messages.composer.MessageComposer
6367
import io.getstream.chat.android.compose.ui.messages.list.LocalSelectedMessageSnapshot
6468
import io.getstream.chat.android.compose.ui.messages.list.MessageList
@@ -70,7 +74,9 @@ import io.getstream.chat.android.compose.ui.theme.MessageActionsParams
7074
import io.getstream.chat.android.compose.ui.theme.MessageReactionPickerParams
7175
import io.getstream.chat.android.compose.ui.theme.ReactionsMenuParams
7276
import io.getstream.chat.android.compose.ui.util.StreamSnackbarHost
77+
import io.getstream.chat.android.compose.ui.util.rememberIsTouchExplorationEnabled
7378
import io.getstream.chat.android.compose.ui.util.rememberMessageListState
79+
import io.getstream.chat.android.compose.ui.util.requestAccessibilityFocusForTestTag
7480
import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel
7581
import io.getstream.chat.android.compose.viewmodel.messages.ChannelViewModelFactory
7682
import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel
@@ -96,6 +102,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.SelectedMessageSt
96102
import io.getstream.chat.android.ui.common.state.messages.list.SendAnyway
97103
import io.getstream.chat.android.ui.common.state.messages.poll.PollSelectionType
98104
import io.getstream.chat.android.ui.common.state.messages.updateMessage
105+
import kotlinx.coroutines.delay
99106
import kotlinx.coroutines.launch
100107

101108
/**
@@ -193,6 +200,8 @@ public fun ChannelScreen(
193200

194201
BackHandler(enabled = true, onBack = backAction)
195202

203+
ComposerScreenReaderEntryFocus(listViewModel)
204+
196205
ChannelScreenContentBox {
197206
Scaffold(
198207
modifier = Modifier.fillMaxSize(),
@@ -275,6 +284,43 @@ public fun ChannelScreen(
275284
}
276285
}
277286

287+
/**
288+
* When a screen reader is active, moves the reading cursor onto the message composer input as a
289+
* message view opens, without moving input focus, so the keyboard stays closed and the user
290+
* double-taps to type. A thread is also a message view, so this re-arms when the view switches
291+
* between the channel and a thread.
292+
*
293+
* The message list loads asynchronously and the screen reader re-asserts its own initial focus when
294+
* content appears, so the cursor is re-applied while the list settles (keyed on the item count) and
295+
* for a short window, then it stops so later incoming messages do not pull focus back.
296+
*
297+
* @param listViewModel The [MessageListViewModel] whose loading state drives the re-apply.
298+
*/
299+
@Composable
300+
private fun ComposerScreenReaderEntryFocus(listViewModel: MessageListViewModel) {
301+
val isTouchExplorationEnabled = rememberIsTouchExplorationEnabled()
302+
if (!isTouchExplorationEnabled) return
303+
304+
val view = LocalView.current
305+
val messagesState by listViewModel.currentMessagesState
306+
// Null in the channel, the parent message id in a thread; switching either way is a new entry.
307+
val viewKey = messagesState.parentMessageId
308+
var entryFocusSettled by rememberSaveable(viewKey) { mutableStateOf(false) }
309+
310+
LaunchedEffect(viewKey, messagesState.messageItems.size) {
311+
if (entryFocusSettled) return@LaunchedEffect
312+
// Defer a frame so the composer is laid out before moving the cursor onto it.
313+
withFrameNanos {}
314+
view.requestAccessibilityFocusForTestTag(ComposerInputTestTag)
315+
}
316+
LaunchedEffect(viewKey) {
317+
delay(ComposerEntryFocusWindowMs)
318+
entryFocusSettled = true
319+
}
320+
}
321+
322+
private const val ComposerEntryFocusWindowMs = 2000L
323+
278324
@Composable
279325
private fun ChannelScreenContentBox(content: @Composable BoxScope.() -> Unit) {
280326
val selectedMessageSnapshot = remember { mutableStateOf<SelectedMessageSnapshot?>(null) }
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.compose.ui.messages.composer
18+
19+
/**
20+
* Test tag of the message composer text input. Single source of truth so the tag the composer sets
21+
* stays in sync with consumers that look the node up by tag (e.g. moving screen-reader focus to it
22+
* on screen entry).
23+
*/
24+
internal const val ComposerInputTestTag: String = "Stream_ComposerInputField"

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/MessageComposerInputCenterContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.input.VisualTransformation
4646
import androidx.compose.ui.text.style.TextDecoration
4747
import androidx.compose.ui.text.style.TextOverflow
4848
import androidx.compose.ui.unit.dp
49+
import io.getstream.chat.android.compose.ui.messages.composer.ComposerInputTestTag
4950
import io.getstream.chat.android.compose.ui.theme.ChatTheme
5051
import io.getstream.chat.android.compose.ui.theme.StreamDesign
5152
import io.getstream.chat.android.compose.ui.theme.StreamTokens
@@ -85,7 +86,7 @@ internal fun MessageComposerInputCenterContent(
8586
BasicTextField(
8687
modifier = modifier
8788
.fillMaxWidth()
88-
.testTag("Stream_ComposerInputField")
89+
.testTag(ComposerInputTestTag)
8990
.heightIn(min = 48.dp),
9091
value = textState,
9192
onValueChange = {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.compose.ui.util
18+
19+
import android.view.View
20+
import android.view.accessibility.AccessibilityManager
21+
import android.view.accessibility.AccessibilityNodeInfo
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.DisposableEffect
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.platform.LocalContext
29+
import androidx.compose.ui.semantics.SemanticsNode
30+
import androidx.compose.ui.semantics.SemanticsOwner
31+
import androidx.compose.ui.semantics.SemanticsProperties
32+
import androidx.compose.ui.semantics.getOrNull
33+
import androidx.core.content.getSystemService
34+
35+
/**
36+
* Observes [AccessibilityManager.isTouchExplorationEnabled] and recomposes when it toggles.
37+
*
38+
* Used to gate behaviour that should only apply when an explore-by-touch service (e.g. TalkBack)
39+
* is active.
40+
*
41+
* @return `true` when an explore-by-touch service is active, `false` otherwise.
42+
*/
43+
@Composable
44+
internal fun rememberIsTouchExplorationEnabled(): Boolean {
45+
val context = LocalContext.current
46+
val manager = remember(context) { context.getSystemService<AccessibilityManager>() } ?: return false
47+
var enabled by remember(manager) { mutableStateOf(manager.isTouchExplorationEnabled) }
48+
DisposableEffect(manager) {
49+
val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled = it }
50+
manager.addTouchExplorationStateChangeListener(listener)
51+
enabled = manager.isTouchExplorationEnabled
52+
onDispose { manager.removeTouchExplorationStateChangeListener(listener) }
53+
}
54+
return enabled
55+
}
56+
57+
/**
58+
* Moves the screen-reader (accessibility) cursor to the composable tagged with [testTag] by
59+
* dispatching [AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS]. It moves only the reading cursor,
60+
* not input focus, so no keyboard opens and the field does not become the input target.
61+
*
62+
* Compose exposes no public API for this, so the node id is resolved from the [SemanticsOwner]
63+
* (the only non-public step). Fails safe: returns `false` and leaves focus untouched if anything
64+
* cannot be resolved.
65+
*
66+
* @param testTag The test tag of the node to focus.
67+
* @return `true` if the accessibility focus action was dispatched, `false` otherwise.
68+
*/
69+
internal fun View.requestAccessibilityFocusForTestTag(testTag: String): Boolean = runCatching {
70+
val nodeId = semanticsNodeIdForTestTag(testTag) ?: return@runCatching false
71+
val provider = accessibilityNodeProvider ?: return@runCatching false
72+
provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null)
73+
}.getOrDefault(false)
74+
75+
/**
76+
* Resolves the accessibility virtual-view id (the Compose semantics node id) of the node tagged with
77+
* [testTag] under this view's [SemanticsOwner]. Returns `null` if this view is not a Compose host,
78+
* the owner cannot be resolved, or no node carries the tag. Fails safe (never throws).
79+
*
80+
* @param testTag The test tag to look up.
81+
* @return The semantics node id, or `null` if it cannot be resolved.
82+
*/
83+
internal fun View.semanticsNodeIdForTestTag(testTag: String): Int? = runCatching {
84+
val owner = javaClass.getMethod("getSemanticsOwner").invoke(this) as? SemanticsOwner
85+
?: return@runCatching null
86+
owner.unmergedRootSemanticsNode.findByTestTag(testTag)?.id
87+
}.getOrNull()
88+
89+
private fun SemanticsNode.findByTestTag(testTag: String): SemanticsNode? =
90+
if (config.getOrNull(SemanticsProperties.TestTag) == testTag) {
91+
this
92+
} else {
93+
children.firstNotNullOfOrNull { it.findByTestTag(testTag) }
94+
}

stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/MessageComposerScreenTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ internal class MessageComposerScreenTest : MockedChatClientTest {
121121

122122
composeTestRule.onNodeWithText("9").assertExists()
123123
composeTestRule.onNodeWithText("Slow mode, wait 9s…").assertExists()
124-
composeTestRule.onNodeWithTag("Stream_ComposerInputField").assertIsNotEnabled()
124+
composeTestRule.onNodeWithTag(ComposerInputTestTag).assertIsNotEnabled()
125125
composeTestRule.onNodeWithTag("Stream_ComposerAttachmentsButton").assertIsNotEnabled()
126126
}
127127

@@ -138,7 +138,7 @@ internal class MessageComposerScreenTest : MockedChatClientTest {
138138
}
139139
}
140140

141-
composeTestRule.onNodeWithTag("Stream_ComposerInputField").assertIsEnabled()
141+
composeTestRule.onNodeWithTag(ComposerInputTestTag).assertIsEnabled()
142142
composeTestRule.onNodeWithTag("Stream_ComposerAttachmentsButton").assertIsEnabled()
143143
}
144144

0 commit comments

Comments
 (0)