1 /*
<lambda>null2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.platform
18 
19 import android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK
20 import android.content.Context
21 import android.content.res.Resources
22 import android.graphics.RectF
23 import android.os.Build
24 import android.os.Bundle
25 import android.os.Handler
26 import android.os.Looper
27 import android.os.SystemClock
28 import android.text.SpannableString
29 import android.util.Log
30 import android.view.MotionEvent
31 import android.view.View
32 import android.view.accessibility.AccessibilityEvent
33 import android.view.accessibility.AccessibilityManager
34 import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener
35 import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener
36 import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH
37 import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX
38 import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
39 import androidx.annotation.IntRange
40 import androidx.annotation.RequiresApi
41 import androidx.annotation.VisibleForTesting
42 import androidx.collection.ArraySet
43 import androidx.collection.IntObjectMap
44 import androidx.collection.MutableIntIntMap
45 import androidx.collection.MutableIntObjectMap
46 import androidx.collection.MutableIntSet
47 import androidx.collection.MutableObjectIntMap
48 import androidx.collection.SparseArrayCompat
49 import androidx.collection.intListOf
50 import androidx.collection.intObjectMapOf
51 import androidx.collection.mutableIntListOf
52 import androidx.collection.mutableIntObjectMapOf
53 import androidx.collection.mutableIntSetOf
54 import androidx.collection.mutableObjectIntMapOf
55 import androidx.compose.ui.ComposeUiFlags.isFocusActionExitsTouchModeEnabled
56 import androidx.compose.ui.ExperimentalComposeUiApi
57 import androidx.compose.ui.R
58 import androidx.compose.ui.contentcapture.ContentCaptureManager
59 import androidx.compose.ui.focus.FocusDirection.Companion.Exit
60 import androidx.compose.ui.geometry.Offset
61 import androidx.compose.ui.geometry.Rect
62 import androidx.compose.ui.internal.checkPreconditionNotNull
63 import androidx.compose.ui.layout.boundsInParent
64 import androidx.compose.ui.layout.positionInRoot
65 import androidx.compose.ui.node.HitTestResult
66 import androidx.compose.ui.node.LayoutNode
67 import androidx.compose.ui.node.Nodes
68 import androidx.compose.ui.node.requireLayoutNode
69 import androidx.compose.ui.platform.accessibility.hasCollectionInfo
70 import androidx.compose.ui.platform.accessibility.setCollectionInfo
71 import androidx.compose.ui.platform.accessibility.setCollectionItemInfo
72 import androidx.compose.ui.semantics.AccessibilityAction
73 import androidx.compose.ui.semantics.CustomAccessibilityAction
74 import androidx.compose.ui.semantics.LiveRegionMode
75 import androidx.compose.ui.semantics.ProgressBarRangeInfo
76 import androidx.compose.ui.semantics.Role
77 import androidx.compose.ui.semantics.Role.Companion.Carousel
78 import androidx.compose.ui.semantics.ScrollAxisRange
79 import androidx.compose.ui.semantics.SemanticsActions
80 import androidx.compose.ui.semantics.SemanticsActions.CustomActions
81 import androidx.compose.ui.semantics.SemanticsActions.PageDown
82 import androidx.compose.ui.semantics.SemanticsActions.PageLeft
83 import androidx.compose.ui.semantics.SemanticsActions.PageRight
84 import androidx.compose.ui.semantics.SemanticsActions.PageUp
85 import androidx.compose.ui.semantics.SemanticsActions.RequestFocus
86 import androidx.compose.ui.semantics.SemanticsConfiguration
87 import androidx.compose.ui.semantics.SemanticsNode
88 import androidx.compose.ui.semantics.SemanticsNodeWithAdjustedBounds
89 import androidx.compose.ui.semantics.SemanticsProperties
90 import androidx.compose.ui.semantics.SemanticsPropertiesAndroid
91 import androidx.compose.ui.semantics.getAllUncoveredSemanticsNodesToIntObjectMap
92 import androidx.compose.ui.semantics.getOrNull
93 import androidx.compose.ui.semantics.isHidden
94 import androidx.compose.ui.semantics.isImportantForAccessibility
95 import androidx.compose.ui.semantics.subtreeSortedByGeometryGrouping
96 import androidx.compose.ui.state.ToggleableState
97 import androidx.compose.ui.text.AnnotatedString
98 import androidx.compose.ui.text.InternalTextApi
99 import androidx.compose.ui.text.font.FontFamily
100 import androidx.compose.ui.text.platform.URLSpanCache
101 import androidx.compose.ui.text.platform.toAccessibilitySpannableString
102 import androidx.compose.ui.unit.LayoutDirection
103 import androidx.compose.ui.unit.toRect
104 import androidx.compose.ui.unit.toSize
105 import androidx.compose.ui.util.fastCoerceIn
106 import androidx.compose.ui.util.fastForEach
107 import androidx.compose.ui.util.fastForEachIndexed
108 import androidx.compose.ui.util.fastJoinToString
109 import androidx.compose.ui.util.fastRoundToInt
110 import androidx.compose.ui.util.trace
111 import androidx.core.view.AccessibilityDelegateCompat
112 import androidx.core.view.ViewCompat.ACCESSIBILITY_LIVE_REGION_ASSERTIVE
113 import androidx.core.view.ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE
114 import androidx.core.view.accessibility.AccessibilityEventCompat
115 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
116 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
117 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.FOCUS_ACCESSIBILITY
118 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.FOCUS_INPUT
119 import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
120 import androidx.lifecycle.Lifecycle
121 import kotlin.math.abs
122 import kotlin.math.ceil
123 import kotlin.math.floor
124 import kotlin.math.max
125 import kotlin.math.min
126 import kotlin.math.roundToInt
127 import kotlin.math.sign
128 import kotlinx.coroutines.channels.Channel
129 import kotlinx.coroutines.delay
130 
131 private fun LayoutNode.findClosestParentNode(selector: (LayoutNode) -> Boolean): LayoutNode? {
132     var currentParent = this.parent
133     while (currentParent != null) {
134         if (selector(currentParent)) {
135             return currentParent
136         } else {
137             currentParent = currentParent.parent
138         }
139     }
140 
141     return null
142 }
143 
144 @Suppress("NullAnnotationGroup")
145 @OptIn(InternalTextApi::class)
146 internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidComposeView) :
147     AccessibilityDelegateCompat() {
148     @Suppress("ConstPropertyName")
149     companion object {
150         /** Virtual node identifier value for invalid nodes. */
151         const val InvalidId = Integer.MIN_VALUE
152         const val ClassName = "android.view.View"
153         const val TextFieldClassName = "android.widget.EditText"
154         const val TextClassName = "android.widget.TextView"
155         const val LogTag = "AccessibilityDelegate"
156         const val ExtraDataTestTagKey = "androidx.compose.ui.semantics.testTag"
157         const val ExtraDataIdKey = "androidx.compose.ui.semantics.id"
158 
159         /**
160          * Intent size limitations prevent sending over a megabyte of data. Limit text length to
161          * 100K characters - 200KB.
162          */
163         const val ParcelSafeTextLength = 100000
164 
165         /** The undefined cursor position. */
166         const val AccessibilityCursorPositionUndefined = -1
167 
168         // 20 is taken from AbsSeekbar.java.
169         const val AccessibilitySliderStepsCount = 20
170 
171         /**
172          * Timeout to determine whether a text selection changed event and the pending text
173          * traversed event could be resulted from the same traverse action.
174          */
175         const val TextTraversedEventTimeoutMillis: Long = 1000
176         private val AccessibilityActionsResourceIds =
177             intListOf(
178                 R.id.accessibility_custom_action_0,
179                 R.id.accessibility_custom_action_1,
180                 R.id.accessibility_custom_action_2,
181                 R.id.accessibility_custom_action_3,
182                 R.id.accessibility_custom_action_4,
183                 R.id.accessibility_custom_action_5,
184                 R.id.accessibility_custom_action_6,
185                 R.id.accessibility_custom_action_7,
186                 R.id.accessibility_custom_action_8,
187                 R.id.accessibility_custom_action_9,
188                 R.id.accessibility_custom_action_10,
189                 R.id.accessibility_custom_action_11,
190                 R.id.accessibility_custom_action_12,
191                 R.id.accessibility_custom_action_13,
192                 R.id.accessibility_custom_action_14,
193                 R.id.accessibility_custom_action_15,
194                 R.id.accessibility_custom_action_16,
195                 R.id.accessibility_custom_action_17,
196                 R.id.accessibility_custom_action_18,
197                 R.id.accessibility_custom_action_19,
198                 R.id.accessibility_custom_action_20,
199                 R.id.accessibility_custom_action_21,
200                 R.id.accessibility_custom_action_22,
201                 R.id.accessibility_custom_action_23,
202                 R.id.accessibility_custom_action_24,
203                 R.id.accessibility_custom_action_25,
204                 R.id.accessibility_custom_action_26,
205                 R.id.accessibility_custom_action_27,
206                 R.id.accessibility_custom_action_28,
207                 R.id.accessibility_custom_action_29,
208                 R.id.accessibility_custom_action_30,
209                 R.id.accessibility_custom_action_31
210             )
211     }
212 
213     // TODO(b/272068594): The current tests assert whether this variable was set. We should instead
214     //  assert the behavior that is affected based on the value set here. (Eg, If we have a
215     //  previously hovered item, we send a hover exit event when a new item is hovered).
216     /** Virtual view id for the currently hovered logical item. */
217     @VisibleForTesting internal var hoveredVirtualViewId = InvalidId
218 
219     // We could use UiAutomation.OnAccessibilityEventListener, but the tests were
220     // flaky, so we use this callback to test accessibility events.
221     @VisibleForTesting
<lambda>null222     internal var onSendAccessibilityEvent: (AccessibilityEvent) -> Boolean = {
223         view.parent.requestSendAccessibilityEvent(view, it)
224     }
225 
226     private val accessibilityManager: AccessibilityManager =
227         view.context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
228 
229     internal var accessibilityForceEnabledForTesting = false
230         set(value) {
231             field = value
232             currentSemanticsNodesInvalidated = true
233         }
234 
235     /**
236      * Delay before dispatching a recurring accessibility event in milliseconds. This delay
237      * guarantees that a recurring event will be send at most once during the
238      * [SendRecurringAccessibilityEventsIntervalMillis] time frame.
239      */
240     internal var SendRecurringAccessibilityEventsIntervalMillis = 100L
241 
enablednull242     private val enabledStateListener = AccessibilityStateChangeListener { enabled ->
243         enabledServices =
244             if (enabled) {
245                 accessibilityManager.getEnabledAccessibilityServiceList(FEEDBACK_ALL_MASK)
246             } else {
247                 emptyList()
248             }
249     }
250 
<lambda>null251     private val touchExplorationStateListener = TouchExplorationStateChangeListener {
252         enabledServices = accessibilityManager.getEnabledAccessibilityServiceList(FEEDBACK_ALL_MASK)
253     }
254 
255     private var enabledServices =
256         accessibilityManager.getEnabledAccessibilityServiceList(FEEDBACK_ALL_MASK)
257 
258     /**
259      * True if any accessibility service enabled in the system, except the UIAutomator (as it
260      * doesn't appear in the list of enabled services)
261      */
262     internal val isEnabled: Boolean
263         get() =
264             accessibilityForceEnabledForTesting ||
265                 // checking the list allows us to filter out the UIAutomator which doesn't appear in
266                 // it
267                 (accessibilityManager.isEnabled && enabledServices.isNotEmpty())
268 
269     /**
270      * True if accessibility service with the touch exploration (e.g. Talkback) is enabled in the
271      * system. Note that UIAutomator doesn't request touch exploration therefore returns false
272      */
273     private val isTouchExplorationEnabled
274         get() =
275             accessibilityForceEnabledForTesting ||
276                 (accessibilityManager.isEnabled && accessibilityManager.isTouchExplorationEnabled)
277 
278     private val handler = Handler(Looper.getMainLooper())
279     private var nodeProvider = ComposeAccessibilityNodeProvider()
280 
281     private var accessibilityFocusedVirtualViewId = InvalidId
282     private var focusedVirtualViewId = InvalidId
283     private var currentlyAccessibilityFocusedANI: AccessibilityNodeInfoCompat? = null
284     private var currentlyFocusedANI: AccessibilityNodeInfoCompat? = null
285     private var sendingFocusAffectingEvent = false
286     private val pendingHorizontalScrollEvents = MutableIntObjectMap<ScrollAxisRange>()
287     private val pendingVerticalScrollEvents = MutableIntObjectMap<ScrollAxisRange>()
288 
289     // For actionIdToId and labelToActionId, the keys are the virtualViewIds. The value of
290     // actionIdToLabel holds assigned custom action id to custom action label mapping. The
291     // value of labelToActionId holds custom action label to assigned custom action id mapping.
292     private var actionIdToLabel = SparseArrayCompat<SparseArrayCompat<CharSequence>>()
293     private var labelToActionId = SparseArrayCompat<MutableObjectIntMap<CharSequence>>()
294     private var accessibilityCursorPosition = AccessibilityCursorPositionUndefined
295 
296     // We hold this node id to reset the [accessibilityCursorPosition] to undefined when
297     // traversal with granularity switches to the next node
298     private var previousTraversedNode: Int? = null
299     private val subtreeChangedLayoutNodes = ArraySet<LayoutNode>()
300     private val boundsUpdateChannel = Channel<Unit>(1)
301     private var currentSemanticsNodesInvalidated = true
302 
303     private class PendingTextTraversedEvent(
304         val node: SemanticsNode,
305         val action: Int,
306         val granularity: Int,
307         val fromIndex: Int,
308         val toIndex: Int,
309         val traverseTime: Long
310     )
311 
312     private var pendingTextTraversedEvent: PendingTextTraversedEvent? = null
313 
314     /**
315      * Up to date semantics nodes in pruned semantics tree. It always reflects the current semantics
316      * tree. They key is the virtual view id(the root node has a key of
317      * AccessibilityNodeProviderCompat.HOST_VIEW_ID and other node has a key of its id).
318      */
319     private var currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds> =
320         intObjectMapOf()
321         get() {
322             if (currentSemanticsNodesInvalidated) { // first instance of retrieving all nodes
323                 currentSemanticsNodesInvalidated = false
324                 field =
325                     view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap(
326                         customRootNodeId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
327                     )
328                 if (isEnabled) {
329                     setTraversalValues(field, idToBeforeMap, idToAfterMap, view.context.resources)
330                 }
331             }
332             return field
333         }
334 
335     private var paneDisplayed = MutableIntSet()
336 
337     internal var idToBeforeMap = MutableIntIntMap()
338     internal var idToAfterMap = MutableIntIntMap()
339     internal val ExtraDataTestTraversalBeforeVal =
340         @Suppress("SpellCheckingInspection")
341         "android.view.accessibility.extra.EXTRA_DATA_TEST_TRAVERSALBEFORE_VAL"
342     internal val ExtraDataTestTraversalAfterVal =
343         @Suppress("SpellCheckingInspection")
344         "android.view.accessibility.extra.EXTRA_DATA_TEST_TRAVERSALAFTER_VAL"
345 
346     private val urlSpanCache = URLSpanCache()
347 
348     // previousSemanticsNodes holds the previous pruned semantics tree so that we can compare the
349     // current and previous trees in onSemanticsChange(). We use SemanticsNodeCopy here because
350     // SemanticsNode's children are dynamically generated and always reflect the current children.
351     // We need to keep a copy of its old structure for comparison.
352     private var previousSemanticsNodes: MutableIntObjectMap<SemanticsNodeCopy> =
353         mutableIntObjectMapOf()
354     private var previousSemanticsRoot =
355         SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, intObjectMapOf())
356     private var checkingForSemanticsChanges = false
357 
358     init {
359         // Remove callbacks that rely on view being attached to a window when we become detached.
360         view.addOnAttachStateChangeListener(
361             object : View.OnAttachStateChangeListener {
onViewAttachedToWindownull362                 override fun onViewAttachedToWindow(view: View) {
363                     with(accessibilityManager) {
364                         addAccessibilityStateChangeListener(enabledStateListener)
365                         addTouchExplorationStateChangeListener(touchExplorationStateListener)
366                     }
367                 }
368 
onViewDetachedFromWindownull369                 override fun onViewDetachedFromWindow(view: View) {
370                     handler.removeCallbacks(semanticsChangeChecker)
371                     with(accessibilityManager) {
372                         removeAccessibilityStateChangeListener(enabledStateListener)
373                         removeTouchExplorationStateChangeListener(touchExplorationStateListener)
374                     }
375                 }
376             }
377         )
378     }
379 
380     /**
381      * Returns true if there is any semantics node in the tree that can scroll in the given
382      * [orientation][vertical] and [direction] at the given [position] in the view associated with
383      * this delegate.
384      *
385      * @param direction The direction to check for scrolling: <0 means scrolling left or up, >0
386      *   means scrolling right or down.
387      * @param position The position in the view to check in view-local coordinates.
388      */
canScrollnull389     internal fun canScroll(vertical: Boolean, direction: Int, position: Offset): Boolean {
390         // Workaround for access from bg thread, it is not supported by semantics (b/298159434)
391         if (Looper.getMainLooper().thread != Thread.currentThread()) {
392             return false
393         }
394 
395         return canScroll(currentSemanticsNodes, vertical, direction, position)
396     }
397 
canScrollnull398     private fun canScroll(
399         currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
400         vertical: Boolean,
401         direction: Int,
402         position: Offset
403     ): Boolean {
404         // No down event has occurred yet which gives us a location to hit test.
405         if (position == Offset.Unspecified || !position.isValid()) return false
406 
407         val scrollRangeProperty =
408             when (vertical) {
409                 true -> SemanticsProperties.VerticalScrollAxisRange
410                 false -> SemanticsProperties.HorizontalScrollAxisRange
411             }
412 
413         var foundNode = false
414         currentSemanticsNodes.forEachValue { node ->
415             // Only consider nodes that are under the touch event. Checks the adjusted bounds to
416             // avoid overlapping siblings. Because position is a float (touch event can happen in-
417             // between pixels), convert the int-based Android Rect to a float-based Compose Rect
418             // before doing the comparison.
419             if (!node.adjustedBounds.toRect().contains(position)) {
420                 return@forEachValue
421             }
422 
423             // Using `unmergedConfig` here is okay since we iterate through all nodes anyway
424             val scrollRange =
425                 node.semanticsNode.unmergedConfig.getOrNull(scrollRangeProperty)
426                     ?: return@forEachValue
427 
428             // A node simply having scrollable semantics doesn't mean it's necessarily scrollable
429             // in the given direction – it must also not be scrolled to its limit in that direction.
430             var actualDirection = if (scrollRange.reverseScrolling) -direction else direction
431             if (direction == 0 && scrollRange.reverseScrolling) {
432                 // The View implementation of canScroll* treat zero as a positive direction, so
433                 // this code should do the same. That means if scrolling is reversed, zero should be
434                 // a negative direction. The actual number doesn't matter, just its sign.
435                 actualDirection = -1
436             }
437 
438             if (actualDirection < 0) {
439                 if (scrollRange.value() > 0) {
440                     foundNode = true
441                     return@forEachValue
442                 }
443             } else {
444                 if (scrollRange.value() < scrollRange.maxValue()) {
445                     foundNode = true
446                     return@forEachValue
447                 }
448             }
449         }
450         return foundNode
451     }
452 
createNodeInfonull453     private fun createNodeInfo(virtualViewId: Int): AccessibilityNodeInfoCompat? {
454         if (
455             view.viewTreeOwners?.lifecycleOwner?.lifecycle?.currentState ==
456                 Lifecycle.State.DESTROYED
457         ) {
458             return emptyNodeInfoOrNull()
459         }
460         val semanticsNodeWithAdjustedBounds =
461             currentSemanticsNodes[virtualViewId] ?: return emptyNodeInfoOrNull()
462         val semanticsNode: SemanticsNode = semanticsNodeWithAdjustedBounds.semanticsNode
463         val info: AccessibilityNodeInfoCompat = AccessibilityNodeInfoCompat.obtain()
464         if (virtualViewId == AccessibilityNodeProviderCompat.HOST_VIEW_ID) {
465             info.setParent(view.getParentForAccessibility() as? View)
466         } else {
467             var parentId =
468                 checkPreconditionNotNull(semanticsNode.parent?.id) {
469                     "semanticsNode $virtualViewId has null parent"
470                 }
471             if (parentId == view.semanticsOwner.unmergedRootSemanticsNode.id) {
472                 parentId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
473             }
474             info.setParent(view, parentId)
475         }
476         info.setSource(view, virtualViewId)
477 
478         info.setBoundsInScreen(boundsInScreen(semanticsNodeWithAdjustedBounds))
479 
480         populateAccessibilityNodeInfoProperties(virtualViewId, info, semanticsNode)
481 
482         return info
483     }
484 
485     /**
486      * There are cases when [createNodeInfo] is called when the view is already destroyed or if the
487      * semantics node is removed from composition. In this case we return null.
488      *
489      * But looks like this is causing crash in Assistant. This happens because 1) Assistant falls
490      * back to using ANIs until we implement b/393515913 and 2) there's no null check in platform's
491      * code until API 35. As a workaround we will return non-null empty ANI for such use cases to
492      * avoid the crash.
493      *
494      * This change should be reverted when b/393515913 is fixed.
495      */
emptyNodeInfoOrNullnull496     private fun emptyNodeInfoOrNull(): AccessibilityNodeInfoCompat? {
497         // Accessibility Manager is not enabled if this code is used by Assistant
498         return if (!accessibilityManager.isEnabled) {
499             AccessibilityNodeInfoCompat.obtain()
500         } else null
501     }
502 
boundsInScreennull503     private fun boundsInScreen(node: SemanticsNodeWithAdjustedBounds): android.graphics.Rect {
504         val boundsInRoot = node.adjustedBounds
505         val topLeftInScreen =
506             view.localToScreen(Offset(boundsInRoot.left.toFloat(), boundsInRoot.top.toFloat()))
507         val bottomRightInScreen =
508             view.localToScreen(Offset(boundsInRoot.right.toFloat(), boundsInRoot.bottom.toFloat()))
509         // Due to rotation, the top left corner of the local bounds may not be the top left corner
510         // of the screen bounds.
511         return android.graphics.Rect(
512             floor(min(topLeftInScreen.x, bottomRightInScreen.x)).toInt(),
513             floor(min(topLeftInScreen.y, bottomRightInScreen.y)).toInt(),
514             ceil(max(topLeftInScreen.x, bottomRightInScreen.x)).toInt(),
515             ceil(max(topLeftInScreen.y, bottomRightInScreen.y)).toInt()
516         )
517     }
518 
populateAccessibilityNodeInfoPropertiesnull519     private fun populateAccessibilityNodeInfoProperties(
520         virtualViewId: Int,
521         info: AccessibilityNodeInfoCompat,
522         semanticsNode: SemanticsNode
523     ) {
524         val resources = view.context.resources
525 
526         // set classname
527         info.className = ClassName
528 
529         // Set a classname for text nodes before setting a classname based on a role ensuring that
530         // the latter if present wins (see b/343392125)
531         if (semanticsNode.unmergedConfig.contains(SemanticsProperties.EditableText)) {
532             info.className = TextFieldClassName
533         }
534         if (semanticsNode.unmergedConfig.contains(SemanticsProperties.Text)) {
535             info.className = TextClassName
536         }
537         val role = semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Role)
538         role?.let {
539             if (semanticsNode.isFake || semanticsNode.replacedChildren.isEmpty()) {
540                 if (role == Role.Tab) {
541                     info.roleDescription = resources.getString(R.string.tab)
542                 } else if (role == Role.Switch) {
543                     info.roleDescription = resources.getString(R.string.switch_role)
544                 } else {
545                     val className = role.toLegacyClassName()
546                     // Images are often minor children of larger widgets, so we only want to
547                     // announce the Image role when the image itself is focusable.
548                     if (
549                         role != Role.Image ||
550                             semanticsNode.isUnmergedLeafNode ||
551                             semanticsNode.unmergedConfig.isMergingSemanticsOfDescendants
552                     ) {
553                         info.className = className
554                     }
555                 }
556             }
557         }
558 
559         info.packageName = view.context.packageName
560 
561         // This property exists to distinguish semantically meaningful nodes from purely structural
562         // or decorative UI elements.  Most nodes are considered important, except:
563         // * Invisible nodes.
564         // * Non-merging nodes with only non-accessibility-speakable properties.
565         //     * Of the built-in ones, the key example is testTag.
566         //     * Custom SemanticsPropertyKeys defined outside the UI package
567         //       are also non-speakable.
568         // * Non-merging nodes that are empty: notably, clearAndSetSemantics {}
569         //   and the root of the SemanticsNode tree.
570         info.isImportantForAccessibility = semanticsNode.isImportantForAccessibility()
571 
572         semanticsNode.replacedChildren.fastForEach { child ->
573             if (currentSemanticsNodes.contains(child.id)) {
574                 val holder = view.androidViewsHandler.layoutNodeToHolder[child.layoutNode]
575                 // Do not add children if the ID is not valid.
576                 if (child.id == View.NO_ID) {
577                     return@fastForEach
578                 }
579                 if (holder != null) {
580                     info.addChild(holder)
581                 } else {
582                     info.addChild(view, child.id)
583                 }
584             }
585         }
586 
587         // Manage internal accessibility focus state.
588         if (virtualViewId == accessibilityFocusedVirtualViewId) {
589             info.isAccessibilityFocused = true
590             info.addAction(AccessibilityActionCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS)
591         } else {
592             info.isAccessibilityFocused = false
593             info.addAction(AccessibilityActionCompat.ACTION_ACCESSIBILITY_FOCUS)
594         }
595 
596         setText(semanticsNode, info)
597         setContentInvalid(semanticsNode, info)
598         info.stateDescription = getInfoStateDescriptionOrNull(semanticsNode, resources)
599         info.isCheckable = getInfoIsCheckable(semanticsNode)
600 
601         val toggleState =
602             semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
603         // TODO(b/406574577): Remove suppression once 1.17.0 stable is released.
604         @Suppress("DEPRECATION")
605         toggleState?.let {
606             if (toggleState == ToggleableState.On) {
607                 info.isChecked = true
608             } else if (toggleState == ToggleableState.Off) {
609                 info.isChecked = false
610             }
611         }
612         semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
613             if (role == Role.Tab) {
614                 // Tab in native android uses selected property
615                 info.isSelected = it
616             } else {
617                 // TODO(b/406574577): Remove suppression once 1.17.0 stable is released.
618                 @Suppress("DEPRECATION")
619                 info.isChecked = it
620             }
621         }
622 
623         if (
624             !semanticsNode.unmergedConfig.isMergingSemanticsOfDescendants ||
625                 // we don't emit fake nodes for nodes without children, therefore we should assign
626                 // content description for such nodes
627                 semanticsNode.replacedChildren.isEmpty()
628         ) {
629             info.contentDescription =
630                 semanticsNode.unmergedConfig
631                     .getOrNull(SemanticsProperties.ContentDescription)
632                     ?.firstOrNull()
633         }
634 
635         // Map testTag to resourceName if testTagsAsResourceId == true (which can be set by an
636         // ancestor)
637         val testTag = semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.TestTag)
638         if (testTag != null) {
639             var testTagsAsResourceId = false
640             var current: SemanticsNode? = semanticsNode
641             while (current != null) {
642                 if (
643                     current.unmergedConfig.contains(SemanticsPropertiesAndroid.TestTagsAsResourceId)
644                 ) {
645                     testTagsAsResourceId =
646                         current.unmergedConfig[SemanticsPropertiesAndroid.TestTagsAsResourceId]
647                     break
648                 } else {
649                     current = current.parent
650                 }
651             }
652 
653             if (testTagsAsResourceId) {
654                 info.viewIdResourceName = testTag
655             }
656         }
657 
658         semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Heading)?.let {
659             info.isHeading = true
660         }
661         info.isPassword = semanticsNode.unmergedConfig.contains(SemanticsProperties.Password)
662         info.isEditable = semanticsNode.unmergedConfig.contains(SemanticsProperties.IsEditable)
663         info.maxTextLength =
664             semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.MaxTextLength) ?: -1
665         info.isEnabled = semanticsNode.enabled()
666         info.isFocusable = semanticsNode.unmergedConfig.contains(SemanticsProperties.Focused)
667         if (info.isFocusable) {
668             info.isFocused = semanticsNode.unmergedConfig[SemanticsProperties.Focused]
669             if (info.isFocused) {
670                 info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS)
671                 focusedVirtualViewId = virtualViewId
672             } else {
673                 info.addAction(AccessibilityNodeInfoCompat.ACTION_FOCUS)
674             }
675         }
676 
677         // Mark invisible nodes
678         info.isVisibleToUser = !semanticsNode.isHidden
679 
680         semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.LiveRegion)?.let {
681             info.liveRegion =
682                 when (it) {
683                     LiveRegionMode.Polite -> ACCESSIBILITY_LIVE_REGION_POLITE
684                     LiveRegionMode.Assertive -> ACCESSIBILITY_LIVE_REGION_ASSERTIVE
685                     else -> ACCESSIBILITY_LIVE_REGION_POLITE
686                 }
687         }
688         info.isClickable = false
689         semanticsNode.unmergedConfig.getOrNull(SemanticsActions.OnClick)?.let {
690             // Selectable tabs and radio buttons that are already selected cannot be selected again
691             // so they should not be exposed as clickable.
692             val isSelected =
693                 semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Selected) == true
694             val isRadioButtonOrTab = role == Role.Tab || role == Role.RadioButton
695             info.isClickable = !isRadioButtonOrTab || (isRadioButtonOrTab && !isSelected)
696             if (semanticsNode.enabled() && info.isClickable) {
697                 info.addAction(
698                     AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_CLICK, it.label)
699                 )
700             }
701         }
702         info.isLongClickable = false
703         semanticsNode.unmergedConfig.getOrNull(SemanticsActions.OnLongClick)?.let {
704             info.isLongClickable = true
705             if (semanticsNode.enabled()) {
706                 info.addAction(
707                     AccessibilityActionCompat(
708                         AccessibilityNodeInfoCompat.ACTION_LONG_CLICK,
709                         it.label
710                     )
711                 )
712             }
713         }
714 
715         // The config will contain this action only if there is a text selection at the moment.
716         semanticsNode.unmergedConfig.getOrNull(SemanticsActions.CopyText)?.let {
717             info.addAction(
718                 AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_COPY, it.label)
719             )
720         }
721         if (semanticsNode.enabled()) {
722             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.SetText)?.let {
723                 info.addAction(
724                     AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_SET_TEXT, it.label)
725                 )
726             }
727 
728             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.OnImeAction)?.let {
729                 info.addAction(
730                     AccessibilityActionCompat(android.R.id.accessibilityActionImeEnter, it.label)
731                 )
732             }
733 
734             // The config will contain this action only if there is a text selection at the moment.
735             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.CutText)?.let {
736                 info.addAction(
737                     AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_CUT, it.label)
738                 )
739             }
740 
741             // The config will contain the action anyway, therefore we check the clipboard text to
742             // decide whether to add the action to the node or not.
743             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.PasteText)?.let {
744                 if (info.isFocused && view.clipboardManager.hasText()) {
745                     info.addAction(
746                         AccessibilityActionCompat(
747                             AccessibilityNodeInfoCompat.ACTION_PASTE,
748                             it.label
749                         )
750                     )
751                 }
752             }
753         }
754 
755         val text = getIterableTextForAccessibility(semanticsNode)
756         if (!text.isNullOrEmpty()) {
757             info.setTextSelection(
758                 getAccessibilitySelectionStart(semanticsNode),
759                 getAccessibilitySelectionEnd(semanticsNode)
760             )
761             val setSelectionAction =
762                 semanticsNode.unmergedConfig.getOrNull(SemanticsActions.SetSelection)
763             // ACTION_SET_SELECTION should be provided even when SemanticsActions.SetSelection
764             // semantics action is not provided by the component
765             info.addAction(
766                 AccessibilityActionCompat(
767                     AccessibilityNodeInfoCompat.ACTION_SET_SELECTION,
768                     setSelectionAction?.label
769                 )
770             )
771             info.addAction(AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY)
772             info.addAction(AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY)
773             info.movementGranularities =
774                 AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER or
775                     AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD or
776                     AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH
777             // We only traverse the text when contentDescription is not set.
778             val contentDescription =
779                 semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)
780             if (
781                 contentDescription.isNullOrEmpty() &&
782                     semanticsNode.unmergedConfig.contains(SemanticsActions.GetTextLayoutResult) &&
783                     // Talkback does not handle below granularities for text field (which includes
784                     // label/hint) when text field is not in focus
785                     !semanticsNode.excludeLineAndPageGranularities()
786             ) {
787                 info.movementGranularities =
788                     info.movementGranularities or
789                         AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE or
790                         AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PAGE
791             }
792         }
793         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
794             val extraDataKeys: MutableList<String> = mutableListOf()
795             extraDataKeys.add(ExtraDataIdKey)
796             if (
797                 !info.text.isNullOrEmpty() &&
798                     semanticsNode.unmergedConfig.contains(SemanticsActions.GetTextLayoutResult)
799             ) {
800                 extraDataKeys.add(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY)
801             }
802             if (semanticsNode.unmergedConfig.contains(SemanticsProperties.TestTag)) {
803                 extraDataKeys.add(ExtraDataTestTagKey)
804             }
805 
806             info.availableExtraData = extraDataKeys
807         }
808 
809         val rangeInfo =
810             semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
811         if (rangeInfo != null) {
812             if (semanticsNode.unmergedConfig.contains(SemanticsActions.SetProgress)) {
813                 info.className = "android.widget.SeekBar"
814             } else {
815                 info.className = "android.widget.ProgressBar"
816             }
817             if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) {
818                 info.rangeInfo =
819                     AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
820                         AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT,
821                         rangeInfo.range.start,
822                         rangeInfo.range.endInclusive,
823                         rangeInfo.current
824                     )
825             }
826             if (
827                 semanticsNode.unmergedConfig.contains(SemanticsActions.SetProgress) &&
828                     semanticsNode.enabled()
829             ) {
830                 if (
831                     rangeInfo.current <
832                         rangeInfo.range.endInclusive.coerceAtLeast(rangeInfo.range.start)
833                 ) {
834                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD)
835                 }
836                 if (
837                     rangeInfo.current >
838                         rangeInfo.range.start.coerceAtMost(rangeInfo.range.endInclusive)
839                 ) {
840                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_BACKWARD)
841                 }
842             }
843         }
844         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
845             Api24Impl.addSetProgressAction(info, semanticsNode)
846         }
847 
848         setCollectionInfo(semanticsNode, info)
849         setCollectionItemInfo(semanticsNode, info)
850 
851         // Will the scrollable scroll when ACTION_SCROLL_FORWARD is performed?
852         fun ScrollAxisRange.canScrollForward(): Boolean {
853             return value() < maxValue() && !reverseScrolling || value() > 0f && reverseScrolling
854         }
855 
856         // Will the scrollable scroll when ACTION_SCROLL_BACKWARD is performed?
857         fun ScrollAxisRange.canScrollBackward(): Boolean {
858             return value() > 0f && !reverseScrolling || value() < maxValue() && reverseScrolling
859         }
860 
861         val xScrollState =
862             semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.HorizontalScrollAxisRange)
863         val scrollAction = semanticsNode.unmergedConfig.getOrNull(SemanticsActions.ScrollBy)
864         if (xScrollState != null && scrollAction != null) {
865             // Talkback defines SCROLLABLE_ROLE_FILTER_FOR_DIRECTION_NAVIGATION, so we need to
866             // assign a role for auto scroll to work. Node with collectionInfo resolved by
867             // Talkback to ROLE_LIST and supports autoscroll too
868             if (!semanticsNode.hasCollectionInfo()) {
869                 info.className = "android.widget.HorizontalScrollView"
870             }
871             if (xScrollState.maxValue() > 0f) {
872                 info.isScrollable = true
873             }
874             if (semanticsNode.enabled()) {
875                 if (xScrollState.canScrollForward()) {
876                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD)
877                     info.addAction(
878                         if (!semanticsNode.isRtl) {
879                             AccessibilityActionCompat.ACTION_SCROLL_RIGHT
880                         } else {
881                             AccessibilityActionCompat.ACTION_SCROLL_LEFT
882                         }
883                     )
884                 }
885                 if (xScrollState.canScrollBackward()) {
886                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_BACKWARD)
887                     info.addAction(
888                         if (!semanticsNode.isRtl) {
889                             AccessibilityActionCompat.ACTION_SCROLL_LEFT
890                         } else {
891                             AccessibilityActionCompat.ACTION_SCROLL_RIGHT
892                         }
893                     )
894                 }
895             }
896         }
897         val yScrollState =
898             semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.VerticalScrollAxisRange)
899         if (yScrollState != null && scrollAction != null) {
900             // Talkback defines SCROLLABLE_ROLE_FILTER_FOR_DIRECTION_NAVIGATION, so we need to
901             // assign a role for auto scroll to work. Node with collectionInfo resolved by
902             // Talkback to ROLE_LIST and supports autoscroll too
903             if (!semanticsNode.hasCollectionInfo()) {
904                 info.className = "android.widget.ScrollView"
905             }
906             if (yScrollState.maxValue() > 0f) {
907                 info.isScrollable = true
908             }
909             if (semanticsNode.enabled()) {
910                 if (yScrollState.canScrollForward()) {
911                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD)
912                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_DOWN)
913                 }
914                 if (yScrollState.canScrollBackward()) {
915                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_BACKWARD)
916                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_UP)
917                 }
918             }
919         }
920 
921         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
922             Api29Impl.addPageActions(info, semanticsNode)
923         }
924 
925         info.paneTitle = semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.PaneTitle)
926 
927         if (semanticsNode.enabled()) {
928             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.Expand)?.let {
929                 info.addAction(
930                     AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_EXPAND, it.label)
931                 )
932             }
933 
934             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.Collapse)?.let {
935                 info.addAction(
936                     AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_COLLAPSE, it.label)
937                 )
938             }
939 
940             semanticsNode.unmergedConfig.getOrNull(SemanticsActions.Dismiss)?.let {
941                 info.addAction(
942                     AccessibilityActionCompat(AccessibilityNodeInfoCompat.ACTION_DISMISS, it.label)
943                 )
944             }
945 
946             if (semanticsNode.unmergedConfig.contains(CustomActions)) {
947                 val customActions = semanticsNode.unmergedConfig[CustomActions]
948                 if (customActions.size >= AccessibilityActionsResourceIds.size) {
949                     throw IllegalStateException(
950                         "Can't have more than " +
951                             "${AccessibilityActionsResourceIds.size} custom actions for one widget"
952                     )
953                 }
954                 val currentActionIdToLabel = SparseArrayCompat<CharSequence>()
955                 val currentLabelToActionId = mutableObjectIntMapOf<CharSequence>()
956                 // If this virtual node had custom action id assignment before, we try to keep the
957                 // id
958                 // unchanged for the same action (identified by action label). This way, we can
959                 // minimize the influence of custom action change between custom actions are
960                 // presented to the user and actually performed.
961                 if (labelToActionId.containsKey(virtualViewId)) {
962                     val oldLabelToActionId = labelToActionId[virtualViewId]
963                     val availableIds =
964                         mutableIntListOf().apply {
965                             AccessibilityActionsResourceIds.forEach { add(it) }
966                         }
967                     val unassignedActions = mutableListOf<CustomAccessibilityAction>()
968                     customActions.fastForEach { action ->
969                         if (oldLabelToActionId!!.contains(action.label)) {
970                             val actionId = oldLabelToActionId[action.label]
971                             currentActionIdToLabel.put(actionId, action.label)
972                             currentLabelToActionId[action.label] = actionId
973                             availableIds.remove(actionId)
974                             info.addAction(AccessibilityActionCompat(actionId, action.label))
975                         } else {
976                             unassignedActions.add(action)
977                         }
978                     }
979                     unassignedActions.fastForEachIndexed { index, action ->
980                         val actionId = availableIds[index]
981                         currentActionIdToLabel.put(actionId, action.label)
982                         currentLabelToActionId[action.label] = actionId
983                         info.addAction(AccessibilityActionCompat(actionId, action.label))
984                     }
985                 } else {
986                     customActions.fastForEachIndexed { index, action ->
987                         val actionId = AccessibilityActionsResourceIds[index]
988                         currentActionIdToLabel.put(actionId, action.label)
989                         currentLabelToActionId[action.label] = actionId
990                         info.addAction(AccessibilityActionCompat(actionId, action.label))
991                     }
992                 }
993                 actionIdToLabel.put(virtualViewId, currentActionIdToLabel)
994                 labelToActionId.put(virtualViewId, currentLabelToActionId)
995             }
996         }
997 
998         info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode, resources)
999 
1000         // `beforeId` refers to the semanticsId that should be read before this `virtualViewId`.
1001         val beforeId = idToBeforeMap.getOrDefault(virtualViewId, -1)
1002         if (beforeId != -1) {
1003             val beforeView = view.androidViewsHandler.semanticsIdToView(beforeId)
1004             if (beforeView != null) {
1005                 // If the node that should come before this one is a view, we want to pass in the
1006                 // "before" view itself, which is retrieved from our `idToViewMap`.
1007                 info.setTraversalBefore(beforeView)
1008             } else {
1009                 // Otherwise, we'll set the "before" value by passing in the semanticsId.
1010                 info.setTraversalBefore(view, beforeId)
1011             }
1012             addExtraDataToAccessibilityNodeInfoHelper(
1013                 virtualViewId,
1014                 info,
1015                 ExtraDataTestTraversalBeforeVal,
1016                 null
1017             )
1018         }
1019 
1020         val afterId = idToAfterMap.getOrDefault(virtualViewId, -1)
1021         if (afterId != -1) {
1022             val afterView = view.androidViewsHandler.semanticsIdToView(afterId)
1023             // Specially use `traversalAfter` value if the node after is a View,
1024             // as expressing the order using traversalBefore in this case would require mutating the
1025             // View itself, which is not under Compose's full control.
1026             if (afterView != null) {
1027                 info.setTraversalAfter(afterView)
1028                 addExtraDataToAccessibilityNodeInfoHelper(
1029                     virtualViewId,
1030                     info,
1031                     ExtraDataTestTraversalAfterVal,
1032                     null
1033                 )
1034             }
1035         }
1036 
1037         // set the className provided through the [SemanticsPropertyReceiver.accessibilityClassName]
1038         // as a last step to ensure it overrides a classname derived from other semantics properties
1039         semanticsNode.unmergedConfig
1040             .getOrNull(SemanticsPropertiesAndroid.AccessibilityClassName)
1041             ?.let { info.className = it }
1042     }
1043 
1044     /** Set the error text for this node */
setContentInvalidnull1045     private fun setContentInvalid(node: SemanticsNode, info: AccessibilityNodeInfoCompat) {
1046         if (node.unmergedConfig.contains(SemanticsProperties.Error)) {
1047             info.isContentInvalid = true
1048             info.error = node.unmergedConfig.getOrNull(SemanticsProperties.Error)
1049         }
1050     }
1051 
1052     @OptIn(InternalTextApi::class)
toSpannableStringnull1053     private fun AnnotatedString.toSpannableString(): SpannableString? {
1054         val fontFamilyResolver: FontFamily.Resolver = view.fontFamilyResolver
1055 
1056         return trimToSize(
1057             toAccessibilitySpannableString(
1058                 density = view.density,
1059                 fontFamilyResolver = fontFamilyResolver,
1060                 urlSpanCache = urlSpanCache
1061             ),
1062             ParcelSafeTextLength
1063         )
1064     }
1065 
setTextnull1066     private fun setText(
1067         node: SemanticsNode,
1068         info: AccessibilityNodeInfoCompat,
1069     ) {
1070         info.text = getInfoText(node)?.toSpannableString()
1071     }
1072 
1073     /**
1074      * Returns whether this virtual view is accessibility focused.
1075      *
1076      * @return True if the view is accessibility focused.
1077      */
isAccessibilityFocusednull1078     private fun isAccessibilityFocused(virtualViewId: Int): Boolean {
1079         return (accessibilityFocusedVirtualViewId == virtualViewId)
1080     }
1081 
1082     /**
1083      * Attempts to give accessibility focus to a virtual view.
1084      *
1085      * <p>
1086      * A virtual view will not actually take focus if {@link AccessibilityManager#isEnabled()}
1087      * returns false, {@link AccessibilityManager#isTouchExplorationEnabled()} returns false, or the
1088      * view already has accessibility focus.
1089      *
1090      * @param virtualViewId The id of the virtual view on which to place accessibility focus.
1091      * @return Whether this virtual view actually took accessibility focus.
1092      */
requestAccessibilityFocusnull1093     private fun requestAccessibilityFocus(virtualViewId: Int): Boolean {
1094         if (!isTouchExplorationEnabled) {
1095             return false
1096         }
1097         // TODO: Check virtual view visibility.
1098         if (!isAccessibilityFocused(virtualViewId)) {
1099             // Clear focus from the previously focused view, if applicable.
1100             if (accessibilityFocusedVirtualViewId != InvalidId) {
1101                 sendEventForVirtualView(
1102                     accessibilityFocusedVirtualViewId,
1103                     AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED
1104                 )
1105             }
1106 
1107             // Set focus on the new view.
1108             accessibilityFocusedVirtualViewId = virtualViewId
1109             // TODO(b/272068594): Do we have to set currentlyFocusedANI object too?
1110 
1111             view.invalidate()
1112             sendEventForVirtualView(
1113                 virtualViewId,
1114                 AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED
1115             )
1116             return true
1117         }
1118         return false
1119     }
1120 
1121     /**
1122      * Populates an event of the specified type with information about an item and attempts to send
1123      * it up through the view hierarchy.
1124      *
1125      * <p>
1126      * You should call this method after performing a user action that normally fires an
1127      * accessibility event, such as clicking on an item.
1128      * <pre>public performItemClick(T item) {
1129      *   ...
1130      *   sendEventForVirtualView(item.id, AccessibilityEvent.TYPE_VIEW_CLICKED)
1131      * }
1132      * </pre>
1133      *
1134      * @param virtualViewId The virtual view id for which to send an event.
1135      * @param eventType The type of event to send.
1136      * @param contentChangeType The contentChangeType of this event.
1137      * @param contentDescription Content description of this event.
1138      * @return true if the event was sent successfully.
1139      */
sendEventForVirtualViewnull1140     private fun sendEventForVirtualView(
1141         virtualViewId: Int,
1142         eventType: Int,
1143         contentChangeType: Int? = null,
1144         contentDescription: List<String>? = null
1145     ): Boolean {
1146         if (virtualViewId == InvalidId || !isEnabled) {
1147             return false
1148         }
1149 
1150         val event: AccessibilityEvent = createEvent(virtualViewId, eventType)
1151         if (contentChangeType != null) {
1152             event.contentChangeTypes = contentChangeType
1153         }
1154         if (contentDescription != null) {
1155             event.contentDescription = contentDescription.fastJoinToString(",")
1156         }
1157 
1158         return sendEvent(event)
1159     }
1160 
1161     /**
1162      * Send an accessibility event.
1163      *
1164      * @param event The accessibility event to send.
1165      * @return true if the event was sent successfully.
1166      */
sendEventnull1167     private fun sendEvent(event: AccessibilityEvent): Boolean {
1168         // only send an event if there's an enabled service listening for events of this type
1169         if (!isEnabled) {
1170             return false
1171         }
1172 
1173         if (
1174             event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED ||
1175                 event.eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED
1176         ) {
1177             sendingFocusAffectingEvent = true
1178         }
1179         try {
1180             return onSendAccessibilityEvent.invoke(event)
1181         } finally {
1182             sendingFocusAffectingEvent = false
1183         }
1184     }
1185 
1186     /**
1187      * Constructs and returns an {@link AccessibilityEvent} populated with information about the
1188      * specified item.
1189      *
1190      * @param virtualViewId The virtual view id for the item for which to construct an event.
1191      * @param eventType The type of event to construct.
1192      * @return An {@link AccessibilityEvent} populated with information about the specified item.
1193      */
1194     @Suppress("DEPRECATION")
1195     @VisibleForTesting
createEventnull1196     private fun createEvent(virtualViewId: Int, eventType: Int): AccessibilityEvent {
1197         val event: AccessibilityEvent = AccessibilityEvent.obtain(eventType)
1198         event.isEnabled = true
1199         // TODO(b/403526104) this might need to also be a proper class name
1200         event.className = ClassName
1201 
1202         // Don't allow the client to override these properties.
1203         event.packageName = view.context.packageName
1204         event.setSource(view, virtualViewId)
1205 
1206         if (isEnabled) {
1207             // populate additional information from the node
1208             currentSemanticsNodes[virtualViewId]?.let {
1209                 event.isPassword =
1210                     it.semanticsNode.unmergedConfig.contains(SemanticsProperties.Password)
1211             }
1212         }
1213 
1214         return event
1215     }
1216 
createTextSelectionChangedEventnull1217     private fun createTextSelectionChangedEvent(
1218         virtualViewId: Int,
1219         fromIndex: Int?,
1220         toIndex: Int?,
1221         itemCount: Int?,
1222         text: CharSequence?
1223     ): AccessibilityEvent {
1224         return createEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED)
1225             .apply {
1226                 fromIndex?.let { this.fromIndex = it }
1227                 toIndex?.let { this.toIndex = it }
1228                 itemCount?.let { this.itemCount = it }
1229                 text?.let { this.text.add(it) }
1230             }
1231     }
1232 
1233     /**
1234      * Attempts to clear accessibility focus from a virtual view.
1235      *
1236      * @param virtualViewId The id of the virtual view from which to clear accessibility focus.
1237      * @return Whether this virtual view actually cleared accessibility focus.
1238      */
clearAccessibilityFocusnull1239     private fun clearAccessibilityFocus(virtualViewId: Int): Boolean {
1240         if (isAccessibilityFocused(virtualViewId)) {
1241             accessibilityFocusedVirtualViewId = InvalidId
1242             currentlyAccessibilityFocusedANI = null
1243             view.invalidate()
1244             sendEventForVirtualView(
1245                 virtualViewId,
1246                 AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED
1247             )
1248             return true
1249         }
1250         return false
1251     }
1252 
performActionHelpernull1253     private fun performActionHelper(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean {
1254         val node = currentSemanticsNodes[virtualViewId]?.semanticsNode ?: return false
1255 
1256         // Actions can be performed when disabled.
1257         when (action) {
1258             AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS ->
1259                 return requestAccessibilityFocus(virtualViewId)
1260             AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS ->
1261                 return clearAccessibilityFocus(virtualViewId)
1262             AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
1263             AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> {
1264                 if (arguments != null) {
1265                     val granularity =
1266                         arguments.getInt(
1267                             AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT
1268                         )
1269                     val extendSelection =
1270                         arguments.getBoolean(
1271                             AccessibilityNodeInfoCompat.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN
1272                         )
1273                     return traverseAtGranularity(
1274                         node,
1275                         granularity,
1276                         action == AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
1277                         extendSelection
1278                     )
1279                 }
1280                 return false
1281             }
1282             AccessibilityNodeInfoCompat.ACTION_SET_SELECTION -> {
1283                 val start =
1284                     arguments?.getInt(
1285                         AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SELECTION_START_INT,
1286                         -1
1287                     ) ?: -1
1288                 val end =
1289                     arguments?.getInt(
1290                         AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SELECTION_END_INT,
1291                         -1
1292                     ) ?: -1
1293                 // Note: This is a little different from current android framework implementation.
1294                 val success = setAccessibilitySelection(node, start, end, false)
1295                 // Text selection changed event already updates the cache. so this may not be
1296                 // necessary.
1297                 if (success) {
1298                     sendEventForVirtualView(
1299                         semanticsNodeIdToAccessibilityVirtualNodeId(node.id),
1300                         AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
1301                     )
1302                 }
1303                 return success
1304             }
1305             AccessibilityNodeInfoCompat.ACTION_COPY -> {
1306                 return node.unmergedConfig.getOrNull(SemanticsActions.CopyText)?.action?.invoke()
1307                     ?: false
1308             }
1309         }
1310 
1311         if (!node.enabled()) {
1312             return false
1313         }
1314 
1315         // Actions can't be performed when disabled.
1316         when (action) {
1317             AccessibilityNodeInfoCompat.ACTION_CLICK -> {
1318                 val result =
1319                     node.unmergedConfig.getOrNull(SemanticsActions.OnClick)?.action?.invoke()
1320                 sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_CLICKED)
1321                 return result ?: false
1322             }
1323             AccessibilityNodeInfoCompat.ACTION_LONG_CLICK -> {
1324                 return node.unmergedConfig.getOrNull(SemanticsActions.OnLongClick)?.action?.invoke()
1325                     ?: false
1326             }
1327             AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD,
1328             AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD,
1329             android.R.id.accessibilityActionScrollDown,
1330             android.R.id.accessibilityActionScrollUp,
1331             android.R.id.accessibilityActionScrollRight,
1332             android.R.id.accessibilityActionScrollLeft -> {
1333                 // Introduce a few shorthands:
1334                 val scrollForward = action == AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
1335                 val scrollBackward = action == AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
1336                 val scrollLeft = action == android.R.id.accessibilityActionScrollLeft
1337                 val scrollRight = action == android.R.id.accessibilityActionScrollRight
1338                 val scrollUp = action == android.R.id.accessibilityActionScrollUp
1339                 val scrollDown = action == android.R.id.accessibilityActionScrollDown
1340 
1341                 val scrollHorizontal = scrollLeft || scrollRight || scrollForward || scrollBackward
1342                 val scrollVertical = scrollUp || scrollDown || scrollForward || scrollBackward
1343 
1344                 if (scrollForward || scrollBackward) {
1345                     val rangeInfo =
1346                         node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
1347                     val setProgressAction =
1348                         node.unmergedConfig.getOrNull(SemanticsActions.SetProgress)
1349                     if (rangeInfo != null && setProgressAction != null) {
1350                         val max = rangeInfo.range.endInclusive.coerceAtLeast(rangeInfo.range.start)
1351                         val min = rangeInfo.range.start.coerceAtMost(rangeInfo.range.endInclusive)
1352                         var increment =
1353                             if (rangeInfo.steps > 0) {
1354                                 (max - min) / (rangeInfo.steps + 1)
1355                             } else {
1356                                 (max - min) / AccessibilitySliderStepsCount
1357                             }
1358                         if (scrollBackward) {
1359                             increment = -increment
1360                         }
1361                         return setProgressAction.action?.invoke(rangeInfo.current + increment)
1362                             ?: false
1363                     }
1364                 }
1365 
1366                 // Will the scrollable scroll when ScrollBy is invoked with the given [amount]?
1367                 fun ScrollAxisRange.canScroll(amount: Float): Boolean {
1368                     return amount < 0 && value() > 0 || amount > 0 && value() < maxValue()
1369                 }
1370 
1371                 val fallbackViewport = node.layoutInfo.coordinates.boundsInParent().size
1372                 val activeViewPortForScroll = getScrollViewportLength(node.unmergedConfig)
1373 
1374                 // The lint warning text is unstable because anonymous lambdas have an autogenerated
1375                 // name, so suppress this lint warning with @SuppressLint instead of a baseline.
1376                 val scrollAction =
1377                     node.unmergedConfig.getOrNull(SemanticsActions.ScrollBy) ?: return false
1378 
1379                 val xScrollState =
1380                     node.unmergedConfig.getOrNull(SemanticsProperties.HorizontalScrollAxisRange)
1381 
1382                 if (xScrollState != null && scrollHorizontal) {
1383                     var amountToScroll = (activeViewPortForScroll ?: fallbackViewport.width)
1384 
1385                     if (scrollLeft || scrollBackward) {
1386                         amountToScroll = -amountToScroll
1387                     }
1388                     if (xScrollState.reverseScrolling) {
1389                         amountToScroll = -amountToScroll
1390                     }
1391                     if (node.isRtl && (scrollLeft || scrollRight)) {
1392                         amountToScroll = -amountToScroll
1393                     }
1394 
1395                     // normal scrollable vs pageable scrollable. If a node can handle
1396                     // page actions it will scroll by a full page.
1397                     if (xScrollState.canScroll(amountToScroll)) {
1398                         val canPageHorizontally =
1399                             node.unmergedConfig.contains(PageLeft) ||
1400                                 node.unmergedConfig.contains(PageRight)
1401                         return if (canPageHorizontally) {
1402                             val horizontalPageAction =
1403                                 if (amountToScroll > 0) {
1404                                     node.unmergedConfig.getOrNull(PageRight)
1405                                 } else {
1406                                     node.unmergedConfig.getOrNull(PageLeft)
1407                                 }
1408                             horizontalPageAction?.action?.invoke() ?: false
1409                         } else {
1410                             scrollAction.action?.invoke(amountToScroll, 0f) ?: false
1411                         }
1412                     }
1413                 }
1414 
1415                 val yScrollState =
1416                     node.unmergedConfig.getOrNull(SemanticsProperties.VerticalScrollAxisRange)
1417                 if (yScrollState != null && scrollVertical) {
1418                     var amountToScroll = (activeViewPortForScroll ?: fallbackViewport.height)
1419 
1420                     if (scrollUp || scrollBackward) {
1421                         amountToScroll = -amountToScroll
1422                     }
1423                     if (yScrollState.reverseScrolling) {
1424                         amountToScroll = -amountToScroll
1425                     }
1426 
1427                     // normal scrollable vs pageable scrollable. If a node can handle
1428                     // page actions it will scroll by a full page.
1429                     if (yScrollState.canScroll(amountToScroll)) {
1430                         val canPageVertically =
1431                             node.unmergedConfig.contains(PageUp) ||
1432                                 node.unmergedConfig.contains(PageDown)
1433                         return if (canPageVertically) {
1434                             val verticalPageAction =
1435                                 if (amountToScroll > 0) {
1436                                     node.unmergedConfig.getOrNull(PageDown)
1437                                 } else {
1438                                     node.unmergedConfig.getOrNull(PageUp)
1439                                 }
1440                             verticalPageAction?.action?.invoke() ?: false
1441                         } else {
1442                             scrollAction.action?.invoke(0f, amountToScroll) ?: false
1443                         }
1444                     }
1445                 }
1446 
1447                 return false
1448             }
1449             android.R.id.accessibilityActionPageUp -> {
1450                 val pageAction = node.unmergedConfig.getOrNull(PageUp)
1451                 return pageAction?.action?.invoke() ?: false
1452             }
1453             android.R.id.accessibilityActionPageDown -> {
1454                 val pageAction = node.unmergedConfig.getOrNull(PageDown)
1455                 return pageAction?.action?.invoke() ?: false
1456             }
1457             android.R.id.accessibilityActionPageLeft -> {
1458                 val pageAction = node.unmergedConfig.getOrNull(PageLeft)
1459                 return pageAction?.action?.invoke() ?: false
1460             }
1461             android.R.id.accessibilityActionPageRight -> {
1462                 val pageAction = node.unmergedConfig.getOrNull(PageRight)
1463                 return pageAction?.action?.invoke() ?: false
1464             }
1465             android.R.id.accessibilityActionSetProgress -> {
1466                 if (
1467                     arguments == null ||
1468                         !arguments.containsKey(
1469                             AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE
1470                         )
1471                 ) {
1472                     return false
1473                 }
1474                 return node.unmergedConfig
1475                     .getOrNull(SemanticsActions.SetProgress)
1476                     ?.action
1477                     ?.invoke(
1478                         arguments.getFloat(
1479                             AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE
1480                         )
1481                     ) ?: false
1482             }
1483             AccessibilityNodeInfoCompat.ACTION_FOCUS -> {
1484                 // The item might not be focusable in touch mode, so we use requestFocusFromTouch()
1485                 // to exit touch mode before requesting focus (b/387576999).
1486                 // Note that this causes a temporary focus shift to the view if it was not focused,
1487                 // which is then corrected by immediately focusing the desired Composable node.
1488                 // We considered calling super.performAccessibilityAction() which would put the
1489                 // system in keyboard mode, but it only works when AndroidComposeView did not have
1490                 // focus.
1491                 if (
1492                     @OptIn(ExperimentalComposeUiApi::class) isFocusActionExitsTouchModeEnabled &&
1493                         view.isInTouchMode
1494                 ) {
1495                     view.requestFocusFromTouch()
1496                 }
1497 
1498                 return node.unmergedConfig.getOrNull(RequestFocus)?.action?.invoke() ?: false
1499             }
1500             AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS -> {
1501                 return if (node.unmergedConfig.getOrNull(SemanticsProperties.Focused) == true) {
1502                     view.focusOwner.clearFocus(
1503                         force = false,
1504                         refreshFocusEvents = true,
1505                         clearOwnerFocus = true,
1506                         focusDirection = Exit
1507                     )
1508                     true
1509                 } else {
1510                     false
1511                 }
1512             }
1513             AccessibilityNodeInfoCompat.ACTION_SET_TEXT -> {
1514                 val text =
1515                     arguments?.getString(
1516                         AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
1517                     )
1518                 return node.unmergedConfig
1519                     .getOrNull(SemanticsActions.SetText)
1520                     ?.action
1521                     ?.invoke(AnnotatedString(text ?: "")) ?: false
1522             }
1523             android.R.id.accessibilityActionImeEnter -> {
1524                 return node.unmergedConfig.getOrNull(SemanticsActions.OnImeAction)?.action?.invoke()
1525                     ?: false
1526             }
1527             AccessibilityNodeInfoCompat.ACTION_PASTE -> {
1528                 return node.unmergedConfig.getOrNull(SemanticsActions.PasteText)?.action?.invoke()
1529                     ?: false
1530             }
1531             AccessibilityNodeInfoCompat.ACTION_CUT -> {
1532                 return node.unmergedConfig.getOrNull(SemanticsActions.CutText)?.action?.invoke()
1533                     ?: false
1534             }
1535             AccessibilityNodeInfoCompat.ACTION_EXPAND -> {
1536                 return node.unmergedConfig.getOrNull(SemanticsActions.Expand)?.action?.invoke()
1537                     ?: false
1538             }
1539             AccessibilityNodeInfoCompat.ACTION_COLLAPSE -> {
1540                 return node.unmergedConfig.getOrNull(SemanticsActions.Collapse)?.action?.invoke()
1541                     ?: false
1542             }
1543             AccessibilityNodeInfoCompat.ACTION_DISMISS -> {
1544                 return node.unmergedConfig.getOrNull(SemanticsActions.Dismiss)?.action?.invoke()
1545                     ?: false
1546             }
1547             android.R.id.accessibilityActionShowOnScreen -> {
1548                 // TODO(b/190865803): Consider scrolling nested containers instead of only the first
1549                 // one.
1550                 var scrollableAncestor: SemanticsNode? = node.parent
1551                 var scrollAction =
1552                     scrollableAncestor?.unmergedConfig?.getOrNull(SemanticsActions.ScrollBy)
1553                 while (scrollableAncestor != null) {
1554                     if (scrollAction != null) {
1555                         break
1556                     }
1557                     scrollableAncestor = scrollableAncestor.parent
1558                     scrollAction =
1559                         scrollableAncestor?.unmergedConfig?.getOrNull(SemanticsActions.ScrollBy)
1560                 }
1561                 if (scrollableAncestor == null) {
1562                     // there's no scrollable ancestor in the Compose hierarchy, let
1563                     // AndroidComposeView handle it
1564                     val rect =
1565                         node.boundsInRoot.run {
1566                             android.graphics.Rect(
1567                                 floor(left).toInt(),
1568                                 floor(top).toInt(),
1569                                 ceil(right).roundToInt(),
1570                                 ceil(bottom).roundToInt()
1571                             )
1572                         }
1573                     return view.requestRectangleOnScreen(rect)
1574                 }
1575 
1576                 // TalkBack expects the minimum amount of movement to fully reveal the node.
1577                 // First, get the viewport and the target bounds in root coordinates
1578                 val viewportInParent = scrollableAncestor.layoutInfo.coordinates.boundsInParent()
1579                 val parentInRoot =
1580                     scrollableAncestor.layoutInfo.coordinates.parentLayoutCoordinates
1581                         ?.positionInRoot() ?: Offset.Zero
1582                 val viewport = viewportInParent.translate(parentInRoot)
1583                 val target = Rect(node.positionInRoot, node.size.toSize())
1584 
1585                 val xScrollState =
1586                     scrollableAncestor.unmergedConfig.getOrNull(
1587                         SemanticsProperties.HorizontalScrollAxisRange
1588                     )
1589                 val yScrollState =
1590                     scrollableAncestor.unmergedConfig.getOrNull(
1591                         SemanticsProperties.VerticalScrollAxisRange
1592                     )
1593 
1594                 // Given the desired scroll value to align either side of the target with the
1595                 // viewport, what delta should we go with?
1596                 // If we need to scroll in opposite directions for both sides, don't scroll at all.
1597                 // Otherwise, take the delta that scrolls the least amount.
1598                 fun scrollDelta(a: Float, b: Float): Float =
1599                     if (sign(a) == sign(b)) if (abs(a) < abs(b)) a else b else 0f
1600 
1601                 // Get the desired delta X
1602                 var dx = scrollDelta(target.left - viewport.left, target.right - viewport.right)
1603                 // And adjust for reversing properties
1604                 if (xScrollState?.reverseScrolling == true) dx = -dx
1605                 if (node.isRtl) dx = -dx
1606 
1607                 // Get the desired delta Y
1608                 var dy = scrollDelta(target.top - viewport.top, target.bottom - viewport.bottom)
1609                 // And adjust for reversing properties
1610                 if (yScrollState?.reverseScrolling == true) dy = -dy
1611 
1612                 return scrollAction?.action?.invoke(dx, dy) == true
1613             }
1614             // TODO: handling for other system actions
1615             else -> {
1616                 val label = actionIdToLabel[virtualViewId]?.get(action) ?: return false
1617                 val customActions = node.unmergedConfig.getOrNull(CustomActions) ?: return false
1618                 customActions.fastForEach { customAction ->
1619                     if (customAction.label == label) {
1620                         return customAction.action()
1621                     }
1622                 }
1623                 return false
1624             }
1625         }
1626     }
1627 
addExtraDataToAccessibilityNodeInfoHelpernull1628     private fun addExtraDataToAccessibilityNodeInfoHelper(
1629         virtualViewId: Int,
1630         info: AccessibilityNodeInfoCompat,
1631         extraDataKey: String,
1632         arguments: Bundle?
1633     ) {
1634         val node = currentSemanticsNodes[virtualViewId]?.semanticsNode ?: return
1635         val text = getIterableTextForAccessibility(node)
1636 
1637         // This extra is just for testing: needed a way to retrieve `traversalBefore` and
1638         // `traversalAfter` from a non-sealed instance of an ANI
1639         if (extraDataKey == ExtraDataTestTraversalBeforeVal) {
1640             idToBeforeMap.getOrDefault(virtualViewId, -1).let {
1641                 if (it != -1) {
1642                     info.extras.putInt(extraDataKey, it)
1643                 }
1644             }
1645         } else if (extraDataKey == ExtraDataTestTraversalAfterVal) {
1646             idToAfterMap.getOrDefault(virtualViewId, -1).let {
1647                 if (it != -1) {
1648                     info.extras.putInt(extraDataKey, it)
1649                 }
1650             }
1651         } else if (
1652             node.unmergedConfig.contains(SemanticsActions.GetTextLayoutResult) &&
1653                 arguments != null &&
1654                 extraDataKey == EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
1655         ) {
1656             val positionInfoStartIndex =
1657                 arguments.getInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, -1)
1658             val positionInfoLength =
1659                 arguments.getInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, -1)
1660             if (
1661                 (positionInfoLength <= 0) ||
1662                     (positionInfoStartIndex < 0) ||
1663                     (positionInfoStartIndex >= (text?.length ?: Int.MAX_VALUE))
1664             ) {
1665                 Log.e(LogTag, "Invalid arguments for accessibility character locations")
1666                 return
1667             }
1668             val textLayoutResult = getTextLayoutResult(node.unmergedConfig) ?: return
1669             val boundingRects = mutableListOf<RectF?>()
1670             for (i in 0 until positionInfoLength) {
1671                 // This is a workaround until we fix the merging issue in b/157474582.
1672                 if (positionInfoStartIndex + i >= textLayoutResult.layoutInput.text.length) {
1673                     boundingRects.add(null)
1674                     continue
1675                 }
1676                 val bounds = textLayoutResult.getBoundingBox(positionInfoStartIndex + i)
1677                 val boundsOnScreen = toScreenCoords(node, bounds)
1678                 boundingRects.add(boundsOnScreen)
1679             }
1680             info.extras.putParcelableArray(extraDataKey, boundingRects.toTypedArray())
1681         } else if (
1682             node.unmergedConfig.contains(SemanticsProperties.TestTag) &&
1683                 arguments != null &&
1684                 extraDataKey == ExtraDataTestTagKey
1685         ) {
1686             val testTag = node.unmergedConfig.getOrNull(SemanticsProperties.TestTag)
1687             if (testTag != null) {
1688                 info.extras.putCharSequence(extraDataKey, testTag)
1689             }
1690         } else if (extraDataKey == ExtraDataIdKey) {
1691             info.extras.putInt(extraDataKey, node.id)
1692         }
1693     }
1694 
toScreenCoordsnull1695     private fun toScreenCoords(textNode: SemanticsNode?, bounds: Rect): RectF? {
1696         if (textNode == null) return null
1697         val boundsInRoot = bounds.translate(textNode.positionInRoot)
1698         val textNodeBoundsInRoot = textNode.boundsInRoot
1699 
1700         // Only visible or partially visible locations are used.
1701         val visibleBounds =
1702             if (boundsInRoot.overlaps(textNodeBoundsInRoot)) {
1703                 boundsInRoot.intersect(textNodeBoundsInRoot)
1704             } else {
1705                 null
1706             }
1707 
1708         return if (visibleBounds != null) {
1709             val topLeftInScreen = view.localToScreen(Offset(visibleBounds.left, visibleBounds.top))
1710             val bottomRightInScreen =
1711                 view.localToScreen(Offset(visibleBounds.right, visibleBounds.bottom))
1712             // Due to rotation, the top left corner of the local bounds may not be the top left
1713             // corner of the screen bounds.
1714             RectF(
1715                 min(topLeftInScreen.x, bottomRightInScreen.x),
1716                 min(topLeftInScreen.y, bottomRightInScreen.y),
1717                 max(topLeftInScreen.x, bottomRightInScreen.x),
1718                 max(topLeftInScreen.y, bottomRightInScreen.y)
1719             )
1720         } else {
1721             null
1722         }
1723     }
1724 
1725     /**
1726      * Dispatches hover {@link android.view.MotionEvent}s to the virtual view hierarchy when the
1727      * Explore by Touch feature is enabled.
1728      *
1729      * <p>
1730      * This method should be called by overriding {@link View#dispatchHoverEvent}:
1731      * <pre>&#64;Override
1732      * public boolean dispatchHoverEvent(MotionEvent event) {
1733      *   if (mHelper.dispatchHoverEvent(this, event) {
1734      *     return true;
1735      *   }
1736      *   return super.dispatchHoverEvent(event);
1737      * }
1738      * </pre>
1739      *
1740      * @param event The hover event to dispatch to the virtual view hierarchy.
1741      * @return Whether the hover event was handled.
1742      */
dispatchHoverEventnull1743     internal fun dispatchHoverEvent(event: MotionEvent): Boolean {
1744         if (!isTouchExplorationEnabled) {
1745             return false
1746         }
1747 
1748         when (event.action) {
1749             MotionEvent.ACTION_HOVER_MOVE,
1750             MotionEvent.ACTION_HOVER_ENTER -> {
1751                 val virtualViewId = hitTestSemanticsAt(event.x, event.y)
1752                 // The android views could be view groups, so the event must be dispatched to the
1753                 // views. Android ViewGroup.java will take care of synthesizing hover enter/exit
1754                 // actions from hover moves.
1755                 // Note that this should be before calling "updateHoveredVirtualView" so that in
1756                 // the corner case of overlapped nodes, the final hover enter event is sent from
1757                 // the node/view that we want to focus.
1758                 val handled = view.androidViewsHandler.dispatchGenericMotionEvent(event)
1759                 updateHoveredVirtualView(virtualViewId)
1760                 return if (virtualViewId == InvalidId) handled else true
1761             }
1762             MotionEvent.ACTION_HOVER_EXIT -> {
1763                 return when {
1764                     hoveredVirtualViewId != InvalidId -> {
1765                         updateHoveredVirtualView(InvalidId)
1766                         true
1767                     }
1768                     else -> {
1769                         view.androidViewsHandler.dispatchGenericMotionEvent(event)
1770                     }
1771                 }
1772             }
1773             else -> {
1774                 return false
1775             }
1776         }
1777     }
1778 
1779     /**
1780      * Hit test the layout tree for semantics wrappers. The return value is a virtual view id, or
1781      * InvalidId if an embedded Android View was hit.
1782      */
1783     @VisibleForTesting
hitTestSemanticsAtnull1784     internal fun hitTestSemanticsAt(x: Float, y: Float): Int {
1785         view.measureAndLayout()
1786 
1787         val hitSemanticsEntities = HitTestResult()
1788         view.root.hitTestSemantics(
1789             pointerPosition = Offset(x, y),
1790             hitSemanticsEntities = hitSemanticsEntities
1791         )
1792 
1793         // Iterate front-to-back until we find a node with semantics that are important-for-a11y
1794         for (i in hitSemanticsEntities.lastIndex downTo 0) {
1795             val layoutNode = hitSemanticsEntities[i].requireLayoutNode()
1796 
1797             // If this node corresponds to an AndroidView, then we should return InvalidId
1798             // to let the View System handle it.
1799             val androidView = view.androidViewsHandler.layoutNodeToHolder[layoutNode]
1800             if (androidView != null) {
1801                 return InvalidId
1802             }
1803 
1804             if (!layoutNode.nodes.has(Nodes.Semantics)) {
1805                 continue
1806             }
1807 
1808             val virtualViewId = semanticsNodeIdToAccessibilityVirtualNodeId(layoutNode.semanticsId)
1809 
1810             // The node below is not added to the tree; it's a wrapper around outer semantics to
1811             // use the methods available to the SemanticsNode
1812             val semanticsNode = SemanticsNode(layoutNode, false)
1813 
1814             // Continue to the next items in the hit test if it's not considered important.
1815             if (!semanticsNode.isImportantForAccessibility()) {
1816                 continue
1817             }
1818 
1819             // Links in text nodes are semantics children. But for Android accessibility support
1820             // we don't publish them to the accessibility services because they are exposed
1821             // as UrlSpan/ClickableSpan spans instead
1822             if (semanticsNode.config.contains(SemanticsProperties.LinkTestMarker)) {
1823                 continue
1824             }
1825 
1826             return virtualViewId
1827         }
1828 
1829         return InvalidId
1830     }
1831 
1832     /**
1833      * Sets the currently hovered item, sending hover accessibility events as necessary to maintain
1834      * the correct state.
1835      *
1836      * @param virtualViewId The virtual view id for the item currently being hovered, or
1837      *   {@link #InvalidId} if no item is hovered within the parent view.
1838      */
updateHoveredVirtualViewnull1839     private fun updateHoveredVirtualView(virtualViewId: Int) {
1840         if (hoveredVirtualViewId == virtualViewId) {
1841             return
1842         }
1843 
1844         val previousVirtualViewId: Int = hoveredVirtualViewId
1845         hoveredVirtualViewId = virtualViewId
1846 
1847         /*
1848         Stay consistent with framework behavior by sending ENTER/EXIT pairs
1849         in reverse order. This is accurate as of API 18.
1850         */
1851         sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER)
1852         sendEventForVirtualView(previousVirtualViewId, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT)
1853     }
1854 
getAccessibilityNodeProvidernull1855     override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat {
1856         return nodeProvider
1857     }
1858 
1859     /**
1860      * Trims the text to [size] length. Returns the string as it is if the length is smaller than
1861      * [size]. If chars at [size] - 1 and [size] is a surrogate pair, returns a CharSequence of
1862      * length [size] - 1.
1863      *
1864      * @param size length of the result, should be greater than 0
1865      */
trimToSizenull1866     private fun <T : CharSequence> trimToSize(
1867         text: T?,
1868         @Suppress("SameParameterValue") @IntRange(from = 1) size: Int
1869     ): T? {
1870         require(size > 0) { "size should be greater than 0" }
1871         var len = size
1872         if (text.isNullOrEmpty() || text.length <= size) return text
1873         if (Character.isHighSurrogate(text[size - 1]) && Character.isLowSurrogate(text[size])) {
1874             len = size - 1
1875         }
1876         @Suppress("UNCHECKED_CAST") return text.subSequence(0, len) as T
1877     }
1878 
1879     // TODO (in a separate cl): Called when the SemanticsNode with id semanticsNodeId disappears.
1880     // fun clearNode(semanticsNodeId: Int) { // clear the actionIdToId and labelToActionId nodes }
1881 
<lambda>null1882     private val semanticsChangeChecker = Runnable {
1883         trace("measureAndLayout") { view.measureAndLayout() }
1884         trace("checkForSemanticsChanges") { checkForSemanticsChanges() }
1885         checkingForSemanticsChanges = false
1886     }
1887 
onSemanticsChangenull1888     internal fun onSemanticsChange() {
1889         // When accessibility is turned off, we still want to keep
1890         // currentSemanticsNodesInvalidated up to date so that when accessibility is turned on
1891         // later, we can refresh currentSemanticsNodes if currentSemanticsNodes is stale.
1892         currentSemanticsNodesInvalidated = true
1893 
1894         if (isEnabled && !checkingForSemanticsChanges) {
1895             checkingForSemanticsChanges = true
1896             handler.post(semanticsChangeChecker)
1897         }
1898     }
1899 
1900     /**
1901      * This suspend function loops for the entire lifetime of the Compose instance: it consumes
1902      * recent layout changes and sends events to the accessibility and content capture framework in
1903      * batches separated by a 100ms delay.
1904      */
boundsUpdatesEventLoopnull1905     internal suspend fun boundsUpdatesEventLoop() {
1906         try {
1907             val subtreeChangedSemanticsNodesIds = MutableIntSet()
1908             for (notification in boundsUpdateChannel) {
1909                 if (isEnabled) {
1910                     for (i in subtreeChangedLayoutNodes.indices) {
1911                         val layoutNode = subtreeChangedLayoutNodes.valueAt(i)
1912                         sendSubtreeChangeAccessibilityEvents(
1913                             layoutNode,
1914                             subtreeChangedSemanticsNodesIds
1915                         )
1916                         sendTypeViewScrolledAccessibilityEvent(layoutNode)
1917                     }
1918                     subtreeChangedSemanticsNodesIds.clear()
1919                     // When the bounds of layout nodes change, we will not always get semantics
1920                     // change notifications because bounds is not part of semantics. And bounds
1921                     // change from a layout node without semantics will affect the global bounds
1922                     // of it children which has semantics. Bounds change will affect which nodes
1923                     // are covered and which nodes are not, so the currentSemanticsNodes is not
1924                     // up to date anymore.
1925                     // After the subtree events are sent, accessibility services will get the
1926                     // current visible/invisible state. We also try to do semantics tree diffing
1927                     // to send out the proper accessibility events and update our copy here so
1928                     // that
1929                     // our incremental changes (represented by accessibility events) are
1930                     // consistent
1931                     // with accessibility services. That is: change - notify - new change -
1932                     // notify, if we don't do the tree diffing and update our copy here, we will
1933                     // combine old change and new change, which is missing finer-grained
1934                     // notification.
1935                     if (!checkingForSemanticsChanges) {
1936                         checkingForSemanticsChanges = true
1937                         handler.post(semanticsChangeChecker)
1938                     }
1939                 }
1940                 subtreeChangedLayoutNodes.clear()
1941                 pendingHorizontalScrollEvents.clear()
1942                 pendingVerticalScrollEvents.clear()
1943                 delay(SendRecurringAccessibilityEventsIntervalMillis)
1944             }
1945         } finally {
1946             subtreeChangedLayoutNodes.clear()
1947         }
1948     }
1949 
onLayoutChangenull1950     internal fun onLayoutChange(layoutNode: LayoutNode) {
1951         // When accessibility is turned off, we still want to keep
1952         // currentSemanticsNodesInvalidated up to date so that when accessibility is turned on
1953         // later, we can refresh currentSemanticsNodes if currentSemanticsNodes is stale.
1954         currentSemanticsNodesInvalidated = true
1955 
1956         // Only using a11y here since the CC manager uses its own flag
1957         if (!isEnabled) {
1958             return
1959         }
1960         // The layout change of a LayoutNode will also affect its children, so even if it doesn't
1961         // have semantics attached, we should process it.
1962         notifySubtreeAccessibilityStateChangedIfNeeded(layoutNode)
1963     }
1964 
notifySubtreeAccessibilityStateChangedIfNeedednull1965     private fun notifySubtreeAccessibilityStateChangedIfNeeded(layoutNode: LayoutNode) {
1966         if (subtreeChangedLayoutNodes.add(layoutNode)) {
1967             boundsUpdateChannel.trySend(Unit)
1968         }
1969     }
1970 
sendTypeViewScrolledAccessibilityEventnull1971     private fun sendTypeViewScrolledAccessibilityEvent(layoutNode: LayoutNode) {
1972         // The node may be no longer available while we were waiting so check
1973         // again.
1974         if (!layoutNode.isAttached) {
1975             return
1976         }
1977         // Android Views will send proper events themselves.
1978         if (view.androidViewsHandler.layoutNodeToHolder.contains(layoutNode)) {
1979             return
1980         }
1981 
1982         val id = layoutNode.semanticsId
1983         val pendingHorizontalScroll = pendingHorizontalScrollEvents[id]
1984         val pendingVerticalScroll = pendingVerticalScrollEvents[id]
1985         if (pendingHorizontalScroll == null && pendingVerticalScroll == null) {
1986             return
1987         }
1988 
1989         val event = createEvent(id, AccessibilityEvent.TYPE_VIEW_SCROLLED)
1990         pendingHorizontalScroll?.let {
1991             event.scrollX = it.value().toInt()
1992             event.maxScrollX = it.maxValue().toInt()
1993         }
1994         pendingVerticalScroll?.let {
1995             event.scrollY = it.value().toInt()
1996             event.maxScrollY = it.maxValue().toInt()
1997         }
1998         sendEvent(event)
1999     }
2000 
sendSubtreeChangeAccessibilityEventsnull2001     private fun sendSubtreeChangeAccessibilityEvents(
2002         layoutNode: LayoutNode,
2003         subtreeChangedSemanticsNodesIds: MutableIntSet
2004     ) {
2005         // The node may be no longer available while we were waiting so check
2006         // again.
2007         if (!layoutNode.isAttached) {
2008             return
2009         }
2010         // Android Views will send proper events themselves.
2011         if (view.androidViewsHandler.layoutNodeToHolder.contains(layoutNode)) {
2012             return
2013         }
2014 
2015         // When we finally send the event, make sure it is an accessibility-focusable node.
2016         var semanticsNode =
2017             if (layoutNode.nodes.has(Nodes.Semantics)) layoutNode
2018             else layoutNode.findClosestParentNode { it.nodes.has(Nodes.Semantics) }
2019 
2020         val config = semanticsNode?.semanticsConfiguration ?: return
2021         if (!config.isMergingSemanticsOfDescendants) {
2022             semanticsNode
2023                 .findClosestParentNode {
2024                     it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
2025                 }
2026                 ?.let { semanticsNode = it }
2027         }
2028         val id = semanticsNode?.semanticsId ?: return
2029 
2030         if (!subtreeChangedSemanticsNodesIds.add(id)) {
2031             return
2032         }
2033 
2034         sendEventForVirtualView(
2035             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2036             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2037             AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE
2038         )
2039     }
2040 
checkForSemanticsChangesnull2041     private fun checkForSemanticsChanges() {
2042         // Accessibility structural change
2043         trace("sendAccessibilitySemanticsStructureChangeEvents") {
2044             if (isEnabled) {
2045                 sendAccessibilitySemanticsStructureChangeEvents(
2046                     view.semanticsOwner.unmergedRootSemanticsNode,
2047                     previousSemanticsRoot
2048                 )
2049             }
2050         }
2051         // Accessibility property change
2052         trace("sendSemanticsPropertyChangeEvents") {
2053             sendSemanticsPropertyChangeEvents(currentSemanticsNodes)
2054         }
2055         trace("updateSemanticsNodesCopyAndPanes") { updateSemanticsNodesCopyAndPanes() }
2056     }
2057 
updateSemanticsNodesCopyAndPanesnull2058     private fun updateSemanticsNodesCopyAndPanes() {
2059         // TODO(b/172606324): removed this compose specific fix when talkback has a proper solution.
2060         val toRemove = MutableIntSet()
2061         paneDisplayed.forEach { id ->
2062             val currentNode = currentSemanticsNodes[id]?.semanticsNode
2063             if (
2064                 currentNode == null ||
2065                     !currentNode.unmergedConfig.contains(SemanticsProperties.PaneTitle)
2066             ) {
2067                 toRemove.add(id)
2068                 sendPaneChangeEvents(
2069                     id,
2070                     AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED,
2071                     previousSemanticsNodes[id]
2072                         ?.unmergedConfig
2073                         ?.getOrNull(SemanticsProperties.PaneTitle)
2074                 )
2075             }
2076         }
2077         paneDisplayed.removeAll(toRemove)
2078         previousSemanticsNodes.clear()
2079         currentSemanticsNodes.forEach { key, value ->
2080             if (
2081                 value.semanticsNode.unmergedConfig.contains(SemanticsProperties.PaneTitle) &&
2082                     paneDisplayed.add(key)
2083             ) {
2084                 sendPaneChangeEvents(
2085                     key,
2086                     AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_APPEARED,
2087                     value.semanticsNode.unmergedConfig[SemanticsProperties.PaneTitle]
2088                 )
2089             }
2090             previousSemanticsNodes[key] =
2091                 SemanticsNodeCopy(value.semanticsNode, currentSemanticsNodes)
2092         }
2093         previousSemanticsRoot =
2094             SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, currentSemanticsNodes)
2095     }
2096 
sendSemanticsPropertyChangeEventsnull2097     private fun sendSemanticsPropertyChangeEvents(
2098         newSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>
2099     ) {
2100         val oldScrollObservationScopes = ArrayList(scrollObservationScopes)
2101         scrollObservationScopes.clear()
2102         newSemanticsNodes.forEachKey { id ->
2103             // We do doing this search because the new configuration is set as a whole, so we
2104             // can't indicate which property is changed when setting the new configuration.
2105             val oldNode = previousSemanticsNodes[id] ?: return@forEachKey
2106             val newNode =
2107                 checkPreconditionNotNull(newSemanticsNodes[id]?.semanticsNode) {
2108                     "no value for specified key"
2109                 }
2110 
2111             var propertyChanged = false
2112 
2113             newNode.unmergedConfig.props.forEach { key, value ->
2114                 var newlyObservingScroll = false
2115                 if (
2116                     key == SemanticsProperties.HorizontalScrollAxisRange ||
2117                         key == SemanticsProperties.VerticalScrollAxisRange
2118                 ) {
2119                     newlyObservingScroll = registerScrollingId(id, oldScrollObservationScopes)
2120                 }
2121                 if (!newlyObservingScroll && value == oldNode.unmergedConfig.getOrNull(key)) {
2122                     return@forEach
2123                 }
2124                 @Suppress("UNCHECKED_CAST")
2125                 when (key) {
2126                     SemanticsProperties.PaneTitle -> {
2127                         val paneTitle = value as String
2128                         // If oldNode doesn't have pane title, it will be handled in
2129                         // updateSemanticsNodesCopyAndPanes().
2130                         if (oldNode.unmergedConfig.contains(SemanticsProperties.PaneTitle)) {
2131                             sendPaneChangeEvents(
2132                                 id,
2133                                 AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_TITLE,
2134                                 paneTitle
2135                             )
2136                         }
2137                     }
2138                     SemanticsProperties.StateDescription,
2139                     SemanticsProperties.ToggleableState -> {
2140                         sendEventForVirtualView(
2141                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2142                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2143                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
2144                         )
2145                         // Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
2146                         // force ViewRootImpl to update its accessibility-focused virtual-node.
2147                         // If we have an androidx fix, we can remove this event.
2148                         sendEventForVirtualView(
2149                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2150                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2151                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
2152                         )
2153                     }
2154                     SemanticsProperties.ProgressBarRangeInfo -> {
2155                         sendEventForVirtualView(
2156                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2157                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2158                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
2159                         )
2160                         // Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
2161                         // force ViewRootImpl to update its accessibility-focused virtual-node.
2162                         // If we have an androidx fix, we can remove this event.
2163                         sendEventForVirtualView(
2164                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2165                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2166                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
2167                         )
2168                     }
2169                     SemanticsProperties.Selected -> {
2170                         // The assumption is among widgets using SemanticsProperties.Selected, only
2171                         // Tab is using AccessibilityNodeInfo#isSelected, and all others are using
2172                         // AccessibilityNodeInfo#isCheckable and setting
2173                         // AccessibilityNodeInfo#stateDescription in this delegate.
2174                         if (
2175                             newNode.unmergedConfig.getOrNull(SemanticsProperties.Role) == Role.Tab
2176                         ) {
2177                             if (
2178                                 newNode.unmergedConfig.getOrNull(SemanticsProperties.Selected) ==
2179                                     true
2180                             ) {
2181                                 val event =
2182                                     createEvent(
2183                                         semanticsNodeIdToAccessibilityVirtualNodeId(id),
2184                                         AccessibilityEvent.TYPE_VIEW_SELECTED
2185                                     )
2186                                 // Here we use the merged node. Because we specifically are using
2187                                 // the merged node, we must also use the merged version of the
2188                                 // SemanticsConfiguration via `config` instead of `unmergedConfig`
2189                                 // as the rest of the file uses.
2190                                 val mergedNode = newNode.copyWithMergingEnabled()
2191                                 val contentDescription =
2192                                     mergedNode.config
2193                                         .getOrNull(SemanticsProperties.ContentDescription)
2194                                         ?.fastJoinToString(",")
2195                                 val text =
2196                                     mergedNode.config
2197                                         .getOrNull(SemanticsProperties.Text)
2198                                         ?.fastJoinToString(",")
2199                                 contentDescription?.let { event.contentDescription = it }
2200                                 text?.let { event.text.add(it) }
2201                                 sendEvent(event)
2202                             } else {
2203                                 // Send this event to match View.java.
2204                                 sendEventForVirtualView(
2205                                     semanticsNodeIdToAccessibilityVirtualNodeId(id),
2206                                     AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2207                                     AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
2208                                 )
2209                             }
2210                         } else {
2211                             sendEventForVirtualView(
2212                                 semanticsNodeIdToAccessibilityVirtualNodeId(id),
2213                                 AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2214                                 AccessibilityEventCompat.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
2215                             )
2216                             // Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
2217                             // force ViewRootImpl to update its accessibility-focused virtual-node.
2218                             // If we have an androidx fix, we can remove this event.
2219                             sendEventForVirtualView(
2220                                 semanticsNodeIdToAccessibilityVirtualNodeId(id),
2221                                 AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2222                                 AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
2223                             )
2224                         }
2225                     }
2226                     SemanticsProperties.ContentDescription -> {
2227                         sendEventForVirtualView(
2228                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2229                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2230                             AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION,
2231                             value as List<String>
2232                         )
2233                     }
2234                     SemanticsProperties.EditableText -> {
2235                         if (newNode.unmergedConfig.contains(SemanticsActions.SetText)) {
2236 
2237                             val oldText = oldNode.unmergedConfig.getTextForTextField() ?: ""
2238                             val newText = newNode.unmergedConfig.getTextForTextField() ?: ""
2239                             val trimmedNewText = trimToSize(newText, ParcelSafeTextLength)
2240 
2241                             var startCount = 0
2242                             // endCount records how many characters are the same from the end.
2243                             var endCount = 0
2244                             val oldTextLen = oldText.length
2245                             val newTextLen = newText.length
2246                             val minLength = oldTextLen.coerceAtMost(newTextLen)
2247                             while (startCount < minLength) {
2248                                 if (oldText[startCount] != newText[startCount]) {
2249                                     break
2250                                 }
2251                                 startCount++
2252                             }
2253                             // abcdabcd vs
2254                             //     abcd
2255                             while (endCount < minLength - startCount) {
2256                                 if (
2257                                     oldText[oldTextLen - 1 - endCount] !=
2258                                         newText[newTextLen - 1 - endCount]
2259                                 ) {
2260                                     break
2261                                 }
2262                                 endCount++
2263                             }
2264                             val removedCount = oldTextLen - endCount - startCount
2265                             val addedCount = newTextLen - endCount - startCount
2266 
2267                             val oldNodeIsPassword =
2268                                 oldNode.unmergedConfig.contains(SemanticsProperties.Password)
2269                             val newNodeIsPassword =
2270                                 newNode.unmergedConfig.contains(SemanticsProperties.Password)
2271                             val oldNodeIsTextfield =
2272                                 oldNode.unmergedConfig.contains(SemanticsProperties.EditableText)
2273 
2274                             // (b/247891690) We won't send a text change event when we only toggle
2275                             // the password visibility of the node
2276                             val becamePasswordNode =
2277                                 oldNodeIsTextfield && !oldNodeIsPassword && newNodeIsPassword
2278                             val becameNotPasswordNode =
2279                                 oldNodeIsTextfield && oldNodeIsPassword && !newNodeIsPassword
2280                             val event =
2281                                 if (becamePasswordNode || becameNotPasswordNode) {
2282                                     // (b/247891690) password visibility toggle is handled by a
2283                                     // selection event. Because internally Talkback already has the
2284                                     // correct cursor position, there will be no announcement.
2285                                     // Therefore we first send the "cursor reset" event with the
2286                                     // selection at (0, 0) and right after that we will send the
2287                                     // event
2288                                     // with the correct cursor position. This behaves similarly to
2289                                     // the
2290                                     // View-based material EditText which also sends two selection
2291                                     // events
2292                                     createTextSelectionChangedEvent(
2293                                         virtualViewId =
2294                                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2295                                         fromIndex = 0,
2296                                         toIndex = 0,
2297                                         itemCount = newTextLen,
2298                                         text = trimmedNewText
2299                                     )
2300                                 } else {
2301                                     createEvent(
2302                                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
2303                                             AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
2304                                         )
2305                                         .apply {
2306                                             this.fromIndex = startCount
2307                                             this.removedCount = removedCount
2308                                             this.addedCount = addedCount
2309                                             this.beforeText = oldText
2310                                             this.text.add(trimmedNewText)
2311                                         }
2312                                 }
2313                             event.className = TextFieldClassName
2314                             sendEvent(event)
2315 
2316                             // (b/247891690) second event with the correct cursor position (see
2317                             // comment above for more details)
2318                             if (becamePasswordNode || becameNotPasswordNode) {
2319                                 val textRange =
2320                                     newNode.unmergedConfig[SemanticsProperties.TextSelectionRange]
2321                                 event.fromIndex = textRange.start
2322                                 event.toIndex = textRange.end
2323                                 sendEvent(event)
2324                             }
2325                         } else {
2326                             sendEventForVirtualView(
2327                                 semanticsNodeIdToAccessibilityVirtualNodeId(id),
2328                                 AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2329                                 AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT
2330                             )
2331                         }
2332                     }
2333                     // do we need to overwrite TextRange equals?
2334                     SemanticsProperties.TextSelectionRange -> {
2335                         val newText = newNode.unmergedConfig.getTextForTextField()?.text ?: ""
2336                         val textRange =
2337                             newNode.unmergedConfig[SemanticsProperties.TextSelectionRange]
2338                         val event =
2339                             createTextSelectionChangedEvent(
2340                                 semanticsNodeIdToAccessibilityVirtualNodeId(id),
2341                                 textRange.start,
2342                                 textRange.end,
2343                                 newText.length,
2344                                 trimToSize(newText, ParcelSafeTextLength)
2345                             )
2346                         sendEvent(event)
2347                         sendPendingTextTraversedAtGranularityEvent(newNode.id)
2348                     }
2349                     SemanticsProperties.HorizontalScrollAxisRange,
2350                     SemanticsProperties.VerticalScrollAxisRange -> {
2351                         notifySubtreeAccessibilityStateChangedIfNeeded(newNode.layoutNode)
2352 
2353                         val scope = scrollObservationScopes.findById(id)!!
2354                         scope.horizontalScrollAxisRange =
2355                             newNode.unmergedConfig.getOrNull(
2356                                 SemanticsProperties.HorizontalScrollAxisRange
2357                             )
2358                         scope.verticalScrollAxisRange =
2359                             newNode.unmergedConfig.getOrNull(
2360                                 SemanticsProperties.VerticalScrollAxisRange
2361                             )
2362                         scheduleScrollEventIfNeeded(scope)
2363                     }
2364                     SemanticsProperties.Focused -> {
2365                         if (value as Boolean) {
2366                             sendEvent(
2367                                 createEvent(
2368                                     semanticsNodeIdToAccessibilityVirtualNodeId(newNode.id),
2369                                     AccessibilityEvent.TYPE_VIEW_FOCUSED
2370                                 )
2371                             )
2372                         }
2373                         // In View.java this window event is sent for unfocused view. But we send
2374                         // it for focused too so that TalkBack invalidates its cache. Otherwise
2375                         // PasteText edit option is not displayed properly on some OS versions.
2376                         sendEventForVirtualView(
2377                             semanticsNodeIdToAccessibilityVirtualNodeId(newNode.id),
2378                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2379                             AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
2380                         )
2381                     }
2382                     CustomActions -> {
2383                         val actions = newNode.unmergedConfig[CustomActions]
2384                         val oldActions = oldNode.unmergedConfig.getOrNull(CustomActions)
2385                         if (oldActions != null) {
2386                             // Suppose actions with the same label should be deduped.
2387                             val labels = mutableSetOf<String>()
2388                             actions.fastForEach { action -> labels.add(action.label) }
2389                             val oldLabels = mutableSetOf<String>()
2390                             oldActions.fastForEach { action -> oldLabels.add(action.label) }
2391                             propertyChanged =
2392                                 !(labels.containsAll(oldLabels) && oldLabels.containsAll(labels))
2393                         } else if (actions.isNotEmpty()) {
2394                             propertyChanged = true
2395                         }
2396                     }
2397                     // TODO(b/151840490) send the correct events for certain properties, like view
2398                     //  selected.
2399                     else -> {
2400                         propertyChanged =
2401                             if (value is AccessibilityAction<*>) {
2402                                 !value.accessibilityEquals(oldNode.unmergedConfig.getOrNull(key))
2403                             } else {
2404                                 true
2405                             }
2406                     }
2407                 }
2408             }
2409 
2410             if (!propertyChanged) {
2411                 propertyChanged = newNode.propertiesDeleted(oldNode.unmergedConfig)
2412             }
2413             if (propertyChanged) {
2414                 // TODO(b/176105563): throttle the window content change events and merge different
2415                 //  sub types. We can use the subtreeChangedLayoutNodes with sub types.
2416                 sendEventForVirtualView(
2417                     semanticsNodeIdToAccessibilityVirtualNodeId(id),
2418                     AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
2419                     AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
2420                 )
2421             }
2422         }
2423     }
2424 
2425     // List of visible scrollable nodes (which are observing scroll state snapshot writes).
2426     private val scrollObservationScopes = mutableListOf<ScrollObservationScope>()
2427 
2428     /*
2429      * Lambda to store in scrolling snapshot observer, which must never be recreated because
2430      * the snapshot system makes use of lambda reference comparisons.
2431      * (Note that recent versions of the Kotlin compiler do maintain a persistent
2432      * object for most lambda expressions, so this is just for the purpose of explicitness.)
2433      */
<lambda>null2434     private val scheduleScrollEventIfNeededLambda: (ScrollObservationScope) -> Unit = {
2435         this.scheduleScrollEventIfNeeded(it)
2436     }
2437 
registerScrollingIdnull2438     private fun registerScrollingId(
2439         id: Int,
2440         oldScrollObservationScopes: List<ScrollObservationScope>
2441     ): Boolean {
2442         var newlyObservingScroll = false
2443         val oldScope = oldScrollObservationScopes.findById(id)
2444         val newScope =
2445             if (oldScope != null) {
2446                 oldScope
2447             } else {
2448                 newlyObservingScroll = true
2449                 ScrollObservationScope(
2450                     semanticsNodeId = id,
2451                     allScopes = scrollObservationScopes,
2452                     oldXValue = null,
2453                     oldYValue = null,
2454                     horizontalScrollAxisRange = null,
2455                     verticalScrollAxisRange = null
2456                 )
2457             }
2458         scrollObservationScopes.add(newScope)
2459         return newlyObservingScroll
2460     }
2461 
scheduleScrollEventIfNeedednull2462     private fun scheduleScrollEventIfNeeded(scrollObservationScope: ScrollObservationScope) {
2463         if (!scrollObservationScope.isValidOwnerScope) {
2464             return
2465         }
2466         view.snapshotObserver.observeReads(
2467             scrollObservationScope,
2468             scheduleScrollEventIfNeededLambda
2469         ) {
2470             val newXState = scrollObservationScope.horizontalScrollAxisRange
2471             val newYState = scrollObservationScope.verticalScrollAxisRange
2472             val oldXValue = scrollObservationScope.oldXValue
2473             val oldYValue = scrollObservationScope.oldYValue
2474 
2475             val deltaX =
2476                 if (newXState != null && oldXValue != null) {
2477                     newXState.value() - oldXValue
2478                 } else {
2479                     0f
2480                 }
2481             val deltaY =
2482                 if (newYState != null && oldYValue != null) {
2483                     newYState.value() - oldYValue
2484                 } else {
2485                     0f
2486                 }
2487 
2488             if (deltaX != 0f || deltaY != 0f) {
2489                 val scrollerId =
2490                     semanticsNodeIdToAccessibilityVirtualNodeId(
2491                         scrollObservationScope.semanticsNodeId
2492                     )
2493 
2494                 // Refresh the current "green box" bounds and invalidate the View to tell
2495                 // ViewRootImpl to redraw it at its latest position.
2496                 currentSemanticsNodes[accessibilityFocusedVirtualViewId]?.let {
2497                     try {
2498                         currentlyAccessibilityFocusedANI?.setBoundsInScreen(boundsInScreen(it))
2499                     } catch (e: IllegalStateException) {
2500                         // setBoundsInScreen could in theory throw an IllegalStateException if the
2501                         // system has previously sealed the AccessibilityNodeInfo.  This cannot
2502                         // happen on stock AOSP, because ViewRootImpl only uses it for bounds
2503                         // checking, and never forwards it to an accessibility service. But that is
2504                         // a non-CTS-enforced implementation detail, so we should avoid crashing if
2505                         // this happens.
2506                     }
2507                 }
2508                 // Refresh the current "blue box" bounds and invalidate the View to tell
2509                 // ViewRootImpl to redraw it at its latest position.
2510                 currentSemanticsNodes[focusedVirtualViewId]?.let {
2511                     try {
2512                         currentlyFocusedANI?.setBoundsInScreen(boundsInScreen(it))
2513                     } catch (e: IllegalStateException) {
2514                         // setBoundsInScreen could in theory throw an IllegalStateException if the
2515                         // system has previously sealed the AccessibilityNodeInfo.  This cannot
2516                         // happen on stock AOSP, because ViewRootImpl only uses it for bounds
2517                         // checking, and never forwards it to an accessibility service. But that is
2518                         // a non-CTS-enforced implementation detail, so we should avoid crashing if
2519                         // this happens.
2520                     }
2521                 }
2522                 view.invalidate()
2523 
2524                 currentSemanticsNodes[scrollerId]?.semanticsNode?.layoutNode?.let { layoutNode ->
2525                     // Store the data needed for TYPE_VIEW_SCROLLED events.
2526                     if (newXState != null) {
2527                         pendingHorizontalScrollEvents[scrollerId] = newXState
2528                     }
2529                     if (newYState != null) {
2530                         pendingVerticalScrollEvents[scrollerId] = newYState
2531                     }
2532 
2533                     // Schedule a content subtree change event for the scroller. As side effects
2534                     // this will also schedule a TYPE_VIEW_SCROLLED event, and suppress separate
2535                     // events from being sent for each child whose bounds moved.
2536                     notifySubtreeAccessibilityStateChangedIfNeeded(layoutNode)
2537                 }
2538             }
2539 
2540             if (newXState != null) {
2541                 scrollObservationScope.oldXValue = newXState.value()
2542             }
2543             if (newYState != null) {
2544                 scrollObservationScope.oldYValue = newYState.value()
2545             }
2546         }
2547     }
2548 
sendPaneChangeEventsnull2549     private fun sendPaneChangeEvents(semanticsNodeId: Int, contentChangeType: Int, title: String?) {
2550         val event =
2551             createEvent(
2552                 semanticsNodeIdToAccessibilityVirtualNodeId(semanticsNodeId),
2553                 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
2554             )
2555         event.contentChangeTypes = contentChangeType
2556         if (title != null) {
2557             event.text.add(title)
2558         }
2559         sendEvent(event)
2560     }
2561 
sendAccessibilitySemanticsStructureChangeEventsnull2562     private fun sendAccessibilitySemanticsStructureChangeEvents(
2563         newNode: SemanticsNode,
2564         oldNode: SemanticsNodeCopy
2565     ) {
2566         val newChildren: MutableIntSet = mutableIntSetOf()
2567 
2568         // If any child is added, clear the subtree rooted at this node and return.
2569         newNode.replacedChildren.fastForEach { child ->
2570             if (currentSemanticsNodes.contains(child.id)) {
2571                 if (!oldNode.children.contains(child.id)) {
2572                     notifySubtreeAccessibilityStateChangedIfNeeded(newNode.layoutNode)
2573                     return
2574                 }
2575                 newChildren.add(child.id)
2576             }
2577         }
2578 
2579         // If any child is deleted, clear the subtree rooted at this node and return.
2580         oldNode.children.forEach { child ->
2581             if (!newChildren.contains(child)) {
2582                 notifySubtreeAccessibilityStateChangedIfNeeded(newNode.layoutNode)
2583                 return
2584             }
2585         }
2586 
2587         newNode.replacedChildren.fastForEach { child ->
2588             if (currentSemanticsNodes.contains(child.id)) {
2589                 sendAccessibilitySemanticsStructureChangeEvents(
2590                     child,
2591                     previousSemanticsNodes[child.id]!!
2592                 )
2593             }
2594         }
2595     }
2596 
semanticsNodeIdToAccessibilityVirtualNodeIdnull2597     private fun semanticsNodeIdToAccessibilityVirtualNodeId(id: Int): Int {
2598         if (id == view.semanticsOwner.unmergedRootSemanticsNode.id) {
2599             return AccessibilityNodeProviderCompat.HOST_VIEW_ID
2600         }
2601         return id
2602     }
2603 
traverseAtGranularitynull2604     private fun traverseAtGranularity(
2605         node: SemanticsNode,
2606         granularity: Int,
2607         forward: Boolean,
2608         extendSelection: Boolean
2609     ): Boolean {
2610         if (node.id != previousTraversedNode) {
2611             accessibilityCursorPosition = AccessibilityCursorPositionUndefined
2612             previousTraversedNode = node.id
2613         }
2614 
2615         val text = getIterableTextForAccessibility(node)
2616         if (text.isNullOrEmpty()) {
2617             return false
2618         }
2619         val iterator = getIteratorForGranularity(node, granularity) ?: return false
2620         var current = getAccessibilitySelectionEnd(node)
2621         if (current == AccessibilityCursorPositionUndefined) {
2622             current = if (forward) 0 else text.length
2623         }
2624         val range =
2625             (if (forward) iterator.following(current) else iterator.preceding(current))
2626                 ?: return false
2627         val segmentStart = range[0]
2628         val segmentEnd = range[1]
2629         var selectionStart: Int
2630         val selectionEnd: Int
2631         if (extendSelection && isAccessibilitySelectionExtendable(node)) {
2632             selectionStart = getAccessibilitySelectionStart(node)
2633             if (selectionStart == AccessibilityCursorPositionUndefined) {
2634                 selectionStart = if (forward) segmentStart else segmentEnd
2635             }
2636             selectionEnd = if (forward) segmentEnd else segmentStart
2637         } else {
2638             selectionStart = if (forward) segmentEnd else segmentStart
2639             selectionEnd = selectionStart
2640         }
2641         val action =
2642             if (forward) AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
2643             else AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
2644         pendingTextTraversedEvent =
2645             PendingTextTraversedEvent(
2646                 node,
2647                 action,
2648                 granularity,
2649                 segmentStart,
2650                 segmentEnd,
2651                 SystemClock.uptimeMillis()
2652             )
2653         setAccessibilitySelection(node, selectionStart, selectionEnd, true)
2654         return true
2655     }
2656 
sendPendingTextTraversedAtGranularityEventnull2657     private fun sendPendingTextTraversedAtGranularityEvent(semanticsNodeId: Int) {
2658         pendingTextTraversedEvent?.let {
2659             // not the same node, do nothing. Don't set pendingTextTraversedEvent to null either.
2660             if (semanticsNodeId != it.node.id) {
2661                 return
2662             }
2663             if (SystemClock.uptimeMillis() - it.traverseTime <= TextTraversedEventTimeoutMillis) {
2664                 val event =
2665                     createEvent(
2666                         semanticsNodeIdToAccessibilityVirtualNodeId(it.node.id),
2667                         AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY
2668                     )
2669                 event.fromIndex = it.fromIndex
2670                 event.toIndex = it.toIndex
2671                 event.action = it.action
2672                 event.movementGranularity = it.granularity
2673                 event.text.add(getIterableTextForAccessibility(it.node))
2674                 sendEvent(event)
2675             }
2676         }
2677         pendingTextTraversedEvent = null
2678     }
2679 
setAccessibilitySelectionnull2680     private fun setAccessibilitySelection(
2681         node: SemanticsNode,
2682         start: Int,
2683         end: Int,
2684         traversalMode: Boolean
2685     ): Boolean {
2686         // Any widget which has custom action_set_selection needs to provide cursor
2687         // positions, so events will be sent when cursor position change.
2688         // When the node is disabled, only the default/virtual set selection can performed.
2689         if (node.unmergedConfig.contains(SemanticsActions.SetSelection) && node.enabled()) {
2690             // Hide all selection controllers used for adjusting selection
2691             // since we are doing so explicitly by other means and these
2692             // controllers interact with how selection behaves. From TextView.java.
2693             return node.unmergedConfig[SemanticsActions.SetSelection]
2694                 .action
2695                 ?.invoke(start, end, traversalMode) ?: false
2696         }
2697         if (start == end && end == accessibilityCursorPosition) {
2698             return false
2699         }
2700         val text = getIterableTextForAccessibility(node) ?: return false
2701         accessibilityCursorPosition =
2702             if (start >= 0 && start == end && end <= text.length) {
2703                 start
2704             } else {
2705                 AccessibilityCursorPositionUndefined
2706             }
2707         val nonEmptyText = text.isNotEmpty()
2708         val event =
2709             createTextSelectionChangedEvent(
2710                 semanticsNodeIdToAccessibilityVirtualNodeId(node.id),
2711                 if (nonEmptyText) accessibilityCursorPosition else null,
2712                 if (nonEmptyText) accessibilityCursorPosition else null,
2713                 if (nonEmptyText) text.length else null,
2714                 text
2715             )
2716         sendEvent(event)
2717         sendPendingTextTraversedAtGranularityEvent(node.id)
2718         return true
2719     }
2720 
2721     /** Returns selection start and end indices in original text */
getAccessibilitySelectionStartnull2722     private fun getAccessibilitySelectionStart(node: SemanticsNode): Int {
2723         // If there is ContentDescription, it will be used instead of text during traversal.
2724         if (
2725             !node.unmergedConfig.contains(SemanticsProperties.ContentDescription) &&
2726                 node.unmergedConfig.contains(SemanticsProperties.TextSelectionRange)
2727         ) {
2728             return node.unmergedConfig[SemanticsProperties.TextSelectionRange].start
2729         }
2730         return accessibilityCursorPosition
2731     }
2732 
getAccessibilitySelectionEndnull2733     private fun getAccessibilitySelectionEnd(node: SemanticsNode): Int {
2734         // If there is ContentDescription, it will be used instead of text during traversal.
2735         if (
2736             !node.unmergedConfig.contains(SemanticsProperties.ContentDescription) &&
2737                 node.unmergedConfig.contains(SemanticsProperties.TextSelectionRange)
2738         ) {
2739             return node.unmergedConfig[SemanticsProperties.TextSelectionRange].end
2740         }
2741         return accessibilityCursorPosition
2742     }
2743 
isAccessibilitySelectionExtendablenull2744     private fun isAccessibilitySelectionExtendable(node: SemanticsNode): Boolean {
2745         // Currently only TextField is extendable. Static text may become extendable later.
2746         return !node.unmergedConfig.contains(SemanticsProperties.ContentDescription) &&
2747             node.unmergedConfig.contains(SemanticsProperties.EditableText)
2748     }
2749 
getIteratorForGranularitynull2750     private fun getIteratorForGranularity(
2751         node: SemanticsNode?,
2752         granularity: Int
2753     ): AccessibilityIterators.TextSegmentIterator? {
2754         if (node == null) return null
2755 
2756         val text = getIterableTextForAccessibility(node)
2757         if (text.isNullOrEmpty()) {
2758             return null
2759         }
2760         // TODO(b/160190186) Make sure locale is right in AccessibilityIterators.
2761         val iterator: AccessibilityIterators.AbstractTextSegmentIterator
2762         @Suppress("DEPRECATION")
2763         when (granularity) {
2764             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER -> {
2765                 iterator =
2766                     AccessibilityIterators.CharacterTextSegmentIterator.getInstance(
2767                         view.context.resources.configuration.locale
2768                     )
2769                 iterator.initialize(text)
2770             }
2771             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD -> {
2772                 iterator =
2773                     AccessibilityIterators.WordTextSegmentIterator.getInstance(
2774                         view.context.resources.configuration.locale
2775                     )
2776                 iterator.initialize(text)
2777             }
2778             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH -> {
2779                 iterator = AccessibilityIterators.ParagraphTextSegmentIterator.getInstance()
2780                 iterator.initialize(text)
2781             }
2782             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE,
2783             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PAGE -> {
2784                 // Line and page granularity are only for static text or text field.
2785                 if (!node.unmergedConfig.contains(SemanticsActions.GetTextLayoutResult)) {
2786                     return null
2787                 }
2788                 val textLayoutResult = getTextLayoutResult(node.unmergedConfig) ?: return null
2789                 if (granularity == AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE) {
2790                     iterator = AccessibilityIterators.LineTextSegmentIterator.getInstance()
2791                     iterator.initialize(text, textLayoutResult)
2792                 } else {
2793                     iterator = AccessibilityIterators.PageTextSegmentIterator.getInstance()
2794                     // TODO: the node should be text/textfield node instead of the current node.
2795                     iterator.initialize(text, textLayoutResult, node)
2796                 }
2797             }
2798             else -> return null
2799         }
2800         return iterator
2801     }
2802 
2803     /**
2804      * Gets the text reported for accessibility purposes. If a text node has a content description
2805      * in the unmerged config, it will be used instead of the text.
2806      *
2807      * This function is basically prioritising the content description over the text or editable
2808      * text of the text and text field nodes.
2809      */
getIterableTextForAccessibilitynull2810     private fun getIterableTextForAccessibility(node: SemanticsNode?): String? {
2811         if (node == null) {
2812             return null
2813         }
2814         // Note in android framework, TextView set this to its text. This is changed to
2815         // prioritize content description, even for Text.
2816         if (node.unmergedConfig.contains(SemanticsProperties.ContentDescription)) {
2817             return node.unmergedConfig[SemanticsProperties.ContentDescription].fastJoinToString(",")
2818         }
2819 
2820         if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) {
2821             return node.unmergedConfig.getTextForTextField()?.text
2822         }
2823 
2824         return node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()?.text
2825     }
2826 
getTextForTextFieldnull2827     private fun SemanticsConfiguration.getTextForTextField(): AnnotatedString? {
2828         return getOrNull(SemanticsProperties.EditableText)
2829     }
2830 
2831     private inner class ComposeAccessibilityNodeProvider : AccessibilityNodeProviderCompat() {
createAccessibilityNodeInfonull2832         override fun createAccessibilityNodeInfo(virtualViewId: Int): AccessibilityNodeInfoCompat? {
2833             return createNodeInfo(virtualViewId).also {
2834                 if (sendingFocusAffectingEvent) {
2835                     if (virtualViewId == accessibilityFocusedVirtualViewId) {
2836                         currentlyAccessibilityFocusedANI = it
2837                     }
2838                     if (virtualViewId == focusedVirtualViewId) {
2839                         currentlyFocusedANI = it
2840                     }
2841                 }
2842             }
2843         }
2844 
performActionnull2845         override fun performAction(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean {
2846             return performActionHelper(virtualViewId, action, arguments)
2847         }
2848 
addExtraDataToAccessibilityNodeInfonull2849         override fun addExtraDataToAccessibilityNodeInfo(
2850             virtualViewId: Int,
2851             info: AccessibilityNodeInfoCompat,
2852             extraDataKey: String,
2853             arguments: Bundle?
2854         ) {
2855             addExtraDataToAccessibilityNodeInfoHelper(virtualViewId, info, extraDataKey, arguments)
2856         }
2857 
findFocusnull2858         override fun findFocus(focus: Int): AccessibilityNodeInfoCompat? {
2859             return when (focus) {
2860                 // TODO(b/364744967): add test for  FOCUS_ACCESSIBILITY
2861                 FOCUS_ACCESSIBILITY ->
2862                     createAccessibilityNodeInfo(accessibilityFocusedVirtualViewId)
2863                 FOCUS_INPUT ->
2864                     if (focusedVirtualViewId == InvalidId) null
2865                     else createAccessibilityNodeInfo(focusedVirtualViewId)
2866                 else -> throw IllegalArgumentException("Unknown focus type: $focus")
2867             }
2868         }
2869     }
2870 
2871     @RequiresApi(Build.VERSION_CODES.N)
2872     private object Api24Impl {
2873         @JvmStatic
addSetProgressActionnull2874         fun addSetProgressAction(info: AccessibilityNodeInfoCompat, semanticsNode: SemanticsNode) {
2875             if (semanticsNode.enabled()) {
2876                 semanticsNode.unmergedConfig.getOrNull(SemanticsActions.SetProgress)?.let {
2877                     info.addAction(
2878                         AccessibilityActionCompat(
2879                             android.R.id.accessibilityActionSetProgress,
2880                             it.label
2881                         )
2882                     )
2883                 }
2884             }
2885         }
2886     }
2887 
2888     @RequiresApi(Build.VERSION_CODES.Q)
2889     private object Api29Impl {
2890         @JvmStatic
addPageActionsnull2891         fun addPageActions(info: AccessibilityNodeInfoCompat, semanticsNode: SemanticsNode) {
2892             val role = semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Role)
2893             if (semanticsNode.enabled() && role != Carousel) {
2894                 semanticsNode.unmergedConfig.getOrNull(PageUp)?.let {
2895                     info.addAction(
2896                         AccessibilityActionCompat(android.R.id.accessibilityActionPageUp, it.label)
2897                     )
2898                 }
2899                 semanticsNode.unmergedConfig.getOrNull(PageDown)?.let {
2900                     info.addAction(
2901                         AccessibilityActionCompat(
2902                             android.R.id.accessibilityActionPageDown,
2903                             it.label
2904                         )
2905                     )
2906                 }
2907                 semanticsNode.unmergedConfig.getOrNull(PageLeft)?.let {
2908                     info.addAction(
2909                         AccessibilityActionCompat(
2910                             android.R.id.accessibilityActionPageLeft,
2911                             it.label
2912                         )
2913                     )
2914                 }
2915                 semanticsNode.unmergedConfig.getOrNull(PageRight)?.let {
2916                     info.addAction(
2917                         AccessibilityActionCompat(
2918                             android.R.id.accessibilityActionPageRight,
2919                             it.label
2920                         )
2921                     )
2922                 }
2923             }
2924         }
2925     }
2926 }
2927 
2928 // Note: This function was separated into a static function due to b/375509809.
2929 /**
2930  * Calculates custom traversal order for the semantics tree represented by [currentSemanticsNodes]
2931  * and saves the result in [outputBeforeMap] and [outputAfterMap].
2932  *
2933  * @param currentSemanticsNodes: A map of all the nodes in the semantics tree.
2934  * @param outputBeforeMap: A mutable map that is an output of this function. It stores references to
2935  *   node that should be traversed before the node specified by the id.
2936  * @param outputAfterMap: A mutable map that is an output of this function. It stores references to
2937  *   node that should be traversed after the node specified by the id.
2938  * @param resources: Application resources.
2939  */
setTraversalValuesnull2940 private fun setTraversalValues(
2941     currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
2942     outputBeforeMap: MutableIntIntMap,
2943     outputAfterMap: MutableIntIntMap,
2944     resources: Resources
2945 ) {
2946     outputBeforeMap.clear()
2947     outputAfterMap.clear()
2948 
2949     val hostSemanticsNode =
2950         currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!!
2951 
2952     val semanticsOrderList =
2953         hostSemanticsNode.subtreeSortedByGeometryGrouping(
2954             isVisible = { currentSemanticsNodes.containsKey(it.id) },
2955             isFocusableContainer = { isScreenReaderFocusable(it, resources) },
2956             listToSort = listOf(hostSemanticsNode)
2957         )
2958 
2959     // Iterate through our ordered list, and creating a mapping of current node to next node ID
2960     // We'll later read through this and set traversal order with IdToBeforeMap
2961     for (i in 1..semanticsOrderList.lastIndex) {
2962         val prevId = semanticsOrderList[i - 1].id
2963         val currId = semanticsOrderList[i].id
2964         outputBeforeMap[prevId] = currId
2965         outputAfterMap[currId] = prevId
2966     }
2967 }
2968 
isScreenReaderFocusablenull2969 private fun isScreenReaderFocusable(node: SemanticsNode, resources: Resources): Boolean {
2970     val nodeContentDescriptionOrNull =
2971         node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull()
2972     val isSpeakingNode =
2973         nodeContentDescriptionOrNull != null ||
2974             getInfoText(node) != null ||
2975             getInfoStateDescriptionOrNull(node, resources) != null ||
2976             getInfoIsCheckable(node)
2977 
2978     return !node.isHidden &&
2979         (node.unmergedConfig.isMergingSemanticsOfDescendants ||
2980             node.isUnmergedLeafNode && isSpeakingNode)
2981 }
2982 
getInfoTextnull2983 private fun getInfoText(node: SemanticsNode): AnnotatedString? {
2984     val editableTextToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.EditableText)
2985     val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()
2986     return editableTextToAssign ?: textToAssign
2987 }
2988 
getInfoStateDescriptionOrNullnull2989 private fun getInfoStateDescriptionOrNull(node: SemanticsNode, resources: Resources): String? {
2990     var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription)
2991     val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
2992     val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
2993 
2994     // Check toggle state and retrieve description accordingly
2995     toggleState?.let {
2996         when (it) {
2997             ToggleableState.On -> {
2998                 // Unfortunately, talkback has a bug of using "checked", so we set state
2999                 // description here
3000                 if (role == Role.Switch && stateDescription == null) {
3001                     stateDescription = resources.getString(R.string.state_on)
3002                 }
3003             }
3004             ToggleableState.Off -> {
3005                 // Unfortunately, talkback has a bug of using "not checked", so we set state
3006                 // description here
3007                 if (role == Role.Switch && stateDescription == null) {
3008                     stateDescription = resources.getString(R.string.state_off)
3009                 }
3010             }
3011             ToggleableState.Indeterminate -> {
3012                 if (stateDescription == null) {
3013                     stateDescription = resources.getString(R.string.indeterminate)
3014                 }
3015             }
3016         }
3017     }
3018 
3019     // Check Selected property and retrieve description accordingly
3020     node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
3021         if (role != Role.Tab) {
3022             if (stateDescription == null) {
3023                 // If a radio entry (radio button + text) is selectable, it won't have the role
3024                 // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say
3025                 // "checked/not checked" instead "selected/note selected".
3026                 stateDescription =
3027                     if (it) {
3028                         resources.getString(R.string.selected)
3029                     } else {
3030                         resources.getString(R.string.not_selected)
3031                     }
3032             }
3033         }
3034     }
3035 
3036     // Check if a node has progress bar range info and retrieve description accordingly
3037     val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
3038     rangeInfo?.let {
3039         // let's set state description here and use state description change events.
3040         // otherwise, we need to send out type_view_selected event, as the old android
3041         // versions do. But the support for type_view_selected event for progress bars
3042         // maybe deprecated in talkback in the future.
3043         if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) {
3044             if (stateDescription == null) {
3045                 val valueRange = rangeInfo.range
3046                 val progress =
3047                     (if (valueRange.endInclusive - valueRange.start == 0f) 0f
3048                         else
3049                             (rangeInfo.current - valueRange.start) /
3050                                 (valueRange.endInclusive - valueRange.start))
3051                         .fastCoerceIn(0f, 1f)
3052 
3053                 // We only display 0% or 100% when it is exactly 0% or 100%.
3054                 val percent =
3055                     when (progress) {
3056                         0f -> 0
3057                         1f -> 100
3058                         else -> (progress * 100).fastRoundToInt().coerceIn(1, 99)
3059                     }
3060                 stateDescription = resources.getString(R.string.template_percent, percent)
3061             }
3062         } else if (stateDescription == null) {
3063             stateDescription = resources.getString(R.string.in_progress)
3064         }
3065     }
3066 
3067     if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) {
3068         stateDescription = createStateDescriptionForTextField(node, resources)
3069     }
3070 
3071     return stateDescription
3072 }
3073 
3074 /**
3075  * Empty text field should not be ignored by the TB so we set a state description. When there is a
3076  * speakable child, like a label or a placeholder text, setting this state description is redundant
3077  */
createStateDescriptionForTextFieldnull3078 private fun createStateDescriptionForTextField(node: SemanticsNode, resources: Resources): String? {
3079     val mergedConfig = node.copyWithMergingEnabled().config
3080     val mergedNodeIsUnspeakable =
3081         mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() &&
3082             mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() &&
3083             mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty()
3084     return if (mergedNodeIsUnspeakable) resources.getString(R.string.state_empty) else null
3085 }
3086 
getInfoIsCheckablenull3087 private fun getInfoIsCheckable(node: SemanticsNode): Boolean {
3088     var isCheckable = false
3089     val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
3090     val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
3091 
3092     toggleState?.let { isCheckable = true }
3093 
3094     node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
3095         if (role != Role.Tab) {
3096             isCheckable = true
3097         }
3098     }
3099 
3100     return isCheckable
3101 }
3102 
3103 // TODO(mnuzen): Move common semantics logic into `SemanticsUtils` file to make a11y delegate
3104 // shorter and more readable.
SemanticsNodenull3105 private fun SemanticsNode.enabled() = (!config.contains(SemanticsProperties.Disabled))
3106 
3107 private fun SemanticsNode.propertiesDeleted(oldConfig: SemanticsConfiguration): Boolean {
3108     for (entry in oldConfig) {
3109         if (!config.contains(entry.key)) {
3110             return true
3111         }
3112     }
3113     return false
3114 }
3115 
3116 private val SemanticsNode.isRtl
3117     get() = layoutInfo.layoutDirection == LayoutDirection.Rtl
3118 
SemanticsNodenull3119 private fun SemanticsNode.excludeLineAndPageGranularities(): Boolean {
3120     // text field that is not in focus
3121     if (
3122         unmergedConfig.contains(SemanticsProperties.EditableText) &&
3123             unmergedConfig.getOrNull(SemanticsProperties.Focused) != true
3124     )
3125         return true
3126 
3127     // text nodes that are part of the 'merged' text field, for example hint or label.
3128     val ancestor =
3129         layoutNode.findClosestParentNode {
3130             // looking for text field merging node
3131             val ancestorSemanticsConfiguration = it.semanticsConfiguration
3132             ancestorSemanticsConfiguration?.isMergingSemanticsOfDescendants == true &&
3133                 ancestorSemanticsConfiguration.contains(SemanticsProperties.EditableText)
3134         }
3135     return ancestor != null &&
3136         ancestor.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused) != true
3137 }
3138 
AccessibilityActionnull3139 private fun AccessibilityAction<*>.accessibilityEquals(other: Any?): Boolean {
3140     if (this === other) return true
3141     if (other !is AccessibilityAction<*>) return false
3142 
3143     if (label != other.label) return false
3144     if (action == null && other.action != null) return false
3145     if (action != null && other.action == null) return false
3146 
3147     return true
3148 }
3149 
3150 @Deprecated(
3151     message = "Use ContentCapture.isEnabled instead",
3152     replaceWith =
3153         ReplaceWith(
3154             expression = "!ContentCaptureManager.isEnabled",
3155             imports =
3156                 ["androidx.compose.ui.contentcapture.ContentCaptureManager.Companion.isEnabled"]
3157         ),
3158     level = DeprecationLevel.WARNING
3159 )
3160 @Suppress("GetterSetterNames", "OPT_IN_MARKER_ON_WRONG_TARGET", "NullAnnotationGroup")
3161 @get:Suppress("GetterSetterNames")
3162 @get:ExperimentalComposeUiApi
3163 @set:ExperimentalComposeUiApi
3164 @ExperimentalComposeUiApi
3165 var DisableContentCapture: Boolean
3166     get() = ContentCaptureManager.isEnabled
3167     set(value) {
3168         ContentCaptureManager.isEnabled = value
3169     }
3170