From 5e963fc743b3a279db90006cb63ec2fe853f2176 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 24 Apr 2026 10:08:37 +0200 Subject: [PATCH] a11y: do not use Overlay if screen reader is active, or external keyboard is connected. Related to #6399 --- .../messages/impl/MessagesFlowNode.kt | 29 +++++++++-- .../features/messages/impl/MessagesNode.kt | 9 ++-- .../pinned/list/PinnedMessagesListNode.kt | 9 +++- .../impl/threads/ThreadedMessagesNode.kt | 9 ++-- .../ui/utils/a11y/hasExternalKeyboard.kt | 49 +++++++++++++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/a11y/hasExternalKeyboard.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 36e94ec456..4079a540f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -143,6 +143,7 @@ class MessagesFlowNode( val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val canUseOverlay: Boolean, ) : NavTarget @Parcelize @@ -227,10 +228,11 @@ class MessagesFlowNode( callback.navigateToRoomDetails() } - override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { + override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean { return processEventClick( timelineMode = timelineMode, event = event, + canUseOverlay = canUseOverlay, ) } @@ -320,7 +322,11 @@ class MessagesFlowNode( ) val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() { - overlay.hide() + if (navTarget.canUseOverlay) { + overlay.hide() + } else { + backstack.pop() + } } override fun viewInTimeline(eventId: EventId) { @@ -414,10 +420,11 @@ class MessagesFlowNode( } NavTarget.PinnedMessagesList -> { val callback = object : PinnedMessagesListNode.Callback { - override fun handleEventClick(event: TimelineItem.Event) { + override fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) { processEventClick( timelineMode = Timeline.Mode.PinnedEvents, event = event, + canUseOverlay = canUseOverlay, ) } @@ -456,10 +463,11 @@ class MessagesFlowNode( focusedEventId = navTarget.focusedEventId, ) val callback = object : ThreadedMessagesNode.Callback { - override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { + override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean { return processEventClick( timelineMode = timelineMode, event = event, + canUseOverlay = canUseOverlay, ) } @@ -547,6 +555,7 @@ class MessagesFlowNode( private fun processEventClick( timelineMode: Timeline.Mode, event: TimelineItem.Event, + canUseOverlay: Boolean, ): Boolean { val navTarget = when (event.content) { is TimelineItemImageContent -> { @@ -556,6 +565,7 @@ class MessagesFlowNode( content = event.content, mediaSource = event.content.mediaSource, thumbnailSource = event.content.thumbnailSource, + canUseOverlay = canUseOverlay, ) } is TimelineItemVideoContent -> { @@ -565,6 +575,7 @@ class MessagesFlowNode( content = event.content, mediaSource = event.content.mediaSource, thumbnailSource = event.content.thumbnailSource, + canUseOverlay = canUseOverlay, ) } is TimelineItemFileContent -> { @@ -574,6 +585,7 @@ class MessagesFlowNode( content = event.content, mediaSource = event.content.mediaSource, thumbnailSource = event.content.thumbnailSource, + canUseOverlay = canUseOverlay, ) } is TimelineItemAudioContent -> { @@ -583,6 +595,7 @@ class MessagesFlowNode( content = event.content, mediaSource = event.content.mediaSource, thumbnailSource = null, + canUseOverlay = canUseOverlay, ) } is TimelineItemLocationContent -> { @@ -603,7 +616,11 @@ class MessagesFlowNode( } return when (navTarget) { is NavTarget.MediaViewer -> { - overlay.show(navTarget) + if (canUseOverlay) { + overlay.show(navTarget) + } else { + backstack.push(navTarget) + } true } is NavTarget.LocationViewer -> { @@ -620,6 +637,7 @@ class MessagesFlowNode( content: TimelineItemEventContentWithAttachment, mediaSource: MediaSource, thumbnailSource: MediaSource?, + canUseOverlay: Boolean, ): NavTarget { return NavTarget.MediaViewer( mode = mode, @@ -647,6 +665,7 @@ class MessagesFlowNode( ), mediaSource = mediaSource, thumbnailSource = thumbnailSource, + canUseOverlay = canUseOverlay, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index a2cf4a3da0..f8c54d284e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -68,6 +68,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard +import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.finishLongRunningTransaction @@ -115,7 +117,7 @@ class MessagesNode( ) interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -247,6 +249,7 @@ class MessagesNode( override fun View(modifier: Modifier) { val activity = requireNotNull(LocalActivity.current) val isDark = ElementTheme.isLightTheme.not() + val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { @@ -268,11 +271,11 @@ class MessagesNode( onRoomDetailsClick = callback::navigateToRoomDetails, onEventContentClick = { isLive, event -> if (isLive) { - callback.handleEventClick(timelineController.mainTimelineMode(), event) + callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay) } else { val detachedTimelineMode = timelineController.detachedTimelineMode() if (detachedTimelineMode != null) { - callback.handleEventClick(detachedTimelineMode, event) + callback.handleEventClick(detachedTimelineMode, event, canUseOverlay) } else { false } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index cddc1831db..9800f0296a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard +import io.element.android.libraries.ui.utils.time.isTalkbackActive @ContributesNode(RoomScope::class) @AssistedInject @@ -50,7 +52,7 @@ class PinnedMessagesListNode( private val permalinkParser: PermalinkParser, ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { - fun handleEventClick(event: TimelineItem.Event) + fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) fun navigateToRoomMemberDetails(userId: UserId) fun viewInTimeline(eventId: EventId) fun handlePermalinkClick(data: PermalinkData.RoomLink) @@ -103,6 +105,7 @@ class PinnedMessagesListNode( @Composable override fun View(modifier: Modifier) { + val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { @@ -113,7 +116,9 @@ class PinnedMessagesListNode( PinnedMessagesListView( state = state, onBackClick = ::navigateUp, - onEventClick = callback::handleEventClick, + onEventClick = { + callback.handleEventClick(it, canUseOverlay) + }, onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) }, onLinkClick = { link -> onLinkClick(context, link.url) }, onLinkLongClick = { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 0949237862..c51c24c168 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -68,6 +68,8 @@ import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard +import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.collections.immutable.ImmutableList @@ -124,7 +126,7 @@ class ThreadedMessagesNode( } interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -252,6 +254,7 @@ class ThreadedMessagesNode( override fun View(modifier: Modifier) { val activity = requireNotNull(LocalActivity.current) val isDark = ElementTheme.isLightTheme.not() + val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard() CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { @@ -271,11 +274,11 @@ class ThreadedMessagesNode( onEventContentClick = { isLive, event -> timelineController?.let { controller -> if (isLive) { - callback.handleEventClick(controller.mainTimelineMode(), event) + callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay) } else { val detachedTimelineMode = controller.detachedTimelineMode() if (detachedTimelineMode != null) { - callback.handleEventClick(detachedTimelineMode, event) + callback.handleEventClick(detachedTimelineMode, event, canUseOverlay) } else { false } diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/a11y/hasExternalKeyboard.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/a11y/hasExternalKeyboard.kt new file mode 100644 index 0000000000..ed35cfbc3e --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/a11y/hasExternalKeyboard.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.a11y + +import android.app.Activity +import android.app.Application +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.LocalActivity +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 + +@Composable +fun hasExternalKeyboard(): Boolean { + val activity = requireNotNull(LocalActivity.current) + var hasExternalKeyboard by remember { mutableStateOf(activity.resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + DisposableEffect(Unit) { + val callback = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) { + // We do not have access to onActivityConfigurationChanged, so update the value when tha Activity is resumed + hasExternalKeyboard = activity.resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS + } + + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + activity.registerActivityLifecycleCallbacks(callback) + onDispose { + activity.unregisterActivityLifecycleCallbacks(callback) + } + } + } + return hasExternalKeyboard +}