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, + ) +}