All Downloads are FREE. Search and download functionalities are using the official Maven repository.

desktopMain.androidx.compose.ui.platform.a11y.AccessibilityController.kt Maven / Gradle / Ivy

/*
 * Copyright 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.ui.platform.a11y

import androidx.collection.mutableScatterMapOf
import androidx.compose.ui.platform.PlatformComponent
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.TextRange
import javax.accessibility.Accessible
import javax.accessibility.AccessibleComponent
import javax.accessibility.AccessibleContext.ACCESSIBLE_CARET_PROPERTY
import javax.accessibility.AccessibleContext.ACCESSIBLE_STATE_PROPERTY
import javax.accessibility.AccessibleContext.ACCESSIBLE_TEXT_PROPERTY
import javax.accessibility.AccessibleContext.ACCESSIBLE_VALUE_PROPERTY
import javax.accessibility.AccessibleState
import kotlin.coroutines.CoroutineContext
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch

/**
 * This class provides a mapping from compose tree of [owner] to tree of [ComposeAccessible],
 * so that each [SemanticsNode] has [ComposeAccessible].
 *
 * @param onFocusReceived a callback that will be called with [ComposeAccessible]
 * when a [SemanticsNode] from [owner] received a focus
 *
 * @see ComposeSceneAccessible
 * @see ComposeAccessible
 */
