From 503e59f70b4a6dd0cc0f2ab1d19486c9e1edc1d5 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Sat, 6 Jun 2026 15:28:31 +0530 Subject: [PATCH 1/4] Implement `ComposeA11yScanner` public API and overlay integration - Implement `ComposeA11yScanner` object with `install()`, `uninstall()`, and `scan()` methods to manage the scanner overlay lifecycle. - Add logic to dynamically attach a `ComposeView` overlay to a `ComponentActivity` for rendering scanner UI components. - Implement `A11yNode` extraction logic using `SemanticsOwner` reflection and view hierarchy traversal. - Add a debug-build guard to ensure the scanner is only functional in debuggable APKs. - Introduce `ScannerOverlayContent` to coordinate the `ScanSummaryBar`, `A11yIssueOverlay`, and `IssueDetailPanel`. - Expose `stateFlow` in `A11yScannerController` to allow observation of scanner state updates. - Update `scanner-ui` dependencies to include `androidx.activity.compose`. --- scanner-ui/build.gradle.kts | 1 + .../composea11yscanner/ComposeA11yScanner.kt | 294 ++++++++++++++++++ .../ui/A11yScannerController.kt | 8 + 3 files changed, 303 insertions(+) create mode 100644 scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt diff --git a/scanner-ui/build.gradle.kts b/scanner-ui/build.gradle.kts index f4ace5c..4b9d6f3 100644 --- a/scanner-ui/build.gradle.kts +++ b/scanner-ui/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(project(":scanner-rules")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) 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..bd5b992 --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt @@ -0,0 +1,294 @@ +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() + } + + // ── 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(config) { + controller.configure(config).startScan().collect { state -> + scannerState = state + if (state is ScannerState.Scanning) selectedIssue = null + } + } + + 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/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. */ From 8ffe78a39c80deba7410d18e5aa82b45f8b3e296 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Sat, 6 Jun 2026 15:44:40 +0530 Subject: [PATCH 2/4] Implement automatic scanner initialization and configuration via App Startup - Add `A11yScannerInitializer` using the AndroidX Startup library to automatically install the scanner in debug builds. - Support scanner configuration through Android Manifest meta-data (min touch target, min contrast, and auto-scan). - Add `autoScan` property to `ScannerConfig` and update `ComposeA11yScanner` to respect this setting. - Register `ActivityLifecycleCallbacks` to automatically install the scanner on `ComponentActivity` resumption. --- gradle/libs.versions.toml | 2 + .../core/model/ScannerConfig.kt | 1 + scanner-ui/build.gradle.kts | 1 + scanner-ui/src/main/AndroidManifest.xml | 16 ++- .../A11yScannerInitializer.kt | 109 ++++++++++++++++++ .../composea11yscanner/ComposeA11yScanner.kt | 5 + 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt 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/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 4b9d6f3..237b79a 100644 --- a/scanner-ui/build.gradle.kts +++ b/scanner-ui/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { 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 index bd5b992..94206a2 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt @@ -256,6 +256,11 @@ private fun ScannerOverlayContent( DisposableEffect(Unit) { onDispose { controller.stopScan() } } LaunchedEffect(config) { + if (!config.autoScan) { + controller.stopScan() + return@LaunchedEffect + } + controller.configure(config).startScan().collect { state -> scannerState = state if (state is ScannerState.Scanning) selectedIssue = null From fc163c8e9c8f80f6ecbf05e77e0ad06a32d94851 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Sat, 6 Jun 2026 16:21:41 +0530 Subject: [PATCH 3/4] Implement manual scan triggers and external scan control - Add `triggerScan()` to `ComposeA11yScanner` to allow consumer-side scan requests. - Implement `scanOnLongPress` modifier and `scanOnShake` composable for gesture and sensor-based triggers. - Decouple scanner state collection from configuration updates in `ComposeA11yScanner` and `A11yScannerScaffold`. - Respect `config.autoScan` in `LaunchedEffect` to allow disabling automatic background scanning. - Add sample activity demonstrating manual, long-press, and shake-to-scan functionality. - Include default scanner configuration metadata in the sample `AndroidManifest.xml`. --- sample/src/main/AndroidManifest.xml | 10 + .../sample/SampleActivity.kt | 217 +++++++++++++++++- .../composea11yscanner/ComposeA11yScanner.kt | 27 ++- .../triggers/ScanTriggerExtensions.kt | 84 +++++++ .../ui/A11yScannerScaffold.kt | 17 +- 5 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt 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-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt index 94206a2..4df94bb 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt @@ -151,6 +151,19 @@ object ComposeA11yScanner { 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) { @@ -255,16 +268,20 @@ private fun ScannerOverlayContent( 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.configure(config).startScan().collect { state -> - scannerState = state - if (state is ScannerState.Scanning) selectedIssue = null - } + controller.startScan() } val scanResult = (scannerState as? ScannerState.Complete)?.result 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/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()) { From 2d99f2b5cfe9c75ded7e1d9844f28e4332434962 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Sat, 6 Jun 2026 16:59:06 +0530 Subject: [PATCH 4/4] Implement `ScanResultExporter` and add export functionality to `ScanReportSheet` - Implement `ScanResultExporter` to support exporting scan results to JSON and Markdown formats. - Add an "Export Report" button to `ScanReportSheet` to share the report as Markdown via a system intent. - Include helper functions for JSON escaping and Markdown table formatting. --- .../export/ScanResultExporter.kt | 234 ++++++++++++++++++ .../composea11yscanner/ui/ScanReportSheet.kt | 36 +++ 2 files changed, 270 insertions(+) create mode 100644 scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt 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/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,