Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -29,4 +31,5 @@ data class A11yNode(
val depth: Int,
val role: A11yRole? = null,
val effectiveTouchBounds: Rect? = null,
val parentNodeId: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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<A11yNode>): List<A11yIssue> =
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
Expand All @@ -38,4 +39,5 @@ class DuplicateContentDescriptionRule : BaseScanRule() {
)
}
}
.toList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ class TouchTargetOverlapRule : BaseScanRule() {
node.isTouchTarget &&
!node.isMergedDescendant &&
node.effectiveTouchBounds?.isEmpty() == false
}
}.distinctBy { it.logicalIdentity() }
val overlapsByNodeId = mutableMapOf<String, MutableSet<String>>()

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)
Expand All @@ -46,6 +51,14 @@ class TouchTargetOverlapRule : BaseScanRule() {
}
}

private fun A11yNode.logicalIdentity(): List<Any?> = listOf(
composableName,
bounds,
effectiveTouchBounds,
contentDescription,
role,
)

private fun Rect.overlaps(other: Rect): Boolean =
left < other.right &&
right > other.left &&
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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'"))
Expand All @@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,4 +35,5 @@ fun createNode(
depth = depth,
role = role,
effectiveTouchBounds = effectiveTouchBounds,
parentNodeId = parentNodeId,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -23,31 +23,36 @@ 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())
}

@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" }
Expand All @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class A11yScannerInitializer : Initializer<Unit> {
*/
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
Expand Down
Loading
Loading