From e40cf2c90a3f8da390022020c0ee9f15cd791f77 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Sun, 9 Aug 2026 11:29:12 +0530 Subject: [PATCH] Fix accessibility scanner false positives - detect duplicate content descriptions only among semantic siblings - ignore expanded or duplicate transition targets in overlap checks - allow scan collection before automatic activity installation - export parent node IDs and update tests and documentation --- README.md | 4 ++ RULES.md | 8 +-- .../composea11yscanner/core/model/A11yNode.kt | 3 + .../rules/DuplicateContentDescriptionRule.kt | 6 +- .../rules/TouchTargetOverlapRule.kt | 15 +++- .../DuplicateContentDescriptionRuleTest.kt | 68 ++++++++++++++----- .../rules/FakeNodeBuilder.kt | 2 + .../rules/TouchTargetOverlapRuleTest.kt | 57 +++++++++++++--- .../A11yScannerInitializer.kt | 3 + .../composea11yscanner/ComposeA11yScanner.kt | 30 ++++++-- .../export/ScanResultExporter.kt | 2 + .../ui/A11yNodeExtractor.kt | 32 +++++++-- 12 files changed, 188 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 37d0200..b9534d4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ import com.composea11yscanner.ComposeA11yScanner ComposeA11yScanner.triggerScan() ``` +`ComposeA11yScanner.scan()` is safe to collect before the first activity reaches `onResume` when +automatic installation is enabled. The flow waits for an installed activity scanner and then +forwards its state. + For shake-to-scan, add one call to a debug-only composable that is active while the screen is visible: ```kotlin diff --git a/RULES.md b/RULES.md index 4133d87..d535573 100644 --- a/RULES.md +++ b/RULES.md @@ -8,9 +8,9 @@ Implementation note: the current codebase exposes 6 built-in rules. This documen | Rule ID | Name | Severity | What It Checks | How To Fix | WCAG Reference | | --- | --- | --- | --- | --- | --- | -| `touch-target-overlap` | Touch Target Overlap | Warning | Interactive nodes whose effective Compose touch bounds overlap another effective target. | Increase spacing, enlarge layout bounds, or restructure controls so their effective hit regions do not overlap. | Android accessibility guidance | +| `touch-target-overlap` | Touch Target Overlap | Warning | Distinct interactive nodes whose effective Compose touch bounds and visual layout bounds overlap. | Increase spacing, enlarge layout bounds, or restructure controls so their hit regions do not overlap. | Android accessibility guidance | | `missing-content-description` | Missing Content Description | Error | Interactive nodes and image-like nodes that do not expose a non-empty content description. | Add a meaningful `contentDescription` through semantics, or pass one directly to image composables that support it. | WCAG 1.1.1 Non-text Content (Level A) | -| `duplicate-content-description` | Duplicate Content Description | Warning | Non-merged nodes at the same semantics depth that reuse the same non-empty content description. | Give each control or item a label that identifies its specific action, state, or content. | WCAG 2.4.6 Headings and Labels (Level AA) | +| `duplicate-content-description` | Duplicate Content Description | Warning | Distinct sibling nodes under the same semantics parent that reuse the same non-empty content description. | Give each control or item a label that identifies its specific action, state, or content. | WCAG 2.4.6 Headings and Labels (Level AA) | | `focus-order` | Focus Order | Error | Focusable nodes whose semantics traversal jumps upward compared with the previous focusable node's visual position. | Reorder composables so focus follows the visual reading order, or set explicit traversal order with semantics. | WCAG 2.4.3 Focus Order (Level A) | | `text-scaling` | Text Scaling | Warning | Text nodes that may overflow or clip inside their parent when simulated at a larger font scale. | Avoid fixed-height containers for text; use flexible height, wrapping, or scrolling so scaled text can reflow. | WCAG 1.4.4 Resize Text (Level AA) | | `image-text-overlay` | Image With Text Overlay | Warning | Text nodes that significantly overlap image nodes, creating a contrast risk across dynamic images. | Add a scrim or solid text background, or otherwise guarantee sufficient contrast for every image state. | WCAG 1.4.3 Contrast Minimum (Level AA) | @@ -20,7 +20,7 @@ Implementation note: the current codebase exposes 6 built-in rules. This documen **Severity:** Warning -**What it checks:** This scan-level rule compares `touchBoundsInRoot` for clickable nodes that are not merged descendants. It reports each affected node once when its effective pointer target intersects one or more other effective targets. Targets that only share an edge are not considered overlapping. +**What it checks:** This scan-level rule compares `touchBoundsInRoot` for distinct clickable nodes that are not merged descendants. It reports each affected node once when both the effective pointer targets and visual layout bounds overlap. Targets that only share an edge, minimum-size touch expansions around visually separate controls, and identical transition copies are not reported. **How to fix:** Increase the layout spacing between controls, give controls layout bounds that accommodate their expanded hit regions, or restructure the layout so each action has an unambiguous pointer target. @@ -74,7 +74,7 @@ Box( **Severity:** Warning -**What it checks:** This rule groups non-merged nodes by semantics depth and content description. It reports nodes when more than one node at the same depth has the same non-empty content description. +**What it checks:** This rule groups distinct non-merged sibling nodes by their immediate semantics parent and content description. It reports nodes when more than one logical child of the same container has the same non-empty description. Repeated content in different sections or collections is not reported merely because it occurs at the same tree depth. **How to fix:** Make labels specific enough for a screen reader user to distinguish each item or action. Include item names, destinations, or state where needed. diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt index ac166b6..7dc860e 100644 --- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt +++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt @@ -14,6 +14,8 @@ package com.composea11yscanner.core.model * @property isFocusable True when the node can participate in focus traversal. * @property isMergedDescendant True when the node is inside a parent that merges semantics. * @property depth Depth in the semantics tree. + * @property parentNodeId Id of the immediate parent in the unmerged semantics tree, or null for + * the root. Rules use this to distinguish true siblings from unrelated nodes at the same depth. * @property role Accessibility role mapped from the platform semantics role, if any. */ data class A11yNode( @@ -29,4 +31,5 @@ data class A11yNode( val depth: Int, val role: A11yRole? = null, val effectiveTouchBounds: Rect? = null, + val parentNodeId: String? = null, ) diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt index 29bdc70..c9cf8e7 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt @@ -20,11 +20,12 @@ class DuplicateContentDescriptionRule : BaseScanRule() { /** WCAG criterion associated with distinguishable labels. */ override val wcagReference = "WCAG 2.4.6 Headings and Labels (Level AA)" - /** Evaluates all nodes together to find repeated labels at the same depth. */ + /** Evaluates all nodes together to find repeated labels among semantic siblings. */ override fun evaluateAll(nodes: List): List = nodes + .asSequence() .filter { !it.contentDescription.isNullOrBlank() && !it.isMergedDescendant } - .groupBy { it.depth to it.contentDescription } + .groupBy { it.parentNodeId to it.contentDescription } .filter { (_, group) -> group.size > 1 } .flatMap { (key, group) -> val text = key.second @@ -38,4 +39,5 @@ class DuplicateContentDescriptionRule : BaseScanRule() { ) } } + .toList() } diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt index 9a6bede..a62b0f7 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt @@ -19,13 +19,18 @@ class TouchTargetOverlapRule : BaseScanRule() { node.isTouchTarget && !node.isMergedDescendant && node.effectiveTouchBounds?.isEmpty() == false - } + }.distinctBy { it.logicalIdentity() } val overlapsByNodeId = mutableMapOf>() targets.forEachIndexed { index, first -> for (secondIndex in index + 1 until targets.size) { val second = targets[secondIndex] if (!first.effectiveTouchBounds!!.overlaps(second.effectiveTouchBounds!!)) continue + // Compose may expand a small control's touch bounds beyond its visual bounds to + // meet the minimum target size. Adjacent controls can therefore have intersecting + // effective rectangles even though they remain distinct hit targets. Only report + // overlap when the actual layout bounds intersect too. + if (!first.bounds.overlaps(second.bounds)) continue overlapsByNodeId.getOrPut(first.nodeId) { mutableSetOf() }.add(second.nodeId) overlapsByNodeId.getOrPut(second.nodeId) { mutableSetOf() }.add(first.nodeId) @@ -46,6 +51,14 @@ class TouchTargetOverlapRule : BaseScanRule() { } } +private fun A11yNode.logicalIdentity(): List = listOf( + composableName, + bounds, + effectiveTouchBounds, + contentDescription, + role, +) + private fun Rect.overlaps(other: Rect): Boolean = left < other.right && right > other.left && diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt index ab8cbcf..eff5875 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt @@ -1,6 +1,7 @@ package com.composea11yscanner.rules import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Rect import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -29,8 +30,8 @@ class DuplicateContentDescriptionRuleTest { @Test fun `same description at different depths is not a duplicate`() { val nodes = listOf( - createNode(depth = 1, contentDescription = "Submit"), - createNode(depth = 2, contentDescription = "Submit"), + createNode(parentNodeId = "toolbar", depth = 1, contentDescription = "Submit"), + createNode(parentNodeId = "dialog", depth = 2, contentDescription = "Submit"), ) assertTrue(rule.evaluateAll(nodes).isEmpty()) } @@ -65,20 +66,20 @@ class DuplicateContentDescriptionRuleTest { // --- failing cases --- @Test - fun `two nodes with same description at same depth produce two issues`() { + fun `two siblings with same description produce two issues`() { val nodes = listOf( - createNode(depth = 1, contentDescription = "Submit"), - createNode(depth = 1, contentDescription = "Submit"), + createNode(depth = 1, bounds = Rect(0, 0, 100, 100), contentDescription = "Submit"), + createNode(depth = 1, bounds = Rect(100, 0, 200, 100), contentDescription = "Submit"), ) assertEquals(2, rule.evaluateAll(nodes).size) } @Test - fun `three nodes with same description at same depth produce three issues`() { + fun `three siblings with same description produce three issues`() { val nodes = listOf( - createNode(depth = 1, contentDescription = "Delete"), - createNode(depth = 1, contentDescription = "Delete"), - createNode(depth = 1, contentDescription = "Delete"), + createNode(depth = 1, bounds = Rect(0, 0, 100, 100), contentDescription = "Delete"), + createNode(depth = 1, bounds = Rect(100, 0, 200, 100), contentDescription = "Delete"), + createNode(depth = 1, bounds = Rect(200, 0, 300, 100), contentDescription = "Delete"), ) assertEquals(3, rule.evaluateAll(nodes).size) } @@ -88,9 +89,9 @@ class DuplicateContentDescriptionRuleTest { @Test fun `only the duplicate group is flagged, non-duplicates are clean`() { val nodes = listOf( - createNode(depth = 1, contentDescription = "Submit"), - createNode(depth = 1, contentDescription = "Submit"), - createNode(depth = 1, contentDescription = "Cancel"), + createNode(depth = 1, bounds = Rect(0, 0, 100, 100), contentDescription = "Submit"), + createNode(depth = 1, bounds = Rect(100, 0, 200, 100), contentDescription = "Submit"), + createNode(depth = 1, bounds = Rect(200, 0, 300, 100), contentDescription = "Cancel"), ) val issues = rule.evaluateAll(nodes) assertEquals(2, issues.size) @@ -100,8 +101,8 @@ class DuplicateContentDescriptionRuleTest { @Test fun `issue message contains the duplicated text`() { val nodes = listOf( - createNode(depth = 1, contentDescription = "Close dialog"), - createNode(depth = 1, contentDescription = "Close dialog"), + createNode(depth = 1, bounds = Rect(0, 0, 100, 100), contentDescription = "Close dialog"), + createNode(depth = 1, bounds = Rect(100, 0, 200, 100), contentDescription = "Close dialog"), ) val issue = rule.evaluateAll(nodes).first() assertTrue(issue.message.contains("'Close dialog'")) @@ -115,12 +116,47 @@ class DuplicateContentDescriptionRuleTest { @Test fun `issue carries correct rule metadata`() { val nodes = listOf( - createNode(depth = 1, contentDescription = "X"), - createNode(depth = 1, contentDescription = "X"), + createNode(depth = 1, bounds = Rect(0, 0, 100, 100), contentDescription = "X"), + createNode(depth = 1, bounds = Rect(100, 0, 200, 100), contentDescription = "X"), ) val issue = rule.evaluateAll(nodes).first() assertEquals("duplicate-content-description", issue.ruleId) assertEquals(A11ySeverity.Warning, issue.severity) assertEquals("WCAG 2.4.6 Headings and Labels (Level AA)", issue.wcagReference) } + + @Test + fun `separate controls with the same label remain duplicates`() { + val first = createNode( + parentNodeId = "toolbar", + depth = 1, + bounds = Rect(0, 0, 100, 100), + contentDescription = "Open item", + ) + val second = first.copy( + nodeId = "second", + bounds = Rect(110, 0, 210, 100), + ) + + assertEquals(2, rule.evaluateAll(listOf(first, second)).size) + } + + @Test + fun `same product label in different collection parents is not a duplicate`() { + val androidPicks = createNode( + nodeId = "android-picks-cupcake", + parentNodeId = "android-picks-row", + depth = 8, + bounds = Rect(66, 528, 534, 1216), + contentDescription = "Cupcake A tag line", + isTouchTarget = true, + ) + val wfhFavourites = androidPicks.copy( + nodeId = "wfh-favourites-cupcake", + parentNodeId = "wfh-favourites-row", + bounds = Rect(66, 2052, 534, 2060), + ) + + assertTrue(rule.evaluateAll(listOf(androidPicks, wfhFavourites)).isEmpty()) + } } diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt index e181aa5..b3359cf 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt @@ -21,6 +21,7 @@ fun createNode( depth: Int = 0, role: A11yRole? = null, nodeId: String = "node-${nodeIdSeq.incrementAndGet()}", + parentNodeId: String? = null, ): A11yNode = A11yNode( nodeId = nodeId, composableName = composableName, @@ -34,4 +35,5 @@ fun createNode( depth = depth, role = role, effectiveTouchBounds = effectiveTouchBounds, + parentNodeId = parentNodeId, ) diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt index 2081986..8d27465 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt @@ -12,8 +12,8 @@ class TouchTargetOverlapRuleTest { @Test fun `overlapping effective targets report each affected node once`() { - val first = target("first", Rect(0, 0, 48, 48)) - val second = target("second", Rect(40, 0, 88, 48)) + val first = target("first", Rect(0, 0, 48, 48), Rect(0, 0, 48, 48)) + val second = target("second", Rect(40, 0, 88, 48), Rect(40, 0, 88, 48)) val issues = rule.evaluateAll(listOf(first, second)) @@ -23,21 +23,26 @@ class TouchTargetOverlapRuleTest { @Test fun `adjacent targets that only share an edge pass`() { - val first = target("first", Rect(0, 0, 48, 48)) - val second = target("second", Rect(48, 0, 96, 48)) + val first = target("first", Rect(0, 0, 48, 48), Rect(0, 0, 48, 48)) + val second = target("second", Rect(48, 0, 96, 48), Rect(48, 0, 96, 48)) assertTrue(rule.evaluateAll(listOf(first, second)).isEmpty()) } @Test fun `non-interactive merged and missing bounds nodes are ignored`() { - val valid = target("valid", Rect(0, 0, 48, 48)) + val valid = target("valid", Rect(0, 0, 48, 48), Rect(0, 0, 48, 48)) val nonInteractive = createNode( nodeId = "non-interactive", isTouchTarget = false, effectiveTouchBounds = Rect(0, 0, 48, 48), ) - val merged = target("merged", Rect(0, 0, 48, 48), isMergedDescendant = true) + val merged = target( + "merged", + Rect(0, 0, 48, 48), + Rect(0, 0, 48, 48), + isMergedDescendant = true, + ) val missingBounds = createNode(nodeId = "missing", isTouchTarget = true) assertTrue(rule.evaluateAll(listOf(valid, nonInteractive, merged, missingBounds)).isEmpty()) @@ -45,9 +50,9 @@ class TouchTargetOverlapRuleTest { @Test fun `one node overlapping multiple targets produces one aggregated issue`() { - val center = target("center", Rect(20, 0, 68, 48)) - val left = target("left", Rect(0, 0, 40, 48)) - val right = target("right", Rect(60, 0, 108, 48)) + val center = target("center", Rect(20, 0, 68, 48), Rect(20, 0, 68, 48)) + val left = target("left", Rect(0, 0, 40, 48), Rect(0, 0, 40, 48)) + val right = target("right", Rect(60, 0, 108, 48), Rect(60, 0, 108, 48)) val centerIssue = rule.evaluateAll(listOf(center, left, right)) .single { it.affectedNode.nodeId == "center" } @@ -57,14 +62,44 @@ class TouchTargetOverlapRuleTest { assertEquals(null, centerIssue.wcagReference) } + @Test + fun `expanded touch bounds do not flag visually separate controls`() { + val first = target( + "first", + visualBounds = Rect(0, 0, 40, 40), + effectiveBounds = Rect(0, 0, 48, 48), + ) + val second = target( + "second", + visualBounds = Rect(40, 0, 80, 40), + effectiveBounds = Rect(32, 0, 80, 48), + ) + + assertTrue(rule.evaluateAll(listOf(first, second)).isEmpty()) + } + + @Test + fun `identical shared-transition targets are treated as one logical control`() { + val original = target( + "original", + visualBounds = Rect(0, 0, 170, 250), + effectiveBounds = Rect(0, 0, 170, 250), + ).copy(contentDescription = "Cupcake A tag line") + val transitionCopy = original.copy(nodeId = "transition-copy") + + assertTrue(rule.evaluateAll(listOf(original, transitionCopy)).isEmpty()) + } + private fun target( id: String, - bounds: Rect, + visualBounds: Rect, + effectiveBounds: Rect, isMergedDescendant: Boolean = false, ) = createNode( nodeId = id, + bounds = visualBounds, isTouchTarget = true, - effectiveTouchBounds = bounds, + effectiveTouchBounds = effectiveBounds, isMergedDescendant = isMergedDescendant, ) } diff --git a/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt index d50ace8..f066d63 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt @@ -27,6 +27,9 @@ class A11yScannerInitializer : Initializer { */ override fun create(context: Context) { val appContext = context.applicationContext + // Seed the context before the debug check so public API calls can distinguish a release + // build from an early call made before the first activity controller is installed. + ComposeA11yScanner.initialize(appContext) if (!appContext.isDebuggable()) return val application = appContext as? Application ?: return diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt index 680a691..57db0b9 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt @@ -41,7 +41,9 @@ import com.composea11yscanner.ui.A11yScannerController import com.composea11yscanner.ui.IssueDetailPanel import com.composea11yscanner.ui.ScanSummaryBar import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest /** * Top-level public API for the Compose Accessibility Scanner. @@ -80,6 +82,12 @@ object ComposeA11yScanner { /** Set during [install] so that [scan] can perform the debug-build check without a [Context]. */ @Volatile private var cachedAppContext: Context? = null + /** + * Controller for the most recently installed activity. Keeping this as state allows callers + * to subscribe before the automatic activity-resume installation has completed. + */ + private val activeController = MutableStateFlow(null) + // ── Public API ───────────────────────────────────────────────────────────── /** @@ -121,6 +129,7 @@ object ComposeA11yScanner { activity.addContentView(overlayView, ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)) entries[activity] = InstallEntry(controller, overlayView) + activeController.value = controller activity.lifecycle.addObserver(AutoUninstallObserver(activity)) } @@ -138,19 +147,25 @@ object ComposeA11yScanner { fun uninstall(activity: ComponentActivity) { requireDebugBuild(activity) entries.remove(activity)?.detach() + activeController.value = entries.values.lastOrNull()?.controller } /** * Returns a [Flow] of [ScannerState] for the most recently installed activity. * * The backing [kotlinx.coroutines.flow.SharedFlow] has `replay = 1`, so late subscribers - * immediately receive the current state. Returns an empty flow when no scanner is installed. + * immediately receive the current state. The returned flow can be collected before automatic + * installation; it begins forwarding state when an activity scanner becomes available. * - * @throws IllegalStateException in non-debug builds or if called before [install]. + * @throws IllegalStateException in non-debug builds, or when automatic initialization is + * disabled and this is called before [install]. */ + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) fun scan(): Flow { requireDebugBuild() - return entries.values.lastOrNull()?.controller?.stateFlow ?: emptyFlow() + return activeController.flatMapLatest { controller -> + controller?.stateFlow ?: emptyFlow() + } } /** @@ -159,7 +174,8 @@ object ComposeA11yScanner { * This is useful for consumer-side triggers such as long press, shake, or debug menu actions. * Returns an empty flow when no scanner is installed. * - * @throws IllegalStateException in non-debug builds or if called before [install]. + * @throws IllegalStateException in non-debug builds, or when automatic initialization is + * disabled and this is called before [install]. */ fun triggerScan(): Flow { requireDebugBuild() @@ -177,6 +193,11 @@ object ComposeA11yScanner { } } + /** Seeds the application context before activity installation when AndroidX Startup is used. */ + internal fun initialize(context: Context) { + cachedAppContext = context.applicationContext + } + // Overload for scan(), which has no Context parameter. private fun requireDebugBuild() { val ctx = cachedAppContext @@ -249,6 +270,7 @@ object ComposeA11yScanner { override fun onDestroy(owner: LifecycleOwner) { // entries[activity] may already be null if uninstall() was called manually first. entries.remove(activity)?.detach() + activeController.value = entries.values.lastOrNull()?.controller } } } diff --git a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt index 45f4071..c932c4f 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt @@ -91,6 +91,8 @@ object ScanResultExporter { append("{") appendJsonPair("nodeId", nodeId) append(", ") + appendJsonPair("parentNodeId", parentNodeId) + append(", ") appendJsonPair("composableName", composableName) append(", ") appendJsonPair("bounds", bounds) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt index 6fa3905..fc9726b 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt @@ -31,7 +31,13 @@ class A11yNodeExtractor { */ fun extract(rootNode: SemanticsNode): List { val result = mutableListOf() - visit(node = rootNode, depth = 0, isParentMerging = false, result = result) + visit( + node = rootNode, + parentNodeId = null, + depth = 0, + isParentMerging = false, + result = result, + ) return result } @@ -49,21 +55,38 @@ class A11yNodeExtractor { private fun visit( node: SemanticsNode, + parentNodeId: String?, depth: Int, isParentMerging: Boolean, result: MutableList, ) { - result.add(node.toA11yNode(depth = depth, isMergedDescendant = isParentMerging)) + result.add( + node.toA11yNode( + parentNodeId = parentNodeId, + depth = depth, + isMergedDescendant = isParentMerging, + ), + ) // Children of a merging node are merged descendants; propagate the flag downward. val mergingForChildren = isParentMerging || node.config.isMergingSemanticsOfDescendants node.children.forEach { child -> - visit(node = child, depth = depth + 1, isParentMerging = mergingForChildren, result = result) + visit( + node = child, + parentNodeId = node.id.toString(), + depth = depth + 1, + isParentMerging = mergingForChildren, + result = result, + ) } } // --- mapping --- - private fun SemanticsNode.toA11yNode(depth: Int, isMergedDescendant: Boolean): A11yNode { + private fun SemanticsNode.toA11yNode( + parentNodeId: String?, + depth: Int, + isMergedDescendant: Boolean, + ): A11yNode { val composeRole = config.getOrNull(SemanticsProperties.Role) val isTextInput = config.contains(SemanticsActions.SetText) val isTouchTarget = config.contains(SemanticsActions.OnClick) @@ -100,6 +123,7 @@ class A11yNodeExtractor { effectiveTouchBounds = touchBoundsInRoot .takeIf { isTouchTarget } ?.toCoreRect(), + parentNodeId = parentNodeId, ) }