internal class AccessibilityController(
    val owner: SemanticsOwner,
    val desktopComponent: PlatformComponent,
    private val onFocusReceived: (ComposeAccessible) -> Unit
) {

    /**
     * Maps the [ComposeAccessible]s we have created by the [SemanticsNode.id] for which they were
     * created.
     */
    private var accessibleByNodeId = mutableScatterMapOf()

    /**
     * Whether [accessibleByNodeId] is up-to-date.
     */
    private var nodeMappingIsValid = false

    /**
     * Returns the [ComposeAccessible] associated with the given semantics node id.
     */
    fun accessibleByNodeId(nodeId: Int): ComposeAccessible? {
        if (!nodeMappingIsValid) {
            syncNodes()
        }

        return accessibleByNodeId[nodeId]
    }

    /**
     * Invoked when a new [ComposeAccessible] is created.
     */
    @Suppress("UNUSED_PARAMETER")
    private fun onNodeAdded(accessible: ComposeAccessible) {}

    /**
     * Invoked when a [ComposeAccessible] is removed.
     */
    private fun onNodeRemoved(accessible: ComposeAccessible) {
        accessible.removed = true
    }

    /**
     * Invoked when the [SemanticsNode] a [ComposeAccessible] represents changes.
     */
    private fun onNodeChanged(
        component: ComposeAccessible,
        previousSemanticsNode: SemanticsNode,
        newSemanticsNode: SemanticsNode
    ) {
        for (entry in newSemanticsNode.config) {
            val prev = previousSemanticsNode.config.getOrNull(entry.key)
            if (entry.value != prev) {
                when (entry.key) {
                    SemanticsProperties.Text -> {
                        component.composeAccessibleContext.firePropertyChange(
                            ACCESSIBLE_TEXT_PROPERTY,
                            prev, entry.value
                        )
                    }

                    SemanticsProperties.EditableText -> {
                        component.composeAccessibleContext.firePropertyChange(
                            ACCESSIBLE_TEXT_PROPERTY,
                            prev, entry.value
                        )
                    }

                    SemanticsProperties.TextSelectionRange -> {
                        component.composeAccessibleContext.firePropertyChange(
                            ACCESSIBLE_CARET_PROPERTY,
                            prev, (entry.value as TextRange).start
                        )
                    }

                    SemanticsProperties.Focused ->
                        if (entry.value as Boolean) {
                            component.composeAccessibleContext.firePropertyChange(
                                ACCESSIBLE_STATE_PROPERTY,
                                null, AccessibleState.FOCUSED
                            )
                            onFocusReceived(component)
                        } else {
                            component.composeAccessibleContext.firePropertyChange(
                                ACCESSIBLE_STATE_PROPERTY,
                                AccessibleState.FOCUSED, null
                            )
                        }

                    SemanticsProperties.ToggleableState -> {
                        when (entry.value as ToggleableState) {
                            ToggleableState.On ->
                                component.composeAccessibleContext.firePropertyChange(
                                    ACCESSIBLE_STATE_PROPERTY,
                                    null, AccessibleState.CHECKED
                                )

                            ToggleableState.Off, ToggleableState.Indeterminate ->
                                component.composeAccessibleContext.firePropertyChange(
                                    ACCESSIBLE_STATE_PROPERTY,
                                    AccessibleState.CHECKED, null
                                )
                        }
                    }

                    SemanticsProperties.ProgressBarRangeInfo -> {
                        val value = entry.value as ProgressBarRangeInfo
                        component.composeAccessibleContext.firePropertyChange(
                            ACCESSIBLE_VALUE_PROPERTY,
                            prev,
                            value.current
                        )
                    }
                }
            }
        }
    }

    /**
     * A channel that triggers the syncing of [ComposeAccessible]s with the semantics node tree.
     */
    private val nodeSyncChannel = Channel(Channel.RENDEZVOUS)

    /**
     * An [ArrayDeque] used in the BFS algorithm that syncs [ComposeAccessible]s with the semantics
     * tree node.
     *
     * This is kept just to avoid allocating a new one each time.
     */
    private val bfsDeque = ArrayDeque()

    /**
     * An auxiliary mapping of semantics node ids to [ComposeAccessible]s that is swapped with
     * [accessibleByNodeId] on each sync, to avoid allocating memory on each sync.
     */
    private var auxAccessibleByNodeId = mutableScatterMapOf()

    /**
     * A list of callbacks ([onNodeAdded], [onNodeRemoved], [onNodeChanged]) to be made after
     * syncing the semantics node tree is completed.
     *
     * This is kept just to avoid allocating a new one each time.
     */
    private val delayedNodeNotifications = mutableListOf<() -> Unit>()

    /**
     * The coroutine syncing the [ComposeAccessible]s with the semantics node tree.
     */
    private var syncingJob: Job? = null

    /**
     * Disposes of this [AccessibilityController], releasing any resources associated with it.
     */
    fun dispose() {
        syncingJob?.cancel()
    }

    /**
     * Launches a coroutine to continuously sync [ComposeAccessible]s with the semantics node tree.
     */
    fun launchSyncLoop(context: CoroutineContext) {
        if (syncingJob != null)
            throw IllegalStateException("Sync loop already running")

        syncingJob = CoroutineScope(context).launch {
            AccessibilityUsage.runActiveController(this@AccessibilityController) {
                while (true) {
                    nodeSyncChannel.receive()
                    syncNodes()
                }
            }
        }
    }

    /**
     * Syncs [accessibleByNodeId] with the semantics node tree.
     */
    private fun syncNodes() {
        fun SemanticsNode.isValid() = layoutNode.let { it.isPlaced && it.isAttached }

        // Build new mapping of ComposeAccessible by node id
        val previous = accessibleByNodeId
        val updated = auxAccessibleByNodeId
        if (rootSemanticNode.isValid())
            bfsDeque.add(rootSemanticNode)
        while (bfsDeque.isNotEmpty()) {
            val node = bfsDeque.removeFirst()

            val existingAccessible = previous[node.id]
            updated[node.id] = if (existingAccessible != null) {
                val prevSemanticsNode = existingAccessible.semanticsNode
                existingAccessible.semanticsNode = node
                delayedNodeNotifications.add {
                    onNodeChanged(existingAccessible, prevSemanticsNode, node)
                }
                existingAccessible
            }
            else {
                val newAccessible = ComposeAccessible(node, this)
                delayedNodeNotifications.add {
                    onNodeAdded(newAccessible)
                }
                newAccessible
            }

            for (child in node.replacedChildren.asReversed()) {
                if (child.isValid()) {
                    bfsDeque.add(child)
                }
            }
        }

        // Call onNodeRemoved with nodes that no longer exist
        previous.forEach { id, node ->
            if (id !in updated) {
                delayedNodeNotifications.add {
                    onNodeRemoved(node)
                }
            }
        }
        auxAccessibleByNodeId = previous.also { it.clear() }
        accessibleByNodeId = updated
        nodeMappingIsValid = true

        // Call the onNodeX functions
        for (notification in delayedNodeNotifications) {
            notification()
        }
        delayedNodeNotifications.clear()
    }

    /**
     * Schedules [syncNodes] to be called later.
     */
    private fun scheduleNodeSyncIfNeeded() {
        if (AccessibilityUsage.recentlyUsed && !nodeMappingIsValid) {
            nodeSyncChannel.trySend(Unit)
        }
    }

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

    /**
     * Invoked when the position and/or size of the [SemanticsNode] with the given semantics id
     * changed.
     */
    fun onLayoutChanged(@Suppress("UNUSED_PARAMETER") nodeId: Int) {
        // TODO: Only recompute the layout-related properties of the node
        nodeMappingIsValid = false
        scheduleNodeSyncIfNeeded()
    }

    /**
     * The [SemanticsNode] that is the root of the semantics node tree.
     */
    private val rootSemanticNode: SemanticsNode
        get() = owner.rootSemanticsNode

    /**
     * The [ComposeAccessible] associated with the root of the semantics node tree.
     */
    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)
            }
        }
    }
}

/**
 * Prints debugging info of the given [Accessible].
 */
internal fun Accessible.print(level: Int = 0) {
    val id = if (this is ComposeAccessible) {
        this.semanticsNode.id.toString()
    } else {
        "unknown"
    }
    with(accessibleContext) {
        println(
            buildString {
                append("\t".repeat(level))
                append("ID: ").append(id)
                append(" Name: ").append(accessibleName)
                append(" Description: ").append(accessibleDescription)
                append(" Role: ").append(accessibleRole)
                append(" Bounds: ").append((this@with as? AccessibleComponent)?.bounds)
            }
        )

        for (childIndex in 0  until accessibleChildrenCount) {
            getAccessibleChild(childIndex).print(level + 1)
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy