Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync all AccessibilityControllers when an a11y query is received. #1283

Merged
merged 3 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -308,16 +282,18 @@ 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)
}
}

/**
* Invoked when the semantics node tree changes.
*/
fun onSemanticsChange() {
nodeMappingIsValid = false
scheduleNodeSync()
scheduleNodeSyncIfNeeded()
}

/**
Expand All @@ -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()
}

/**
Expand All @@ -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<AccessibilityController>()

/**
* 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)
}
}
}
}

/**
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading