Skip to content

mohdaquib/ComposeA11yScanner

Repository files navigation

Compose A11y Scanner

Build and Test API Docs JitPack

Runtime accessibility scanner that overlays issues directly on your Compose UI

Annotated GIF showing the Compose A11y Scanner issue summary, view highlights, and issue detail sheet in the sample app

Architecture

flowchart LR
    SemanticsTree["SemanticsTree"] --> Extractor[":scanner-ui<br/>A11yNodeExtractor"]
    Extractor --> ColorExtractor["ColorExtractor"]
    ColorExtractor --> Nodes["A11yNode list"]
    Extractor --> Nodes
    Nodes --> Core[":scanner-core<br/>A11yScanEngine"]
    Rules[":scanner-rules<br/>Built-in A11yRule implementations"] --> Core
    Core --> Result["ScanResult / ScannerState"]
    Result --> Overlay[":scanner-ui<br/>Overlay, summary, report sheet"]
Loading

:scanner-core owns the scan engine, models, result state, and A11yRule contract. :scanner-rules contains the built-in rules. :scanner-ui reads the Compose semantics tree, samples colors through ColorExtractor, runs scans, and draws issue overlays on top of the host UI.

Installation

repositories { maven("https://jitpack.io") }
dependencies { debugImplementation("com.github.mohdaquib.ComposeA11yScanner:scanner-ui:v1.0.0") }

Use a debug-only dependency because the scanner is intended for development builds. :scanner-ui brings :scanner-core and :scanner-rules transitively.

Integration

A11yScannerScaffold

Wrap the screen you want to inspect with A11yScannerScaffold. Provide an A11yScannerController that can extract nodes from your current Compose semantics tree.

@Composable
fun DebugScannerScreen(
    scannerController: A11yScannerController,
    content: @Composable () -> Unit,
) {
    val config = remember {
        ScannerConfig(
            enabledRules = ScannerRules.allRuleIds().toSet(),
            autoScan = false,
        )
    }

    A11yScannerScaffold(
        scannerController = scannerController,
        config = config,
        modifier = Modifier.fillMaxSize(),
    ) {
        content()
    }
}

The scaffold renders your content first, then draws issue highlight boxes, the scan summary bar, and the issue detail sheet above it.

Shake To Scan

Call scanOnShake() from a composable that is active while the scanned screen is visible.

scanOnShake(
    enabled = true,
    onScanRequested = {
        scannerController.startScan()
    },
)

Manual Trigger

Wire a debug menu item, toolbar button, or test-only action directly to the controller.

IconButton(onClick = { scannerController.startScan() }) {
    Icon(
        imageVector = Icons.Default.Search,
        contentDescription = "Scan accessibility",
    )
}

If you use the top-level activity overlay API, call ComposeA11yScanner.triggerScan() instead.

ComposeA11yScanner.triggerScan()

Built-In Rules

See RULES.md for complete behavior, fixes, WCAG references, and examples.

Rule ID Name Severity Details
touch-target-size Touch Target Size Error RULES.md
missing-content-description Missing Content Description Error RULES.md
duplicate-content-description Duplicate Content Description Warning RULES.md
focus-order Focus Order Error RULES.md
text-scaling Text Scaling Warning RULES.md
image-text-overlay Image With Text Overlay Warning RULES.md
clickable-role Clickable Role Error RULES.md

Custom Rules

Create a rule by implementing A11yRule. Use a stable ruleId, assign a severity, and return an A11yIssue only when the node fails your check.

class MissingTestTagRule : A11yRule {
    override val ruleId = "missing-test-tag"
    override val ruleName = "Missing Test Tag"
    override val severity = A11ySeverity.Warning
    override val wcagReference: String? = null

    override fun evaluate(node: A11yNode): A11yIssue? {
        if (!node.isTouchTarget || node.isMergedDescendant) return null
        if (node.composableName.contains("TestTag", ignoreCase = true)) return null

        return A11yIssue(
            issueId = "${ruleId}_${node.nodeId}",
            severity = severity,
            ruleId = ruleId,
            ruleName = ruleName,
            affectedNode = node,
            message = "Interactive node does not expose a stable test tag.",
            howToFix = "Add Modifier.testTag() to make this control easier to identify in tests.",
            wcagReference = wcagReference,
        )
    }
}

Register custom rules on the controller:

val scannerController = A11yScannerController(
    nodeProvider = { extractNodesFromCurrentSemanticsTree() },
    screenDensity = density,
).withRules(MissingTestTagRule())

Custom rule IDs are automatically enabled by A11yScannerController.withRules(...) before each scan.

Packages

 
 
 

Contributors