Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/studiobot.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 3 additions & 6 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
18 changes: 10 additions & 8 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -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
#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
29 changes: 18 additions & 11 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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" }
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions sample/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
}

android {
Expand Down
7 changes: 7 additions & 0 deletions scanner-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
}

android {
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<A11yRule>,
private val config: ScannerConfig,
) {
private val enabledRules: List<A11yRule> = rules.filter { it.ruleId in config.enabledRules }

fun scan(nodes: List<A11yNode>): Flow<ScannerState> = 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<A11yIssue>()
val failedRuleIds = mutableSetOf<String>()

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<A11yIssue>,
failedRuleIds: Set<String>,
): 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,
)
}
Loading
Loading