a11y: do not use Overlay if screen reader is active, or external keyboard is connected.

Related to #6399
This commit is contained in:
Benoit Marty 2026-04-24 10:08:37 +02:00 committed by Benoit Marty
parent feb61437a2
commit 6de5ac6f9a
5 changed files with 92 additions and 13 deletions

View file

@ -143,6 +143,7 @@ class MessagesFlowNode(
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -227,10 +228,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails() 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( return processEventClick(
timelineMode = timelineMode, timelineMode = timelineMode,
event = event, event = event,
canUseOverlay = canUseOverlay,
) )
} }
@ -320,7 +322,11 @@ class MessagesFlowNode(
) )
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() { override fun onDone() {
overlay.hide() if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
} }
override fun viewInTimeline(eventId: EventId) { override fun viewInTimeline(eventId: EventId) {
@ -414,10 +420,11 @@ class MessagesFlowNode(
} }
NavTarget.PinnedMessagesList -> { NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback { val callback = object : PinnedMessagesListNode.Callback {
override fun handleEventClick(event: TimelineItem.Event) { override fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) {
processEventClick( processEventClick(
timelineMode = Timeline.Mode.PinnedEvents, timelineMode = Timeline.Mode.PinnedEvents,
event = event, event = event,
canUseOverlay = canUseOverlay,
) )
} }
@ -456,10 +463,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId, focusedEventId = navTarget.focusedEventId,
) )
val callback = object : ThreadedMessagesNode.Callback { 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( return processEventClick(
timelineMode = timelineMode, timelineMode = timelineMode,
event = event, event = event,
canUseOverlay = canUseOverlay,
) )
} }
@ -547,6 +555,7 @@ class MessagesFlowNode(
private fun processEventClick( private fun processEventClick(
timelineMode: Timeline.Mode, timelineMode: Timeline.Mode,
event: TimelineItem.Event, event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean { ): Boolean {
val navTarget = when (event.content) { val navTarget = when (event.content) {
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
@ -556,6 +565,7 @@ class MessagesFlowNode(
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
) )
} }
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
@ -565,6 +575,7 @@ class MessagesFlowNode(
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
) )
} }
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
@ -574,6 +585,7 @@ class MessagesFlowNode(
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
) )
} }
is TimelineItemAudioContent -> { is TimelineItemAudioContent -> {
@ -583,6 +595,7 @@ class MessagesFlowNode(
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
thumbnailSource = null, thumbnailSource = null,
canUseOverlay = canUseOverlay,
) )
} }
is TimelineItemLocationContent -> { is TimelineItemLocationContent -> {
@ -603,7 +616,11 @@ class MessagesFlowNode(
} }
return when (navTarget) { return when (navTarget) {
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
overlay.show(navTarget) if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
true true
} }
is NavTarget.LocationViewer -> { is NavTarget.LocationViewer -> {
@ -620,6 +637,7 @@ class MessagesFlowNode(
content: TimelineItemEventContentWithAttachment, content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource, mediaSource: MediaSource,
thumbnailSource: MediaSource?, thumbnailSource: MediaSource?,
canUseOverlay: Boolean,
): NavTarget { ): NavTarget {
return NavTarget.MediaViewer( return NavTarget.MediaViewer(
mode = mode, mode = mode,
@ -647,6 +665,7 @@ class MessagesFlowNode(
), ),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = thumbnailSource, thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
) )
} }

View file

@ -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.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings 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.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analytics.api.finishLongRunningTransaction
@ -115,7 +117,7 @@ class MessagesNode(
) )
interface Callback : Plugin { 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<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
@ -247,6 +249,7 @@ class MessagesNode(
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not() val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
@ -268,11 +271,11 @@ class MessagesNode(
onRoomDetailsClick = callback::navigateToRoomDetails, onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event -> onEventContentClick = { isLive, event ->
if (isLive) { if (isLive) {
callback.handleEventClick(timelineController.mainTimelineMode(), event) callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay)
} else { } else {
val detachedTimelineMode = timelineController.detachedTimelineMode() val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) { if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event) callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else { } else {
false false
} }

View file

@ -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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings 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) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@ -50,7 +52,7 @@ class PinnedMessagesListNode(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event) fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId) fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink) fun handlePermalinkClick(data: PermalinkData.RoomLink)
@ -103,6 +105,7 @@ class PinnedMessagesListNode(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
@ -113,7 +116,9 @@ class PinnedMessagesListNode(
PinnedMessagesListView( PinnedMessagesListView(
state = state, state = state,
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onEventClick = callback::handleEventClick, onEventClick = {
callback.handleEventClick(it, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) }, onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) }, onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = { onLinkLongClick = {

View file

@ -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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer 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.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -124,7 +126,7 @@ class ThreadedMessagesNode(
} }
interface Callback : Plugin { 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<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
@ -252,6 +254,7 @@ class ThreadedMessagesNode(
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not() val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
@ -271,11 +274,11 @@ class ThreadedMessagesNode(
onEventContentClick = { isLive, event -> onEventContentClick = { isLive, event ->
timelineController?.let { controller -> timelineController?.let { controller ->
if (isLive) { if (isLive) {
callback.handleEventClick(controller.mainTimelineMode(), event) callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay)
} else { } else {
val detachedTimelineMode = controller.detachedTimelineMode() val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) { if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event) callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else { } else {
false false
} }

View file

@ -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
}