diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/AccessibilityController.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/AccessibilityController.kt index 47dcded1a1eb3..77649eef8f319 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/AccessibilityController.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/AccessibilityController.kt @@ -166,17 +166,6 @@ internal class AccessibilityController( } } - /** - * Called to notify us when an accessibility call is received from the system. - * - * This starts a process that actively synchronized the [ComposeAccessible]s with the semantics - * node tree. - */ - fun notifyIsInUse() { - lastUseTimeNanos = System.nanoTime() - scheduleNodeSync() - } - /** * A channel that triggers the syncing of [ComposeAccessible]s with the semantics node tree. */ @@ -204,21 +193,6 @@ internal class AccessibilityController( */ private val delayedNodeNotifications = mutableListOf<() -> Unit>() - /** - * The time of the latest accessibility call from the system. - */ - // Set initial value such that accessibilityRecentlyUsed is initially `false` - private var lastUseTimeNanos: Long = System.nanoTime() - (MaxIdleTimeNanos + 1) - - /** - * Whether an accessibility call from the system has been received "recently". - * - * When this returns `false` the active syncing of [ComposeAccessible]s with the semantics node - * tree is paused. - */ - private val accessibilityRecentlyUsed - get() = System.nanoTime() - lastUseTimeNanos < MaxIdleTimeNanos - /** * The coroutine syncing the [ComposeAccessible]s with the semantics node tree. */ @@ -239,9 +213,9 @@ internal class AccessibilityController( throw IllegalStateException("Sync loop already running") syncingJob = CoroutineScope(context).launch { - while (true) { - nodeSyncChannel.receive() - if (accessibilityRecentlyUsed && !nodeMappingIsValid) { + AccessibilityUsage.runActiveController(this@AccessibilityController) { + while (true) { + nodeSyncChannel.receive() syncNodes() } } @@ -308,8 +282,10 @@ internal class AccessibilityController( /** * Schedules [syncNodes] to be called later. */ - private fun scheduleNodeSync() { - nodeSyncChannel.trySend(Unit) + private fun scheduleNodeSyncIfNeeded() { + if (AccessibilityUsage.recentlyUsed && !nodeMappingIsValid) { + nodeSyncChannel.trySend(Unit) + } } /** @@ -317,7 +293,7 @@ internal class AccessibilityController( */ fun onSemanticsChange() { nodeMappingIsValid = false - scheduleNodeSync() + scheduleNodeSyncIfNeeded() } /** @@ -327,7 +303,7 @@ internal class AccessibilityController( fun onLayoutChanged(@Suppress("UNUSED_PARAMETER") nodeId: Int) { // TODO: Only recompute the layout-related properties of the node nodeMappingIsValid = false - scheduleNodeSync() + scheduleNodeSyncIfNeeded() } /** @@ -341,6 +317,77 @@ internal class AccessibilityController( */ val rootAccessible: ComposeAccessible get() = accessibleByNodeId(rootSemanticNode.id)!! + + /** + * Holds how recently the system has queried the program's accessibility state and manages + * enabling/disabling the syncing of [AccessibilityController]s with the semantic tree when the + * system has not queried the program's accessibility state for a while. + */ + object AccessibilityUsage { + + /** + * The time before we stop actively syncing [ComposeAccessible]s with the semantics node + * tree if we don't receive any accessibility calls from the system. + */ + private val MaxIdleTimeNanos = 5.minutes.inWholeNanoseconds + + /** + * The set of "live" [AccessibilityController]s. + */ + private val activeControllers = mutableSetOf() + + /** + * The time of the latest accessibility call from the system. + */ + // Set initial value such that accessibilityRecentlyUsed is initially `false` + private var lastUseTimeNanos: Long = System.nanoTime() - (MaxIdleTimeNanos + 1) + + /** + * Resets this object to its initial state. This is needed for tests. + */ + internal fun reset() { + assert(activeControllers.isEmpty()) + lastUseTimeNanos = System.nanoTime() - (MaxIdleTimeNanos + 1) + } + + /** + * Called to notify us when an accessibility query is received from the system. + * + * This starts a process that actively synchronized the [ComposeAccessible]s with the + * semantics node tree. + */ + fun notifyInUse() { + lastUseTimeNanos = System.nanoTime() + for (controller in activeControllers) { + controller.scheduleNodeSyncIfNeeded() + } + } + + /** + * Whether an accessibility call from the system has been received "recently". + * + * When this returns `false` the active syncing of [ComposeAccessible]s with the semantics + * node tree is paused. + */ + val recentlyUsed + get() = System.nanoTime() - lastUseTimeNanos < MaxIdleTimeNanos + + + /** + * Registers the given controller as an active one until [block] returns. + */ + suspend fun runActiveController( + controller: AccessibilityController, + block: suspend () -> Unit + ) { + try { + activeControllers.add(controller) + block() + } finally { + activeControllers.remove(controller) + } + } + } } /** @@ -369,9 +416,3 @@ internal fun Accessible.print(level: Int = 0) { } } } - -/** - * The time before we stop actively syncing [ComposeAccessible]s with the semantics node tree if we - * don't receive any accessibility calls from the system. - */ -private val MaxIdleTimeNanos = 5.minutes.inWholeNanoseconds diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/ComposeAccessible.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/ComposeAccessible.kt index b8db5e39ba78c..9cb1f2616d09a 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/ComposeAccessible.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/ComposeAccessible.kt @@ -413,7 +413,7 @@ internal class ComposeAccessible( // ----------------------------------- override fun getAccessibleRole(): AccessibleRole { - controller.notifyIsInUse() + AccessibilityController.AccessibilityUsage.notifyInUse() val fromSemanticRole = when (semanticsConfig.getOrNull(SemanticsProperties.Role)) { Role.Button -> AccessibleRole.PUSH_BUTTON Role.Checkbox -> AccessibleRole.CHECK_BOX diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt index e0f2fafc8d132..749259e1588fd 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.InternalTestApi import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SkikoComposeUiTest -import androidx.compose.ui.test.defaultTestDispatcher import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.runInternalSkikoComposeUiTest import androidx.compose.ui.toDpSize @@ -53,10 +52,11 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import java.awt.Point import javax.accessibility.AccessibleComponent +import javax.accessibility.AccessibleContext import javax.accessibility.AccessibleRole import javax.accessibility.AccessibleText -import javax.accessibility.AccessibleContext import kotlin.test.fail +import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Assert.assertEquals import org.junit.Test @@ -239,7 +239,10 @@ private fun runDesktopA11yTest(block: ComposeA11yTestScope.() -> Unit) { semanticsOwnerListener.accessibilityControllers } - val testDispatcher = defaultTestDispatcher() + // Reset the a11y usage, to avoid having one test affect the next + AccessibilityController.AccessibilityUsage.reset() + + val testDispatcher = StandardTestDispatcher() runInternalSkikoComposeUiTest( semanticsOwnerListener = semanticsOwnerListener, coroutineDispatcher = testDispatcher