// 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> = 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>() val observedAndroidPlugins = mutableMapOf>() val containerProjects = mutableSetOf() 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() } .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() 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 , 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 = 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 = 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. // // And only the SHIPPING variants. AGP writes a merged manifest for test // variants too, under `merged_manifest/debugUnitTest/`, which appears // the moment a module turns on `unitTests.isIncludeAndroidResources`. // That manifest carries the test runner's own permissions — REORDER_TASKS // among them — none of which reach a user. Reading it failed the build // over a permission that does not ship, and the tempting fix is to add // it to `allowedPermissions`, which would then permit it in the real // manifest as well and quietly undo the guard. val shippingVariants = setOf("debug", "release") val variantDirs = intermediates.resolve("merged_manifest").listFiles() ?.filter { it.isDirectory && it.name in shippingVariants } .orEmpty() val files = variantDirs.flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.name == "AndroidManifest.xml" }.toList() } // Never a silent pass, and it has to be stricter than "found something". // Checking debug while release quietly went unread is the failure that // matters here: the Play listing and the Data Safety form describe the // release manifest, so a guard that only ever saw debug would be green // over the one that ships. val seen = variantDirs.map { it.name }.toSet() if (files.isEmpty() || seen != shippingVariants) { throw GradleException( "expected a merged manifest for each of $shippingVariants but read " + "${seen.ifEmpty { "none" }}, so the permission set was not checked. " + "This is not a pass — 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("""]*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 = 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 = 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() 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 = 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 = 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 = dir.listFiles() ?.filter { it.isFile } ?.map { it.name.substringBeforeLast(".") } ?.toSet() .orEmpty() val violations = mutableListOf() 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)}." } } // 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)}." } } } // 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")) } }