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 dcd0a98c0c
commit 5e963fc743
5 changed files with 92 additions and 13 deletions

View file

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

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.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<Attachment>, 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
}

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.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 = {

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.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<Attachment>, 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
}

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
}