diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..23bf3b9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.{kt,kts}] +ktlint_code_style = intellij_idea +max_line_length = 120 +# @Composable functions are PascalCase by convention — don't flag them as bad function names. +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..979f0c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +# Normalize line endings across platforms. +* text=auto eol=lf + +# Windows scripts must keep CRLF. +*.bat text eol=crlf + +# The Gradle wrapper shell script must keep LF (it runs under sh on Unix/macOS). +gradlew text eol=lf + +# Treat these as binary. +*.jar binary +*.ttf binary +*.otf binary +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.apk binary +*.aab binary +*.keystore binary +*.jks binary + +# The FFmpeg archive under bin/ and the native libraries inside it. Without these a +# normalizing checkout would rewrite bytes that happen to look like line endings and +# corrupt the archive -- which the "FFmpeg binary" CI job exists to catch, but which +# is far cheaper to prevent here. +*.aar binary +*.so binary diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2b917c..557f7e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,18 @@ +import org.gradle.testing.jacoco.tasks.JacocoReport + plugins { alias(libs.plugins.android.application) // Required even under AGP 9: the Compose compiler plugin is NOT built in. alias(libs.plugins.kotlin.compose) + + // Lint/format. Resolved from the Gradle Plugin Portal, not AGP's buildscript + // classpath -- neither is an Android plugin. + alias(libs.plugins.ktlint) + alias(libs.plugins.detekt) + + // JaCoCo (Gradle built-in) instruments the JVM testDebugUnitTest task. Report only: + // there is deliberately no coverage gate, see the jacocoTestReport block below. + jacoco } android { @@ -62,6 +73,15 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + lint { + // ktlint and detekt both fail the build on any finding. Android lint by default + // aborts on errors only, so warnings would land in the report while the gate stayed + // green -- a gate that passes while the report has content is not a gate. + warningsAsErrors = true + // Already the default. Stated so a later edit cannot turn the gate off by accident. + abortOnError = true + } + packaging { jniLibs { // Uncompressed .so, so the APK zip-aligns them on 16 KB boundaries. @@ -76,6 +96,68 @@ kotlin { compilerOptions {} } +detekt { + // Merge the project overrides in config/detekt onto detekt's bundled defaults, so this + // repo's file only has to carry the rules it actually changes. + buildUponDefaultConfig = true + config.setFrom(rootProject.file("config/detekt/detekt.yml")) +} + +// Pin the coverage agent rather than inheriting whatever Gradle bundles. +jacoco { + toolVersion = libs.versions.jacoco.get() +} + +// --- Unit-test coverage ------------------------------------------------------------------- +// Report only. There is deliberately no coverage gate: a floor is only meaningful against a +// measured baseline, and the JVM test stack here is still junit-only. This task produces the +// number a floor would need; add `jacocoTestCoverageVerification` once it is known. + +// Generated code, stripped from the denominator so the percentage reflects hand-written Kotlin. +// No DI framework is in use, so there are no Hilt/Dagger patterns to exclude. +val jacocoGeneratedExcludes = listOf( + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + // Room lands in a later phase; its KSP output comes out the same door as hand-written code. + "**/*_Impl*", + // One per file with @Composable lambdas -- Compose compiler output, not written by anyone. + "**/ComposableSingletons*", +) + +// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the +// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the +// javac output (BuildConfig and R only) is not read at all. +val jacocoDebugKotlinClasses = layout.buildDirectory.dir( + "intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", +) + +// Accept both the base `jacoco` plugin's default exec location and AGP's +// enableUnitTestCoverage one, so the wiring survives either being the source of truth. +val jacocoExecutionData = fileTree(layout.buildDirectory) { + include( + "jacoco/testDebugUnitTest.exec", + "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", + ) +} + +tasks.register("jacocoTestReport") { + // The exec data does not exist until the tests have run. + dependsOn("testDebugUnitTest") + group = "verification" + description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests." + + reports { + xml.required.set(true) + html.required.set(true) + } + + classDirectories.setFrom(fileTree(jacocoDebugKotlinClasses) { exclude(jacocoGeneratedExcludes) }) + sourceDirectories.setFrom(files("src/main/java")) + executionData.setFrom(jacocoExecutionData) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..0845e5a --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,49 @@ +# Project overrides merged onto detekt's bundled defaults (buildUponDefaultConfig = true in +# app/build.gradle.kts). Only rules that need tuning for this project appear here. +# +# Guiding principle: Compose UI intentionally breaks several OOP-era metrics, and a few default +# thresholds are stricter than this project's chosen style. Those are relaxed here with a reason. +# Genuine smells (swallowed exceptions, an over-complex condition, a misnamed file) are fixed in +# the code, not silenced. +# +# Formatting is deliberately absent: ktlint owns it. detekt's formatting ruleset is not enabled, +# so the two tools can never disagree about the same line. + +complexity: + LongMethod: + # Declarative @Composable functions are read top-to-bottom and are legitimately long. + ignoreAnnotated: ['Composable'] + CyclomaticComplexMethod: + # Branchy layout code (when/if inside a UI tree) isn't algorithmic complexity. + ignoreAnnotated: ['Composable'] + TooManyFunctions: + # Screen files group many small @Composable helpers next to their screen, and the codec / + # container matrix files are intentionally operation-rich cohesive APIs. detekt's default of + # 11 is far too low for either. Files past ~40 functions still flag as genuinely bloated. + ignoreAnnotated: ['Composable'] + allowedFunctionsPerFile: 40 + allowedFunctionsPerClass: 40 + allowedFunctionsPerInterface: 40 + +naming: + FunctionNaming: + # @Composable functions are PascalCase by Compose convention. + ignoreAnnotated: ['Composable'] + +style: + MagicNumber: + # dp / sp / duration literals are idiomatic inline in Compose. + ignoreAnnotated: ['Composable'] + ignorePropertyDeclaration: true + ignoreNamedArgument: true + ReturnCount: + # Allow guard-clause-style early returns; detekt's default of 2 is overly strict. + max: 4 + LoopWithTooManyJumpStatements: + # Clear early-continue / early-return loops read fine; the default of 1 is strict. + maxJumpCount: 3 + ThrowsCount: + # Guard-clause throws don't count; allow a few more for functions validating several + # preconditions. + excludeGuardClauses: true + max: 3 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdba997..4234142 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,16 @@ androidxJunit = "1.3.0" espressoCore = "3.7.0" smartException = "0.2.1" +# Lint/format. ktlint owns formatting; detekt owns static analysis (its formatting +# ruleset stays off, so the two can never disagree about the same line). +# detekt 2.0 is the only line with Gradle 9 support -- stable 1.23.x tops out at +# Gradle 8.12, and this project is on 9.5. +ktlint = "14.2.0" +detekt = "2.0.0-alpha.5" +# JaCoCo coverage agent. Pinned rather than inheriting whatever Gradle 9.5 bundles, +# so the agent version that reads Kotlin 2.2.10 bytecode is stated, not implied. +jacoco = "0.8.13" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -78,4 +88,6 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +detekt = { id = "dev.detekt", version.ref = "detekt" } # DO NOT add org.jetbrains.kotlin.android — AGP 9 built-in Kotlin makes it a build failure.