diff --git a/build.gradle.kts b/build.gradle.kts
index 0d22eeb..51cd901 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,6 +9,10 @@ plugins {
alias(libs.plugins.detekt)
}
+dependencies {
+ detektPlugins(libs.detekt.formatting)
+}
+
detekt {
source.setFrom(
fileTree(rootDir) {
@@ -19,4 +23,4 @@ detekt {
config.setFrom(file("config/detekt/detekt.yml"))
buildUponDefaultConfig = true
parallel = true
-}
\ No newline at end of file
+}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 45c957c..8e84c09 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -13,6 +13,17 @@ style:
active: false
MagicNumber:
active: false
+ NewLineAtEndOfFile:
+ active: false
+ ForbiddenComment:
+ active: true
+ comments:
+ - "TODO:"
+ - "FIXME:"
+
+formatting:
+ FinalNewline:
+ active: false
complexity:
LongMethod:
@@ -28,5 +39,5 @@ complexity:
naming:
FunctionNaming:
- functionPattern: '[a-z][a-zA-Z0-9]*'
+ functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
excludes: ['**/test/**', '**/androidTest/**']
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8ee581a..1b7e9be 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,7 +20,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-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-bom = { group = "androidx.compose.bom", 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-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
@@ -32,6 +32,7 @@ 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" }
+detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/sample/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/sample/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.webp b/sample/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.webp b/sample/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt
new file mode 100644
index 0000000..bff27c1
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt
@@ -0,0 +1,12 @@
+package com.composea11yscanner.core.model
+
+data class A11yIssue(
+ val issueId: String,
+ val severity: A11ySeverity,
+ val ruleId: String,
+ val ruleName: String,
+ val affectedNode: A11yNode,
+ val message: String,
+ val howToFix: String,
+ val wcagReference: String?,
+)
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
new file mode 100644
index 0000000..cf84970
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
@@ -0,0 +1,15 @@
+package com.composea11yscanner.core.model
+
+data class A11yNode(
+ val nodeId: String,
+ val composableName: String,
+ val bounds: Rect,
+ val contentDescription: String?,
+ val isTouchTarget: Boolean,
+ val touchTargetSize: DpSize,
+ val textColor: Color?,
+ val backgroundColors: List,
+ val isFocusable: Boolean,
+ val isMergedDescendant: Boolean,
+ val depth: Int,
+)
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt
new file mode 100644
index 0000000..84ace53
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt
@@ -0,0 +1,16 @@
+package com.composea11yscanner.core.model
+
+sealed interface A11ySeverity : Comparable {
+ val sortOrder: Int
+
+ override fun compareTo(other: A11ySeverity): Int = sortOrder.compareTo(other.sortOrder)
+
+ /** Must fix — blocks accessibility. */
+ data object Error : A11ySeverity { override val sortOrder = 0 }
+
+ /** Should fix — degrades experience. */
+ data object Warning : A11ySeverity { override val sortOrder = 1 }
+
+ /** Consider fixing — best practice violation. */
+ data object Info : A11ySeverity { override val sortOrder = 2 }
+}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt
new file mode 100644
index 0000000..8ced408
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt
@@ -0,0 +1,15 @@
+package com.composea11yscanner.core.model
+
+/**
+ * Packed ARGB color stored as a 64-bit long.
+ *
+ * The bit layout matches Compose's Color.value (each channel 16 bits: alpha, red, green, blue).
+ * Conversion from a Compose Color in :scanner-ui is zero-cost:
+ * Color(composeColor.value.toLong())
+ */
+@JvmInline
+value class Color(val value: Long) {
+ companion object {
+ val Unspecified = Color(0L)
+ }
+}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt
new file mode 100644
index 0000000..ed1d374
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt
@@ -0,0 +1,12 @@
+package com.composea11yscanner.core.model
+
+/**
+ * Density-independent size (dp units). Mirrors the Compose DpSize API
+ * without pulling in the Compose dependency.
+ */
+data class DpSize(val width: Float, val height: Float) {
+ companion object {
+ val Zero = DpSize(0f, 0f)
+ val Unspecified = DpSize(Float.NaN, Float.NaN)
+ }
+}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt
new file mode 100644
index 0000000..5cfacf6
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt
@@ -0,0 +1,21 @@
+package com.composea11yscanner.core.model
+
+/**
+ * Immutable pixel-coordinate bounding box in screen space.
+ * Android screen coordinates: origin top-left, y increases downward.
+ */
+data class Rect(
+ val left: Int,
+ val top: Int,
+ val right: Int,
+ val bottom: Int,
+) {
+ val width: Int get() = right - left
+ val height: Int get() = bottom - top
+
+ fun isEmpty(): Boolean = width <= 0 || height <= 0
+
+ companion object {
+ val Zero = Rect(0, 0, 0, 0)
+ }
+}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt
new file mode 100644
index 0000000..e749da9
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt
@@ -0,0 +1,26 @@
+package com.composea11yscanner.core.model
+
+data class ScanResult(
+ val scanId: String,
+ val timestamp: Long,
+ val totalNodes: Int,
+ val issues: List,
+ val passedRules: Int,
+ val failedRules: Int,
+) {
+ val errorCount: Int get() = issues.count { it.severity == A11ySeverity.Error }
+ val warningCount: Int get() = issues.count { it.severity == A11ySeverity.Warning }
+ val infoCount: Int get() = issues.count { it.severity == A11ySeverity.Info }
+
+ val hasErrors: Boolean get() = errorCount > 0
+
+ /**
+ * Score from 0–100 representing the percentage of rules that passed.
+ * Returns 100 when no rules have run yet.
+ */
+ val overallScore: Float
+ get() {
+ val total = passedRules + failedRules
+ return if (total == 0) 100f else (passedRules.toFloat() / total) * 100f
+ }
+}
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
new file mode 100644
index 0000000..d85a833
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
@@ -0,0 +1,8 @@
+package com.composea11yscanner.core.model
+
+data class ScannerConfig(
+ val enabledRules: Set,
+ val minTouchTargetDp: Int = 48,
+ val minContrastRatio: Float = 4.5f,
+ val debugOverlay: Boolean = true,
+)
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt
new file mode 100644
index 0000000..3297f7c
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt
@@ -0,0 +1,11 @@
+package com.composea11yscanner.core.model
+
+sealed interface ScannerState {
+ data object Idle : ScannerState
+
+ data class Scanning(val progress: Float) : ScannerState
+
+ data class Complete(val result: ScanResult) : ScannerState
+
+ data class Error(val message: String) : ScannerState
+}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt b/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt
new file mode 100644
index 0000000..da92121
--- /dev/null
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt
@@ -0,0 +1,40 @@
+package com.composea11yscanner.core.rule
+
+import com.composea11yscanner.core.model.A11yIssue
+import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.A11ySeverity
+
+interface A11yRule {
+ val ruleId: String
+ val ruleName: String
+ val severity: A11ySeverity
+ val wcagReference: String?
+
+ fun evaluate(node: A11yNode): A11yIssue?
+}
+
+abstract class BaseA11yRule : A11yRule {
+
+ /**
+ * Entry point called by the scanner engine. Sealed final so cross-cutting
+ * concerns (logging, timing, exception guarding) can be added here without
+ * touching every concrete rule.
+ */
+ final override fun evaluate(node: A11yNode): A11yIssue? = check(node)
+
+ /** Concrete rules implement their logic here and return null if the node passes. */
+ protected abstract fun check(node: A11yNode): A11yIssue?
+
+ /** Builds an [A11yIssue] with all rule-level fields pre-filled. */
+ protected fun issue(node: A11yNode, message: String, howToFix: String): A11yIssue =
+ A11yIssue(
+ issueId = "${ruleId}_${node.nodeId}",
+ severity = severity,
+ ruleId = ruleId,
+ ruleName = ruleName,
+ affectedNode = node,
+ message = message,
+ howToFix = howToFix,
+ wcagReference = wcagReference,
+ )
+}