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>@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