a11y: do not use Overlay if screen reader is active, or external keyboard is connected.
Related to #6399
This commit is contained in:
parent
feb61437a2
commit
6de5ac6f9a
5 changed files with 92 additions and 13 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue