Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions stream-chat-android-compose/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<AccessibilityManager>() } ?: 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -193,6 +200,8 @@ public fun ChannelScreen(

BackHandler(enabled = true, onBack = backAction)

ComposerScreenReaderEntryFocus(listViewModel)

ChannelScreenContentBox {
Scaffold(
modifier = Modifier.fillMaxSize(),
Expand Down Expand Up @@ -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<SelectedMessageSnapshot?>(null) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,7 +86,7 @@ internal fun MessageComposerInputCenterContent(
BasicTextField(
modifier = modifier
.fillMaxWidth()
.testTag("Stream_ComposerInputField")
.testTag(ComposerInputTestTag)
.heightIn(min = 48.dp),
value = textState,
onValueChange = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AccessibilityManager>() } ?: 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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<Context>()
.getSystemService<AccessibilityManager>()!!
Shadows.shadowOf(manager).setTouchExplorationEnabled(enabled)
}
}

private const val EntryFocusWindowElapsedMs = 2_100L

@Composable
private fun ChannelScreen() {
ChannelScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -138,7 +138,7 @@ internal class MessageComposerScreenTest : MockedChatClientTest {
}
}

composeTestRule.onNodeWithTag("Stream_ComposerInputField").assertIsEnabled()
composeTestRule.onNodeWithTag(ComposerInputTestTag).assertIsEnabled()
composeTestRule.onNodeWithTag("Stream_ComposerAttachmentsButton").assertIsEnabled()
}

Expand Down
Loading
Loading