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)