Add a simple interactive a11y node inspector to the demo app.

Test: n/a
Relnote: n/a
Change-Id: I83708ec80a42c4849ddd5060f0a2cad0a5251163
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/AccessibilityNodeInspector.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/AccessibilityNodeInspector.kt
new file mode 100644
index 0000000..f1050e7
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/AccessibilityNodeInspector.kt
@@ -0,0 +1,1232 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.demos
+
+import android.graphics.Matrix
+import android.graphics.Rect
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.view.accessibility.AccessibilityNodeInfo
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Button
+import androidx.compose.material.Divider
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.darkColors
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.lightColors
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collection.mutableVectorOf
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.isSpecified
+import androidx.compose.ui.graphics.ClipOp
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.toComposeIntRect
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
+import androidx.compose.ui.input.pointer.changedToUp
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.requireLayoutCoordinates
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTag
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.offset
+import androidx.compose.ui.unit.round
+import androidx.compose.ui.unit.toOffset
+import androidx.compose.ui.unit.toSize
+import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+private const val InspectorButtonTestTag =
+    "androidx.compose.foundation.demos.AccessibilityNodeInspectorButton"
+
+/** The key used to read Compose testTag semantics properties from accessibility nodes' extras. */
+private const val TestTagExtrasKey = "androidx.compose.ui.semantics.testTag"
+
+private const val LogTag = "A11yNodeInspector"
+
+private val UnsupportedMessage =
+    "This tool is not supported on this device. AccessibilityNodeInfo objects are not readable " +
+        "by code in the same process without an accessibility service before API 34.\n\n" +
+        "This device is running API ${Build.VERSION.SDK_INT}."
+
+private const val UsageMessage =
+    "Drag anywhere to explore accessibility nodes.\n\n" +
+        "Release to view the node's properties and print the information to logcat " +
+        "(tagged \"$LogTag\").\n\n" +
+        "Go back to close inspector."
+
+/**
+ * A composable that, when touched or dragged, will immediately show an overlay on the current
+ * window that allows the user to interactively explore accessibility nodes and view their
+ * properties.
+ */
+@Composable
+fun AccessibilityNodeInspectorButton(
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit
+) {
+    var active by remember { mutableStateOf(false) }
+    val state = rememberAccessibilityNodeInspectorState()
+    Box(
+        propagateMinConstraints = true,
+        modifier = modifier
+            // This node needs to have the same gesture modifier as the dedicated inspector overlay
+            // since when the button is dragged initially, the pointer events will all still be sent
+            // to the button, and not the overlay, even though the overlay will immediately be
+            // shown. Because node coordinates are all communicated in screen space, it doesn't
+            // actually matter which window accepts the pointer events.
+            .then(NodeSelectionGestureModifier(state, onDragStarted = { active = true }))
+            // Tag the button so the inspector can detect when the button itself is selected and
+            // show a help message.
+            .semantics(mergeDescendants = true) {
+                testTag = InspectorButtonTestTag
+            }
+    ) {
+        content()
+
+        if (active) {
+            if (Build.VERSION.SDK_INT >= 34) {
+                AccessibilityNodeInspector(
+                    state = state,
+                    onDismissRequest = { active = false }
+                )
+            } else {
+                AlertDialog(
+                    onDismissRequest = { active = false },
+                    title = { Text("Accessibility Node Inspector") },
+                    text = { Text(UnsupportedMessage) },
+                    buttons = {
+                        Button(
+                            onClick = { active = false },
+                            modifier = Modifier
+                                .padding(16.dp)
+                                .fillMaxWidth()
+                        ) {
+                            Text("DISMISS")
+                        }
+                    }
+                )
+            }
+        }
+    }
+}
+
+/**
+ * Returns true if this [NodeInfo] or any of its ancestors represents an
+ * [AccessibilityNodeInspectorButton].
+ */
+private val NodeInfo.isInspectorButton: Boolean
+    get() {
+        if (Build.VERSION.SDK_INT >= 26) {
+            visitSelfAndParents {
+                val testTag = AccessibilityNodeInfoHelper.readExtraData(
+                    it.nodeInfo.unwrap(),
+                    TestTagExtrasKey
+                )
+                if (testTag == InspectorButtonTestTag) {
+                    return true
+                }
+            }
+        }
+        return false
+    }
+
+// region Selection UI
+
+/**
+ * A popup that overlays another window and allows exploring its accessibility nodes by touch.
+ */
+@Composable
+private fun AccessibilityNodeInspector(
+    state: AccessibilityNodeInspectorState,
+    onDismissRequest: () -> Unit,
+) {
+    if (state.isReady) {
+        Popup(
+            popupPositionProvider = state,
+            properties = PopupProperties(
+                focusable = true,
+                excludeFromSystemGesture = false,
+            ),
+            onDismissRequest = onDismissRequest
+        ) {
+            Box(
+                propagateMinConstraints = true,
+                modifier = Modifier
+                    .width { state.inspectorWindowSize.width }
+                    .height { state.inspectorWindowSize.height }
+            ) {
+                // Selection UI and input handling.
+                Box(
+                    Modifier
+                        .then(NodeSelectionGestureModifier(state))
+                        .then(DrawSelectionOverlayModifier(state))
+                )
+
+                state.selectedNode?.let {
+                    if (it.isInspectorButton) {
+                        // Don't use Surface here, it breaks touch input.
+                        Text(
+                            UsageMessage,
+                            modifier = Modifier
+                                .wrapContentSize()
+                                .padding(16.dp)
+                                .background(MaterialTheme.colors.surface)
+                                .padding(16.dp)
+                        )
+                    } else {
+                        InspectorNodeDetailsDialog(
+                            leafNode = it,
+                            onBack = onDismissRequest,
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+/**
+ * A modifier that draws the current selection of an [AccessibilityNodeInspectorState] in an
+ * [AccessibilityNodeInspector].
+ */
+private data class DrawSelectionOverlayModifier(
+    val state: AccessibilityNodeInspectorState
+) : ModifierNodeElement<DrawSelectionOverlayModifierNode>() {
+    override fun create(): DrawSelectionOverlayModifierNode =
+        DrawSelectionOverlayModifierNode(state)
+
+    override fun update(node: DrawSelectionOverlayModifierNode) {
+        check(node.state === state) { "Cannot change state" }
+    }
+
+    override fun InspectorInfo.inspectableProperties() {}
+}
+
+private class DrawSelectionOverlayModifierNode(
+    val state: AccessibilityNodeInspectorState
+) : Modifier.Node(), DrawModifierNode {
+    override fun ContentDrawScope.draw() {
+        val coords = requireLayoutCoordinates()
+        state.nodes.let { nodes ->
+            if (nodes.isNotEmpty()) {
+                val layerAlpha = 0.8f / nodes.size
+                nodes.fastForEach { node ->
+                    val bounds = coords.screenToLocal(node.boundsInScreen)
+                    clipRect(
+                        left = bounds.left.toFloat(),
+                        top = bounds.top.toFloat(),
+                        right = bounds.right.toFloat(),
+                        bottom = bounds.bottom.toFloat(),
+                        clipOp = ClipOp.Difference
+                    ) {
+                        drawRect(Color.Black.copy(alpha = layerAlpha))
+                    }
+                }
+                val lastBounds = coords.screenToLocal(nodes.last().boundsInScreen)
+                drawRect(
+                    Color.Green,
+                    style = Stroke(1.dp.toPx()),
+                    topLeft = lastBounds.topLeft.toOffset(),
+                    size = lastBounds.size.toSize()
+                )
+            }
+        }
+
+        state.selectionOffset.takeIf { it.isSpecified }?.let { screenOffset ->
+            val localOffset = coords.screenToLocal(screenOffset)
+            drawLine(
+                Color.Red,
+                start = Offset(0f, localOffset.y),
+                end = Offset(size.width, localOffset.y)
+            )
+            drawLine(
+                Color.Red,
+                start = Offset(localOffset.x, 0f),
+                end = Offset(localOffset.x, size.height)
+            )
+        }
+    }
+
+    private fun LayoutCoordinates.screenToLocal(rect: IntRect): IntRect {
+        return IntRect(
+            topLeft = screenToLocal(rect.topLeft.toOffset()).round(),
+            bottomRight = screenToLocal(rect.bottomRight.toOffset()).round(),
+        )
+    }
+}
+
+/**
+ * A modifier that accepts pointer input to select accessibility nodes in an
+ * [AccessibilityNodeInspectorState].
+ */
+private data class NodeSelectionGestureModifier(
+    val state: AccessibilityNodeInspectorState,
+    val onDragStarted: (() -> Unit)? = null,
+) : ModifierNodeElement<NodeSelectionGestureModifierNode>() {
+    override fun create(): NodeSelectionGestureModifierNode =
+        NodeSelectionGestureModifierNode(state, onDragStarted)
+
+    override fun update(node: NodeSelectionGestureModifierNode) {
+        check(node.state === state) { "Cannot change state" }
+        node.onDragStarted = onDragStarted
+    }
+
+    override fun InspectorInfo.inspectableProperties() {}
+}
+
+private class NodeSelectionGestureModifierNode(
+    val state: AccessibilityNodeInspectorState,
+    var onDragStarted: (() -> Unit)?,
+) : DelegatingNode() {
+
+    private val pass = PointerEventPass.Initial
+
+    @Suppress("unused")
+    private val inputNode = delegate(SuspendingPointerInputModifierNode {
+        // Detect drag gestures but without slop.
+        val layoutCoords = requireLayoutCoordinates()
+        awaitEachGesture {
+            try {
+                val firstChange = awaitFirstDown(pass = pass)
+                state.updateNodeSelection(firstChange.position, layoutCoords)
+                onDragStarted?.invoke()
+                firstChange.consume()
+
+                while (true) {
+                    val event = awaitPointerEvent(pass = pass)
+                    event.changes.fastFirstOrNull { it.id == firstChange.id }?.let { change ->
+                        if (change.changedToUp()) {
+                            return@awaitEachGesture
+                        } else {
+                            state.updateNodeSelection(change.position, layoutCoords)
+                        }
+                    }
+                }
+            } finally {
+                state.showNodeDescription()
+            }
+        }
+    })
+}
+
+// endregion
+
+// region Details UI
+
+/**
+ * A dialog that shows all the properties of [leafNode] and all its ancestors and allows exploring
+ * them interactively.
+ */
+@Composable
+private fun InspectorNodeDetailsDialog(
+    leafNode: NodeInfo,
+    onBack: () -> Unit,
+) {
+    Dialog(
+        properties = DialogProperties(usePlatformDefaultWidth = false),
+        onDismissRequest = onBack
+    ) {
+        MaterialTheme(colors = if (isSystemInDarkTheme()) darkColors() else lightColors()) {
+            Surface(
+                modifier = Modifier.padding(16.dp),
+                elevation = 4.dp
+            ) {
+                Column {
+                    TopAppBar(
+                        title = { Text("AccessibilityNodeInfos") },
+                        navigationIcon = {
+                            IconButton(onClick = onBack) {
+                                Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+                            }
+                        }
+                    )
+
+                    val nodesFromRoot =
+                        buildList { leafNode.visitSelfAndParents(::add) }.asReversed()
+                    var selectedNodeIndex by remember {
+                        mutableIntStateOf(nodesFromRoot.size - 1)
+                    }
+                    Accordion(
+                        selectedIndex = selectedNodeIndex,
+                        onSelectIndex = { selectedNodeIndex = it },
+                        modifier = Modifier
+                            .verticalScroll(rememberScrollState())
+                            .padding(16.dp)
+                    ) {
+                        nodesFromRoot.forEach {
+                            item({ NodeAccordionHeader(it) }) {
+                                NodeAccordionBody(it)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun NodeAccordionHeader(node: NodeInfo) {
+    val (nodeClassPackage, nodeClassName) = node.nodeInfo.parseClassPackageAndName()
+    Text(nodeClassName, fontWeight = FontWeight.Medium)
+    Spacer(Modifier.width(8.dp))
+    Text(
+        nodeClassPackage,
+        style = MaterialTheme.typography.caption,
+        modifier = Modifier.alpha(0.5f),
+        overflow = TextOverflow.Ellipsis,
+        softWrap = false,
+    )
+}
+
+@Composable
+private fun NodeAccordionBody(node: NodeInfo) {
+    SelectionContainer {
+        Column(modifier = Modifier.padding(bottom = 8.dp)) {
+            val properties = node.getProperties()
+            KeyValueView(elements = properties.toList())
+        }
+    }
+}
+
+/**
+ * Shows a table of keys and their values. Values are rendered using [PropertyValueRepresentation].
+ */
+@Composable
+private fun KeyValueView(elements: List<Pair<String, Any?>>) {
+    Column(verticalArrangement = spacedBy(8.dp)) {
+        elements.forEach { (name, value) ->
+            KeyValueRow(name, value)
+        }
+    }
+}
+
+/**
+ * A row inside a [KeyValueView] that shows a single key and its value. The value will be shown
+ * beside the row, if there's space, otherwise it will be placed below it.
+ */
+@Composable
+private fun KeyValueRow(name: String, value: Any?) {
+    KeyValueRowLayout(
+        contentPadding = 8.dp,
+        keyContent = {
+            Text(
+                name,
+                fontWeight = FontWeight.Medium,
+                style = MaterialTheme.typography.caption,
+                modifier = Modifier.alpha(0.5f)
+            )
+        },
+        valueContent = {
+            val valueRepresentation = PropertyValueRepresentation(value)
+            if (valueRepresentation.customRenderer != null) {
+                valueRepresentation.customRenderer.invoke()
+            } else {
+                Text(
+                    valueRepresentation.text,
+                    fontFamily = FontFamily.Monospace,
+                    modifier = Modifier.horizontalScroll(rememberScrollState())
+                )
+            }
+        }
+    )
+}
+
+/**
+ * Places [keyContent] and [valueContent] on the same line if they both fit with [contentPadding]
+ * spacing, otherwise places [valueContent] below [keyContent] and indents it by [contentPadding].
+ * If [valueContent] wraps and fills all available space, a thin line is drawn in the margin to help
+ * visually track the nesting level.
+ */
+@Composable
+private inline fun KeyValueRowLayout(
+    contentPadding: Dp,
+    keyContent: @Composable RowScope.() -> Unit,
+    valueContent: @Composable RowScope.() -> Unit,
+) {
+    var nestingIndicator: Pair<Offset, Offset>? by remember { mutableStateOf(null) }
+
+    Layout(
+        modifier = Modifier.drawBehind {
+            nestingIndicator?.let { (start, end) ->
+                drawLine(
+                    start = start,
+                    end = end,
+                    color = Color.Gray,
+                    alpha = 0.3f,
+                    strokeWidth = 1.dp.toPx(),
+                )
+            }
+        },
+        content = {
+            Row(content = keyContent)
+            Row(content = valueContent)
+        },
+        measurePolicy = { measurables, constraints ->
+            val contentPaddingPx = contentPadding.roundToPx()
+            val (keyMeasurable, valueMeasurable) = measurables
+            val keyConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+            // contentPadding will either act as the spacing between items if they fit on the same
+            // line, or indent if content wraps, so inset the constraints either way.
+            val valueConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+                .offset(horizontal = -contentPaddingPx)
+            val keyPlaceable = keyMeasurable.measure(keyConstraints)
+            val valuePlaceable = valueMeasurable.measure(valueConstraints)
+            val wrap =
+                keyPlaceable.width + contentPaddingPx + valuePlaceable.width > constraints.maxWidth
+
+            val totalWidth = constraints.maxWidth
+            val totalHeight = if (wrap) {
+                keyPlaceable.height + valuePlaceable.height
+            } else {
+                maxOf(keyPlaceable.height, valuePlaceable.height)
+            }
+
+            // Only draw the nesting indicator if the value filled its max width, which indicates it
+            // will probably be taller, and harder to track the start edge visually.
+            nestingIndicator = if (wrap && valuePlaceable.width == valueConstraints.maxWidth) {
+                Pair(
+                    Offset(contentPaddingPx / 2f, keyPlaceable.height.toFloat()),
+                    Offset(contentPaddingPx / 2f, totalHeight.toFloat())
+                )
+            } else {
+                null
+            }
+
+            layout(totalWidth, totalHeight) {
+                val valueX = totalWidth - valuePlaceable.width
+                if (wrap) {
+                    // Arrange vertically.
+                    keyPlaceable.placeRelative(0, 0)
+                    valuePlaceable.placeRelative(valueX, keyPlaceable.height)
+                } else {
+                    // Arrange horizontally.
+                    val keyY = Alignment.CenterVertically.align(
+                        size = keyPlaceable.height,
+                        space = totalHeight
+                    )
+                    keyPlaceable.placeRelative(0, keyY)
+
+                    val valueY = Alignment.CenterVertically.align(
+                        size = valuePlaceable.height,
+                        space = totalHeight
+                    )
+                    valuePlaceable.placeRelative(valueX, valueY)
+                }
+            }
+        }
+    )
+}
+
+/**
+ * A representation of an arbitrary value as a potentially-styled [AnnotatedString], and optionally
+ * also as a completely custom composable. To create an instance for standard types, call the
+ * [PropertyValueRepresentation] function.
+ */
+private data class PropertyValueRepresentation(
+    val text: AnnotatedString,
+    val customRenderer: (@Composable () -> Unit)? = null
+)
+
+private val ValueTypeTextStyle = TextStyle(
+    fontFamily = FontFamily.Monospace,
+)
+
+/**
+ * Creates a [PropertyValueRepresentation] appropriate for certain well-known types. For other types
+ * returns a representation that is just the result of the value's [toString].
+ */
+private fun PropertyValueRepresentation(value: Any?): PropertyValueRepresentation =
+    when (value) {
+        is CharSequence -> PropertyValueRepresentation(value.toFormattedDebugString())
+
+        is Iterable<*> -> {
+            val valueType = value.javaClass.canonicalName ?: value.javaClass.name
+            // No isEmpty on iterable.
+            if (!value.iterator().hasNext()) {
+                PropertyValueRepresentation(AnnotatedString("$valueType()"))
+            } else {
+                PropertyValueRepresentation(AnnotatedString(value.toString())) {
+                    Column {
+                        Text(valueType, style = ValueTypeTextStyle)
+                        KeyValueView(value.mapIndexed { index, element ->
+                            Pair("[$index]", element)
+                        })
+                    }
+                }
+            }
+        }
+
+        is Map<*, *> -> {
+            val valueType = value.javaClass.canonicalName ?: value.javaClass.name
+            if (value.isEmpty()) {
+                PropertyValueRepresentation(AnnotatedString("$valueType()"))
+            } else {
+                PropertyValueRepresentation(AnnotatedString(value.toString())) {
+                    Column {
+                        Text(valueType, style = ValueTypeTextStyle)
+                        KeyValueView(value.entries.map { (key, value) ->
+                            Pair(key.toString(), value)
+                        })
+                    }
+                }
+            }
+        }
+
+        is Bundle -> {
+            if (value.isEmpty) {
+                PropertyValueRepresentation(
+                    AnnotatedString(
+                        "empty Bundle",
+                        SpanStyle(fontStyle = FontStyle.Italic)
+                    )
+                )
+            } else {
+                PropertyValueRepresentation(AnnotatedString(value.toString())) {
+                    KeyValueView(value.keySet().map { key ->
+                        @Suppress("DEPRECATION")
+                        Pair(key, value.get(key))
+                    })
+                }
+            }
+        }
+
+        else -> PropertyValueRepresentation(AnnotatedString(value.toString()))
+    }
+
+/**
+ * Returns the package and simple name parts of a FQCN by splitting at the last '.' character.
+ */
+private fun AccessibilityNodeInfoCompat.parseClassPackageAndName(): Pair<String, String> {
+    val separatorIndex = className.indexOfLast { it == '.' }
+    return Pair(
+        className.substring(0, separatorIndex),
+        className.substring(separatorIndex + 1)
+    )
+}
+
+/**
+ * A column of expandable headers. Only one header can be expanded at a time. To create an item
+ * call [AccordionScope.item] in [content].
+ */
+@Composable
+private fun Accordion(
+    selectedIndex: Int,
+    onSelectIndex: (Int) -> Unit,
+    modifier: Modifier = Modifier,
+    content: AccordionScope.() -> Unit
+) {
+    Column(modifier) {
+        // Don't rebuild the items every time the selection changes.
+        val items by remember(content) { derivedStateOf { buildAccordionItems(content) } }
+        val isSelectedIndexValid = selectedIndex in items.indices
+        items.fastForEachIndexed { index, item ->
+            val isItemSelected = index == selectedIndex
+            AccordionItemView(
+                item = item,
+                headerHeight = 40.dp,
+                isExpanded = isItemSelected,
+                shrinkHeader = !isItemSelected && isSelectedIndexValid,
+                onHeaderClicked = {
+                    onSelectIndex(if (selectedIndex == index) -1 else index)
+                },
+            )
+            if (index < items.size - 1) {
+                Divider()
+            }
+        }
+    }
+}
+
+/**
+ * An item header and optionally-visible content inside an [Accordion]. Only intended to be called
+ * by [Accordion] itself.
+ */
+@Composable
+private fun AccordionItemView(
+    item: AccordionItem,
+    headerHeight: Dp,
+    isExpanded: Boolean,
+    shrinkHeader: Boolean,
+    onHeaderClicked: () -> Unit
+) {
+    // Shrink collapsed headers to give more space to the expanded body.
+    val headerScale by animateFloatAsState(if (shrinkHeader) 0.8f else 1f, label = "headerScale")
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .height { (headerHeight * headerScale).roundToPx() }
+            .fillMaxWidth()
+            .selectable(selected = isExpanded, onClick = onHeaderClicked)
+            .graphicsLayer {
+                scaleX = headerScale
+                scaleY = headerScale
+                transformOrigin = TransformOrigin(0f, 0.5f)
+            },
+    ) {
+        val iconRotation by animateFloatAsState(
+            if (isExpanded) 0f else -90f,
+            label = "iconRotation"
+        )
+        Icon(
+            Icons.Filled.ArrowDropDown,
+            contentDescription = null,
+            modifier = Modifier.graphicsLayer {
+                rotationZ = iconRotation
+            })
+        item.header()
+    }
+    AnimatedVisibility(
+        visible = isExpanded,
+        enter = expandVertically(expandFrom = Alignment.Top),
+        exit = shrinkVertically(shrinkTowards = Alignment.Top),
+    ) {
+        item.content()
+    }
+}
+
+private interface AccordionScope {
+    /**
+     * Creates an accordion item with a [header] that is always visible, and a [body] that is only
+     * visible when the item is expanded.
+     */
+    fun item(
+        header: @Composable () -> Unit,
+        body: @Composable () -> Unit
+    )
+}
+
+private data class AccordionItem(
+    val header: @Composable () -> Unit,
+    val content: @Composable () -> Unit
+)
+
+private fun buildAccordionItems(content: AccordionScope.() -> Unit): List<AccordionItem> {
+    return buildList {
+        content(object : AccordionScope {
+            override fun item(
+                header: @Composable () -> Unit,
+                body: @Composable () -> Unit
+            ) {
+                add(AccordionItem(header, body))
+            }
+        })
+    }
+}
+
+/**
+ * Sets [key] to [value] in this map if [value] is not [unspecifiedValue] (null by default).
+ */
+private fun MutableMap<String, Any?>.setIfSpecified(
+    key: String,
+    value: Any?,
+    unspecifiedValue: Any? = null
+) {
+    if (value != unspecifiedValue) {
+        set(key, value)
+    }
+}
+
+/**
+ * Sets [key] to [value] in this map if [value] is not [unspecifiedValue] (false by default).
+ */
+private fun MutableMap<String, Any?>.setIfSpecified(
+    key: String,
+    value: Boolean,
+    unspecifiedValue: Boolean = false
+) {
+    if (value != unspecifiedValue) {
+        set(key, value)
+    }
+}
+
+/**
+ * Sets [key] to [value] in this map if [value] is not [unspecifiedValue] (0 by default).
+ */
+private fun MutableMap<String, Any?>.setIfSpecified(
+    key: String,
+    value: Int,
+    unspecifiedValue: Int = 0
+) {
+    if (value != unspecifiedValue) {
+        set(key, value)
+    }
+}
+
+/**
+ * Returns an [AnnotatedString] that makes this [CharSequence] value easier to read for debugging.
+ * Wraps the value in stylized quote marks so empty strings are more clear, and replaces invisible
+ * control characters (e.g. `'\n'`) with their stylized literal escape sequences.
+ */
+private fun CharSequence.toFormattedDebugString(): AnnotatedString = buildAnnotatedString {
+    val quoteStyle = SpanStyle(
+        color = Color.Gray,
+        fontWeight = FontWeight.Bold
+    )
+    val specialStyle = SpanStyle(
+        color = Color.Red,
+        fontWeight = FontWeight.Bold,
+    )
+
+    withStyle(quoteStyle) { append('"') }
+
+    [email protected] { c ->
+        var formattedChar: String? = null
+        when (c) {
+            '\n' -> formattedChar = "\\n"
+            '\r' -> formattedChar = "\\r"
+            '\t' -> formattedChar = "\\t"
+            '\b' -> formattedChar = "\\b"
+        }
+        if (formattedChar != null) {
+            withStyle(specialStyle) {
+                append(formattedChar)
+            }
+        } else {
+            append(c)
+        }
+    }
+
+    withStyle(quoteStyle) { append('"') }
+}
+
+// endregion
+
+/**
+ * Like the standard [Modifier.width] modifier but the width is only calculated at measure time.
+ */
+private fun Modifier.width(calculateWidth: Density.() -> Int): Modifier =
+    layout { measurable, constraints ->
+        val calculatedWidth = calculateWidth()
+        val childConstraints = constraints.copy(
+            minWidth = calculatedWidth,
+            maxWidth = calculatedWidth
+        )
+        val placeable = measurable.measure(childConstraints)
+        layout(placeable.width, placeable.height) {
+            placeable.place(0, 0)
+        }
+    }
+
+/**
+ * Like the standard [Modifier.height] modifier but the height is only calculated at measure time.
+ */
+private fun Modifier.height(calculateHeight: Density.() -> Int): Modifier =
+    layout { measurable, constraints ->
+        val calculatedHeight = calculateHeight()
+        val childConstraints = constraints.copy(
+            minHeight = calculatedHeight,
+            maxHeight = calculatedHeight
+        )
+        val placeable = measurable.measure(childConstraints)
+        layout(placeable.width, placeable.height) {
+            placeable.place(0, 0)
+        }
+    }
+
+// region Accessibility node access
+
+/**
+ * Creates and remembers an [AccessibilityNodeInspectorState] for inspecting the nodes in the window
+ * hosting this composition.
+ */
+@Composable
+private fun rememberAccessibilityNodeInspectorState(): AccessibilityNodeInspectorState {
+    val hostView = LocalView.current
+    val state = remember(hostView) { AccessibilityNodeInspectorState(hostView = hostView) }
+    LaunchedEffect(state) { state.runWhileDisplayed() }
+    return state
+}
+
+/** State holder for an [AccessibilityNodeInspectorButton]. */
+private class AccessibilityNodeInspectorState(
+    private val hostView: View
+) : PopupPositionProvider,
+    View.OnLayoutChangeListener {
+
+    var inspectorWindowSize: IntSize by mutableStateOf(calculateInspectorWindowSize())
+        private set
+
+    private val service: InspectableTreeProvider =
+        if (Build.VERSION.SDK_INT >= 34) {
+            AccessibilityTreeInspectorApi34(hostView.rootView)
+        } else {
+            NoopTreeProvider
+        }
+
+    val isReady: Boolean by derivedStateOf {
+        inspectorWindowSize.width > 0 && inspectorWindowSize.height > 0
+    }
+
+    var selectionOffset: Offset by mutableStateOf(Offset.Unspecified)
+        private set
+
+    var nodes: List<NodeInfo> by mutableStateOf(emptyList())
+        private set
+
+    var selectedNode: NodeInfo? by mutableStateOf(null)
+        private set
+
+    /**
+     * Temporarily select the node at [localOffset] in the window being inspected. This should be
+     * called while the user is dragging.
+     */
+    fun updateNodeSelection(localOffset: Offset, layoutCoordinates: LayoutCoordinates) {
+        hideNodeDescription()
+        val screenOffset = layoutCoordinates.localToScreen(localOffset)
+        selectionOffset = screenOffset
+        this.nodes = service.findNodesAt(screenOffset)
+    }
+
+    /**
+     * Locks in the currently-selected node and shows the inspector dialog with the nodes'
+     * properties.
+     */
+    fun showNodeDescription() {
+        selectionOffset = Offset.Unspecified
+        selectedNode = nodes.lastOrNull()?.also {
+            it.dumpToLog(tag = LogTag)
+        }
+    }
+
+    /**
+     * Hides the inspector dialog to allow the user to select a different node.
+     */
+    fun hideNodeDescription() {
+        selectedNode = null
+    }
+
+    /**
+     * Runs any coroutine effects the state holder requires while it's connected to some UI.
+     */
+    suspend fun runWhileDisplayed() {
+        coroutineScope {
+            // Update the overlay window size when the target window is resized.
+            launch {
+                hostView.addOnLayoutChangeListener(this@AccessibilityNodeInspectorState)
+                try {
+                    awaitCancellation()
+                } finally {
+                    hostView.removeOnLayoutChangeListener(this@AccessibilityNodeInspectorState)
+                }
+            }
+        }
+    }
+
+    override fun onLayoutChange(
+        v: View?,
+        left: Int,
+        top: Int,
+        right: Int,
+        bottom: Int,
+        oldLeft: Int,
+        oldTop: Int,
+        oldRight: Int,
+        oldBottom: Int
+    ) {
+        inspectorWindowSize = calculateInspectorWindowSize()
+    }
+
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize
+    ): IntOffset = IntOffset.Zero
+
+    private fun calculateInspectorWindowSize(): IntSize {
+        return Rect().also {
+            hostView.getWindowVisibleDisplayFrame(it)
+        }.let { IntSize(it.width(), it.height()) }
+    }
+}
+
+private data class NodeInfo(
+    val nodeInfo: AccessibilityNodeInfoCompat,
+    val boundsInScreen: IntRect,
+)
+
+/** Returns a map with all the inspectable properties of this [NodeInfo]. */
+private fun NodeInfo.getProperties(): Map<String, Any?> = buildMap {
+    val node = nodeInfo
+    // Don't render className, it's in the title.
+    setIfSpecified("packageName", node.packageName)
+    setIfSpecified("boundsInScreen", Rect().also(node::getBoundsInScreen))
+    setIfSpecified("boundsInWindow", Rect().also(node::getBoundsInWindow))
+    setIfSpecified("viewIdResourceName", node.viewIdResourceName)
+    setIfSpecified("uniqueId", node.uniqueId)
+    setIfSpecified("text", node.text)
+    setIfSpecified("textSelectionStart", node.textSelectionStart, unspecifiedValue = -1)
+    setIfSpecified("textSelectionEnd", node.textSelectionEnd, unspecifiedValue = -1)
+    setIfSpecified("contentDescription", node.contentDescription)
+    setIfSpecified("collectionInfo", node.collectionInfo)
+    setIfSpecified("collectionItemInfo", node.collectionItemInfo)
+    setIfSpecified("containerTitle", node.containerTitle)
+    setIfSpecified("childCount", node.childCount)
+    setIfSpecified("drawingOrder", node.drawingOrder)
+    setIfSpecified("error", node.error)
+    setIfSpecified("hintText", node.hintText)
+    setIfSpecified("inputType", node.inputType)
+    setIfSpecified("isAccessibilityDataSensitive", node.isAccessibilityDataSensitive)
+    setIfSpecified("isAccessibilityFocused", node.isAccessibilityFocused)
+    setIfSpecified("isCheckable", node.isCheckable)
+    setIfSpecified("isChecked", node.isChecked)
+    setIfSpecified("isClickable", node.isClickable)
+    setIfSpecified("isLongClickable", node.isLongClickable)
+    setIfSpecified("isContextClickable", node.isContextClickable)
+    setIfSpecified("isContentInvalid", node.isContentInvalid)
+    setIfSpecified("isDismissable", node.isDismissable)
+    setIfSpecified("isEditable", node.isEditable)
+    setIfSpecified("isEnabled", node.isEnabled, unspecifiedValue = true)
+    setIfSpecified("isFocusable", node.isFocusable)
+    setIfSpecified("isFocused", node.isFocused)
+    setIfSpecified("isGranularScrollingSupported", node.isGranularScrollingSupported)
+    setIfSpecified("isHeading", node.isHeading)
+    set("isImportantForAccessibility", node.isImportantForAccessibility)
+    setIfSpecified("isMultiLine", node.isMultiLine)
+    setIfSpecified("isPassword", node.isPassword)
+    setIfSpecified("isScreenReaderFocusable", node.isScreenReaderFocusable)
+    setIfSpecified("isScrollable", node.isScrollable)
+    setIfSpecified("isSelected", node.isSelected)
+    setIfSpecified("isShowingHintText", node.isShowingHintText)
+    setIfSpecified("isTextEntryKey", node.isTextEntryKey)
+    setIfSpecified("isTextSelectable", node.isTextSelectable)
+    setIfSpecified("isVisibleToUser", node.isVisibleToUser, unspecifiedValue = true)
+    setIfSpecified("labelFor", node.labelFor)
+    setIfSpecified("labeledBy", node.labeledBy)
+    setIfSpecified("liveRegion", node.liveRegion)
+    setIfSpecified("maxTextLength", node.maxTextLength, unspecifiedValue = -1)
+    setIfSpecified("movementGranularities", node.movementGranularities)
+    setIfSpecified("paneTitle", node.paneTitle)
+    setIfSpecified("rangeInfo", node.rangeInfo)
+    setIfSpecified("roleDescription", node.roleDescription)
+    setIfSpecified("stateDescription", node.stateDescription)
+    setIfSpecified("tooltipText", node.tooltipText)
+    setIfSpecified("touchDelegateInfo", node.touchDelegateInfo)
+    setIfSpecified("windowId", node.windowId, unspecifiedValue = -1)
+    setIfSpecified("canOpenPopup", node.canOpenPopup())
+    setIfSpecified(
+        "hasRequestInitialAccessibilityFocus",
+        node.hasRequestInitialAccessibilityFocus()
+    )
+    setIfSpecified("extras", node.extrasWithoutExtraData)
+    setIfSpecified("extraRenderingInfo", node.extraRenderingInfo)
+
+    if (Build.VERSION.SDK_INT >= 26 && node.availableExtraData.isNotEmpty()) {
+        val extraData = mutableMapOf<String, Any?>()
+        node.availableExtraData.forEach { key ->
+            extraData[key] = AccessibilityNodeInfoHelper.readExtraData(node.unwrap(), key)
+        }
+        setIfSpecified("extraData (from availableExtraData)", extraData)
+    }
+}
+
+/**
+ * Returns the extras bundle, but without any keys from
+ * [AccessibilityNodeInfoCompat.getAvailableExtraData], since those are reported separately.
+ */
+private val AccessibilityNodeInfoCompat.extrasWithoutExtraData: Bundle
+    get() {
+        val extras = Bundle(extras)
+        availableExtraData.forEach {
+            extras.remove(it)
+        }
+        return extras
+    }
+
+/** Class verification helper for reading extras data from an [AccessibilityNodeInfo]. */
+@RequiresApi(26)
+private object AccessibilityNodeInfoHelper {
+    fun readExtraData(
+        node: AccessibilityNodeInfo,
+        key: String
+    ): Any? {
+        if (key in node.availableExtraData && node.refreshWithExtraData(key, Bundle())) {
+            @Suppress("DEPRECATION")
+            return node.extras.get(key)
+        } else {
+            return null
+        }
+    }
+}
+
+private interface InspectableTreeProvider {
+    fun findNodesAt(screenOffset: Offset): List<NodeInfo>
+}
+
+private object NoopTreeProvider : InspectableTreeProvider {
+    override fun findNodesAt(screenOffset: Offset): List<NodeInfo> = emptyList()
+}
+
+@RequiresApi(34)
+private class AccessibilityTreeInspectorApi34(
+    private val rootView: View
+) : InspectableTreeProvider {
+
+    private val matrixCache = Matrix()
+
+    override fun findNodesAt(screenOffset: Offset): List<NodeInfo> {
+        rootView.transformMatrixToLocal(matrixCache)
+
+        val nodes = mutableListOf<NodeInfo>()
+        val rootInfo = rootView.createNodeInfo()
+        rootInfo.visitNodeAndChildren { node ->
+            if (node.hitTest(screenOffset)) {
+                nodes += node
+                true
+            } else {
+                false
+            }
+        }
+        return nodes
+    }
+
+    private fun NodeInfo.hitTest(screenOffset: Offset): Boolean {
+        return boundsInScreen.contains(screenOffset.round())
+    }
+
+    private inline fun NodeInfo.visitNodeAndChildren(
+        visitor: (NodeInfo) -> Boolean
+    ) {
+        val queue = mutableVectorOf(this)
+        while (queue.isNotEmpty()) {
+            val current = queue.removeAt(queue.lastIndex)
+            val visitChildren = visitor(current)
+            if (visitChildren) {
+                for (i in 0 until current.nodeInfo.childCount) {
+                    queue += current.nodeInfo.getChild(i).toNodeInfo()
+                }
+            }
+        }
+    }
+
+    private fun View.createNodeInfo(): NodeInfo {
+        val rawNodeInfo = createAccessibilityNodeInfo()
+        val nodeInfoCompat = AccessibilityNodeInfoCompat.wrap(rawNodeInfo)
+        rawNodeInfo.setQueryFromAppProcessEnabled(this, true)
+        return nodeInfoCompat.toNodeInfo()
+    }
+}
+
+private fun AccessibilityNodeInfoCompat.toNodeInfo(): NodeInfo = NodeInfo(
+    nodeInfo = this,
+    boundsInScreen = Rect().also(::getBoundsInScreen).toComposeIntRect(),
+)
+
+private fun NodeInfo.dumpToLog(tag: String) {
+    val indent = "  "
+    var depth = 0
+    visitSelfAndParents { node ->
+        Log.d(tag, indent.repeat(depth) + node.nodeInfo.unwrap().toString())
+        depth++
+    }
+}
+
+private inline fun NodeInfo.visitSelfAndParents(block: (NodeInfo) -> Unit) {
+    var node: NodeInfo? = this
+    while (node != null) {
+        block(node)
+        node = node.parent
+    }
+}
+
+private val NodeInfo.parent: NodeInfo?
+    get() = nodeInfo.parent?.toNodeInfo()
+
+// endregion
diff --git a/compose/integration-tests/demos/build.gradle b/compose/integration-tests/demos/build.gradle
index 1bfb611..5b3f0fa 100644
--- a/compose/integration-tests/demos/build.gradle
+++ b/compose/integration-tests/demos/build.gradle
@@ -31,6 +31,7 @@
     implementation(project(":compose:foundation:foundation-layout"))
     implementation(project(":compose:integration-tests:demos:common"))
     implementation(project(":compose:material:material"))
+    implementation(project(":compose:material:material-icons-extended"))
     implementation(project(":compose:material3:material3"))
     implementation(project(":compose:runtime:runtime"))
     implementation(project(":compose:ui:ui"))
diff --git a/compose/integration-tests/demos/src/main/java/androidx/compose/integration/demos/DemoApp.kt b/compose/integration-tests/demos/src/main/java/androidx/compose/integration/demos/DemoApp.kt
index 7dbf951..7acc778 100644
--- a/compose/integration-tests/demos/src/main/java/androidx/compose/integration/demos/DemoApp.kt
+++ b/compose/integration-tests/demos/src/main/java/androidx/compose/integration/demos/DemoApp.kt
@@ -18,6 +18,7 @@
 
 import androidx.compose.animation.Crossfade
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.demos.AccessibilityNodeInspectorButton
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -40,6 +41,7 @@
 import androidx.compose.material.LocalContentColor
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Api
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -101,8 +103,7 @@
                 onEndFiltering = onEndFiltering
             )
         },
-        modifier = Modifier
-            .nestedScroll(scrollBehavior.nestedScrollConnection)
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
     ) { innerPadding ->
         val modifier = Modifier
             // as scaffold currently doesn't consume - consume what's needed
@@ -246,6 +247,7 @@
             scrollBehavior = scrollBehavior,
             navigationIcon = navigationIcon,
             actions = {
+                AppBarIcons.AccessibilityNodeInspector()
                 AppBarIcons.Filter(onClick = onStartFiltering)
                 AppBarIcons.Settings(onClick = launchSettings)
             }
@@ -262,6 +264,15 @@
     }
 
     @Composable
+    fun AccessibilityNodeInspector() {
+        AccessibilityNodeInspectorButton {
+            IconButton(onClick = {}) {
+                Icon(Icons.Filled.Api, contentDescription = null)
+            }
+        }
+    }
+
+    @Composable
     fun Filter(onClick: () -> Unit) {
         IconButton(modifier = Modifier.testTag(Tags.FilterButton), onClick = onClick) {
             Icon(Icons.Filled.Search, null)