diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 02c4aa5..c3a1bd6 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -10,6 +10,10 @@
+
+
+
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 1a1bf72..74dd639 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,7 @@
+
-
+
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 267b8c7..11a0f92 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -3,15 +3,12 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.composea11yscanner"
- compileSdk {
- version = release(36) {
- minorApiLevel = 1
- }
- }
+ compileSdk = 36
defaultConfig {
applicationId = "com.composea11yscanner"
@@ -63,4 +60,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
-}
\ No newline at end of file
+}
diff --git a/gradle.properties b/gradle.properties
index 34c5e9e..7abc5bd 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,15 +1,17 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
+## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
+#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# Default value: -Xmx1024m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
-# Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
\ No newline at end of file
+#Wed May 06 12:20:37 SGT 2026
+android.enableJetifier=true
+android.useAndroidX=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding\=UTF-8
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1b7e9be..189549c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,16 +1,18 @@
[versions]
-agp = "9.1.1"
-coreKtx = "1.18.0"
+agp = "8.13.2"
+coreKtx = "1.15.0"
junit = "4.13.2"
-junitVersion = "1.3.0"
-espressoCore = "3.7.0"
-lifecycleRuntimeKtx = "2.10.0"
-activityCompose = "1.13.0"
-kotlin = "2.2.10"
-composeBom = "2024.09.00"
-hilt = "2.59.2"
-ksp = "2.3.7"
+junitVersion = "1.2.1"
+espressoCore = "3.6.1"
+lifecycleRuntimeKtx = "2.8.7"
+activityCompose = "1.9.3"
+kotlin = "2.3.21"
+composeBom = "2024.12.01"
+hilt = "2.54"
+ksp = "2.1.0-1.0.29"
coroutines = "1.10.1"
+mockk = "1.13.12"
+turbine = "1.2.0"
detekt = "1.23.7"
[libraries]
@@ -20,9 +22,11 @@ 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-compose-bom = { group = "androidx.compose.bom", name = "compose-bom", version.ref = "composeBom" }
+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" }
+androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit" }
+androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
@@ -32,6 +36,9 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
[plugins]
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
index ec76133..b5e1330 100644
--- a/sample/build.gradle.kts
+++ b/sample/build.gradle.kts
@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.android)
}
android {
diff --git a/scanner-core/build.gradle.kts b/scanner-core/build.gradle.kts
index 2030c22..c87c248 100644
--- a/scanner-core/build.gradle.kts
+++ b/scanner-core/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.android)
}
android {
@@ -35,7 +36,13 @@ dependencies {
ksp(libs.hilt.android.compiler)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.turbine)
+ testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt b/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
new file mode 100644
index 0000000..023af10
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
@@ -0,0 +1,80 @@
+package com.composea11yscanner.core
+
+import com.composea11yscanner.core.model.A11yIssue
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.ScanResult
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.core.model.ScannerState
+import com.composea11yscanner.core.rule.A11yRule
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import java.util.UUID
+
+/**
+ * Core orchestrator: runs a fixed set of accessibility rules over a list of nodes and
+ * streams scan progress as a [Flow] of [ScannerState].
+ *
+ * Emission sequence:
+ * Scanning(0f) → Scanning(1/n) → … → Scanning(1f) → Complete(result)
+ * or Complete(emptyResult) when no enabled rules / no nodes.
+ * or Error(message) if a rule throws an unexpected exception.
+ *
+ * The entire flow body runs on [Dispatchers.Default] via [flowOn]; collectors receive
+ * emissions on their own dispatcher.
+ *
+ * @param rules All candidate rules. Only those whose [A11yRule.ruleId] appears in
+ * [config.enabledRules] will be evaluated — defensive against callers that pass the
+ * full rule set regardless of config.
+ */
+class A11yScanEngine(
+ rules: List,
+ private val config: ScannerConfig,
+) {
+ private val enabledRules: List = rules.filter { it.ruleId in config.enabledRules }
+
+ fun scan(nodes: List): Flow = flow {
+ // Fast path: nothing to evaluate.
+ if (enabledRules.isEmpty() || nodes.isEmpty()) {
+ emit(ScannerState.Complete(buildResult(nodes.size, emptyList(), emptySet())))
+ return@flow
+ }
+
+ emit(ScannerState.Scanning(0f))
+
+ val allIssues = mutableListOf()
+ val failedRuleIds = mutableSetOf()
+
+ try {
+ enabledRules.forEachIndexed { index, rule ->
+ val issues = rule.evaluateAll(nodes)
+ allIssues += issues
+ if (issues.isNotEmpty()) failedRuleIds += rule.ruleId
+ // Progress advances to 1f after the last rule.
+ emit(ScannerState.Scanning((index + 1f) / enabledRules.size))
+ }
+ emit(ScannerState.Complete(buildResult(nodes.size, allIssues, failedRuleIds)))
+ } catch (e: CancellationException) {
+ throw e // never swallow cancellation
+ } catch (e: Exception) {
+ emit(ScannerState.Error(e.message ?: "Scan failed"))
+ }
+ }.flowOn(Dispatchers.Default)
+
+ // --- helpers ---
+
+ private fun buildResult(
+ totalNodes: Int,
+ issues: List,
+ failedRuleIds: Set,
+ ): ScanResult = ScanResult(
+ scanId = UUID.randomUUID().toString(),
+ timestamp = System.currentTimeMillis(),
+ totalNodes = totalNodes,
+ issues = issues.sortedWith(compareBy({ it.severity.sortOrder }, { it.ruleId })),
+ passedRules = enabledRules.size - failedRuleIds.size,
+ failedRules = failedRuleIds.size,
+ )
+}
diff --git a/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt b/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt
new file mode 100644
index 0000000..6c0288a
--- /dev/null
+++ b/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt
@@ -0,0 +1,389 @@
+package com.composea11yscanner.core
+
+import app.cash.turbine.test
+import com.composea11yscanner.core.model.A11yIssue
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.A11ySeverity
+import com.composea11yscanner.core.model.DpSize
+import com.composea11yscanner.core.model.Rect
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.core.model.ScannerState
+import com.composea11yscanner.core.rule.A11yRule
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class A11yScanEngineTest {
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private fun stubNode(id: String = "node-1") = A11yNode(
+ nodeId = id,
+ composableName = "Box",
+ bounds = Rect(0, 0, 100, 100),
+ contentDescription = null,
+ isTouchTarget = false,
+ touchTargetSize = DpSize(48f, 48f),
+ textColor = null,
+ backgroundColors = emptyList(),
+ isFocusable = false,
+ isMergedDescendant = false,
+ depth = 0,
+ )
+
+ private fun stubIssue(
+ ruleId: String,
+ nodeId: String = "node-1",
+ severity: A11ySeverity = A11ySeverity.Error,
+ ) = A11yIssue(
+ issueId = "${ruleId}_$nodeId",
+ severity = severity,
+ ruleId = ruleId,
+ ruleName = "Stub $ruleId",
+ affectedNode = stubNode(nodeId),
+ message = "stub message",
+ howToFix = "stub fix",
+ wcagReference = null,
+ )
+
+ private fun mockRule(
+ id: String,
+ issues: List = emptyList(),
+ ): A11yRule = mockk {
+ every { ruleId } returns id
+ every { evaluateAll(any()) } returns issues
+ }
+
+ /** Builds a [ScannerConfig] with only [ruleIds] enabled; all other fields use defaults. */
+ private fun configOf(vararg ruleIds: String) =
+ ScannerConfig(enabledRules = ruleIds.toSet())
+
+ // ── fast path (empty rules / nodes) ──────────────────────────────────────
+
+ @Test
+ fun `empty node list emits Complete with zero totalNodes immediately`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a")),
+ config = configOf("rule-a"),
+ )
+ engine.scan(emptyList()).test {
+ val state = awaitItem() as ScannerState.Complete
+ assertEquals(0, state.result.totalNodes)
+ assertEquals(0, state.result.issues.size)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `no enabled rules emits Complete immediately without any Scanning emission`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a")),
+ config = configOf(), // empty — rule-a is not enabled
+ )
+ engine.scan(listOf(stubNode())).test {
+ assertTrue(awaitItem() is ScannerState.Complete)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `rules not present in enabledRules are filtered and never evaluated`() = runTest {
+ val rule = mockRule("rule-a")
+ val engine = A11yScanEngine(
+ rules = listOf(rule),
+ config = configOf("rule-b"), // rule-a is absent
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem() // Complete (fast path because enabledRules is empty after filter)
+ awaitComplete()
+ }
+ verify(exactly = 0) { rule.evaluateAll(any()) }
+ }
+
+ // ── progress emissions ────────────────────────────────────────────────────
+
+ @Test
+ fun `single rule emits Scanning 0f then Scanning 1f before Complete`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a")),
+ config = configOf("rule-a"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ assertEquals(ScannerState.Scanning(0f), awaitItem())
+ assertEquals(ScannerState.Scanning(1f), awaitItem())
+ assertTrue(awaitItem() is ScannerState.Complete)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `three rules emit progress fractions matching batch count`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf("rule-a", "rule-b", "rule-c").map { mockRule(it) },
+ config = configOf("rule-a", "rule-b", "rule-c"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ assertEquals(ScannerState.Scanning(0f), awaitItem())
+ assertEquals(ScannerState.Scanning(1f / 3f), awaitItem())
+ assertEquals(ScannerState.Scanning(2f / 3f), awaitItem())
+ assertEquals(ScannerState.Scanning(3f / 3f), awaitItem())
+ assertTrue(awaitItem() is ScannerState.Complete)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `last progress emission is always Scanning 1f regardless of rule count`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf("rule-a", "rule-b").map { mockRule(it) },
+ config = configOf("rule-a", "rule-b"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem() // Scanning(0f)
+ awaitItem() // Scanning(0.5f)
+ assertEquals(ScannerState.Scanning(1f), awaitItem())
+ awaitItem() // Complete
+ awaitComplete()
+ }
+ }
+
+ // ── issue aggregation ─────────────────────────────────────────────────────
+
+ @Test
+ fun `single rule failure emits correct issue inside Complete`() = runTest {
+ val issue = stubIssue("rule-a")
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a", listOf(issue))),
+ config = configOf("rule-a"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem(); awaitItem() // Scanning(0f), Scanning(1f)
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(1, result.issues.size)
+ assertEquals(issue, result.issues[0])
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `passing rule contributes to passedRules with zero failedRules`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a")), // no issues
+ config = configOf("rule-a"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem(); awaitItem()
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(1, result.passedRules)
+ assertEquals(0, result.failedRules)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `failing rule contributes to failedRules with zero passedRules`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a", listOf(stubIssue("rule-a")))),
+ config = configOf("rule-a"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem(); awaitItem()
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(0, result.passedRules)
+ assertEquals(1, result.failedRules)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `multiple rules aggregate issues from all failing rules`() = runTest {
+ val issueA = stubIssue("rule-a")
+ val issueB = stubIssue("rule-b", nodeId = "node-2")
+ val engine = A11yScanEngine(
+ rules = listOf(
+ mockRule("rule-a", listOf(issueA)),
+ mockRule("rule-b", listOf(issueB)),
+ mockRule("rule-c"), // passing — no issues
+ ),
+ config = configOf("rule-a", "rule-b", "rule-c"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ repeat(4) { awaitItem() } // Scanning(0f), (1/3), (2/3), (1f)
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(2, result.issues.size)
+ assertTrue(result.issues.contains(issueA))
+ assertTrue(result.issues.contains(issueB))
+ assertEquals(1, result.passedRules) // rule-c
+ assertEquals(2, result.failedRules) // rule-a, rule-b
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `issues are sorted by severity order before ruleId`() = runTest {
+ val warningIssue = stubIssue("rule-z", severity = A11ySeverity.Warning)
+ val errorIssue = stubIssue("rule-a", severity = A11ySeverity.Error)
+ val engine = A11yScanEngine(
+ rules = listOf(
+ mockRule("rule-z", listOf(warningIssue)), // Warning emitted first by order of rules
+ mockRule("rule-a", listOf(errorIssue)),
+ ),
+ config = configOf("rule-a", "rule-z"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ repeat(3) { awaitItem() } // Scanning x3
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(errorIssue, result.issues[0]) // Error (sortOrder 0) before Warning (sortOrder 1)
+ assertEquals(warningIssue, result.issues[1])
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `issues with equal severity are sorted by ruleId alphabetically`() = runTest {
+ val issueB = stubIssue("rule-b")
+ val issueA = stubIssue("rule-a")
+ val engine = A11yScanEngine(
+ rules = listOf(
+ mockRule("rule-b", listOf(issueB)), // rule-b encountered first
+ mockRule("rule-a", listOf(issueA)),
+ ),
+ config = configOf("rule-a", "rule-b"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ repeat(3) { awaitItem() } // Scanning x3
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(issueA, result.issues[0]) // "rule-a" < "rule-b"
+ assertEquals(issueB, result.issues[1])
+ awaitComplete()
+ }
+ }
+
+ // ── ScanResult metadata ───────────────────────────────────────────────────
+
+ @Test
+ fun `totalNodes in ScanResult reflects the actual input list size`() = runTest {
+ val nodes = List(7) { stubNode("node-$it") }
+ val engine = A11yScanEngine(
+ rules = listOf(mockRule("rule-a")),
+ config = configOf("rule-a"),
+ )
+ engine.scan(nodes).test {
+ repeat(2) { awaitItem() } // Scanning(0f), Scanning(1f)
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertEquals(7, result.totalNodes)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `evaluateAll is called exactly once per rule with the provided nodes`() = runTest {
+ val nodes = listOf(stubNode("n1"), stubNode("n2"))
+ val rule = mockRule("rule-a")
+ val engine = A11yScanEngine(rules = listOf(rule), config = configOf("rule-a"))
+
+ engine.scan(nodes).test {
+ repeat(3) { awaitItem() } // Scanning(0f), Scanning(1f), Complete
+ awaitComplete()
+ }
+
+ verify(exactly = 1) { rule.evaluateAll(nodes) }
+ }
+
+ @Test
+ fun `scanId is non-blank in every Complete result`() = runTest {
+ val engine = A11yScanEngine(rules = emptyList(), config = configOf())
+ engine.scan(emptyList()).test {
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertTrue(result.scanId.isNotBlank())
+ awaitComplete()
+ }
+ }
+
+ // ── error handling ────────────────────────────────────────────────────────
+
+ @Test
+ fun `rule that throws RuntimeException emits Error state with the exception message`() = runTest {
+ val throwingRule = mockk {
+ every { ruleId } returns "throws-rule"
+ every { evaluateAll(any()) } throws RuntimeException("boom")
+ }
+ val engine = A11yScanEngine(
+ rules = listOf(throwingRule),
+ config = configOf("throws-rule"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ assertEquals(ScannerState.Scanning(0f), awaitItem())
+ val error = awaitItem() as ScannerState.Error
+ assertEquals("boom", error.message)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `exception with null message emits fallback error text`() = runTest {
+ val throwingRule = mockk {
+ every { ruleId } returns "throws-rule"
+ every { evaluateAll(any()) } throws RuntimeException(null as String?)
+ }
+ val engine = A11yScanEngine(
+ rules = listOf(throwingRule),
+ config = configOf("throws-rule"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem() // Scanning(0f)
+ val error = awaitItem() as ScannerState.Error
+ assertEquals("Scan failed", error.message)
+ awaitComplete()
+ }
+ }
+
+ // ── cancellation ──────────────────────────────────────────────────────────
+
+ @Test
+ fun `scan cancellation via flow cancellation terminates without emitting Error`() = runTest {
+ val engine = A11yScanEngine(
+ rules = listOf("rule-a", "rule-b", "rule-c", "rule-d", "rule-e").map { mockRule(it) },
+ config = configOf("rule-a", "rule-b", "rule-c", "rule-d", "rule-e"),
+ )
+ engine.scan(listOf(stubNode())).test {
+ awaitItem() // Scanning(0f)
+ cancelAndIgnoreRemainingEvents()
+ }
+ // Reaching here without exception confirms clean cancellation
+ }
+
+ @Test
+ fun `CancellationException from rule propagates and is not mapped to Error state`() = runTest {
+ val throwingRule = mockk {
+ every { ruleId } returns "throws-rule"
+ every { evaluateAll(any()) } throws CancellationException("from rule")
+ }
+ val engine = A11yScanEngine(
+ rules = listOf(throwingRule),
+ config = configOf("throws-rule"),
+ )
+
+ val collected = mutableListOf()
+ // runCatching captures the propagated CancellationException without cancelling
+ // the test coroutine's own job (the CE originates from the flow, not job cancellation).
+ val outcome = runCatching {
+ engine.scan(listOf(stubNode())).collect { collected.add(it) }
+ }
+
+ assertFalse(
+ "Error state must not be emitted for CancellationException",
+ collected.any { it is ScannerState.Error },
+ )
+ assertTrue(
+ "CancellationException should propagate to the collector",
+ outcome.exceptionOrNull() is CancellationException,
+ )
+ }
+}
diff --git a/scanner-rules/build.gradle.kts b/scanner-rules/build.gradle.kts
index 44b75fb..746373c 100644
--- a/scanner-rules/build.gradle.kts
+++ b/scanner-rules/build.gradle.kts
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.android)
}
android {
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt
index ca95f8a..bc8ab66 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt
@@ -1,7 +1,39 @@
package com.composea11yscanner.rules
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.core.rule.A11yRule
+
object ScannerRules {
const val VERSION = "0.1.0"
- fun allRuleIds(): List = emptyList()
+ fun allRuleIds(): List = listOf(
+ "touch-target-size",
+ "missing-content-description",
+ "duplicate-content-description",
+ "focus-order",
+ "text-scaling",
+ "image-text-overlay",
+ "clickable-role",
+ )
+
+ /**
+ * Builds the list of rules enabled by [config], wiring density-dependent rules with
+ * [screenDensity] (from DisplayMetrics.density — must not be zero).
+ */
+ fun buildRules(config: ScannerConfig, screenDensity: Float): List = buildList {
+ if ("touch-target-size" in config.enabledRules)
+ add(TouchTargetRule(config.minTouchTargetDp))
+ if ("missing-content-description" in config.enabledRules)
+ add(MissingContentDescriptionRule())
+ if ("duplicate-content-description" in config.enabledRules)
+ add(DuplicateContentDescriptionRule())
+ if ("focus-order" in config.enabledRules)
+ add(FocusOrderRule(screenDensity))
+ if ("text-scaling" in config.enabledRules)
+ add(TextScalingRule(screenDensity))
+ if ("image-text-overlay" in config.enabledRules)
+ add(ImageWithTextOverlayRule())
+ if ("clickable-role" in config.enabledRules)
+ add(ClickableRoleRule())
+ }
}
diff --git a/scanner-ui/build.gradle.kts b/scanner-ui/build.gradle.kts
index 2c81ae4..4f2f05e 100644
--- a/scanner-ui/build.gradle.kts
+++ b/scanner-ui/build.gradle.kts
@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.android)
}
android {
@@ -41,6 +42,10 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
+
+ implementation(libs.androidx.compose.ui.unit)
+ implementation(libs.androidx.compose.ui.util)
+
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.kotlinx.coroutines.core)
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
new file mode 100644
index 0000000..04bce69
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
@@ -0,0 +1,131 @@
+package com.composea11yscanner.ui
+
+import androidx.compose.ui.InternalComposeUiApi
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.semantics.SemanticsOwner
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.unit.Density
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.A11yRole
+import com.composea11yscanner.core.model.DpSize
+import com.composea11yscanner.core.model.Rect
+import kotlin.math.roundToInt
+
+/**
+ * Walks a Compose semantics tree and converts each [SemanticsNode] to an [A11yNode].
+ *
+ * Use the unmerged tree to see all rendered nodes including merged descendants:
+ * - Test: `composeTestRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()`
+ * - Production: `SemanticsOwner.rootSemanticsNode` via [extract(SemanticsOwner)]
+ */
+class A11yNodeExtractor(private val density: Density) {
+
+ /**
+ * Recursively extracts all nodes from the tree rooted at [rootNode].
+ * Returns a flat list in depth-first order.
+ */
+ fun extract(rootNode: SemanticsNode): List {
+ val result = mutableListOf()
+ visit(node = rootNode, depth = 0, isParentMerging = false, result = result)
+ return result
+ }
+
+ /**
+ * Entry point for production use. Requires opting in to the internal Compose UI API
+ * needed to access [SemanticsOwner].
+ */
+ @OptIn(InternalComposeUiApi::class)
+ fun extract(owner: SemanticsOwner): List = extract(owner.rootSemanticsNode)
+
+ // --- recursion ---
+
+ private fun visit(
+ node: SemanticsNode,
+ depth: Int,
+ isParentMerging: Boolean,
+ result: MutableList,
+ ) {
+ result.add(node.toA11yNode(depth = depth, isMergedDescendant = isParentMerging))
+ // Children of a merging node are merged descendants; propagate the flag downward.
+ val mergingForChildren = isParentMerging || node.config.isMergingSemanticsOfDescendants
+ node.children.forEach { child ->
+ visit(node = child, depth = depth + 1, isParentMerging = mergingForChildren, result = result)
+ }
+ }
+
+ // --- mapping ---
+
+ private fun SemanticsNode.toA11yNode(depth: Int, isMergedDescendant: Boolean): A11yNode {
+ val composeRole = config.getOrNull(SemanticsProperties.Role)
+ val bounds = boundsInRoot.toCoreRect()
+
+ return A11yNode(
+ nodeId = id.toString(),
+ composableName = resolveComposableName(composeRole),
+ bounds = bounds,
+ contentDescription = config
+ .getOrNull(SemanticsProperties.ContentDescription)
+ ?.joinToString(separator = ", "),
+ isTouchTarget = config.contains(SemanticsActions.OnClick),
+ touchTargetSize = bounds.toDpSize(),
+ textColor = null, // not available via semantics
+ backgroundColors = emptyList(), // not available via semantics
+ isFocusable = config.contains(SemanticsActions.OnClick)
+ || config.contains(SemanticsActions.RequestFocus)
+ || composeRole != null,
+ isMergedDescendant = isMergedDescendant,
+ depth = depth,
+ role = composeRole?.toA11yRole(),
+ )
+ }
+
+ /**
+ * Resolves a human-readable composable name.
+ * Priority: explicit TestTag → inferred from Role → inferred from Text property → "Unknown".
+ */
+ private fun SemanticsNode.resolveComposableName(composeRole: Role?): String =
+ config.getOrNull(SemanticsProperties.TestTag)
+ ?: when (composeRole) {
+ Role.Button -> "Button"
+ Role.Checkbox -> "Checkbox"
+ Role.Switch -> "Switch"
+ Role.RadioButton -> "RadioButton"
+ Role.Tab -> "Tab"
+ Role.Image -> "Image"
+ Role.DropdownList -> "DropdownList"
+ else -> {
+ val text = config.getOrNull(SemanticsProperties.Text)
+ if (!text.isNullOrEmpty()) "Text" else "Unknown"
+ }
+ }
+
+ private fun androidx.compose.ui.geometry.Rect.toCoreRect(): Rect = Rect(
+ left = left.roundToInt(),
+ top = top.roundToInt(),
+ right = right.roundToInt(),
+ bottom = bottom.roundToInt(),
+ )
+
+ private fun Rect.toDpSize(): DpSize = with(density) {
+ DpSize(
+ width = width.toDp().value,
+ height = height.toDp().value,
+ )
+ }
+}
+
+// --- Role mapping ---
+
+private fun Role.toA11yRole(): A11yRole? = when (this) {
+ Role.Button -> A11yRole.Button
+ Role.Checkbox -> A11yRole.Checkbox
+ Role.Switch -> A11yRole.Switch
+ Role.RadioButton -> A11yRole.RadioButton
+ Role.Tab -> A11yRole.Tab
+ Role.Image -> A11yRole.Image
+ Role.DropdownList -> A11yRole.DropdownList
+ else -> null // forward-compatibility: unknown future roles map to null
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt
new file mode 100644
index 0000000..352a3b1
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt
@@ -0,0 +1,47 @@
+package com.composea11yscanner.ui
+
+import androidx.compose.ui.InternalComposeUiApi
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.semantics.SemanticsOwner
+import androidx.compose.ui.unit.Density
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.ScanResult
+import com.composea11yscanner.core.rule.A11yRule
+import java.util.UUID
+
+/**
+ * Orchestrates a single accessibility scan.
+ *
+ * @param rules The rules to run. Build from [com.composea11yscanner.rules.ScannerRules.buildRules]
+ * or supply a custom list.
+ * @param density Compose [Density] used by [A11yNodeExtractor] for pixel↔dp conversion.
+ */
+class A11yScanner(
+ private val rules: List,
+ private val density: Density,
+) {
+ /** Scans from a semantics node (test or manual tree walk). */
+ fun scan(rootNode: SemanticsNode): ScanResult =
+ buildResult(A11yNodeExtractor(density).extract(rootNode))
+
+ /** Scans the live Compose semantics tree via [SemanticsOwner]. */
+ @OptIn(InternalComposeUiApi::class)
+ fun scan(owner: SemanticsOwner): ScanResult =
+ buildResult(A11yNodeExtractor(density).extract(owner))
+
+ private fun buildResult(nodes: List): ScanResult {
+ val issues = rules
+ .flatMap { it.evaluateAll(nodes) }
+ .sortedWith(compareBy({ it.severity.sortOrder }, { it.ruleId }))
+ val failedRuleIds = issues.map { it.ruleId }.toSet()
+
+ return ScanResult(
+ scanId = UUID.randomUUID().toString(),
+ timestamp = System.currentTimeMillis(),
+ totalNodes = nodes.size,
+ issues = issues,
+ passedRules = rules.size - failedRuleIds.size,
+ failedRules = failedRuleIds.size,
+ )
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
new file mode 100644
index 0000000..fce509f
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
@@ -0,0 +1,127 @@
+package com.composea11yscanner.ui
+
+import com.composea11yscanner.core.A11yScanEngine
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.core.model.ScannerState
+import com.composea11yscanner.core.rule.A11yRule
+import com.composea11yscanner.rules.ScannerRules
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+
+/**
+ * Public SDK entry point for running accessibility scans.
+ *
+ * Typical usage:
+ * ```
+ * val controller = A11yScannerController(
+ * nodeProvider = { A11yNodeExtractor(density).extract(semanticsOwner) },
+ * screenDensity = resources.displayMetrics.density,
+ * )
+ * .configure(ScannerConfig(enabledRules = ScannerRules.allRuleIds().toSet()))
+ * .withRules(MyCustomRule())
+ *
+ * lifecycleScope.launch {
+ * controller.startScan().collect { state -> /* update UI */ }
+ * }
+ * // ...
+ * controller.stopScan()
+ * // ...
+ * controller.destroy() // cancel the internal scope when the host is destroyed
+ * ```
+ *
+ * @param nodeProvider Called once per [startScan] invocation to produce the node list.
+ * Must be safe to call on [Dispatchers.Default].
+ * @param screenDensity [DisplayMetrics.density] — forwarded to density-dependent rules.
+ */
+class A11yScannerController(
+ private val nodeProvider: () -> List,
+ private val screenDensity: Float,
+) {
+ private var config: ScannerConfig = ScannerConfig(
+ enabledRules = ScannerRules.allRuleIds().toSet(),
+ )
+ private val customRules = mutableListOf()
+
+ // SupervisorJob: a rule exception cancels only the current scan job, not the whole scope.
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var scanJob: Job? = null
+
+ // replay = 1 so late subscribers immediately receive the latest state.
+ // 64 extra slots is orders of magnitude more than needed (≤ ~10 states per scan),
+ // DROP_OLDEST is a last-resort safety net that preserves the most recent state.
+ private val _state = MutableSharedFlow(
+ replay = 1,
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ // --- Builder methods ---
+
+ /** Replaces the active configuration. Returns [this] for chaining. */
+ fun configure(config: ScannerConfig): A11yScannerController = apply {
+ this.config = config
+ }
+
+ /**
+ * Appends [rules] to the set that will run alongside the standard rule set.
+ * Custom rules are automatically added to [ScannerConfig.enabledRules] so the
+ * engine never filters them out. Returns [this] for chaining.
+ */
+ fun withRules(vararg rules: A11yRule): A11yScannerController = apply {
+ customRules += rules
+ }
+
+ // --- Scan control ---
+
+ /**
+ * Cancels any in-progress scan, then starts a new one.
+ *
+ * Returns a [Flow] backed by a [MutableSharedFlow] — multiple collectors are safe,
+ * and late subscribers receive the most recent state immediately via [replay].
+ *
+ * Emission sequence: Scanning(0f) → Scanning(k/n) … → Scanning(1f) → Complete(result)
+ * or Complete(emptyResult) if there are no enabled rules or no nodes.
+ */
+ fun startScan(): Flow {
+ stopScan()
+
+ val standardRules = ScannerRules.buildRules(config, screenDensity)
+
+ // Custom rule IDs must be present in enabledRules; otherwise A11yScanEngine would
+ // silently filter them out during its own config-gated filtering pass.
+ val effectiveConfig = if (customRules.isEmpty()) config else {
+ config.copy(enabledRules = config.enabledRules + customRules.map { it.ruleId })
+ }
+
+ val engine = A11yScanEngine(
+ rules = standardRules + customRules,
+ config = effectiveConfig,
+ )
+
+ scanJob = scope.launch {
+ engine.scan(nodeProvider()).collect { _state.emit(it) }
+ }
+
+ return _state.asSharedFlow()
+ }
+
+ /** Cancels the current scan if one is running. The [Flow] returned by [startScan] stops emitting. */
+ fun stopScan() {
+ scanJob?.cancel()
+ scanJob = null
+ }
+
+ /** Cancels the internal [CoroutineScope]. Call when the host (Activity/Fragment/ViewModel) is destroyed. */
+ fun destroy() {
+ scope.cancel()
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt
new file mode 100644
index 0000000..6e3f52d
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt
@@ -0,0 +1,87 @@
+package com.composea11yscanner.ui
+
+import android.graphics.Bitmap
+import android.view.View
+import androidx.core.view.drawToBitmap
+import com.composea11yscanner.core.model.Color
+import com.composea11yscanner.core.model.Rect
+
+/**
+ * Samples background and foreground colors from a composable's rendered bounds.
+ *
+ * Strategy:
+ * - Background: 8 sample points — 4 corners + 4 edge midpoints. Nodes at the edge of a
+ * container have consistent background color there; deduplication returns only distinct
+ * candidates, so a solid background yields a single-element list.
+ * - Text/foreground: single pixel at the node's geometric center.
+ *
+ * [Rect] coordinates must be root-relative (matching [SemanticsNode.boundsInRoot]).
+ * [rootView] must be laid out with non-zero dimensions before calling [extractColors].
+ *
+ * Note: [drawToBitmap] captures a software copy of the entire view on every call.
+ * When sampling multiple nodes, prefer creating the bitmap once externally and calling
+ * [sampleEdges]/[sampleCenter] directly with the shared [Bitmap].
+ */
+class ColorExtractor(private val rootView: View) {
+
+ data class Result(
+ val backgroundColors: List,
+ val textColor: Color?,
+ )
+
+ fun extractColors(bounds: Rect): Result {
+ if (bounds.isEmpty()) return Result(emptyList(), null)
+ val bitmap = rootView.drawToBitmap()
+ return try {
+ Result(
+ backgroundColors = sampleEdges(bitmap, bounds),
+ textColor = sampleCenter(bitmap, bounds),
+ )
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ fun sampleEdges(bitmap: Bitmap, bounds: Rect): List {
+ val l = bounds.left
+ val t = bounds.top
+ val r = (bounds.right - 1).coerceAtLeast(l)
+ val b = (bounds.bottom - 1).coerceAtLeast(t)
+ val midX = (l + r) / 2
+ val midY = (t + b) / 2
+
+ return listOf(
+ l to t, // top-left corner
+ r to t, // top-right corner
+ l to b, // bottom-left corner
+ r to b, // bottom-right corner
+ midX to t, // top edge midpoint
+ midX to b, // bottom edge midpoint
+ l to midY, // left edge midpoint
+ r to midY, // right edge midpoint
+ )
+ .mapNotNull { (x, y) -> bitmap.safePixel(x, y) }
+ .distinct()
+ }
+
+ fun sampleCenter(bitmap: Bitmap, bounds: Rect): Color? {
+ val cx = (bounds.left + bounds.right) / 2
+ val cy = (bounds.top + bounds.bottom) / 2
+ return bitmap.safePixel(cx, cy)
+ }
+
+ // --- internal helpers ---
+
+ private fun Bitmap.safePixel(x: Int, y: Int): Color? {
+ if (x < 0 || y < 0 || x >= width || y >= height) return null
+ return getPixel(x, y).toColor()
+ }
+}
+
+/**
+ * Converts an Android ARGB [ColorInt] to [Color] using Compose's Color.value packing:
+ * value = (argb & 0xFFFFFFFFL) shl 32
+ * This keeps the sRGB color space ID (0) in bits 0-5 as zero, matching [Color.Unspecified]'s
+ * convention and making round-trips with Compose's Color(Int) constructor lossless.
+ */
+private fun Int.toColor(): Color = Color((this.toLong() and 0xFFFFFFFFL) shl 32)