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