597 lines
28 KiB
Plaintext
597 lines
28 KiB
Plaintext
// AGP 9 has built-in Kotlin support, so there is no org.jetbrains.kotlin.android
|
|
// plugin here — applying it is now an error rather than a redundancy.
|
|
// The Compose compiler plugin is still applied separately.
|
|
// See https://developer.android.com/build/migrate-to-built-in-kotlin
|
|
plugins {
|
|
alias(libs.plugins.android.application) apply false
|
|
alias(libs.plugins.android.library) apply false
|
|
alias(libs.plugins.kotlin.jvm) apply false
|
|
alias(libs.plugins.kotlin.compose) apply false
|
|
alias(libs.plugins.ksp) apply false
|
|
alias(libs.plugins.hilt) apply false
|
|
alias(libs.plugins.room) apply false
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Module boundaries
|
|
// ===========================================================================
|
|
//
|
|
// The dependency table in docs/architecture/README.md, as a check.
|
|
//
|
|
// Two boundaries in this project are load-bearing and neither can be held up by
|
|
// people remembering them:
|
|
//
|
|
// 1. `domain:*` must not see the Android SDK. It is why the prediction
|
|
// engine's tests run in a second instead of on an emulator, and one
|
|
// contributor reaching for a convenient Android API would end that with no
|
|
// test failing.
|
|
// 2. The advertising subsystem must never reach cycle data. PRODUCT_PLAN.md
|
|
// §34 states it as non-negotiable: no menstrual date, cycle length,
|
|
// fertility status, ovulation estimate, prediction confidence, prediction
|
|
// history or spotting record may reach advertising, ever.
|
|
//
|
|
// The second is written down before `core:ads` exists on purpose. A guard added
|
|
// alongside the code it constrains is a guard that was shaped around whatever
|
|
// exception somebody wanted at the time.
|
|
//
|
|
// Run: ./gradlew checkModuleBoundaries (also wired into `check`)
|
|
// Prove it fails: bash scripts/prove-guard.sh
|
|
//
|
|
// Exit is non-zero with every violation listed, not just the first — a guard
|
|
// that reports one problem per run turns a five-minute fix into five runs.
|
|
|
|
/** Project dependencies each module is permitted. Anything else fails. */
|
|
val allowedProjectDependencies: Map<String, Set<String>> = mapOf(
|
|
":app" to setOf(
|
|
":core:designsystem", ":core:data", ":core:datastore", ":core:notifications",
|
|
":core:security", ":domain:cycle", ":domain:prediction",
|
|
),
|
|
":core:designsystem" to emptySet(),
|
|
":core:database" to setOf(":domain:cycle", ":domain:prediction"),
|
|
":core:datastore" to emptySet(),
|
|
":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"),
|
|
":core:notifications" to setOf(":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"),
|
|
// Empty on purpose, and it is load-bearing. The app lock's key material and
|
|
// its backoff state live here; a dependency on :core:data would make this a
|
|
// module that can see a cycle date, and the erase path deliberately runs in
|
|
// :app so that never has to happen.
|
|
":core:security" to emptySet(),
|
|
":domain:cycle" to emptySet(),
|
|
":domain:prediction" to setOf(":domain:cycle"),
|
|
// Batch 07. Empty, and that is the whole point: the ads module may reach
|
|
// NOTHING in this project. It talks to the UI through an AdProvider
|
|
// interface owned by :app.
|
|
":core:ads" to emptySet(),
|
|
)
|
|
|
|
/** Modules that must never see the Android SDK, by never applying an Android plugin. */
|
|
val mustStayPureJvm = setOf(":domain:cycle", ":domain:prediction")
|
|
|
|
/**
|
|
* Configurations that describe what SHIPS. Test-only dependencies are not a
|
|
* product boundary — a test may reach for a fake or an in-memory database that
|
|
* production must not — so they are deliberately not examined here.
|
|
*/
|
|
val shippingConfigurations = setOf("implementation", "api", "compileOnly", "runtimeOnly", "ksp")
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collected in afterEvaluate, and that is not a detail.
|
|
//
|
|
// The first version of this read `subprojects.configurations` directly in the
|
|
// root build script. The root project is configured BEFORE its subprojects, so
|
|
// every configuration was empty, every module had no dependencies, and the task
|
|
// printed "7 modules checked, no violations" while checking nothing at all.
|
|
//
|
|
// It was caught by `scripts/prove-guard.sh` on the first run — a deliberate
|
|
// forbidden dependency was added to :domain:prediction and the guard stayed
|
|
// green. That is precisely the failure GUARDS.md §1 exists for, and it is why
|
|
// no guard here is believed until it has been watched failing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
val observedProjectDependencies = mutableMapOf<String, Set<String>>()
|
|
val observedAndroidPlugins = mutableMapOf<String, List<String>>()
|
|
val containerProjects = mutableSetOf<String>()
|
|
|
|
subprojects {
|
|
afterEvaluate {
|
|
if (!buildFile.exists()) {
|
|
// `include(":core:database")` makes Gradle create an intermediate
|
|
// `:core` project with nothing to build. Containers, not modules —
|
|
// named in the output rather than dropped, because "skipped" and
|
|
// "passed" must not look the same.
|
|
containerProjects += path
|
|
return@afterEvaluate
|
|
}
|
|
observedProjectDependencies[path] = configurations
|
|
.filter { it.name in shippingConfigurations }
|
|
.flatMap { conf -> conf.dependencies.filterIsInstance<ProjectDependency>() }
|
|
.map { it.path }
|
|
.toSet()
|
|
observedAndroidPlugins[path] = plugins.mapNotNull { plugin ->
|
|
plugin::class.qualifiedName?.takeIf { it.contains("com.android.build") }
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.register("checkModuleBoundaries") {
|
|
group = "verification"
|
|
description = "Every module's project dependencies must match docs/architecture/README.md."
|
|
|
|
val allowed = allowedProjectDependencies
|
|
val observed = observedProjectDependencies
|
|
val androidPlugins = observedAndroidPlugins
|
|
val pureJvm = mustStayPureJvm
|
|
val containers = containerProjects
|
|
|
|
// Providers, not values: these maps are filled during afterEvaluate, which
|
|
// has not run when this task is being registered. Reading them eagerly here
|
|
// is the same mistake as reading them in the root script.
|
|
inputs.property("allowed", allowed.toString())
|
|
inputs.property("observed", provider { observed.toString() })
|
|
inputs.property("androidPlugins", provider { androidPlugins.toString() })
|
|
|
|
doLast {
|
|
val violations = mutableListOf<String>()
|
|
|
|
val modules = observed.keys
|
|
|
|
// Refuse to report a pass over nothing. An empty map here means the
|
|
// collection above did not run, which is exactly how this guard was
|
|
// green while checking nothing.
|
|
if (modules.isEmpty()) {
|
|
throw GradleException(
|
|
"no modules were examined, so nothing was checked. This is not a pass — " +
|
|
"see the afterEvaluate note in build.gradle.kts.",
|
|
)
|
|
}
|
|
|
|
// A module nobody declared a rule for is not "allowed anything" — it is
|
|
// unmeasured, and reporting it as a pass is how a boundary quietly stops
|
|
// covering half the project.
|
|
(modules - allowed.keys).sorted().forEach {
|
|
violations += "$it has no entry in allowedProjectDependencies, so its dependencies were never checked."
|
|
}
|
|
|
|
observed.forEach { (module, deps) ->
|
|
if (module in containers) return@forEach
|
|
val permitted = allowed[module] ?: return@forEach
|
|
(deps - permitted).sorted().forEach { dep ->
|
|
violations += "$module depends on $dep, which the architecture does not permit."
|
|
}
|
|
}
|
|
|
|
pureJvm.forEach { module ->
|
|
androidPlugins[module]?.takeIf { it.isNotEmpty() }?.let { plugins ->
|
|
violations += "$module applies an Android plugin (${plugins.first()}). " +
|
|
"It must stay pure JVM so the prediction engine is testable without an emulator."
|
|
}
|
|
}
|
|
|
|
if (violations.isNotEmpty()) {
|
|
logger.error("")
|
|
logger.error("Module boundary violations:")
|
|
violations.forEach { logger.error(" - $it") }
|
|
logger.error("")
|
|
logger.error("These are the rules in docs/architecture/README.md. If the architecture")
|
|
logger.error("changed on purpose, change that table and the map in build.gradle.kts in")
|
|
logger.error("the same commit. If it did not, this dependency is the mistake.")
|
|
throw GradleException("${violations.size} module boundary violation(s).")
|
|
}
|
|
|
|
logger.lifecycle(
|
|
"module boundaries: ${modules.size} module(s) checked, " +
|
|
"${pureJvm.size} required to stay pure JVM, no violations.",
|
|
)
|
|
if (containers.isNotEmpty()) {
|
|
logger.lifecycle(
|
|
" (skipped ${containers.size} container project(s) with no build file: " +
|
|
"${containers.sorted().joinToString(", ")})",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Permissions
|
|
// ===========================================================================
|
|
//
|
|
// The Play listing shows this list, the Data Safety form has to describe it, and
|
|
// a privacy-first period tracker is judged on it before anybody opens the app.
|
|
//
|
|
// It is also the list most likely to grow without anyone deciding to grow it: a
|
|
// dependency added for one feature brings its own <uses-permission>, the merge
|
|
// is silent, and it appears in the store listing months later. Adding
|
|
// WorkManager to this project added four in one line — WAKE_LOCK,
|
|
// ACCESS_NETWORK_STATE, RECEIVE_BOOT_COMPLETED and FOREGROUND_SERVICE — none of
|
|
// them typed by anybody.
|
|
//
|
|
// So the set is declared here and checked. Growing it is allowed; growing it by
|
|
// accident is not.
|
|
|
|
val allowedPermissions: Set<String> = setOf(
|
|
// Asked for when a reminder is switched on, never on first launch (§31).
|
|
"android.permission.POST_NOTIFICATIONS",
|
|
|
|
// The four WorkManager brings. None is requested by this project's own code.
|
|
// RECEIVE_BOOT_COMPLETED is the one that earns its place: it is how a
|
|
// reminder survives a restart.
|
|
"android.permission.WAKE_LOCK",
|
|
"android.permission.ACCESS_NETWORK_STATE",
|
|
"android.permission.RECEIVE_BOOT_COMPLETED",
|
|
"android.permission.FOREGROUND_SERVICE",
|
|
|
|
// The two androidx.biometric brings, for the app lock (§45). Neither is
|
|
// typed anywhere in this project's own manifest.
|
|
//
|
|
// USE_FINGERPRINT is the one that looks removable and is not. It is the
|
|
// pre-API-28 path, which minSdk 26 admits, and BiometricFragment reaches
|
|
// FingerprintManagerCompat through it — stripping it with tools:node="remove"
|
|
// would break the lock on exactly the oldest devices, which are the ones
|
|
// least able to fall back to anything else.
|
|
"android.permission.USE_BIOMETRIC",
|
|
"android.permission.USE_FINGERPRINT",
|
|
)
|
|
|
|
/**
|
|
* Permissions this app must NEVER declare, whatever else changes.
|
|
*
|
|
* Separate from "not in the allowlist" because these deserve their own message.
|
|
* §31 rules out exact alarms specifically: a period reminder does not need
|
|
* alarm-clock precision, and the permission costs Play scrutiny for nothing.
|
|
*/
|
|
val forbiddenPermissions: Set<String> = setOf(
|
|
"android.permission.SCHEDULE_EXACT_ALARM",
|
|
"android.permission.USE_EXACT_ALARM",
|
|
"android.permission.ACCESS_FINE_LOCATION",
|
|
"android.permission.ACCESS_COARSE_LOCATION",
|
|
"android.permission.READ_CONTACTS",
|
|
"android.permission.READ_CALENDAR",
|
|
"android.permission.CAMERA",
|
|
"android.permission.RECORD_AUDIO",
|
|
)
|
|
|
|
tasks.register("checkPermissions") {
|
|
group = "verification"
|
|
description = "The merged manifest may declare only the permissions listed in build.gradle.kts."
|
|
|
|
val allowed = allowedPermissions
|
|
val forbidden = forbiddenPermissions
|
|
val intermediates = layout.projectDirectory.dir("app/build/intermediates").asFile
|
|
|
|
// The manifest has to exist and be CURRENT before this reads it.
|
|
//
|
|
// Without this the task ran happily against whatever was left on disk from
|
|
// a previous build. prove-guard.sh caught it: a deliberate
|
|
// SCHEDULE_EXACT_ALARM was added to the manifest and the check stayed green,
|
|
// because it read the merged file from before the edit. The second guard in
|
|
// this project to be confidently green over exactly its own target.
|
|
// Both variants, and release is the one that matters: the Play listing and
|
|
// the Data Safety form describe the shipped manifest, not the debug one.
|
|
dependsOn(":app:processDebugMainManifest", ":app:processReleaseMainManifest")
|
|
|
|
doLast {
|
|
// Walked at execution time, not configuration time — a file tree
|
|
// resolved during configuration does not see a manifest written later
|
|
// in the same build.
|
|
// Only the outputs of the tasks above. AGP also leaves a legacy
|
|
// `merged_manifests` (plural) tree that nothing here regenerates, and
|
|
// reading it meant a stale file from an earlier build failing the check
|
|
// — a guard that cries wolf gets switched off.
|
|
val files = intermediates.resolve("merged_manifest").walkTopDown()
|
|
.filter { it.isFile && it.name == "AndroidManifest.xml" }
|
|
.toList()
|
|
if (files.isEmpty()) {
|
|
// Never a silent pass: no manifest means the check did not run.
|
|
throw GradleException(
|
|
"no merged manifest found, so no permission was checked. Build :app first.",
|
|
)
|
|
}
|
|
|
|
// Comments are stripped before parsing. This file's own comment names
|
|
// SCHEDULE_EXACT_ALARM to explain why it is absent, and a naive grep
|
|
// reported the explanation as the violation.
|
|
val commentRe = Regex("<!--.*?-->", RegexOption.DOT_MATCHES_ALL)
|
|
val permissionRe = Regex("""<uses-permission[^>]*android:name="([^"]+)"""")
|
|
|
|
val found = files.flatMap { file ->
|
|
permissionRe.findAll(commentRe.replace(file.readText(), ""))
|
|
.map { it.groupValues[1] }
|
|
}.toSet().filterNot { it.endsWith("DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION") }
|
|
|
|
val bad = found.filter { it in forbidden }
|
|
val unexpected = found.filterNot { it in allowed || it in forbidden }
|
|
|
|
if (bad.isNotEmpty() || unexpected.isNotEmpty()) {
|
|
logger.error("")
|
|
bad.forEach {
|
|
logger.error(" FORBIDDEN permission in the merged manifest: $it")
|
|
}
|
|
unexpected.forEach {
|
|
logger.error(" Undeclared permission in the merged manifest: $it")
|
|
}
|
|
logger.error("")
|
|
logger.error("A permission nobody typed usually arrives with a dependency. Find which,")
|
|
logger.error("decide whether this app should have it, and either remove it with")
|
|
logger.error("tools:node='remove' or add it to allowedPermissions with a reason.")
|
|
logger.error("Whatever you do, update docs/security/SECURITY.md — the Play listing and")
|
|
logger.error("the Data Safety form both describe this list.")
|
|
throw GradleException("${bad.size + unexpected.size} unapproved permission(s).")
|
|
}
|
|
|
|
logger.lifecycle("permissions: ${found.size} declared, all approved.")
|
|
}
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PRODUCT_PLAN.md §45: no health data in logs, and not by good behaviour
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// §45 forbids writing cycle dates to logs and requires verbose logging gone from
|
|
// release builds. Today the app honours that by simply not logging at all — two
|
|
// references to `android.util.Log` exist in the whole tree and neither is a log
|
|
// call. That is a property of nobody having typed one yet, not of anything
|
|
// stopping them.
|
|
//
|
|
// One `Log.d("cycle", record.toString())` added while chasing a defect writes
|
|
// menstrual dates to logcat, where a bug report, a crash reporter, and on older
|
|
// Android versions any app with log access can read them. It is one line, it
|
|
// looks harmless in review, and it is exactly what §45 is written about.
|
|
//
|
|
// ## The two things that make this guard rather than a grep
|
|
//
|
|
// Both are GUARDS.md §2, and both are live in this repository right now:
|
|
//
|
|
// - `PeriodApplication.kt` passes `android.util.Log.WARN` to WorkManager as a
|
|
// CONSTANT. It is not a log call and must not fail the build. So the check
|
|
// matches a method call — `Log.d(` — and never the class name alone.
|
|
// - `ReminderWorker.kt`'s KDoc says "a `Log.d` in a worker is the kind that
|
|
// survives", explaining why there isn't one. A naive grep fails on the
|
|
// comment that exists to prevent the thing. Comments are stripped first.
|
|
//
|
|
// A guard that punishes the clearest possible explanation gets the explanation
|
|
// deleted, which is a worse outcome than no guard.
|
|
|
|
/** Modules that can see a cycle date. `core/designsystem` cannot, so it is absent. */
|
|
val modulesSeeingHealthData: List<String> = listOf(
|
|
"app", "core/data", "core/database", "core/datastore",
|
|
"core/notifications", "core/security", "domain/cycle", "domain/prediction",
|
|
)
|
|
|
|
/**
|
|
* Call shapes that put a value somewhere a person can read it later.
|
|
*
|
|
* `printStackTrace` is in the list for the same reason as the rest: the stack it
|
|
* prints carries whatever the exception message holds, and the easiest way to
|
|
* write an exception message is to interpolate the record that caused it.
|
|
*/
|
|
val forbiddenLoggingCalls: List<String> = listOf(
|
|
"Log.v(", "Log.d(", "Log.i(", "Log.w(", "Log.e(", "Log.wtf(", "Log.println(",
|
|
"println(", "print(", "System.out", "System.err", "printStackTrace(",
|
|
)
|
|
|
|
tasks.register("checkNoHealthLogging") {
|
|
group = "verification"
|
|
description = "No logging call may exist in a module that can see a cycle date (PRODUCT_PLAN §45)."
|
|
|
|
val roots = modulesSeeingHealthData.map { layout.projectDirectory.dir(it).asFile }
|
|
val forbidden = forbiddenLoggingCalls
|
|
// Resolved at configuration time. Reaching for `layout` inside doLast
|
|
// captures the Project itself, which the configuration cache refuses to
|
|
// serialize — the build fails with a cache problem rather than a guard
|
|
// result, which reads like the guard is broken when it is not.
|
|
val repoRoot = layout.projectDirectory.asFile
|
|
|
|
doLast {
|
|
// Comments go before anything is matched. Block comments first, so a
|
|
// KDoc spanning lines cannot leave its middle behind, then line
|
|
// comments. Kotlin has no nested block comments to worry about here.
|
|
fun codeOf(text: String): String =
|
|
text.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "")
|
|
.lines().joinToString("\n") { it.substringBefore("//") }
|
|
|
|
var scanned = 0
|
|
val hits = mutableListOf<String>()
|
|
|
|
roots.forEach { root ->
|
|
root.walkTopDown()
|
|
.filter { it.isFile && it.extension == "kt" }
|
|
// `/build/` and `/bin/` are outputs, not source. `bin/` is an
|
|
// IDE artefact, gitignored, and it holds stale COPIES of test
|
|
// files — the first run of this guard failed on a println in a
|
|
// copy of a test that the real tree exempts. A guard that fails
|
|
// on untracked build output is a guard somebody switches off.
|
|
.filterNot { it.path.contains("/build/") || it.path.contains("/bin/") }
|
|
// Test sources are exempt: a test that prints is a test being
|
|
// debugged, it never ships, and forbidding it would push people
|
|
// to debug by other means.
|
|
.filterNot { it.path.contains("/src/test/") || it.path.contains("/src/androidTest/") }
|
|
.forEach { file ->
|
|
scanned++
|
|
val code = codeOf(file.readText())
|
|
code.lines().forEachIndexed { i, line ->
|
|
forbidden.forEach { pattern ->
|
|
if (line.contains(pattern)) {
|
|
hits += "${file.relativeTo(repoRoot)}:${i + 1} $pattern ${line.trim()}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Never a silent pass. A path typo in modulesSeeingHealthData would
|
|
// otherwise report a clean build having read nothing at all, which is
|
|
// how the module-boundary guard spent its first day green.
|
|
if (scanned == 0) {
|
|
throw GradleException(
|
|
"no Kotlin sources found, so no logging was checked. " +
|
|
"Check the paths in modulesSeeingHealthData.",
|
|
)
|
|
}
|
|
|
|
if (hits.isNotEmpty()) {
|
|
logger.error("")
|
|
logger.error("PRODUCT_PLAN.md §45: health data must never reach a log.")
|
|
logger.error("")
|
|
hits.forEach { logger.error(" $it") }
|
|
logger.error("")
|
|
logger.error("These modules can see a cycle date, so a log call in them can print one.")
|
|
logger.error("If you are debugging, delete the line before committing. If you genuinely")
|
|
logger.error("need diagnostics, emit a non-sensitive event NAME with no values, as §45's")
|
|
logger.error("debug logging rule shows, and add the call shape to an allowlist here with")
|
|
logger.error("the reason.")
|
|
throw GradleException("${hits.size} logging call(s) where health data is visible.")
|
|
}
|
|
|
|
logger.lifecycle("health logging: $scanned Kotlin file(s) checked, no logging calls.")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Every themed drawable has a night twin — PRODUCT_PLAN.md §37
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The theme swap is pure resource resolution, which is what makes it robust and
|
|
// also what makes a gap silent. A missing `drawable-night` file does not fail,
|
|
// does not warn, and renders the light illustration on a dark screen. Nobody
|
|
// sees it unless they open that one screen in that one theme.
|
|
//
|
|
// `OnboardingPreviews.kt` renders all eight in a light/dark pair for exactly
|
|
// this reason, and its own KDoc says why: "a set of eight where seven have a
|
|
// night variant looks completely fine in light mode." But a preview is not a
|
|
// test. It fails no build and nothing runs it.
|
|
|
|
/** Resource roots whose light/dark pairing is enforced. */
|
|
val themedResourceRoots: List<String> = listOf(
|
|
"app/src/main/res",
|
|
"core/designsystem/src/main/res",
|
|
"core/notifications/src/main/res",
|
|
)
|
|
|
|
/**
|
|
* Resource names that legitimately have no night twin, each with its reason.
|
|
*
|
|
* An exemption list rather than a narrower scope, because the rule should cover
|
|
* drawables added later by default — the failure this guards against is a file
|
|
* somebody forgot, and a scope that only names today's illustrations would not
|
|
* cover tomorrow's.
|
|
*/
|
|
val themedDrawableExemptions: Map<String, String> = mapOf(
|
|
"ic_launcher_monochrome" to
|
|
"themed monochrome vector — the launcher tints it from the system palette, " +
|
|
"so a night copy would be a second source of truth for one shape",
|
|
"ic_notification" to
|
|
"alpha-only status bar mark — Android masks a small icon to a silhouette " +
|
|
"and supplies the colour itself, so a dark variant would never be drawn",
|
|
)
|
|
|
|
tasks.register("checkThemedDrawables") {
|
|
group = "verification"
|
|
description = "Every drawable must have a night twin of the same name, and vice versa."
|
|
|
|
// Resolved at configuration time. Reaching for `layout` inside doLast
|
|
// captures the Project, which the configuration cache refuses to serialize.
|
|
val roots = themedResourceRoots.map { layout.projectDirectory.dir(it).asFile }
|
|
val repoRoot = layout.projectDirectory.asFile
|
|
val exemptions = themedDrawableExemptions
|
|
|
|
doLast {
|
|
/** `drawable-hdpi` -> `drawable-night-hdpi`; `drawable` -> `drawable-night`. */
|
|
fun nightNameOf(light: String): String =
|
|
if (light == "drawable") "drawable-night" else light.replaceFirst("drawable-", "drawable-night-")
|
|
|
|
/** Resource name is the file name without its extension: art_welcome.webp -> art_welcome. */
|
|
fun resourceNamesIn(dir: File): Set<String> =
|
|
dir.listFiles()
|
|
?.filter { it.isFile }
|
|
?.map { it.name.substringBeforeLast(".") }
|
|
?.toSet()
|
|
.orEmpty()
|
|
|
|
val violations = mutableListOf<String>()
|
|
var pairsChecked = 0
|
|
var resourcesSeen = 0
|
|
|
|
roots.forEach { root ->
|
|
if (!root.isDirectory) return@forEach
|
|
|
|
val drawableDirs = root.listFiles()
|
|
?.filter { it.isDirectory && it.name.startsWith("drawable") }
|
|
// `/build/` and `/bin/` are outputs, not source — the same trap
|
|
// checkNoHealthLogging fell into on its first run.
|
|
?.filterNot { it.path.contains("/build/") || it.path.contains("/bin/") }
|
|
.orEmpty()
|
|
|
|
val lightDirs = drawableDirs.filterNot { it.name.contains("-night") }
|
|
val nightDirs = drawableDirs.filter { it.name.contains("-night") }
|
|
|
|
lightDirs.forEach { lightDir ->
|
|
val nightDir = File(root, nightNameOf(lightDir.name))
|
|
val light = resourceNamesIn(lightDir).filterNot { it in exemptions }.toSet()
|
|
val night = resourceNamesIn(nightDir)
|
|
resourcesSeen += light.size
|
|
pairsChecked++
|
|
|
|
(light - night).sorted().forEach {
|
|
violations += "$it has no night twin — expected " +
|
|
"${File(nightDir, it).relativeTo(repoRoot)}.<ext>"
|
|
}
|
|
}
|
|
|
|
// The other direction. A night asset with no light counterpart is
|
|
// the same defect seen from the other side, and it renders as
|
|
// nothing at all in light mode rather than as the wrong picture.
|
|
nightDirs.forEach { nightDir ->
|
|
val lightName = nightDir.name.replaceFirst("drawable-night", "drawable").ifEmpty { "drawable" }
|
|
val lightDir = File(root, if (lightName == "drawable-") "drawable" else lightName)
|
|
val night = resourceNamesIn(nightDir).filterNot { it in exemptions }.toSet()
|
|
val light = resourceNamesIn(lightDir)
|
|
resourcesSeen += night.size
|
|
|
|
(night - light).sorted().forEach {
|
|
violations += "$it exists only in the dark set — expected " +
|
|
"${File(lightDir, it).relativeTo(repoRoot)}.<ext>"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Never a silent pass. A renamed folder or a changed module path would
|
|
// otherwise report a clean build having compared nothing, which is how
|
|
// checkModuleBoundaries spent its first day green.
|
|
if (resourcesSeen == 0) {
|
|
throw GradleException(
|
|
"no drawables were found, so no pairing was checked. This is not a pass — " +
|
|
"check the paths in themedResourceRoots.",
|
|
)
|
|
}
|
|
|
|
if (violations.isNotEmpty()) {
|
|
logger.error("")
|
|
logger.error("Themed drawables must exist in both light and dark:")
|
|
logger.error("")
|
|
violations.forEach { logger.error(" - $it") }
|
|
logger.error("")
|
|
logger.error("A missing night asset does not fail at runtime. Android falls back to the")
|
|
logger.error("light drawable and renders it on a dark screen, so the only way to notice")
|
|
logger.error("is to open that screen in that theme. If a drawable genuinely needs no")
|
|
logger.error("night variant, add it to themedDrawableExemptions with the reason.")
|
|
throw GradleException("${violations.size} drawable(s) missing a light or dark counterpart.")
|
|
}
|
|
|
|
logger.lifecycle(
|
|
"themed drawables: $resourcesSeen resource(s) across $pairsChecked folder pair(s), " +
|
|
"all paired.",
|
|
)
|
|
}
|
|
}
|
|
|
|
// Wired into `check` so it runs with the tests rather than only when remembered.
|
|
subprojects {
|
|
tasks.matching { it.name == "check" }.configureEach {
|
|
dependsOn(rootProject.tasks.named("checkModuleBoundaries"))
|
|
if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions"))
|
|
dependsOn(rootProject.tasks.named("checkNoHealthLogging"))
|
|
dependsOn(rootProject.tasks.named("checkThemedDrawables"))
|
|
}
|
|
}
|