Skip to content

Repository files navigation

ComposeA11yScanner

Catch accessibility issues while building Jetpack Compose UIs - including rendered text-contrast problems that are not available from the Compose semantics tree alone.

Featured in Android Weekly Featured in Jetpack Compose Newsletter License: Apache 2.0 Build and Test API Docs JitPack

ComposeA11yScanner is a debug-first runtime scanner that finds accessibility issues in Jetpack Compose and highlights them directly on the rendered UI. Non-debuggable builds are denied by default and can opt in explicitly for trusted internal use.

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

Why ComposeA11yScanner?

  • Immediate visual feedback - issues are outlined where they occur on the screen.
  • Semantics and rendered analysis - rules inspect Compose semantics, while text contrast is estimated from a captured Compose host.
  • Actionable guidance - every finding includes its severity, WCAG reference, and a suggested fix.
  • Minimal setup - AndroidX Startup handles activity tracking and installation.
  • Default-deny integration - debug builds work automatically unless explicitly disabled; trusted builds must opt in.
  • Extensible rules - use the bundled rules or add checks for your own accessibility standards.

What's new in 3.0.0

Version 3.0.0 is available on JitPack. See the release notes for highlights and breaking API changes.

  • Runtime availability control: ComposeA11yScanner.toggleScanner(enabled) enables or disables the scanner, including explicit opt-in for trusted non-debuggable builds.
  • Inspection controls: switch between inspecting issue highlights and interacting with the app without uninstalling the scanner.
  • More reliable scans: improved activity routing, lifecycle cleanup, Compose host selection, semantic stability checks, and stale-result invalidation.
  • More accurate findings: improved duplicate-label and rendered text-contrast checks. The sample Form preserves offscreen traversal context so the Amount focus-order issue is detected.
  • Android compatibility: semantics checks support API 24+; rendered-pixel contrast analysis uses hardware-compatible capture on API 26+.

3.0.0 changes public API signatures. Read Migrating from 2.1.0 before upgrading, especially if you construct controllers, embed the scaffold, or distribute libraries using the scanner.

Changes since 2.1.0

Contents

Migrating from 2.1.0

Update the dependency to 3.0.0 and rebuild all consuming modules and libraries together. Public constructor and method signatures changed; this is not a binary-compatible replacement for 2.1.0. Many existing Kotlin calls still compile because the added parameters have defaults.

API Change and migration
A11yScannerController nodeProvider is now suspend () -> List<A11yNode> and runs on the main dispatcher. Inline lambdas can stay as they are; wrap existing synchronous providers as { existingProvider() }. Keep UI extraction on the main thread and move any expensive non-UI work to an appropriate dispatcher. An optional ruleNodeOverridesProvider supplies nodes for individual rules.
A11yScannerScaffold Adds summaryBarTopOffset and inspectionToggleBottomOffset before content. Use named arguments and a trailing content lambda; a previous fifth positional content argument must be updated.
A11yScanEngine.scan Adds optional ruleNodeOverrides. Ordinary Kotlin scan(nodes) calls still work after recompilation; Java callers must supply the additional map argument.
A11yNode Adds label, traversal-group, unclipped-bounds, and visibility metadata with defaults. Recompile code that constructs or copies nodes, and update Java constructor calls to include the new fields.
RenderedTextContrastAnalyzer analyze(nodes) is deprecated. For rendered analysis, use analyze(nodes, bitmap) with captureRenderedView(window, view) on API 26+, and recycle the bitmap afterward. On API 24–25, retain semantic nodes without pixel enrichment.

Automatic debug-only integrations retain their default setup. toggleScanner(true) is needed only when explicitly enabling a non-debuggable build or re-enabling a disabled scanner. toggleScanner(false) removes overlays and blocks scanning; uninstall(activity) remains safe to call afterward.

For custom node providers, return visible nodes normally. When using offscreen nodes as traversal context in a rule override, mark them isVisibleToUser = false so the engine does not report issues on those nodes. Preserve the full traversal sequence and use unclipped layout bounds for that analysis.

Quick start

1. Add the dependency

ComposeA11yScanner supports Android API 24 and newer. Add JitPack to dependency resolution and add the scanner to the debug variant only:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://jitpack.io")
    }
}

// app/build.gradle.kts
dependencies {
    debugImplementation("com.github.mohdaquib.ComposeA11yScanner:scanner-ui:3.0.0")
}

