1 /*
<lambda>null2  * Copyright 2024 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.integration.demos
18 
19 import android.graphics.Matrix
20 import android.graphics.Rect
21 import android.os.Build
22 import android.os.Bundle
23 import android.util.Log
24 import android.view.View
25 import android.view.ViewGroup
26 import android.view.accessibility.AccessibilityNodeInfo
27 import androidx.annotation.RequiresApi
28 import androidx.compose.animation.AnimatedVisibility
29 import androidx.compose.animation.core.animateFloatAsState
30 import androidx.compose.animation.expandVertically
31 import androidx.compose.animation.shrinkVertically
32 import androidx.compose.foundation.background
33 import androidx.compose.foundation.gestures.awaitEachGesture
34 import androidx.compose.foundation.gestures.awaitFirstDown
35 import androidx.compose.foundation.horizontalScroll
36 import androidx.compose.foundation.interaction.MutableInteractionSource
37 import androidx.compose.foundation.interaction.collectIsPressedAsState
38 import androidx.compose.foundation.isSystemInDarkTheme
39 import androidx.compose.foundation.layout.Arrangement.spacedBy
40 import androidx.compose.foundation.layout.Box
41 import androidx.compose.foundation.layout.Column
42 import androidx.compose.foundation.layout.Row
43 import androidx.compose.foundation.layout.RowScope
44 import androidx.compose.foundation.layout.fillMaxWidth
45 import androidx.compose.foundation.layout.height
46 import androidx.compose.foundation.layout.padding
47 import androidx.compose.foundation.layout.width
48 import androidx.compose.foundation.layout.wrapContentSize
49 import androidx.compose.foundation.rememberScrollState
50 import androidx.compose.foundation.selection.selectable
51 import androidx.compose.foundation.text.selection.SelectionContainer
52 import androidx.compose.foundation.verticalScroll
53 import androidx.compose.material.AlertDialog
54 import androidx.compose.material.Button
55 import androidx.compose.material.Divider
56 import androidx.compose.material.Icon
57 import androidx.compose.material.IconButton
58 import androidx.compose.material.MaterialTheme
59 import androidx.compose.material.Surface
60 import androidx.compose.material.Text
61 import androidx.compose.material.TopAppBar
62 import androidx.compose.material.darkColors
63 import androidx.compose.material.icons.Icons
64 import androidx.compose.material.icons.automirrored.filled.ArrowBack
65 import androidx.compose.material.icons.filled.ArrowDropDown
66 import androidx.compose.material.icons.filled.Info
67 import androidx.compose.material.lightColors
68 import androidx.compose.material.primarySurface
69 import androidx.compose.runtime.Composable
70 import androidx.compose.runtime.DisposableEffect
71 import androidx.compose.runtime.LaunchedEffect
72 import androidx.compose.runtime.collection.mutableVectorOf
73 import androidx.compose.runtime.derivedStateOf
74 import androidx.compose.runtime.getValue
75 import androidx.compose.runtime.mutableStateOf
76 import androidx.compose.runtime.remember
77 import androidx.compose.runtime.setValue
78 import androidx.compose.ui.Alignment
79 import androidx.compose.ui.Modifier
80 import androidx.compose.ui.draw.alpha
81 import androidx.compose.ui.draw.drawBehind
82 import androidx.compose.ui.geometry.Offset
83 import androidx.compose.ui.geometry.isSpecified
84 import androidx.compose.ui.graphics.ClipOp
85 import androidx.compose.ui.graphics.Color
86 import androidx.compose.ui.graphics.TransformOrigin
87 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
88 import androidx.compose.ui.graphics.drawscope.Stroke
89 import androidx.compose.ui.graphics.drawscope.clipRect
90 import androidx.compose.ui.graphics.graphicsLayer
91 import androidx.compose.ui.graphics.toComposeIntRect
92 import androidx.compose.ui.input.pointer.PointerEventPass
93 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
94 import androidx.compose.ui.input.pointer.changedToUp
95 import androidx.compose.ui.layout.Layout
96 import androidx.compose.ui.layout.LayoutCoordinates
97 import androidx.compose.ui.layout.layout
98 import androidx.compose.ui.node.DelegatingNode
99 import androidx.compose.ui.node.DrawModifierNode
100 import androidx.compose.ui.node.ModifierNodeElement
101 import androidx.compose.ui.node.RootForTest
102 import androidx.compose.ui.node.requireLayoutCoordinates
103 import androidx.compose.ui.platform.InspectorInfo
104 import androidx.compose.ui.platform.LocalView
105 import androidx.compose.ui.semantics.semantics
106 import androidx.compose.ui.semantics.testTag
107 import androidx.compose.ui.text.AnnotatedString
108 import androidx.compose.ui.text.LinkAnnotation
109 import androidx.compose.ui.text.SpanStyle
110 import androidx.compose.ui.text.TextStyle
111 import androidx.compose.ui.text.buildAnnotatedString
112 import androidx.compose.ui.text.font.FontFamily
113 import androidx.compose.ui.text.font.FontStyle
114 import androidx.compose.ui.text.font.FontWeight
115 import androidx.compose.ui.text.style.TextOverflow
116 import androidx.compose.ui.text.withLink
117 import androidx.compose.ui.text.withStyle
118 import androidx.compose.ui.unit.Density
119 import androidx.compose.ui.unit.Dp
120 import androidx.compose.ui.unit.IntOffset
121 import androidx.compose.ui.unit.IntRect
122 import androidx.compose.ui.unit.IntSize
123 import androidx.compose.ui.unit.LayoutDirection
124 import androidx.compose.ui.unit.dp
125 import androidx.compose.ui.unit.offset
126 import androidx.compose.ui.unit.round
127 import androidx.compose.ui.unit.toOffset
128 import androidx.compose.ui.unit.toSize
129 import androidx.compose.ui.util.fastFirstOrNull
130 import androidx.compose.ui.util.fastForEach
131 import androidx.compose.ui.util.fastForEachIndexed
132 import androidx.compose.ui.window.Dialog
133 import androidx.compose.ui.window.DialogProperties
134 import androidx.compose.ui.window.Popup
135 import androidx.compose.ui.window.PopupPositionProvider
136 import androidx.compose.ui.window.PopupProperties
137 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
138 import androidx.core.view.children
139 import kotlinx.coroutines.awaitCancellation
140 import kotlinx.coroutines.coroutineScope
141 import kotlinx.coroutines.launch
142 
143 private const val InspectorButtonTestTag =
144     "androidx.compose.foundation.demos.AccessibilityNodeInspectorButton"
145 
146 /** The key used to read Compose testTag semantics properties from accessibility nodes' extras. */
147 private const val TestTagExtrasKey = "androidx.compose.ui.semantics.testTag"
148 
149 private const val LogTag = "A11yNodeInspector"
150 
151 private val UnsupportedMessage =
152     "This tool is not supported on this device. AccessibilityNodeInfo objects are not readable " +
153         "by code in the same process without an accessibility service before API 34.\n\n" +
154         "This device is running API ${Build.VERSION.SDK_INT}."
155 
156 private const val UsageMessage =
157     "Drag anywhere to explore accessibility nodes.\n\n" +
158         "Release to view the node's properties and print the information to logcat " +
159         "(tagged \"$LogTag\").\n\n" +
160         "Go back to close inspector."
161 
162 /**
163  * A composable that, when touched or dragged, will immediately show an overlay on the current
164  * window that allows the user to interactively explore accessibility nodes and view their
165  * properties.
166  */
167 @Composable
168 fun AccessibilityNodeInspectorButton(
169     modifier: Modifier = Modifier,
170     content: @Composable () -> Unit
171 ) {
172     var active by remember { mutableStateOf(false) }
173     val state = rememberAccessibilityNodeInspectorState()
174     Box(
175         propagateMinConstraints = true,
176         modifier =
177             modifier
178                 // This node needs to have the same gesture modifier as the dedicated inspector
179                 // overlay
180                 // since when the button is dragged initially, the pointer events will all still be
181                 // sent
182                 // to the button, and not the overlay, even though the overlay will immediately be
183                 // shown. Because node coordinates are all communicated in screen space, it doesn't
184                 // actually matter which window accepts the pointer events.
185                 .then(NodeSelectionGestureModifier(state, onDragStarted = { active = true }))
186                 // Tag the button so the inspector can detect when the button itself is selected and
187                 // show a help message.
188                 .semantics(mergeDescendants = true) { testTag = InspectorButtonTestTag }
189     ) {
190         content()
191 
192         if (active) {
193             if (Build.VERSION.SDK_INT >= 34) {
194                 AccessibilityNodeInspector(state = state, onDismissRequest = { active = false })
195             } else {
196                 AlertDialog(
197                     onDismissRequest = { active = false },
198                     title = { Text("Accessibility Node Inspector") },
199                     text = { Text(UnsupportedMessage) },
200                     buttons = {
201                         Button(
202                             onClick = { active = false },
203                             modifier = Modifier.padding(16.dp).fillMaxWidth()
204                         ) {
205                             Text("DISMISS")
206                         }
207                     }
208                 )
209             }
210         }
211     }
212 }
213 
214 /**
215  * Returns true if this [NodeInfo] or any of its ancestors represents an
216  * [AccessibilityNodeInspectorButton].
217  */
218 private val NodeInfo.isInspectorButton: Boolean
219     get() {
220         if (Build.VERSION.SDK_INT >= 26) {
<lambda>null221             visitSelfAndAncestors {
222                 val testTag =
223                     AccessibilityNodeInfoHelper.readExtraData(
224                         it.nodeInfo.unwrap(),
225                         TestTagExtrasKey
226                     )
227                 if (testTag == InspectorButtonTestTag) {
228                     return true
229                 }
230             }
231         }
232         return false
233     }
234 
235 // region Selection UI
236 
237 /** A popup that overlays another window and allows exploring its accessibility nodes by touch. */
238 @Composable
AccessibilityNodeInspectornull239 private fun AccessibilityNodeInspector(
240     state: AccessibilityNodeInspectorState,
241     onDismissRequest: () -> Unit,
242 ) {
243     if (state.isReady) {
244         Popup(
245             popupPositionProvider = state,
246             properties =
247                 PopupProperties(
248                     focusable = true,
249                     excludeFromSystemGesture = false,
250                 ),
251             onDismissRequest = onDismissRequest
252         ) {
253             Box(
254                 propagateMinConstraints = true,
255                 modifier =
256                     Modifier.width { state.inspectorWindowSize.width }
257                         .height { state.inspectorWindowSize.height }
258             ) {
259                 // Selection UI and input handling.
260                 Box(
261                     Modifier.then(NodeSelectionGestureModifier(state))
262                         .then(DrawSelectionOverlayModifier(state))
263                 )
264 
265                 state.nodeUnderInspection?.let {
266                     if (it.isInspectorButton) {
267                         // Don't use Surface here, it breaks touch input.
268                         Text(
269                             UsageMessage,
270                             modifier =
271                                 Modifier.wrapContentSize()
272                                     .padding(16.dp)
273                                     .background(MaterialTheme.colors.surface)
274                                     .padding(16.dp)
275                         )
276                     } else {
277                         InspectorNodeDetailsDialog(
278                             leafNode = it,
279                             onNodeClick = state::inspectNode,
280                             onBack = onDismissRequest,
281                         )
282                     }
283                 }
284             }
285         }
286     }
287 }
288 
289 /**
290  * A modifier that draws the current selection of an [AccessibilityNodeInspectorState] in an
291  * [AccessibilityNodeInspector].
292  */
293 private data class DrawSelectionOverlayModifier(val state: AccessibilityNodeInspectorState) :
294     ModifierNodeElement<DrawSelectionOverlayModifierNode>() {
createnull295     override fun create(): DrawSelectionOverlayModifierNode =
296         DrawSelectionOverlayModifierNode(state)
297 
298     override fun update(node: DrawSelectionOverlayModifierNode) {
299         check(node.state === state) { "Cannot change state" }
300     }
301 
inspectablePropertiesnull302     override fun InspectorInfo.inspectableProperties() {}
303 }
304 
305 private class DrawSelectionOverlayModifierNode(val state: AccessibilityNodeInspectorState) :
306     Modifier.Node(), DrawModifierNode {
drawnull307     override fun ContentDrawScope.draw() {
308         val coords = requireLayoutCoordinates()
309         state.nodesUnderCursor.let { nodes ->
310             if (nodes.isNotEmpty()) {
311                 val layerAlpha = 0.8f / nodes.size
312                 nodes.fastForEach { node ->
313                     val bounds = coords.screenToLocal(node.boundsInScreen)
314                     clipRect(
315                         left = bounds.left.toFloat(),
316                         top = bounds.top.toFloat(),
317                         right = bounds.right.toFloat(),
318                         bottom = bounds.bottom.toFloat(),
319                         clipOp = ClipOp.Difference
320                     ) {
321                         drawRect(Color.Black.copy(alpha = layerAlpha))
322                     }
323                 }
324             }
325         }
326 
327         state.highlightedNode?.let { node ->
328             val lastBounds = coords.screenToLocal(node.boundsInScreen)
329             drawRect(
330                 Color.Green,
331                 style = Stroke(1.dp.toPx()),
332                 topLeft = lastBounds.topLeft.toOffset(),
333                 size = lastBounds.size.toSize()
334             )
335         }
336 
337         state.selectionOffset
338             .takeIf { it.isSpecified }
339             ?.let { screenOffset ->
340                 val localOffset = coords.screenToLocal(screenOffset)
341                 drawLine(
342                     Color.Red,
343                     start = Offset(0f, localOffset.y),
344                     end = Offset(size.width, localOffset.y)
345                 )
346                 drawLine(
347                     Color.Red,
348                     start = Offset(localOffset.x, 0f),
349                     end = Offset(localOffset.x, size.height)
350                 )
351             }
352     }
353 
LayoutCoordinatesnull354     private fun LayoutCoordinates.screenToLocal(rect: IntRect): IntRect {
355         return IntRect(
356             topLeft = screenToLocal(rect.topLeft.toOffset()).round(),
357             bottomRight = screenToLocal(rect.bottomRight.toOffset()).round(),
358         )
359     }
360 }
361 
362 /**
363  * A modifier that accepts pointer input to select accessibility nodes in an
364  * [AccessibilityNodeInspectorState].
365  */
366 private class NodeSelectionGestureModifier(
367     val state: AccessibilityNodeInspectorState,
368     val onDragStarted: (() -> Unit)? = null,
369 ) : ModifierNodeElement<NodeSelectionGestureModifierNode>() {
createnull370     override fun create(): NodeSelectionGestureModifierNode =
371         NodeSelectionGestureModifierNode(state, onDragStarted)
372 
373     override fun update(node: NodeSelectionGestureModifierNode) {
374         check(node.state === state) { "Cannot change state" }
375         node.onDragStarted = onDragStarted
376     }
377 
inspectablePropertiesnull378     override fun InspectorInfo.inspectableProperties() {}
379 
equalsnull380     override fun equals(other: Any?): Boolean {
381         if (this === other) return true
382         if (other !is NodeSelectionGestureModifier) return false
383 
384         if (state != other.state) return false
385         if (onDragStarted !== other.onDragStarted) return false
386 
387         return true
388     }
389 
hashCodenull390     override fun hashCode(): Int {
391         var result = state.hashCode()
392         result = 31 * result + (onDragStarted?.hashCode() ?: 0)
393         return result
394     }
395 }
396 
397 private class NodeSelectionGestureModifierNode(
398     val state: AccessibilityNodeInspectorState,
399     var onDragStarted: (() -> Unit)?,
400 ) : DelegatingNode() {
401 
402     private val pass = PointerEventPass.Initial
403 
404     @Suppress("unused")
405     private val inputNode =
406         delegate(
<lambda>null407             SuspendingPointerInputModifierNode {
408                 // Detect drag gestures but without slop.
409                 val layoutCoords = requireLayoutCoordinates()
410                 awaitEachGesture {
411                     try {
412                         val firstChange = awaitFirstDown(pass = pass)
413                         state.setNodeCursor(firstChange.position, layoutCoords)
414                         onDragStarted?.invoke()
415                         firstChange.consume()
416 
417                         while (true) {
418                             val event = awaitPointerEvent(pass = pass)
419                             event.changes
420                                 .fastFirstOrNull { it.id == firstChange.id }
421                                 ?.let { change ->
422                                     if (change.changedToUp()) {
423                                         return@awaitEachGesture
424                                     } else {
425                                         state.setNodeCursor(change.position, layoutCoords)
426                                     }
427                                 }
428                         }
429                     } finally {
430                         state.inspectNodeUnderCursor()
431                     }
432                 }
433             }
434         )
435 }
436 
437 // endregion
438 
439 // region Details UI
440 
441 /**
442  * A dialog that shows all the properties of [leafNode] and all its ancestors and allows exploring
443  * them interactively.
444  */
445 @Composable
InspectorNodeDetailsDialognull446 private fun InspectorNodeDetailsDialog(
447     leafNode: NodeInfo,
448     onNodeClick: (NodeInfo) -> Unit,
449     onBack: () -> Unit,
450 ) {
451     Dialog(
452         properties = DialogProperties(usePlatformDefaultWidth = false),
453         onDismissRequest = onBack
454     ) {
455         InspectorNodeDetails(leafNode = leafNode, onNodeClick = onNodeClick, onBack = onBack)
456     }
457 }
458 
459 @Composable
InspectorNodeDetailsnull460 private fun InspectorNodeDetails(
461     leafNode: NodeInfo,
462     onNodeClick: (NodeInfo) -> Unit,
463     onBack: () -> Unit
464 ) {
465     MaterialTheme(colors = if (isSystemInDarkTheme()) darkColors() else lightColors()) {
466         val peekInteractionSource = remember { MutableInteractionSource() }
467         val peeking by peekInteractionSource.collectIsPressedAsState()
468         Surface(
469             modifier = Modifier.padding(16.dp).alpha(if (peeking) 0f else 1f),
470             elevation = 4.dp
471         ) {
472             Column {
473                 TopAppBar(
474                     title = { NodeHeader(leafNode) },
475                     navigationIcon = {
476                         IconButton(onClick = onBack) {
477                             Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
478                         }
479                     },
480                     actions = {
481                         IconButton(onClick = {}, interactionSource = peekInteractionSource) {
482                             Icon(Icons.Filled.Info, contentDescription = null)
483                         }
484                     }
485                 )
486 
487                 NodeProperties(
488                     node = leafNode,
489                     onNodeClick = onNodeClick,
490                     modifier = Modifier.verticalScroll(rememberScrollState())
491                 )
492             }
493         }
494     }
495 }
496 
NodeInfonull497 private fun NodeInfo.selfAndAncestorsToList() =
498     buildList { visitSelfAndAncestors(::add) }.asReversed()
499 
500 @Composable
NodeHeadernull501 private fun NodeHeader(node: NodeInfo) {
502     Column {
503         val (nodeClassPackage, nodeClassName) = node.nodeInfo.parseClassPackageAndName()
504         Text(nodeClassName, fontWeight = FontWeight.Medium)
505         Text(
506             nodeClassPackage,
507             style = MaterialTheme.typography.caption,
508             modifier = Modifier.alpha(0.5f),
509             overflow = TextOverflow.Ellipsis,
510             softWrap = false,
511         )
512     }
513 }
514 
515 @Composable
NodePropertiesnull516 private fun NodeProperties(node: NodeInfo, onNodeClick: (NodeInfo) -> Unit, modifier: Modifier) {
517     SelectionContainer {
518         Column(modifier = modifier) {
519             NodeAncestorLinks(node, onNodeClick)
520 
521             val properties =
522                 node
523                     .getProperties()
524                     .mapValues { (_, v) ->
525                         // Turn references to other nodes into links that actually open those nodes
526                         // in the inspector.
527                         if (v is AccessibilityNodeInfoCompat) {
528                             nodeLinkRepresentation(
529                                 node = v,
530                                 onClick = { onNodeClick(v.toNodeInfo()) }
531                             )
532                         } else {
533                             PropertyValueRepresentation(v)
534                         }
535                     }
536                     .toList()
537             KeyValueView(
538                 elements = properties,
539                 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
540             )
541         }
542     }
543 }
544 
545 @Composable
NodeAncestorLinksnull546 private fun NodeAncestorLinks(node: NodeInfo, onNodeClick: (NodeInfo) -> Unit) {
547     val ancestors = remember(node) { node.selfAndAncestorsToList().dropLast(1) }
548     if (ancestors.isNotEmpty()) {
549         val ancestorLinks =
550             remember(ancestors) {
551                 buildAnnotatedString {
552                     ancestors.fastForEachIndexed { index, ancestorNode ->
553                         withLink(
554                             LinkAnnotation.Clickable("ancestor") { onNodeClick(ancestorNode) }
555                         ) {
556                             append(ancestorNode.nodeInfo.parseClassPackageAndName().second)
557                         }
558 
559                         if (index < ancestors.size - 1) {
560                             append(" > ")
561                         }
562                     }
563                 }
564             }
565 
566         Surface(
567             color = MaterialTheme.colors.primarySurface.copy(alpha = 0.5f),
568             modifier = Modifier.fillMaxWidth(),
569         ) {
570             Text(ancestorLinks, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
571         }
572     }
573 }
574 
nodeLinkRepresentationnull575 private fun nodeLinkRepresentation(node: AccessibilityNodeInfoCompat, onClick: () -> Unit) =
576     PropertyValueRepresentation(
577         buildAnnotatedString {
578             withLink(LinkAnnotation.Clickable("node") { onClick() }) { append(node.className) }
579         }
580     )
581 
582 /**
583  * Shows a table of keys and their values. Values are rendered using [PropertyValueRepresentation].
584  */
585 @Composable
KeyValueViewnull586 private fun KeyValueView(
587     elements: List<Pair<String, PropertyValueRepresentation>>,
588     modifier: Modifier = Modifier,
589 ) {
590     Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
591         elements.forEach { (name, valueRepresentation) -> KeyValueRow(name, valueRepresentation) }
592     }
593 }
594 
595 /**
596  * A row inside a [KeyValueView] that shows a single key and its value. The value will be shown
597  * beside the row, if there's space, otherwise it will be placed below it.
598  */
599 @Composable
KeyValueRownull600 private fun KeyValueRow(name: String, valueRepresentation: PropertyValueRepresentation) {
601     KeyValueRowLayout(
602         contentPadding = 8.dp,
603         keyContent = {
604             Text(
605                 name,
606                 fontWeight = FontWeight.Medium,
607                 style = MaterialTheme.typography.caption,
608                 modifier = Modifier.alpha(0.5f)
609             )
610         },
611         valueContent = {
612             if (valueRepresentation.customRenderer != null) {
613                 valueRepresentation.customRenderer.invoke()
614             } else {
615                 Text(
616                     valueRepresentation.text,
617                     fontFamily = FontFamily.Monospace,
618                     modifier = Modifier.horizontalScroll(rememberScrollState())
619                 )
620             }
621         }
622     )
623 }
624 
625 /**
626  * Places [keyContent] and [valueContent] on the same line if they both fit with [contentPadding]
627  * spacing, otherwise places [valueContent] below [keyContent] and indents it by [contentPadding].
628  * If [valueContent] wraps and fills all available space, a thin line is drawn in the margin to help
629  * visually track the nesting level.
630  */
631 @Composable
KeyValueRowLayoutnull632 private inline fun KeyValueRowLayout(
633     contentPadding: Dp,
634     keyContent: @Composable RowScope.() -> Unit,
635     valueContent: @Composable RowScope.() -> Unit,
636 ) {
637     var nestingIndicator: Pair<Offset, Offset>? by remember { mutableStateOf(null) }
638 
639     Layout(
640         modifier =
641             Modifier.drawBehind {
642                 nestingIndicator?.let { (start, end) ->
643                     drawLine(
644                         start = start,
645                         end = end,
646                         color = Color.Gray,
647                         alpha = 0.3f,
648                         strokeWidth = 1.dp.toPx(),
649                     )
650                 }
651             },
652         content = {
653             Row(content = keyContent)
654             Row(content = valueContent)
655         },
656         measurePolicy = { measurables, constraints ->
657             val contentPaddingPx = contentPadding.roundToPx()
658             val (keyMeasurable, valueMeasurable) = measurables
659             val keyConstraints = constraints.copyMaxDimensions()
660             // contentPadding will either act as the spacing between items if they fit on the same
661             // line, or indent if content wraps, so inset the constraints either way.
662             val valueConstraints =
663                 constraints.copyMaxDimensions().offset(horizontal = -contentPaddingPx)
664             val keyPlaceable = keyMeasurable.measure(keyConstraints)
665             val valuePlaceable = valueMeasurable.measure(valueConstraints)
666             val wrap =
667                 keyPlaceable.width + contentPaddingPx + valuePlaceable.width > constraints.maxWidth
668 
669             val totalWidth = constraints.maxWidth
670             val totalHeight =
671                 if (wrap) {
672                     keyPlaceable.height + valuePlaceable.height
673                 } else {
674                     maxOf(keyPlaceable.height, valuePlaceable.height)
675                 }
676 
677             // Only draw the nesting indicator if the value filled its max width, which indicates it
678             // will probably be taller, and harder to track the start edge visually.
679             nestingIndicator =
680                 if (wrap && valuePlaceable.width == valueConstraints.maxWidth) {
681                     Pair(
682                         Offset(contentPaddingPx / 2f, keyPlaceable.height.toFloat()),
683                         Offset(contentPaddingPx / 2f, totalHeight.toFloat())
684                     )
685                 } else {
686                     null
687                 }
688 
689             layout(totalWidth, totalHeight) {
690                 val valueX = totalWidth - valuePlaceable.width
691                 if (wrap) {
692                     // Arrange vertically.
693                     keyPlaceable.placeRelative(0, 0)
694                     valuePlaceable.placeRelative(valueX, keyPlaceable.height)
695                 } else {
696                     // Arrange horizontally.
697                     val keyY =
698                         Alignment.CenterVertically.align(
699                             size = keyPlaceable.height,
700                             space = totalHeight
701                         )
702                     keyPlaceable.placeRelative(0, keyY)
703 
704                     val valueY =
705                         Alignment.CenterVertically.align(
706                             size = valuePlaceable.height,
707                             space = totalHeight
708                         )
709                     valuePlaceable.placeRelative(valueX, valueY)
710                 }
711             }
712         }
713     )
714 }
715 
716 /**
717  * A representation of an arbitrary value as a potentially-styled [AnnotatedString], and optionally
718  * also as a completely custom composable. To create an instance for standard types, call the
719  * [PropertyValueRepresentation] function.
720  */
721 private data class PropertyValueRepresentation(
722     val text: AnnotatedString,
723     val customRenderer: (@Composable () -> Unit)? = null
724 )
725 
726 private val ValueTypeTextStyle = TextStyle(fontFamily = FontFamily.Monospace)
727 
728 /**
729  * Creates a [PropertyValueRepresentation] appropriate for certain well-known types. For other types
730  * returns a representation that is just the result of the value's [toString].
731  */
PropertyValueRepresentationnull732 private fun PropertyValueRepresentation(value: Any?): PropertyValueRepresentation =
733     when (value) {
734         is CharSequence -> PropertyValueRepresentation(value.toFormattedDebugString())
735         is Iterable<*> -> {
736             val valueType = value.javaClass.canonicalName ?: value.javaClass.name
737             // No isEmpty on iterable.
738             if (!value.iterator().hasNext()) {
739                 PropertyValueRepresentation(AnnotatedString("$valueType()"))
740             } else {
741                 PropertyValueRepresentation(AnnotatedString(value.toString())) {
742                     Column {
743                         Text(valueType, style = ValueTypeTextStyle)
744                         KeyValueView(
745                             value.mapIndexed { index, element ->
746                                 Pair("[$index]", PropertyValueRepresentation(element))
747                             }
748                         )
749                     }
750                 }
751             }
752         }
753         is Map<*, *> -> {
754             val valueType = value.javaClass.canonicalName ?: value.javaClass.name
755             if (value.isEmpty()) {
756                 PropertyValueRepresentation(AnnotatedString("$valueType()"))
757             } else {
758                 PropertyValueRepresentation(AnnotatedString(value.toString())) {
759                     Column {
760                         Text(valueType, style = ValueTypeTextStyle)
761                         KeyValueView(
762                             value.entries.map { (key, value) ->
763                                 Pair(key.toString(), PropertyValueRepresentation(value))
764                             }
765                         )
766                     }
767                 }
768             }
769         }
770         is Bundle -> {
771             if (value.isEmpty) {
772                 PropertyValueRepresentation(
773                     AnnotatedString("empty Bundle", SpanStyle(fontStyle = FontStyle.Italic))
774                 )
775             } else {
776                 PropertyValueRepresentation(AnnotatedString(value.toString())) {
777                     KeyValueView(
778                         value.keySet().map { key ->
779                             @Suppress("DEPRECATION") val rawValue = value.get(key)
780                             Pair(key, PropertyValueRepresentation(rawValue))
781                         }
782                     )
783                 }
784             }
785         }
786         else -> PropertyValueRepresentation(AnnotatedString(value.toString()))
787     }
788 
789 /** Returns the package and simple name parts of a FQCN by splitting at the last '.' character. */
AccessibilityNodeInfoCompatnull790 private fun AccessibilityNodeInfoCompat.parseClassPackageAndName(): Pair<String, String> {
791     val separatorIndex = className.indexOfLast { it == '.' }
792     return Pair(className.substring(0, separatorIndex), className.substring(separatorIndex + 1))
793 }
794 
795 /**
796  * A column of expandable headers. Only one header can be expanded at a time. To create an item call
797  * [AccordionScope.item] in [content].
798  */
799 @Composable
Accordionnull800 private fun Accordion(
801     selectedIndex: Int,
802     onSelectIndex: (Int) -> Unit,
803     modifier: Modifier = Modifier,
804     content: AccordionScope.() -> Unit
805 ) {
806     Column(modifier) {
807         // Don't rebuild the items every time the selection changes.
808         val items by remember(content) { derivedStateOf { buildAccordionItems(content) } }
809         val isSelectedIndexValid = selectedIndex in items.indices
810         items.fastForEachIndexed { index, item ->
811             val isItemSelected = index == selectedIndex
812             AccordionItemView(
813                 item = item,
814                 headerHeight = 40.dp,
815                 isExpanded = isItemSelected,
816                 shrinkHeader = !isItemSelected && isSelectedIndexValid,
817                 onHeaderClick = { onSelectIndex(if (selectedIndex == index) -1 else index) },
818             )
819             if (index < items.size - 1) {
820                 Divider()
821             }
822         }
823     }
824 }
825 
826 /**
827  * An item header and optionally-visible content inside an [Accordion]. Only intended to be called
828  * by [Accordion] itself.
829  */
830 @Composable
AccordionItemViewnull831 private fun AccordionItemView(
832     item: AccordionItem,
833     headerHeight: Dp,
834     isExpanded: Boolean,
835     shrinkHeader: Boolean,
836     onHeaderClick: () -> Unit
837 ) {
838     // Shrink collapsed headers to give more space to the expanded body.
839     val headerScale by animateFloatAsState(if (shrinkHeader) 0.8f else 1f, label = "headerScale")
840     Row(
841         verticalAlignment = Alignment.CenterVertically,
842         modifier =
843             Modifier.height { (headerHeight * headerScale).roundToPx() }
844                 .fillMaxWidth()
845                 .selectable(selected = isExpanded, onClick = onHeaderClick)
846                 .graphicsLayer {
847                     scaleX = headerScale
848                     scaleY = headerScale
849                     transformOrigin = TransformOrigin(0f, 0.5f)
850                 },
851     ) {
852         val iconRotation by
853             animateFloatAsState(if (isExpanded) 0f else -90f, label = "iconRotation")
854         Icon(
855             Icons.Filled.ArrowDropDown,
856             contentDescription = null,
857             modifier = Modifier.graphicsLayer { rotationZ = iconRotation }
858         )
859         item.header()
860     }
861     AnimatedVisibility(
862         visible = isExpanded,
863         enter = expandVertically(expandFrom = Alignment.Top),
864         exit = shrinkVertically(shrinkTowards = Alignment.Top),
865     ) {
866         item.content()
867     }
868 }
869 
870 private interface AccordionScope {
871     /**
872      * Creates an accordion item with a [header] that is always visible, and a [body] that is only
873      * visible when the item is expanded.
874      */
itemnull875     fun item(header: @Composable () -> Unit, body: @Composable () -> Unit)
876 }
877 
878 private data class AccordionItem(
879     val header: @Composable () -> Unit,
880     val content: @Composable () -> Unit
881 )
882 
883 private fun buildAccordionItems(content: AccordionScope.() -> Unit): List<AccordionItem> {
884     return buildList {
885         content(
886             object : AccordionScope {
887                 override fun item(header: @Composable () -> Unit, body: @Composable () -> Unit) {
888                     add(AccordionItem(header, body))
889                 }
890             }
891         )
892     }
893 }
894 
895 /** Sets [key] to [value] in this map if [value] is not [unspecifiedValue] (null by default). */
MutableMapnull896 private fun MutableMap<String, Any?>.setIfSpecified(
897     key: String,
898     value: Any?,
899     unspecifiedValue: Any? = null
900 ) {
901     if (value != unspecifiedValue) {
902         set(key, value)
903     }
904 }
905 
906 /** Sets [key] to [value] in this map if [value] is not [unspecifiedValue] (false by default). */
MutableMapnull907 private fun MutableMap<String, Any?>.setIfSpecified(
908     key: String,
909     value: Boolean,
910     unspecifiedValue: Boolean = false
911 ) {
912     if (value != unspecifiedValue) {
913         set(key, value)
914     }
915 }
916 
917 /** Sets [key] to [value] in this map if [value] is not [unspecifiedValue] (0 by default). */
MutableMapnull918 private fun MutableMap<String, Any?>.setIfSpecified(
919     key: String,
920     value: Int,
921     unspecifiedValue: Int = 0
922 ) {
923     if (value != unspecifiedValue) {
924         set(key, value)
925     }
926 }
927 
928 /**
929  * Returns an [AnnotatedString] that makes this [CharSequence] value easier to read for debugging.
930  * Wraps the value in stylized quote marks so empty strings are more clear, and replaces invisible
931  * control characters (e.g. `'\n'`) with their stylized literal escape sequences.
932  */
<lambda>null933 private fun CharSequence.toFormattedDebugString(): AnnotatedString = buildAnnotatedString {
934     val quoteStyle = SpanStyle(color = Color.Gray, fontWeight = FontWeight.Bold)
935     val specialStyle =
936         SpanStyle(
937             color = Color.Red,
938             fontWeight = FontWeight.Bold,
939         )
940 
941     withStyle(quoteStyle) { append('"') }
942 
943     this@toFormattedDebugString.forEach { c ->
944         var formattedChar: String? = null
945         when (c) {
946             '\n' -> formattedChar = "\\n"
947             '\r' -> formattedChar = "\\r"
948             '\t' -> formattedChar = "\\t"
949             '\b' -> formattedChar = "\\b"
950         }
951         if (formattedChar != null) {
952             withStyle(specialStyle) { append(formattedChar) }
953         } else {
954             append(c)
955         }
956     }
957 
958     withStyle(quoteStyle) { append('"') }
959 }
960 
961 // endregion
962 
963 /** Like the standard [Modifier.width] modifier but the width is only calculated at measure time. */
widthnull964 private fun Modifier.width(calculateWidth: Density.() -> Int): Modifier =
965     layout { measurable, constraints ->
966         val calculatedWidth = calculateWidth()
967         val childConstraints =
968             constraints.copy(minWidth = calculatedWidth, maxWidth = calculatedWidth)
969         val placeable = measurable.measure(childConstraints)
970         layout(placeable.width, placeable.height) { placeable.place(0, 0) }
971     }
972 
973 /**
974  * Like the standard [Modifier.height] modifier but the height is only calculated at measure time.
975  */
Modifiernull976 private fun Modifier.height(calculateHeight: Density.() -> Int): Modifier =
977     layout { measurable, constraints ->
978         val calculatedHeight = calculateHeight()
979         val childConstraints =
980             constraints.copy(minHeight = calculatedHeight, maxHeight = calculatedHeight)
981         val placeable = measurable.measure(childConstraints)
982         layout(placeable.width, placeable.height) { placeable.place(0, 0) }
983     }
984 
985 // region Accessibility node access
986 
987 /**
988  * Creates and remembers an [AccessibilityNodeInspectorState] for inspecting the nodes in the window
989  * hosting this composition.
990  */
991 @Composable
rememberAccessibilityNodeInspectorStatenull992 private fun rememberAccessibilityNodeInspectorState(): AccessibilityNodeInspectorState {
993     val hostView = LocalView.current
994     val state = remember(hostView) { AccessibilityNodeInspectorState(hostView = hostView) }
995     LaunchedEffect(state) { state.runWhileDisplayed() }
996 
997     DisposableEffect(hostView) {
998         val testRoot = hostView as RootForTest
999         onDispose { testRoot.forceAccessibilityForTesting(false) }
1000     }
1001     return state
1002 }
1003 
1004 /** State holder for an [AccessibilityNodeInspectorButton]. */
1005 private class AccessibilityNodeInspectorState(private val hostView: View) :
1006     PopupPositionProvider, View.OnLayoutChangeListener {
1007 
1008     var inspectorWindowSize: IntSize by mutableStateOf(calculateInspectorWindowSize())
1009         private set
1010 
1011     private val service: InspectableTreeProvider =
1012         if (Build.VERSION.SDK_INT >= 34) {
1013             AccessibilityTreeInspectorApi34(hostView.rootView)
1014         } else {
1015             NoopTreeProvider
1016         }
1017 
<lambda>null1018     val isReady: Boolean by derivedStateOf {
1019         inspectorWindowSize.width > 0 && inspectorWindowSize.height > 0
1020     }
1021 
1022     var selectionOffset: Offset by mutableStateOf(Offset.Unspecified)
1023         private set
1024 
1025     /**
1026      * All the nodes that pass the hit test after a call to [setNodeCursor], or if a node is
1027      * programmatically selected via [inspectNode] then that node and all its ancestors.
1028      */
1029     var nodesUnderCursor: List<NodeInfo> by mutableStateOf(emptyList())
1030         private set
1031 
1032     /**
1033      * The node to highlight – during selection, this will be the node that will be opened in the
1034      * inspector when the gesture is finished.
1035      */
1036     var highlightedNode: NodeInfo? by mutableStateOf(null)
1037         private set
1038 
1039     /** If non-null, the node being shown in the inspector. */
1040     var nodeUnderInspection: NodeInfo? by mutableStateOf(null)
1041         private set
1042 
1043     /**
1044      * Temporarily select the node at [localOffset] in the window being inspected. This should be
1045      * called while the user is dragging.
1046      */
setNodeCursornull1047     fun setNodeCursor(localOffset: Offset, layoutCoordinates: LayoutCoordinates) {
1048         hideInspector()
1049         val screenOffset = layoutCoordinates.localToScreen(localOffset)
1050         selectionOffset = screenOffset
1051         nodesUnderCursor = service.findNodesAt(screenOffset)
1052         highlightedNode = nodesUnderCursor.lastOrNull()
1053     }
1054 
1055     /** Opens the node under the selection cursor in the inspector and dumps it to logcat. */
inspectNodeUnderCursornull1056     fun inspectNodeUnderCursor() {
1057         selectionOffset = Offset.Unspecified
1058         nodeUnderInspection = highlightedNode?.also { it.dumpToLog(tag = LogTag) }
1059     }
1060 
1061     /**
1062      * Highlights the given node in the selection popup, dumps it to logcat, and opens it in the
1063      * inspector.
1064      */
inspectNodenull1065     fun inspectNode(node: NodeInfo?) {
1066         highlightedNode = node
1067         nodesUnderCursor = node?.selfAndAncestorsToList() ?: emptyList()
1068         nodeUnderInspection = node
1069         node?.also { it.dumpToLog(tag = LogTag) }
1070     }
1071 
1072     /** Hides the inspector dialog to allow the user to select a different node. */
hideInspectornull1073     fun hideInspector() {
1074         nodeUnderInspection = null
1075     }
1076 
1077     /** Runs any coroutine effects the state holder requires while it's connected to some UI. */
runWhileDisplayednull1078     suspend fun runWhileDisplayed() {
1079         service.initialize()
1080 
1081         coroutineScope {
1082             // Update the overlay window size when the target window is resized.
1083             launch {
1084                 hostView.addOnLayoutChangeListener(this@AccessibilityNodeInspectorState)
1085                 try {
1086                     awaitCancellation()
1087                 } finally {
1088                     hostView.removeOnLayoutChangeListener(this@AccessibilityNodeInspectorState)
1089                 }
1090             }
1091         }
1092     }
1093 
onLayoutChangenull1094     override fun onLayoutChange(
1095         v: View?,
1096         left: Int,
1097         top: Int,
1098         right: Int,
1099         bottom: Int,
1100         oldLeft: Int,
1101         oldTop: Int,
1102         oldRight: Int,
1103         oldBottom: Int
1104     ) {
1105         inspectorWindowSize = calculateInspectorWindowSize()
1106     }
1107 
calculatePositionnull1108     override fun calculatePosition(
1109         anchorBounds: IntRect,
1110         windowSize: IntSize,
1111         layoutDirection: LayoutDirection,
1112         popupContentSize: IntSize
1113     ): IntOffset = IntOffset.Zero
1114 
1115     private fun calculateInspectorWindowSize(): IntSize {
1116         return Rect()
1117             .also { hostView.getWindowVisibleDisplayFrame(it) }
1118             .let { IntSize(it.width(), it.height()) }
1119     }
1120 }
1121 
1122 private data class NodeInfo(
1123     val nodeInfo: AccessibilityNodeInfoCompat,
1124     val boundsInScreen: IntRect,
1125 )
1126 
1127 /** Returns a map with all the inspectable properties of this [NodeInfo]. */
<lambda>null1128 private fun NodeInfo.getProperties(): Map<String, Any?> = buildMap {
1129     val node = nodeInfo
1130     // Don't render className, it's in the title.
1131     setIfSpecified("packageName", node.packageName)
1132     setIfSpecified("boundsInScreen", Rect().also(node::getBoundsInScreen))
1133     setIfSpecified("boundsInWindow", Rect().also(node::getBoundsInWindow))
1134     setIfSpecified("viewIdResourceName", node.viewIdResourceName)
1135     setIfSpecified("uniqueId", node.uniqueId)
1136     setIfSpecified("text", node.text)
1137     setIfSpecified("textSelectionStart", node.textSelectionStart, unspecifiedValue = -1)
1138     setIfSpecified("textSelectionEnd", node.textSelectionEnd, unspecifiedValue = -1)
1139     setIfSpecified("contentDescription", node.contentDescription)
1140     setIfSpecified("collectionInfo", node.collectionInfo)
1141     setIfSpecified("collectionItemInfo", node.collectionItemInfo)
1142     setIfSpecified("containerTitle", node.containerTitle)
1143     setIfSpecified("childCount", node.childCount)
1144     setIfSpecified("drawingOrder", node.drawingOrder)
1145     setIfSpecified("error", node.error)
1146     setIfSpecified("hintText", node.hintText)
1147     setIfSpecified("inputType", node.inputType)
1148     setIfSpecified("isAccessibilityDataSensitive", node.isAccessibilityDataSensitive)
1149     setIfSpecified("isAccessibilityFocused", node.isAccessibilityFocused)
1150     setIfSpecified("isCheckable", node.isCheckable)
1151     // TODO(b/406574577): Remove suppression once 1.17.0 stable is released.
1152     @Suppress("DEPRECATION") setIfSpecified("isChecked", node.isChecked)
1153     setIfSpecified("isClickable", node.isClickable)
1154     setIfSpecified("isLongClickable", node.isLongClickable)
1155     setIfSpecified("isContextClickable", node.isContextClickable)
1156     setIfSpecified("isContentInvalid", node.isContentInvalid)
1157     setIfSpecified("isDismissable", node.isDismissable)
1158     setIfSpecified("isEditable", node.isEditable)
1159     setIfSpecified("isEnabled", node.isEnabled, unspecifiedValue = true)
1160     setIfSpecified("isFocusable", node.isFocusable)
1161     setIfSpecified("isFocused", node.isFocused)
1162     setIfSpecified("isGranularScrollingSupported", node.isGranularScrollingSupported)
1163     setIfSpecified("isHeading", node.isHeading)
1164     set("isImportantForAccessibility", node.isImportantForAccessibility)
1165     setIfSpecified("isMultiLine", node.isMultiLine)
1166     setIfSpecified("isPassword", node.isPassword)
1167     setIfSpecified("isScreenReaderFocusable", node.isScreenReaderFocusable)
1168     setIfSpecified("isScrollable", node.isScrollable)
1169     setIfSpecified("isSelected", node.isSelected)
1170     setIfSpecified("isShowingHintText", node.isShowingHintText)
1171     setIfSpecified("isTextEntryKey", node.isTextEntryKey)
1172     setIfSpecified("isTextSelectable", node.isTextSelectable)
1173     setIfSpecified("isVisibleToUser", node.isVisibleToUser, unspecifiedValue = true)
1174     setIfSpecified("labelFor", node.labelFor)
1175     setIfSpecified("labeledBy", node.labeledBy)
1176     setIfSpecified("liveRegion", node.liveRegion)
1177     setIfSpecified("maxTextLength", node.maxTextLength, unspecifiedValue = -1)
1178     setIfSpecified("movementGranularities", node.movementGranularities)
1179     setIfSpecified("paneTitle", node.paneTitle)
1180     setIfSpecified("rangeInfo", node.rangeInfo)
1181     setIfSpecified("roleDescription", node.roleDescription)
1182     setIfSpecified("stateDescription", node.stateDescription)
1183     setIfSpecified("tooltipText", node.tooltipText)
1184     setIfSpecified("touchDelegateInfo", node.touchDelegateInfo)
1185     setIfSpecified("windowId", node.windowId, unspecifiedValue = -1)
1186     setIfSpecified("canOpenPopup", node.canOpenPopup())
1187     setIfSpecified(
1188         "hasRequestInitialAccessibilityFocus",
1189         node.hasRequestInitialAccessibilityFocus()
1190     )
1191     setIfSpecified("extras", node.extrasWithoutExtraData)
1192     setIfSpecified("extraRenderingInfo", node.extraRenderingInfo)
1193 
1194     if (Build.VERSION.SDK_INT >= 26 && node.availableExtraData.isNotEmpty()) {
1195         val extraData = mutableMapOf<String, Any?>()
1196         node.availableExtraData.forEach { key ->
1197             extraData[key] = AccessibilityNodeInfoHelper.readExtraData(node.unwrap(), key)
1198         }
1199         setIfSpecified("extraData (from availableExtraData)", extraData)
1200     }
1201 
1202     setIfSpecified("traversalBefore", node.traversalBefore)
1203     setIfSpecified("traversalAfter", node.traversalAfter)
1204 }
1205 
1206 /**
1207  * Returns the extras bundle, but without any keys from
1208  * [AccessibilityNodeInfoCompat.getAvailableExtraData], since those are reported separately.
1209  */
1210 private val AccessibilityNodeInfoCompat.extrasWithoutExtraData: Bundle
1211     get() {
1212         val extras = Bundle(extras)
<lambda>null1213         availableExtraData.forEach { extras.remove(it) }
1214         return extras
1215     }
1216 
1217 /** Class verification helper for reading extras data from an [AccessibilityNodeInfo]. */
1218 @RequiresApi(26)
1219 private object AccessibilityNodeInfoHelper {
readExtraDatanull1220     fun readExtraData(node: AccessibilityNodeInfo, key: String): Any? {
1221         if (key in node.availableExtraData && node.refreshWithExtraData(key, Bundle())) {
1222             @Suppress("DEPRECATION") return node.extras.get(key)
1223         } else {
1224             return null
1225         }
1226     }
1227 }
1228 
1229 private interface InspectableTreeProvider {
initializenull1230     fun initialize() {}
1231 
findNodesAtnull1232     fun findNodesAt(screenOffset: Offset): List<NodeInfo>
1233 }
1234 
1235 private object NoopTreeProvider : InspectableTreeProvider {
1236     override fun findNodesAt(screenOffset: Offset): List<NodeInfo> = emptyList()
1237 }
1238 
1239 @RequiresApi(34)
1240 private class AccessibilityTreeInspectorApi34(private val rootView: View) :
1241     InspectableTreeProvider {
1242 
1243     private val matrixCache = Matrix()
1244 
initializenull1245     override fun initialize() {
1246         // This will call setQueryableFromApp process, which enables accessibility on the platform,
1247         // which allows us to tell compose views to force accessibility support. This is required
1248         // for certain fields, such as traversal before/after, to be populated.
1249         rootView.createNodeInfo()
1250         rootView.visitViewAndChildren { view ->
1251             (view as? RootForTest)?.forceAccessibilityForTesting(true)
1252             true
1253         }
1254     }
1255 
findNodesAtnull1256     override fun findNodesAt(screenOffset: Offset): List<NodeInfo> {
1257         rootView.transformMatrixToLocal(matrixCache)
1258 
1259         val nodes = mutableListOf<NodeInfo>()
1260         val rootInfo = rootView.createNodeInfo()
1261         rootInfo.visitNodeAndChildren { node ->
1262             if (node.hitTest(screenOffset)) {
1263                 nodes += node
1264                 true
1265             } else {
1266                 false
1267             }
1268         }
1269         return nodes
1270     }
1271 
NodeInfonull1272     private fun NodeInfo.hitTest(screenOffset: Offset): Boolean {
1273         return boundsInScreen.contains(screenOffset.round())
1274     }
1275 
visitViewAndChildrennull1276     private inline fun View.visitViewAndChildren(visitor: (View) -> Boolean) {
1277         val queue = mutableVectorOf(this)
1278         while (queue.isNotEmpty()) {
1279             val current = queue.removeAt(queue.lastIndex)
1280             val visitChildren = visitor(current)
1281             if (visitChildren && current is ViewGroup) {
1282                 for (child in current.children) {
1283                     queue += child
1284                 }
1285             }
1286         }
1287     }
1288 
visitNodeAndChildrennull1289     private inline fun NodeInfo.visitNodeAndChildren(visitor: (NodeInfo) -> Boolean) {
1290         val queue = mutableVectorOf(this)
1291         while (queue.isNotEmpty()) {
1292             val current = queue.removeAt(queue.lastIndex)
1293             val visitChildren = visitor(current)
1294             if (visitChildren) {
1295                 for (i in 0 until current.nodeInfo.childCount) {
1296                     queue += current.nodeInfo.getChild(i).toNodeInfo()
1297                 }
1298             }
1299         }
1300     }
1301 
createNodeInfonull1302     private fun View.createNodeInfo(): NodeInfo {
1303         val rawNodeInfo = createAccessibilityNodeInfo()
1304         val nodeInfoCompat = AccessibilityNodeInfoCompat.wrap(rawNodeInfo)
1305         rawNodeInfo.setQueryFromAppProcessEnabled(this, true)
1306         return nodeInfoCompat.toNodeInfo()
1307     }
1308 }
1309 
toNodeInfonull1310 private fun AccessibilityNodeInfoCompat.toNodeInfo(): NodeInfo =
1311     NodeInfo(
1312         nodeInfo = this,
1313         boundsInScreen = Rect().also(::getBoundsInScreen).toComposeIntRect(),
1314     )
1315 
1316 private fun NodeInfo.dumpToLog(tag: String) {
1317     val indent = "  "
1318     var depth = 0
1319     visitSelfAndAncestors { node ->
1320         Log.d(tag, indent.repeat(depth) + node.nodeInfo.unwrap().toString())
1321         depth++
1322     }
1323 }
1324 
visitSelfAndAncestorsnull1325 private inline fun NodeInfo.visitSelfAndAncestors(block: (NodeInfo) -> Unit) {
1326     var node: NodeInfo? = this
1327     while (node != null) {
1328         block(node)
1329         node = node.parent
1330     }
1331 }
1332 
1333 private val NodeInfo.parent: NodeInfo?
1334     get() = nodeInfo.parent?.toNodeInfo()
1335 
1336 // endregion
1337