diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e7c561f..0f29b7e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,6 +6,7 @@ junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
+appStartup = "1.2.0"
kotlin = "2.3.21"
composeBom = "2024.12.01"
hilt = "2.54"
@@ -23,6 +24,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "appStartup" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index d70c5c6..017cbce 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -10,6 +10,16 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ComposeA11yScanner">
+
+
+
+
- Text(text = "ComposeA11yScanner Sample", modifier = Modifier.padding(innerPadding))
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Scan Trigger Demo",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
+ val useWideLayout = maxWidth >= 720.dp
+ if (useWideLayout) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ TriggerOptionCard(
+ title = "Manual",
+ body = "Button tap",
+ modifier = Modifier.weight(1f),
+ ) {
+ Button(
+ onClick = { ComposeA11yScanner.triggerScan() },
+ contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
+ ) {
+ Text("Run Scan")
+ }
+ }
+ TriggerOptionCard(
+ title = "Long Press",
+ body = "Hold this panel",
+ modifier = Modifier
+ .weight(1f)
+ .scanOnLongPress(),
+ ) {
+ TriggerTarget("Hold")
+ }
+ ShakeTriggerCard(
+ enabled = shakeEnabled,
+ onEnabledChange = { shakeEnabled = it },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ } else {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ TriggerOptionCard(title = "Manual", body = "Button tap") {
+ Button(onClick = { ComposeA11yScanner.triggerScan() }) {
+ Text("Run Scan")
+ }
+ }
+ TriggerOptionCard(
+ title = "Long Press",
+ body = "Hold this panel",
+ modifier = Modifier.scanOnLongPress(),
+ ) {
+ TriggerTarget("Hold")
+ }
+ ShakeTriggerCard(
+ enabled = shakeEnabled,
+ onEnabledChange = { shakeEnabled = it },
+ )
+ }
+ }
+ }
+
+ DemoScanContent()
+ }
+ }
+}
+
+@Composable
+private fun TriggerOptionCard(
+ title: String,
+ body: String,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Card(
+ modifier = modifier.height(164.dp),
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(body, style = MaterialTheme.typography.bodyMedium)
+ }
+ content()
+ }
+ }
+}
+
+@Composable
+private fun ShakeTriggerCard(
+ enabled: Boolean,
+ onEnabledChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ TriggerOptionCard(
+ title = "Shake",
+ body = if (enabled) "Sensor armed" else "Sensor off",
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("Enabled", style = MaterialTheme.typography.labelLarge)
+ Switch(checked = enabled, onCheckedChange = onEnabledChange)
+ }
+ }
+}
+
+@Composable
+private fun TriggerTarget(label: String) {
+ Box(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.SemiBold)
+ }
+}
+
+@Composable
+private fun DemoScanContent() {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Inspectable Content", style = MaterialTheme.typography.titleMedium)
+ Text("These controls intentionally include a few accessibility issues for the scanner.")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .background(Color(0xFF1976D2), RoundedCornerShape(4.dp))
+ .clickable { },
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("Small clickable swatch")
+ }
+ Text(
+ text = "Low contrast text sample",
+ color = Color(0xFFBDBDBD),
+ modifier = Modifier.background(Color.White),
+ )
+ }
}
}
@Preview(showBackground = true)
@Composable
-fun SampleAppPreview() {
- SampleApp()
+fun ScanTriggerDemoPreview() {
+ MaterialTheme {
+ ScanTriggerDemo()
+ }
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
index d85a833..9dc96f0 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
@@ -5,4 +5,5 @@ data class ScannerConfig(
val minTouchTargetDp: Int = 48,
val minContrastRatio: Float = 4.5f,
val debugOverlay: Boolean = true,
+ val autoScan: Boolean = true,
)
diff --git a/scanner-ui/build.gradle.kts b/scanner-ui/build.gradle.kts
index f4ace5c..237b79a 100644
--- a/scanner-ui/build.gradle.kts
+++ b/scanner-ui/build.gradle.kts
@@ -38,6 +38,8 @@ dependencies {
implementation(project(":scanner-rules"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.startup.runtime)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
diff --git a/scanner-ui/src/main/AndroidManifest.xml b/scanner-ui/src/main/AndroidManifest.xml
index b2d3ea1..da2812b 100644
--- a/scanner-ui/src/main/AndroidManifest.xml
+++ b/scanner-ui/src/main/AndroidManifest.xml
@@ -1,2 +1,16 @@
-
+
+
+
+
+
+
+
+
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt
new file mode 100644
index 0000000..cecb911
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt
@@ -0,0 +1,109 @@
+package com.composea11yscanner
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.startup.Initializer
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.rules.ScannerRules
+
+/**
+ * AndroidX App Startup initializer for installing [ComposeA11yScanner] in debug builds.
+ *
+ * Configure the scanner from the app manifest with:
+ * - `a11y_scanner_min_touch_target`
+ * - `a11y_scanner_min_contrast`
+ * - `a11y_scanner_auto_scan`
+ */
+class A11yScannerInitializer : Initializer {
+
+ override fun create(context: Context) {
+ val appContext = context.applicationContext
+ if (!appContext.isDebuggable()) return
+
+ val application = appContext as? Application ?: return
+ val config = appContext.readScannerConfig()
+
+ application.registerActivityLifecycleCallbacks(
+ object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityResumed(activity: Activity) {
+ val componentActivity = activity as? ComponentActivity ?: return
+ componentActivity.window.decorView.post {
+ if (componentActivity.isDestroyed) return@post
+ ComposeA11yScanner.install(componentActivity, config)
+ }
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
+ override fun onActivityStarted(activity: Activity) = Unit
+ override fun onActivityPaused(activity: Activity) = Unit
+ override fun onActivityStopped(activity: Activity) = Unit
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
+ override fun onActivityDestroyed(activity: Activity) = Unit
+ },
+ )
+ }
+
+ override fun dependencies(): List>> = emptyList()
+
+ private fun Context.isDebuggable(): Boolean =
+ applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
+
+ private fun Context.readScannerConfig(): ScannerConfig {
+ val metadata = applicationMetadata()
+ val defaults = ScannerConfig(enabledRules = emptySet())
+ return ScannerConfig(
+ enabledRules = ScannerRules.allRuleIds().toSet(),
+ minTouchTargetDp = metadata.intValue(
+ key = META_MIN_TOUCH_TARGET,
+ defaultValue = defaults.minTouchTargetDp,
+ ),
+ minContrastRatio = metadata.floatValue(
+ key = META_MIN_CONTRAST,
+ defaultValue = defaults.minContrastRatio,
+ ),
+ autoScan = metadata.booleanValue(
+ key = META_AUTO_SCAN,
+ defaultValue = defaults.autoScan,
+ ),
+ )
+ }
+
+ private fun Context.applicationMetadata(): Bundle {
+ val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
+ return appInfo.metaData ?: Bundle.EMPTY
+ }
+
+ private fun Bundle.intValue(key: String, defaultValue: Int): Int =
+ when (val value = get(key)) {
+ is Int -> value
+ is Number -> value.toInt()
+ is String -> value.toIntOrNull() ?: defaultValue
+ else -> defaultValue
+ }
+
+ private fun Bundle.floatValue(key: String, defaultValue: Float): Float =
+ when (val value = get(key)) {
+ is Float -> value
+ is Number -> value.toFloat()
+ is String -> value.toFloatOrNull() ?: defaultValue
+ else -> defaultValue
+ }
+
+ private fun Bundle.booleanValue(key: String, defaultValue: Boolean): Boolean =
+ when (val value = get(key)) {
+ is Boolean -> value
+ is String -> value.toBooleanStrictOrNull() ?: defaultValue
+ else -> defaultValue
+ }
+
+ private companion object {
+ const val META_MIN_TOUCH_TARGET = "a11y_scanner_min_touch_target"
+ const val META_MIN_CONTRAST = "a11y_scanner_min_contrast"
+ const val META_AUTO_SCAN = "a11y_scanner_auto_scan"
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
new file mode 100644
index 0000000..4df94bb
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
@@ -0,0 +1,316 @@
+package com.composea11yscanner
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import androidx.activity.ComponentActivity
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.platform.AbstractComposeView
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.semantics.SemanticsOwner
+import androidx.compose.ui.unit.Density
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.composea11yscanner.core.model.A11yIssue
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.core.model.ScannerState
+import com.composea11yscanner.rules.ScannerRules
+import com.composea11yscanner.ui.A11yIssueOverlay
+import com.composea11yscanner.ui.A11yNodeExtractor
+import com.composea11yscanner.ui.A11yScannerController
+import com.composea11yscanner.ui.IssueDetailPanel
+import com.composea11yscanner.ui.ScanSummaryBar
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+
+/**
+ * Top-level public API for the Compose Accessibility Scanner.
+ *
+ * [install] attaches a transparent overlay to a [ComponentActivity] that renders the scan
+ * summary bar, issue detail panel, and highlight boxes over flagged nodes. The overlay is
+ * removed automatically when the activity is destroyed, so explicit [uninstall] calls are
+ * only needed if the scanner should stop before destroy.
+ *
+ * **All three methods throw [IllegalStateException] in non-debug builds** (i.e., when
+ * [ApplicationInfo.FLAG_DEBUGGABLE] is absent from the running APK). This is the correct
+ * runtime check for library code; `BuildConfig.DEBUG` in a library module does not reflect
+ * the consuming app's build type.
+ *
+ * Usage:
+ * ```kotlin
+ * // Activity.onCreate — after setContent { … }
+ * ComposeA11yScanner.install(this)
+ *
+ * // Anywhere:
+ * lifecycleScope.launch {
+ * ComposeA11yScanner.scan().collect { state -> /* react to ScannerState */ }
+ * }
+ * ```
+ */
+object ComposeA11yScanner {
+
+ /**
+ * Active scanner entries keyed by activity. [LinkedHashMap] preserves insertion order so
+ * `entries.values.last()` always refers to the most recently installed activity.
+ *
+ * Must only be read/written on the main thread.
+ */
+ private val entries = LinkedHashMap()
+
+ /** Set during [install] so that [scan] can perform the debug-build check without a [Context]. */
+ @Volatile private var cachedAppContext: Context? = null
+
+ // ── Public API ─────────────────────────────────────────────────────────────
+
+ /**
+ * Attaches the accessibility scanner overlay to [activity].
+ *
+ * A default [ScannerConfig] that enables all built-in rules is used when [config] is
+ * omitted. Calling [install] for an activity that is already installed is a no-op.
+ *
+ * Must be called on the main thread, typically in `Activity.onCreate` after `setContent`.
+ *
+ * @throws IllegalStateException in non-debug builds.
+ */
+ fun install(
+ activity: ComponentActivity,
+ config: ScannerConfig = ScannerConfig(enabledRules = ScannerRules.allRuleIds().toSet()),
+ ) {
+ requireDebugBuild(activity)
+ if (entries.containsKey(activity)) return
+
+ cachedAppContext = activity.applicationContext
+
+ val controller = A11yScannerController(
+ nodeProvider = { extractNodes(activity) },
+ screenDensity = activity.resources.displayMetrics.density,
+ ).configure(config)
+
+ val overlayView = ComposeView(activity).also { view ->
+ view.setViewCompositionStrategy(
+ ViewCompositionStrategy.DisposeOnLifecycleDestroyed(activity),
+ )
+ view.setContent {
+ MaterialTheme {
+ ScannerOverlayContent(controller = controller, config = config)
+ }
+ }
+ }
+ activity.addContentView(overlayView, ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
+
+ entries[activity] = InstallEntry(controller, overlayView)
+ activity.lifecycle.addObserver(AutoUninstallObserver(activity))
+ }
+
+ /**
+ * Removes the scanner overlay from [activity] and cancels the internal coroutine scope.
+ *
+ * This is called automatically when the activity is destroyed. Explicit calls are only
+ * needed to stop the scanner while the activity is still alive.
+ *
+ * Must be called on the main thread.
+ *
+ * @throws IllegalStateException in non-debug builds.
+ */
+ fun uninstall(activity: ComponentActivity) {
+ requireDebugBuild(activity)
+ entries.remove(activity)?.detach()
+ }
+
+ /**
+ * 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.
+ *
+ * @throws IllegalStateException in non-debug builds or if called before [install].
+ */
+ fun scan(): Flow {
+ requireDebugBuild()
+ return entries.values.lastOrNull()?.controller?.stateFlow ?: emptyFlow()
+ }
+
+ /**
+ * Starts a scan for the most recently installed activity and returns the shared state flow.
+ *
+ * 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].
+ */
+ fun triggerScan(): Flow {
+ requireDebugBuild()
+ return entries.values.lastOrNull()?.controller?.startScan() ?: emptyFlow()
+ }
+
+ // ── Debug guard ─────────────────────────────────────────────────────────────
+
+ private fun requireDebugBuild(context: Context) {
+ if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
+ throw IllegalStateException(
+ "ComposeA11yScanner must only be used in debug builds. " +
+ "Remove all ComposeA11yScanner calls before shipping to production.",
+ )
+ }
+ }
+
+ // Overload for scan(), which has no Context parameter.
+ private fun requireDebugBuild() {
+ val ctx = cachedAppContext
+ ?: throw IllegalStateException(
+ "ComposeA11yScanner.scan() called before install(). " +
+ "ComposeA11yScanner may only be used in debug builds.",
+ )
+ requireDebugBuild(ctx)
+ }
+
+ // ── Node extraction ──────────────────────────────────────────────────────────
+
+ // nodeProvider is invoked from Dispatchers.Default (inside A11yScannerController).
+ // Reading the decor-view hierarchy and SemanticsOwner from a background thread is safe for
+ // this debug tool: view-hierarchy reads do not trigger layout/draw callbacks, and the
+ // Compose semantics snapshot is immutable once produced on the main thread.
+ // runCatching provides a last-resort safety net in case of unexpected threading issues.
+ private fun extractNodes(activity: ComponentActivity): List =
+ runCatching { extractNodesUnchecked(activity) }.getOrDefault(emptyList())
+
+ private fun extractNodesUnchecked(activity: ComponentActivity): List {
+ val overlayView = entries[activity]?.overlayView
+ val hostView = (activity.window.decorView as? ViewGroup)
+ ?.findFirstAbstractComposeView(excludeView = overlayView)
+ ?: return emptyList()
+ val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList()
+ return A11yNodeExtractor(Density(activity)).extract(semanticsOwner)
+ }
+
+ private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? {
+ val composeOwnerView = getChildAt(0) ?: return null
+ return runCatching {
+ composeOwnerView.javaClass
+ .getMethod("getSemanticsOwner")
+ .invoke(composeOwnerView) as? SemanticsOwner
+ }.getOrNull()
+ }
+
+ private fun ViewGroup.findFirstAbstractComposeView(
+ excludeView: View?,
+ ): AbstractComposeView? {
+ for (i in 0 until childCount) {
+ val child = getChildAt(i)
+ if (child === excludeView) continue
+ if (child is AbstractComposeView) return child
+ if (child is ViewGroup) {
+ child.findFirstAbstractComposeView(excludeView)?.let { return it }
+ }
+ }
+ return null
+ }
+
+ // ── Inner types ──────────────────────────────────────────────────────────────
+
+ private class InstallEntry(
+ val controller: A11yScannerController,
+ val overlayView: ComposeView,
+ ) {
+ fun detach() {
+ overlayView.disposeComposition()
+ (overlayView.parent as? ViewGroup)?.removeView(overlayView)
+ controller.stopScan()
+ controller.destroy()
+ }
+ }
+
+ private class AutoUninstallObserver(
+ private val activity: ComponentActivity,
+ ) : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ // entries[activity] may already be null if uninstall() was called manually first.
+ entries.remove(activity)?.detach()
+ }
+ }
+}
+
+// ── Overlay composable ──────────────────────────────────────────────────────────
+
+/**
+ * Internal composable rendered inside the overlay [ComposeView] that [ComposeA11yScanner.install]
+ * adds on top of the activity's content. Mirrors the layer structure of [A11yScannerScaffold]
+ * without re-wrapping the host content.
+ */
+@Composable
+private fun ScannerOverlayContent(
+ controller: A11yScannerController,
+ config: ScannerConfig,
+) {
+ var scannerState by remember { mutableStateOf(ScannerState.Idle) }
+ var selectedIssue by remember { mutableStateOf(null) }
+
+ DisposableEffect(Unit) { onDispose { controller.stopScan() } }
+
+ LaunchedEffect(Unit) {
+ controller.stateFlow.collect { state ->
+ scannerState = state
+ if (state is ScannerState.Scanning) selectedIssue = null
+ }
+ }
+
+ LaunchedEffect(config) {
+ controller.configure(config)
+ if (!config.autoScan) {
+ controller.stopScan()
+ return@LaunchedEffect
+ }
+ controller.startScan()
+ }
+
+ val scanResult = (scannerState as? ScannerState.Complete)?.result
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ A11yIssueOverlay(
+ scanResult = scanResult,
+ onIssueSelected = { selectedIssue = it },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ AnimatedVisibility(
+ visible = scannerState !is ScannerState.Idle,
+ enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
+ exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth(),
+ ) {
+ ScanSummaryBar(
+ state = scannerState,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ IssueDetailPanel(
+ issue = selectedIssue,
+ onDismiss = { selectedIssue = null },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt
new file mode 100644
index 0000000..60a2e29
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt
@@ -0,0 +1,234 @@
+package com.composea11yscanner.export
+
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.A11ySeverity
+import com.composea11yscanner.core.model.Color
+import com.composea11yscanner.core.model.DpSize
+import com.composea11yscanner.core.model.Rect
+import com.composea11yscanner.core.model.ScanResult
+import java.util.Locale
+
+object ScanResultExporter {
+
+ fun exportToJson(result: ScanResult): String = buildString {
+ appendLine("{")
+ appendJsonField("scanId", result.scanId, indent = 2, trailingComma = true)
+ appendJsonField("timestamp", result.timestamp, indent = 2, trailingComma = true)
+ appendJsonField("totalNodes", result.totalNodes, indent = 2, trailingComma = true)
+ appendJsonField("passedRules", result.passedRules, indent = 2, trailingComma = true)
+ appendJsonField("failedRules", result.failedRules, indent = 2, trailingComma = true)
+ appendJsonField("errorCount", result.errorCount, indent = 2, trailingComma = true)
+ appendJsonField("warningCount", result.warningCount, indent = 2, trailingComma = true)
+ appendJsonField("infoCount", result.infoCount, indent = 2, trailingComma = true)
+ appendJsonField("overallScore", result.overallScore, indent = 2, trailingComma = true)
+ appendLine(" \"issues\": [")
+ result.issues.forEachIndexed { index, issue ->
+ appendLine(" {")
+ appendJsonField("issueId", issue.issueId, indent = 6, trailingComma = true)
+ appendJsonField("severity", issue.severity.label(), indent = 6, trailingComma = true)
+ appendJsonField("ruleId", issue.ruleId, indent = 6, trailingComma = true)
+ appendJsonField("ruleName", issue.ruleName, indent = 6, trailingComma = true)
+ appendJsonField("message", issue.message, indent = 6, trailingComma = true)
+ appendJsonField("howToFix", issue.howToFix, indent = 6, trailingComma = true)
+ appendJsonNullableField("wcagReference", issue.wcagReference, indent = 6, trailingComma = true)
+ appendLine(" \"affectedNode\": ${issue.affectedNode.toJson()}")
+ append(" }")
+ if (index != result.issues.lastIndex) append(",")
+ appendLine()
+ }
+ appendLine(" ]")
+ append("}")
+ }
+
+ fun exportToMarkdown(result: ScanResult): String = buildString {
+ appendLine("# Compose Accessibility Scan Report")
+ appendLine()
+ appendLine("- Scan ID: `${result.scanId}`")
+ appendLine("- Timestamp: `${result.timestamp}`")
+ appendLine("- Score: ${result.overallScore.formatPercent()}")
+ appendLine("- Nodes scanned: ${result.totalNodes}")
+ appendLine("- Rules passed: ${result.passedRules}")
+ appendLine("- Rules failed: ${result.failedRules}")
+ appendLine("- Issues: ${result.issues.size} (${result.errorCount} errors, ${result.warningCount} warnings, ${result.infoCount} info)")
+ appendLine()
+
+ if (result.issues.isEmpty()) {
+ appendLine("No issues found.")
+ return@buildString
+ }
+
+ appendLine("| Severity | Rule | Node | Message | How to fix | WCAG |")
+ appendLine("| --- | --- | --- | --- | --- | --- |")
+ result.issues.forEach { issue ->
+ appendLine(
+ listOf(
+ issue.severity.label(),
+ issue.ruleName,
+ issue.affectedNode.composableName,
+ issue.message,
+ issue.howToFix,
+ issue.wcagReference ?: "",
+ ).joinToString(prefix = "| ", separator = " | ", postfix = " |") {
+ it.escapeMarkdownTableCell()
+ },
+ )
+ }
+ }
+
+ private fun A11yNode.toJson(): String = buildString {
+ append("{")
+ appendJsonPair("nodeId", nodeId)
+ append(", ")
+ appendJsonPair("composableName", composableName)
+ append(", ")
+ appendJsonPair("bounds", bounds)
+ append(", ")
+ appendJsonPair("contentDescription", contentDescription)
+ append(", ")
+ appendJsonPair("isTouchTarget", isTouchTarget)
+ append(", ")
+ appendJsonPair("touchTargetSize", touchTargetSize)
+ append(", ")
+ appendJsonPair("textColor", textColor)
+ append(", ")
+ append("\"backgroundColors\": [")
+ append(backgroundColors.joinToString { it.toJson() })
+ append("], ")
+ appendJsonPair("isFocusable", isFocusable)
+ append(", ")
+ appendJsonPair("isMergedDescendant", isMergedDescendant)
+ append(", ")
+ appendJsonPair("depth", depth)
+ append(", ")
+ appendJsonPair("role", role?.name)
+ append("}")
+ }
+
+ private fun StringBuilder.appendJsonField(
+ name: String,
+ value: String,
+ indent: Int,
+ trailingComma: Boolean,
+ ) {
+ append(" ".repeat(indent))
+ appendJsonPair(name, value)
+ if (trailingComma) append(",")
+ appendLine()
+ }
+
+ private fun StringBuilder.appendJsonNullableField(
+ name: String,
+ value: String?,
+ indent: Int,
+ trailingComma: Boolean,
+ ) {
+ append(" ".repeat(indent))
+ appendJsonPair(name, value)
+ if (trailingComma) append(",")
+ appendLine()
+ }
+
+ private fun StringBuilder.appendJsonField(
+ name: String,
+ value: Number,
+ indent: Int,
+ trailingComma: Boolean,
+ ) {
+ append(" ".repeat(indent))
+ appendJsonPair(name, value)
+ if (trailingComma) append(",")
+ appendLine()
+ }
+
+ private fun StringBuilder.appendJsonPair(name: String, value: String?) {
+ append("\"")
+ append(name.escapeJson())
+ append("\": ")
+ append(value?.let { "\"${it.escapeJson()}\"" } ?: "null")
+ }
+
+ private fun StringBuilder.appendJsonPair(name: String, value: Number) {
+ append("\"")
+ append(name.escapeJson())
+ append("\": ")
+ append(value)
+ }
+
+ private fun StringBuilder.appendJsonPair(name: String, value: Boolean) {
+ append("\"")
+ append(name.escapeJson())
+ append("\": ")
+ append(value)
+ }
+
+ private fun StringBuilder.appendJsonPair(name: String, value: Rect) {
+ append("\"")
+ append(name.escapeJson())
+ append("\": ")
+ append(value.toJson())
+ }
+
+ private fun StringBuilder.appendJsonPair(name: String, value: DpSize) {
+ append("\"")
+ append(name.escapeJson())
+ append("\": ")
+ append(value.toJson())
+ }
+
+ private fun StringBuilder.appendJsonPair(name: String, value: Color?) {
+ append("\"")
+ append(name.escapeJson())
+ append("\": ")
+ append(value?.toJson() ?: "null")
+ }
+
+ private fun Rect.toJson(): String =
+ """{"left": $left, "top": $top, "right": $right, "bottom": $bottom, "width": $width, "height": $height}"""
+
+ private fun DpSize.toJson(): String =
+ """{"width": ${width.toJsonNumber()}, "height": ${height.toJsonNumber()}}"""
+
+ private fun Color.toJson(): String =
+ """{"value": $value}"""
+
+ private fun Float.toJsonNumber(): String =
+ if (isFinite()) toString() else "null"
+
+ private fun Float.formatPercent(): String =
+ String.format(Locale.US, "%.1f%%", this)
+
+ private fun A11ySeverity.label(): String = when (this) {
+ A11ySeverity.Error -> "Error"
+ A11ySeverity.Warning -> "Warning"
+ A11ySeverity.Info -> "Info"
+ }
+
+ private fun String.escapeJson(): String = buildString {
+ this@escapeJson.forEach { char ->
+ when (char) {
+ '\\' -> append("\\\\")
+ '"' -> append("\\\"")
+ '\b' -> append("\\b")
+ '\u000C' -> append("\\f")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ else -> {
+ if (char.code < 0x20) {
+ append("\\u")
+ append(char.code.toString(16).padStart(4, '0'))
+ } else {
+ append(char)
+ }
+ }
+ }
+ }
+ }
+
+ private fun String.escapeMarkdownTableCell(): String =
+ replace("\\", "\\\\")
+ .replace("|", "\\|")
+ .replace("\r", " ")
+ .replace("\n", " ")
+ .trim()
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt b/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt
new file mode 100644
index 0000000..a3335b1
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt
@@ -0,0 +1,84 @@
+package com.composea11yscanner.triggers
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.os.SystemClock
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import com.composea11yscanner.ComposeA11yScanner
+
+/**
+ * Starts a ComposeA11yScanner scan when this element is long-pressed.
+ *
+ * Kept as a consumer-side convenience extension in `:scanner-ui`; scanner core has no
+ * dependency on gestures, sensors, Android framework callbacks, or Compose modifiers.
+ */
+fun Modifier.scanOnLongPress(
+ enabled: Boolean = true,
+ onScanRequested: () -> Unit = { ComposeA11yScanner.triggerScan() },
+): Modifier {
+ if (!enabled) return this
+ return pointerInput(onScanRequested) {
+ detectTapGestures(onLongPress = { onScanRequested() })
+ }
+}
+
+/**
+ * Starts a ComposeA11yScanner scan when the device is shaken.
+ *
+ * Call from a composable screen that wants shake-triggered scans. If no accelerometer is
+ * present, this quietly does nothing.
+ */
+@Composable
+fun scanOnShake(
+ enabled: Boolean = true,
+ shakeThresholdG: Float = 2.7f,
+ minTriggerIntervalMillis: Long = 1_500L,
+ onScanRequested: () -> Unit = { ComposeA11yScanner.triggerScan() },
+) {
+ val context = LocalContext.current
+ val currentOnScanRequested = rememberUpdatedState(onScanRequested)
+
+ DisposableEffect(context, enabled, shakeThresholdG, minTriggerIntervalMillis) {
+ if (!enabled) return@DisposableEffect onDispose { }
+
+ val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ ?: return@DisposableEffect onDispose { }
+ val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ ?: return@DisposableEffect onDispose { }
+
+ var lastTriggerAt = 0L
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ val gX = event.values[0] / SensorManager.GRAVITY_EARTH
+ val gY = event.values[1] / SensorManager.GRAVITY_EARTH
+ val gZ = event.values[2] / SensorManager.GRAVITY_EARTH
+ val gForce = kotlin.math.sqrt(gX * gX + gY * gY + gZ * gZ)
+ val now = SystemClock.elapsedRealtime()
+
+ if (gForce >= shakeThresholdG && now - lastTriggerAt >= minTriggerIntervalMillis) {
+ lastTriggerAt = now
+ currentOnScanRequested.value()
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
+ }
+
+ sensorManager.registerListener(
+ listener,
+ accelerometer,
+ SensorManager.SENSOR_DELAY_NORMAL,
+ )
+
+ onDispose { sensorManager.unregisterListener(listener) }
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
index fce509f..fc95830 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
@@ -64,6 +64,14 @@ class A11yScannerController(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
+ /**
+ * Hot [Flow] backed by the internal [MutableSharedFlow].
+ *
+ * Observe this to receive [ScannerState] updates without triggering a new scan.
+ * Use [startScan] to both initiate a scan and receive its emissions.
+ */
+ val stateFlow: Flow = _state.asSharedFlow()
+
// --- Builder methods ---
/** Replaces the active configuration. Returns [this] for chaining. */
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
index dc2f2da..edcd376 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
@@ -64,16 +64,23 @@ fun A11yScannerScaffold(
onDispose { scannerController.stopScan() }
}
- // Apply config and (re)start the scan whenever config changes.
- // Clearing selectedIssue at scan start prevents the detail panel from
- // showing stale data while the new result is being produced.
- LaunchedEffect(config) {
- scannerController.configure(config).startScan().collect { state ->
+ LaunchedEffect(Unit) {
+ scannerController.stateFlow.collect { state ->
scannerState = state
if (state is ScannerState.Scanning) selectedIssue = null
}
}
+ // Apply config and (re)start the scan whenever config changes.
+ LaunchedEffect(config) {
+ scannerController.configure(config)
+ if (config.autoScan) {
+ scannerController.startScan()
+ } else {
+ scannerController.stopScan()
+ }
+ }
+
val scanResult = (scannerState as? ScannerState.Complete)?.result
Box(modifier = modifier.fillMaxSize()) {
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt
index 86c5f7b..cd536a8 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt
@@ -1,5 +1,6 @@
package com.composea11yscanner.ui
+import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -18,7 +19,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
@@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -45,6 +49,7 @@ import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.model.DpSize
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScanResult
+import com.composea11yscanner.export.ScanResultExporter
import java.util.Locale
// ─────────────────────────────────────────────────────────────────────────────
@@ -99,6 +104,7 @@ fun ScanReportSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
+ val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var activeFilter by remember { mutableStateOf(null) }
var selectedIssue by remember { mutableStateOf(null) }
@@ -121,6 +127,18 @@ fun ScanReportSheet(
LazyColumn(contentPadding = PaddingValues(bottom = 32.dp)) {
item {
ReportScoreHeader(result = result)
+ ExportReportButton(
+ onClick = {
+ val sendIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/markdown"
+ putExtra(Intent.EXTRA_SUBJECT, "Compose Accessibility Scan Report")
+ putExtra(Intent.EXTRA_TEXT, ScanResultExporter.exportToMarkdown(result))
+ }
+ context.startActivity(
+ Intent.createChooser(sendIntent, "Export Report"),
+ )
+ },
+ )
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
@@ -229,6 +247,24 @@ private fun ReportScoreHeader(result: ScanResult) {
}
}
+@Composable
+private fun ExportReportButton(onClick: () -> Unit) {
+ Button(
+ onClick = onClick,
+ modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 16.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Share,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ text = "Export Report",
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+}
+
@Composable
private fun FilterChipRow(
result: ScanResult,