That is all the integration required. The merged scanner manifest declares an AndroidX Startup initializer that runs before Application.onCreate, registers activity lifecycle callbacks, and attaches the overlay for every resumed ComponentActivity in a debuggable app.

Important

debugImplementation remains the recommended setup and keeps scanner code out of release builds.

Automatic startup and scanner availability

Before the first toggleScanner() call, debuggable builds are enabled automatically and non-debuggable builds are denied. toggleScanner(true) enables every build; toggleScanner(false) removes every scanner overlay and blocks automatic installation, manual installation, and public scan APIs in every build until enabled again.

Apps whose policy defaults to disabled must call toggleScanner(false) synchronously from Application.onCreate, before the first activity resumes. Do not wait for asynchronous endpoint, account, or remote-config resolution: a debuggable activity may otherwise install and auto-scan first. Runtime disable intentionally keeps the Startup callbacks registered and tracks resumed activities so toggleScanner(true) can install immediately. Use the hard opt-out when a variant must perform no scanner startup or activity-tracking work.

Trusted non-debuggable builds

Use implementation only when a trusted internal build must run the scanner without being Android-debuggable:

dependencies {
    implementation("com.github.mohdaquib.ComposeA11yScanner:scanner-ui:3.0.0")
}

Override scanner availability from the main thread whenever the consuming app's policy changes:

ComposeA11yScanner.toggleScanner(enabled = scannerEnabled)

scannerEnabled is owned by the consuming app and should default to false. Derive it from a positive allowlist and prefer an internal flavor/source set when available. The library does not know about the consuming app's endpoints or build policy. Calling toggleScanner(true) before an activity resumes installs on resume; calling it afterward installs immediately on every tracked resumed activity.

2. Trigger a scan

For debug-only integration, call the API from src/debug. For trusted non-debuggable builds, place the trigger in src/main or the trusted variant's source set:

import com.composea11yscanner.ComposeA11yScanner

ComposeA11yScanner.triggerScan()

Trusted builds must invoke direct triggers only while the same scannerEnabled value passed to toggleScanner() is true:

if (scannerEnabled) 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, then forwards its state.

Optional: shake to scan

Add scanOnShake() to a composable compiled into the enabled variant: src/debug for debug-only integration, or src/main/the trusted source set for non-debuggable integration:

import com.composea11yscanner.triggers.scanOnShake

@Composable
fun App() {
    scanOnShake(enabled = scannerEnabled)
    AppContent()
}

For trusted builds, derive scannerEnabled from the same condition passed to toggleScanner() so triggers stop before production permission is disabled.

All built-in rules are enabled by default, and the overlay is removed when its activity is destroyed. For debug-only integration, keep direct scanner imports in src/debug; dependencies added with debugImplementation are intentionally unavailable to release source sets.

Configuration

Hard opt-out: disable automatic initialization

For a variant that must perform no scanner startup, metadata reads, lifecycle callback registration, or activity tracking, remove the scanner initializer in that variant's manifest:

<manifest xmlns:tools="http://schemas.android.com/tools">
    <application>
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            tools:node="merge">
            <meta-data
                android:name="com.composea11yscanner.A11yScannerInitializer"
                tools:node="remove" />
        </provider>
    </application>
</manifest>

This prevents AndroidX Startup from discovering A11yScannerInitializer. With the initializer removed, toggleScanner(true) only grants permission; the app must call install() manually for each activity.

Automatic scanner configuration

Optional manifest metadata controls the auto-installed scanner:

<application>
    <meta-data
        android:name="a11y_scanner_min_contrast"
        android:value="4.5" />
    <meta-data
        android:name="a11y_scanner_auto_scan"
        android:value="false" />
</application>

Manual installation

Use manual installation only when you need a programmatic ScannerConfig. Apply the hard opt-out above, then install after setContent:

setContent { App() }

ComposeA11yScanner.install(
    activity = this,
    config = ScannerConfig(
        enabledRules = ScannerRules.allRuleIds().toSet(),
        minContrastRatio = 4.5f,
        autoScan = false,
    ),
)

Do not combine automatic and manual installation. Repeated installation on the same activity is ignored, but keeping one ownership path makes configuration predictable. With multiple manual activities, global APIs target the latest surviving installation and fall back after removal.

