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..5372f358e25 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 list view opens, without moving input focus, so the keyboard stays closed and the user + * double-taps to type. A thread is also a message list 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/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/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..0437192c0ed --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/AccessibilityUtilsTest.kt @@ -0,0 +1,140 @@ +/* + * 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")) + } + + @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 +private fun TaggedNode(tag: String) { + Box( + modifier = Modifier + .size(48.dp) + .testTag(tag) + .semantics { contentDescription = "tagged" }, + ) +}