For Navigation Compose, provide the current route when installing manually. An explicit key reliably invalidates stale results even when two destinations have the same semantics structure:

ComposeA11yScanner.install(
    activity = this,
    destinationKeyProvider = {
        navController.currentBackStackEntry?.destination?.route
    },
)

If a navigation framework cannot expose a route provider, automatic host/semantics detection remains enabled. A custom navigator can also invalidate the current result explicitly:

ComposeA11yScanner.notifyScreenChanged()

Inspection controls

After a scan completes, select Interact with app to hide the highlights, summary, and issue panel while using the underlying UI. Select Resume issue inspection to show the results again. This controls the inspection UI; use toggleScanner(false) when you want to disable the scanner.

Embedded scaffold

A11yScannerScaffold is the advanced API for apps that want the scanner UI inside their own Compose hierarchy or need a custom node provider. It requires an A11yScannerController; most integrations should use the automatic activity overlay above.

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

Built-in rules

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

Rule Severity Detects
Touch Target Overlap Warning Interactive elements whose effective touch and visual bounds overlap.
Missing Content Description Error Interactive or image-like elements without a readable label.
Duplicate Content Description Warning Distinct controls in the same logical scope that expose the same description.
Focus Order Error Focus traversal that conflicts with the expected visual reading order.
Text Scaling Warning Text likely to clip or overflow when the user increases font size.
Image With Text Overlay Warning Text overlapping an image, where dynamic content can create contrast risk.
Clickable Role Error Clickable elements without an appropriate semantic role or label.
Text Contrast Warning Confidently measured rendered text below the configured contrast ratio.

Text contrast and known limitations

Rendered-pixel analysis requires Android API 26 or newer. On API 24–25, the built-in integration and sample continue semantics-based checks and skip rendered contrast measurements. Custom capture code must apply the same API guard.

TextContrastRule complements semantics-based checks with rendered-pixel analysis. The scanner captures the selected Compose host once, samples enabled semantic Text nodes, and applies the WCAG relative-luminance formula when it can confidently identify a foreground and a solid-looking background. The default minimum ratio is 4.5:1 and can be changed through ScannerConfig or manifest metadata.

The estimator intentionally skips uncertain results instead of guessing. Keep these boundaries in mind when interpreting a scan:

  • Photos, gradients, textured surfaces, and other visually ambiguous text backgrounds may be skipped.
  • Text drawn on a Canvas, embedded in an image, or otherwise absent from Compose semantics is not discovered through OCR.
  • One configurable ratio is applied to measured text; separate large-text thresholds are not inferred.
  • A scan represents the currently rendered theme, state, content, and destination. Scan every state that users can encounter.
  • Automated findings complement, but do not replace, testing with TalkBack, font scaling, keyboard or switch access, and human accessibility review.

If a result appears incorrect, include the affected screen, scanner version, exported scan result, and a minimal reproduction when opening an issue.

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.

Architecture

flowchart LR
    SemanticsTree["SemanticsTree"] --> Extractor[":scanner-ui<br/>A11yNodeExtractor"]
    Extractor --> Nodes["A11yNode list"]
    Nodes --> Core[":scanner-core<br/>A11yScanEngine"]
    Rules[":scanner-rules<br/>Built-in and custom rules"] --> Core
    Core --> Result["ScanResult / ScannerState"]
    Result --> Overlay[":scanner-ui<br/>Overlay and issue details"]
Loading

:scanner-core owns the scan engine and public models. :scanner-rules contains built-in rules. :scanner-ui handles Android/Compose integration, node extraction, triggers, and the overlay.

Support and contributions

Questions, bug reports, rule proposals, and pull requests are welcome. Use GitHub Issues and choose a title that identifies whether the report is a false positive, false negative, integration problem, or feature request.

For scanner-result problems, please include:

  • ComposeA11yScanner version and Android version.
  • Navigation and hosting setup, such as Navigation Compose, Fragments, or nested ComposeViews.
  • A screenshot and exported scan-result JSON with sensitive information removed.
  • Expected behavior, actual behavior, and reproduction steps.

See the sample app for broken and corrected examples of the bundled rules, and browse the API documentation for public types and functions.

Featured in

ComposeA11yScanner has been featured in the Jetpack Compose Newsletter and Android Weekly.

License

ComposeA11yScanner is available under the Apache License 2.0.

Releases

Packages

Contributors

Languages