chore: adopt the project template and add the Kotlin/Compose skeleton
Period was a bare directory holding one 2,527-line specification, with no git
repository, no tracker and no documentation convention. This is the adoption
from Projects/Template/START-HERE-New-Project.md, plus a project that compiles
so the hooks and future guards have something real to run against.
Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to
docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the
capitalised Docs/ is gone. Every scaffolded document was filled in for Period.
docs/OPERATIONS.md deleted — an offline app is not a deployed service.
DOC_TRUST_MAP.md written last, describing what is actually here, including what
this project deliberately does not have.
Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm")
and cannot see the Android SDK, so the engine is testable without an emulator —
17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51.
BaselinePredictionEngine is a robust-median prototype and explicitly not the
product; it exists so Batch 02's replacement can be shown to be better rather
than merely different.
Versions verified against their official sources today rather than inherited
from the specification's own numbers, which that document asks for: Kotlin
2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt
2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no
longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk
stays 36, Play's floor from 2026-08-31, and the difference is deliberate.
Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md.
Three hooks in .githooks/, with pre-commit adapted to Gradle.
closes #1
closes #2
2026-08-18 02:16:47 -05:00
|
|
|
// 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
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
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(
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
":app" to setOf(
|
|
|
|
|
":core:designsystem", ":core:data", ":core:datastore", ":core:notifications",
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
":core:security", ":core:export", ":domain:cycle", ":domain:prediction",
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
),
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
":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"),
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
":core:notifications" to setOf(":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"),
|
feat: app lock, with no way to reset a forgotten PIN
§45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has
existed since Batch 01 with nothing outside its own module reading it; this
wires it, and adds the rest.
The recovery question was the reason #34 sat open, and it is decided: there is
no recovery. A backdoor into a period tracker's lock would be used by exactly
the person the lock exists to stop. Everything below follows from that.
## The gate
AppLockGate wraps the whole composition rather than being a screen inside it.
Today, Calendar and Insights each start collecting from CycleRepository the
moment they compose, so a lock implemented as a nav destination would already
have read the history before the user proved anything. content() is invoked
only in the unlocked branch.
Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings
and a permission dialog. Two guards on top: isChangingConfigurations, or
rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM
biometric overlay that stops the activity produces a lock that can never be
opened. No grace period: SECURITY.md leads with "someone who picks up an
unlocked phone", which is the window a grace period covers.
The unlock flag lives in a @Singleton, never in saved state. rememberSaveable
looks like the obvious home and would restore a background-killed app already
unlocked — the single most likely way to meet the lock screen would be the one
path that skipped it.
## What is stored is not the PIN
mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k))
Two layers because they defend different things. The Keystore MAC is what makes
a six-digit PIN safe at all — a million candidates is nothing to an attacker who
can compute the hash, and impossible for one who cannot get the key off the
device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a
domain-separation tag; the lockout counter is MACed under 0x02.
The key omits six builder calls and the KDoc names every one. setUserAuthenti-
cationRequired is the important absence: it would bind the key to the device
lock, so changing a passcode would destroy it — and under no-recovery that is
somebody's whole history gone for an unrelated reason. It would also be a
bypass, since SECURITY.md already names "someone who knows the unlock PIN" as
an adversary. The biometric key is separate and takes the opposite policy,
where invalidation correctly degrades to "use your PIN".
## Wrong PINs cost time, never data
Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and
no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a
pocket destroy a history while knowing nothing. Both clock bypasses are closed —
the wait is the longer of a wall-clock and a monotonic deadline, and a reboot
re-applies it in full, detected by elapsedRealtime going backwards.
## Two writes that had to move
Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on
the phone's own lock screen, reachable by anybody, so the action is now parked
in AppLockController and applied only after an unlock — dropped if the session
never unlocks. Behaviour is unchanged when the lock is off.
The erase behind "Forgot your PIN?" deletes health data, then the Keystore key,
then the lock store. Skipping the middle step leaves the user erased AND still
locked out; prove-guard mutates that line out and requires exactly one red.
## Found by testing, not by review
- A fresh install began in a 15-minute lockout: "no counter yet" and "counter
was tampered with" were the same value. They are now distinct.
- Setting a PIN locked you out of the session you set it in. Found on the
emulator, not in a test.
- Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice.
## Verified
244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and
PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice
here with no margin, and that the key is not auth-bound on either.
On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on
the lock screen, turning the lock off requires the current PIN, and
`adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real.
androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx
never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed
checkPermissions until they were allowed on purpose, and it drags fragment to
1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity.
closes #34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
|
|
|
// 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(),
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
// Only :domain:cycle, and the omission is the rule. An export must contain
|
|
|
|
|
// "only the user's own data — no derived analytics": not depending on
|
|
|
|
|
// :domain:prediction puts Prediction, PredictionAccuracy, FertilityEstimate
|
|
|
|
|
// and CycleRecord off this module's classpath entirely, so adding one is a
|
|
|
|
|
// compile error rather than something review has to catch.
|
|
|
|
|
":core:export" to setOf(":domain:cycle"),
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
":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. */
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
val mustStayPureJvm = setOf(":domain:cycle", ":domain:prediction", ":core:export")
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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>()
|
|
|
|
|
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
/** Directory of every real module, relative to the root, filled in `afterEvaluate`. */
|
|
|
|
|
val observedModuleDirs = mutableSetOf<String>()
|
|
|
|
|
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
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
|
|
|
|
|
}
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
observedModuleDirs += projectDir.relativeTo(rootDir).invariantSeparatorsPath
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
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(", ")})",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
// ===========================================================================
|
|
|
|
|
// 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",
|
feat: app lock, with no way to reset a forgotten PIN
§45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has
existed since Batch 01 with nothing outside its own module reading it; this
wires it, and adds the rest.
The recovery question was the reason #34 sat open, and it is decided: there is
no recovery. A backdoor into a period tracker's lock would be used by exactly
the person the lock exists to stop. Everything below follows from that.
## The gate
AppLockGate wraps the whole composition rather than being a screen inside it.
Today, Calendar and Insights each start collecting from CycleRepository the
moment they compose, so a lock implemented as a nav destination would already
have read the history before the user proved anything. content() is invoked
only in the unlocked branch.
Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings
and a permission dialog. Two guards on top: isChangingConfigurations, or
rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM
biometric overlay that stops the activity produces a lock that can never be
opened. No grace period: SECURITY.md leads with "someone who picks up an
unlocked phone", which is the window a grace period covers.
The unlock flag lives in a @Singleton, never in saved state. rememberSaveable
looks like the obvious home and would restore a background-killed app already
unlocked — the single most likely way to meet the lock screen would be the one
path that skipped it.
## What is stored is not the PIN
mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k))
Two layers because they defend different things. The Keystore MAC is what makes
a six-digit PIN safe at all — a million candidates is nothing to an attacker who
can compute the hash, and impossible for one who cannot get the key off the
device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a
domain-separation tag; the lockout counter is MACed under 0x02.
The key omits six builder calls and the KDoc names every one. setUserAuthenti-
cationRequired is the important absence: it would bind the key to the device
lock, so changing a passcode would destroy it — and under no-recovery that is
somebody's whole history gone for an unrelated reason. It would also be a
bypass, since SECURITY.md already names "someone who knows the unlock PIN" as
an adversary. The biometric key is separate and takes the opposite policy,
where invalidation correctly degrades to "use your PIN".
## Wrong PINs cost time, never data
Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and
no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a
pocket destroy a history while knowing nothing. Both clock bypasses are closed —
the wait is the longer of a wall-clock and a monotonic deadline, and a reboot
re-applies it in full, detected by elapsedRealtime going backwards.
## Two writes that had to move
Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on
the phone's own lock screen, reachable by anybody, so the action is now parked
in AppLockController and applied only after an unlock — dropped if the session
never unlocks. Behaviour is unchanged when the lock is off.
The erase behind "Forgot your PIN?" deletes health data, then the Keystore key,
then the lock store. Skipping the middle step leaves the user erased AND still
locked out; prove-guard mutates that line out and requires exactly one red.
## Found by testing, not by review
- A fresh install began in a 15-minute lockout: "no counter yet" and "counter
was tampered with" were the same value. They are now distinct.
- Setting a PIN locked you out of the session you set it in. Found on the
emulator, not in a test.
- Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice.
## Verified
244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and
PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice
here with no margin, and that the key is not auth-bound on either.
On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on
the lock screen, turning the lock off requires the current PIN, and
`adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real.
androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx
never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed
checkPermissions until they were allowed on purpose, and it drags fragment to
1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity.
closes #34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
|
|
|
|
|
|
|
|
// 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",
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
feat: the privacy promise appears in Settings, from one string
§4 requires the promise in onboarding, in Settings, and on a public privacy
page. It was made once, during onboarding, before the user had entered a single
date — which makes it a marketing line. Repeated above the controls that act on
that data, it is a statement somebody can hold the product to.
One copy, in strings.xml, read by both screens. A second literal is how two
versions of a promise come to exist, which is the failure DOC_TRUST_MAP.md
exists to prevent, here in code rather than in prose.
There is NO Privacy Policy row. §4 wants one and no hosted page exists, and a
policy link that 404s is worse than no link — which is also the convention
SettingsScreen already states: a row for something unbuilt is absent, not
disabled. The issue's verify line allows exactly this.
Three tests, and the second is the one that matters. The promise must say we
never SELL the data, and must NOT have been strengthened into claims the app
cannot keep — no third party, never shared, end-to-end — because Play Billing
and an ad SDK eventually will process something, and a promise the
implementation cannot keep is worse than a narrower one that holds. The third
scans Kotlin for a re-introduced literal, with comments stripped first per
GUARDS.md §2, or the KDoc explaining the rule would fail it.
Proved: replacing the resource lookup with the literal fails exactly one test.
## Two defects found on the way, both pre-existing
**No Robolectric test in :app could read a string resource.** core/database and
core/data have carried unitTests.isIncludeAndroidResources since they were
written; app never did. So the module owning almost all of the user-facing copy
was the one module whose copy could not be tested, and every getString() threw
NotFoundException with an id that had resolved perfectly well.
**checkPermissions read manifests that do not ship.** Turning the above on made
AGP write merged_manifest/debugUnitTest/, the guard walked the whole tree, and
the build failed on REORDER_TASKS — a test-runner permission no user ever sees.
The tempting fix is to allowlist it, which would then permit it in the real
manifest too and quietly undo the guard. It now reads only debug and release,
and refuses to pass unless it read BOTH: checking debug while release went
unread is the failure that matters, since the Play listing and the Data Safety
form describe the release manifest.
That is strictly stricter than before, and proved twice — a forbidden permission
in the app manifest still fails it, and a missing release manifest now fails it
where it used to pass.
GUARDS.md §8 gains a third prove-guard edge, found while proving the above: a
FAIL_PATTERN matching nothing gives the same "caught it, and only it" verdict as
one matching exactly once, because the script only refuses on more than one. The
empty "what failed" block is the tell.
closes #37
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:00:06 -05:00
|
|
|
//
|
|
|
|
|
// 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) {
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
throw GradleException(
|
feat: the privacy promise appears in Settings, from one string
§4 requires the promise in onboarding, in Settings, and on a public privacy
page. It was made once, during onboarding, before the user had entered a single
date — which makes it a marketing line. Repeated above the controls that act on
that data, it is a statement somebody can hold the product to.
One copy, in strings.xml, read by both screens. A second literal is how two
versions of a promise come to exist, which is the failure DOC_TRUST_MAP.md
exists to prevent, here in code rather than in prose.
There is NO Privacy Policy row. §4 wants one and no hosted page exists, and a
policy link that 404s is worse than no link — which is also the convention
SettingsScreen already states: a row for something unbuilt is absent, not
disabled. The issue's verify line allows exactly this.
Three tests, and the second is the one that matters. The promise must say we
never SELL the data, and must NOT have been strengthened into claims the app
cannot keep — no third party, never shared, end-to-end — because Play Billing
and an ad SDK eventually will process something, and a promise the
implementation cannot keep is worse than a narrower one that holds. The third
scans Kotlin for a re-introduced literal, with comments stripped first per
GUARDS.md §2, or the KDoc explaining the rule would fail it.
Proved: replacing the resource lookup with the literal fails exactly one test.
## Two defects found on the way, both pre-existing
**No Robolectric test in :app could read a string resource.** core/database and
core/data have carried unitTests.isIncludeAndroidResources since they were
written; app never did. So the module owning almost all of the user-facing copy
was the one module whose copy could not be tested, and every getString() threw
NotFoundException with an id that had resolved perfectly well.
**checkPermissions read manifests that do not ship.** Turning the above on made
AGP write merged_manifest/debugUnitTest/, the guard walked the whole tree, and
the build failed on REORDER_TASKS — a test-runner permission no user ever sees.
The tempting fix is to allowlist it, which would then permit it in the real
manifest too and quietly undo the guard. It now reads only debug and release,
and refuses to pass unless it read BOTH: checking debug while release went
unread is the failure that matters, since the Play listing and the Data Safety
form describe the release manifest.
That is strictly stricter than before, and proved twice — a forbidden permission
in the app manifest still fails it, and a missing release manifest now fails it
where it used to pass.
GUARDS.md §8 gains a third prove-guard edge, found while proving the above: a
FAIL_PATTERN matching nothing gives the same "caught it, and only it" verdict as
one matching exactly once, because the script only refuses on more than one. The
empty "what failed" block is the tell.
closes #37
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:00:06 -05:00
|
|
|
"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.",
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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.
|
|
|
|
|
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
/**
|
|
|
|
|
* Modules that provably cannot see a cycle date, each with the reason.
|
|
|
|
|
*
|
|
|
|
|
* Promoted from a comment to a declaration, because the two lists together are
|
|
|
|
|
* what makes the check below complete. A module in neither is not "assumed
|
|
|
|
|
* fine" — it is unmeasured, and that is reported.
|
|
|
|
|
*/
|
|
|
|
|
val modulesWithNoHealthData: Map<String, String> = mapOf(
|
|
|
|
|
"core/designsystem" to
|
|
|
|
|
"colour and type tokens only — it depends on nothing in this project, " +
|
|
|
|
|
"so no cycle type is even on its classpath",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/** Modules that can see a cycle date. */
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
val modulesSeeingHealthData: List<String> = listOf(
|
|
|
|
|
"app", "core/data", "core/database", "core/datastore",
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
"core/notifications", "core/security", "core/export",
|
|
|
|
|
"domain/cycle", "domain/prediction",
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
// Read in doLast by reference, filled by the afterEvaluate block above —
|
|
|
|
|
// the same arrangement checkModuleBoundaries uses for its observations.
|
|
|
|
|
val allModules = observedModuleDirs
|
|
|
|
|
val scanned0 = modulesSeeingHealthData.toSet()
|
|
|
|
|
val exemptModules = modulesWithNoHealthData
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
// 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()}"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
// The hole this closes is the one this project has documented twice and
|
|
|
|
|
// been unable to check: a module missing from modulesSeeingHealthData is
|
|
|
|
|
// not scanned, and an unscanned module looks exactly like a clean one.
|
|
|
|
|
// `app/proguard-rules.pro` describes it — "somebody adding a module and
|
|
|
|
|
// forgetting to list it gets no warning" — and both :core:security and
|
|
|
|
|
// :core:export would have shipped key material and a serializer of raw
|
|
|
|
|
// cycle dates through it.
|
|
|
|
|
//
|
|
|
|
|
// So every module must be in one list or the other, and being in
|
|
|
|
|
// neither is a violation rather than an exemption.
|
|
|
|
|
val undeclared = (allModules - scanned0 - exemptModules.keys).sorted()
|
|
|
|
|
if (undeclared.isNotEmpty()) {
|
|
|
|
|
logger.error("")
|
|
|
|
|
logger.error("These modules are in neither list, so nothing checked them:")
|
|
|
|
|
undeclared.forEach { logger.error(" - $it is in neither modulesSeeingHealthData nor modulesWithNoHealthData") }
|
|
|
|
|
logger.error("")
|
|
|
|
|
logger.error("A module that can see a cycle date belongs in the first. One that")
|
|
|
|
|
logger.error("provably cannot belongs in the second, WITH ITS REASON. Absence from")
|
|
|
|
|
logger.error("both is not an exemption — it is a module nobody measured.")
|
|
|
|
|
throw GradleException("${undeclared.size} module(s) declared in neither logging list.")
|
|
|
|
|
}
|
|
|
|
|
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
// 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.")
|
|
|
|
|
}
|
|
|
|
|
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
logger.lifecycle(
|
|
|
|
|
"health logging: $scanned Kotlin file(s) across ${scanned0.size} module(s) checked, " +
|
|
|
|
|
"${exemptModules.size} declared unable to see health data, no logging calls.",
|
|
|
|
|
)
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: a guard that every drawable has a night twin
The light/dark pairing was held by a @Preview and nothing else. A preview fails
no build and nothing runs it, and the KDoc on OnboardingPreviews.kt already said
why that matters: "a set of eight where seven have a night variant looks
completely fine in light mode."
The failure mode is what makes this worth a guard. A missing drawable-night file
does not crash, does not warn, and does not fall back to nothing — Android
resolves the light drawable and draws it on a dark screen. The only other way to
find it is to open that one screen in that one theme, which is how #44 was found
and how it sat unnoticed until somebody looked.
checkThemedDrawables walks both directions: a light asset with no night twin,
and a night asset with no light one. The second is the same defect from the
other side and renders as nothing rather than as the wrong picture.
Exemptions are a named map with a reason each, rather than a narrowed scope.
ic_launcher_monochrome is the only entry: the launcher tints it from the system
palette, so a night copy would be a second source of truth for one shape. A
scope that only listed today's eight illustrations would not cover tomorrow's,
and the defect this guards against is a file somebody forgot.
Proved four ways, because prove-guard.sh cannot drive this one — it replaces a
string inside a file, and this guard's failure mode is a file that is not there,
in a set that is all .webp. GUARDS.md gains section 9 for that class of guard,
and the manual recipe from section 1 was run instead:
- a night twin deleted -> exactly 1 violation, naming art_welcome
- a dark-only asset added -> exactly 1 violation, naming art_orphan
- both restored -> green, 64 resources across 5 folder pairs
- roots pointed at a folder
that does not exist -> "no drawables were found ... not a pass"
The fourth is the one worth copying. Refusing to report a pass over an empty
observation is itself a thing to prove: a guard that finds nothing and says
"clean" is the failure GUARDS.md was written after, and two guards in this
project have done exactly that.
closes #42
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:37:43 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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",
|
feat: the status bar shows this app's mark, not a framework glyph
Both builders in PeriodNotifier called setSmallIcon(android.R.drawable.ic_dialog_info).
Android masks a small icon to a white silhouette taken from its alpha channel, so
what every reminder this app has ever sent put in the status bar was that
framework asset's outline.
ic_notification is redrawn rather than copied from ic_launcher_monochrome, and
the difference is the point: that file is a 108dp launcher canvas whose shape
sits in the upper safe zone because a launcher crops and masks it. Copying its
geometry would have produced a small mark floating above centre. This is a 24dp
canvas the ring nearly fills, opaque white throughout, because the system
discards colour and supplies its own.
Both call sites, not one. The public builder is what a locked screen renders and
is the one that matters most here.
## Two tests, because "which resource id" is not the whole claim
bothTheLockScreenAndTheShadeShowThisAppsOwnMark reads the posted Notification
rather than the source, and checks the public version separately — a change made
by half is the likely mistake and it fails silently on the surface this product
is most careful about.
theStatusBarMarkRendersAsAReadableSilhouette renders the vector and measures
alpha coverage. Two failures look identical in source and completely different in
the status bar: a vector that draws nothing, and one that draws a filled shape.
Neither is caught by asserting a resource id.
Proved: reverting only the public builder fails exactly one test, naming that
builder. prove-guard exit 0.
## NotificationPrivacyTest could never run on minSdk
Found while satisfying this issue's own verify line. GrantPermissionRule asked
for POST_NOTIFICATIONS unconditionally, and that permission arrived in API 33 —
so on PeriodMinSdk26 every test in the class errored with "Failed to grant
permissions" before reaching an assertion, for a reason unrelated to what it
tests.
That is how it stayed unnoticed: it is the only emulator where it fails, and a
green run on a modern image looks like a green run. The class guards what a
LOCKED SCREEN shows. "Passes on the newest device" was never the claim worth
having. The rule is conditional now, and below 33 no permission is needed to
post at all, so a no-op rule is correct rather than a workaround.
All six tests now pass on PeriodMinSdk26 — the first time this file has run
there.
## Not verified
The API 36 instrumented run. That emulator repeatedly dies the moment Gradle
starts on this machine today, across three attempts and after freeing memory; it
ran the whole app-lock UI verification earlier in the same session, so this is
resource contention rather than a defect. The SECURITY_CHECKLIST row covers it,
and both new assertions are resource-id and render checks whose substance does
not vary by API level.
closes #43
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:51:54 -05:00
|
|
|
"core/notifications/src/main/res",
|
feat: a guard that every drawable has a night twin
The light/dark pairing was held by a @Preview and nothing else. A preview fails
no build and nothing runs it, and the KDoc on OnboardingPreviews.kt already said
why that matters: "a set of eight where seven have a night variant looks
completely fine in light mode."
The failure mode is what makes this worth a guard. A missing drawable-night file
does not crash, does not warn, and does not fall back to nothing — Android
resolves the light drawable and draws it on a dark screen. The only other way to
find it is to open that one screen in that one theme, which is how #44 was found
and how it sat unnoticed until somebody looked.
checkThemedDrawables walks both directions: a light asset with no night twin,
and a night asset with no light one. The second is the same defect from the
other side and renders as nothing rather than as the wrong picture.
Exemptions are a named map with a reason each, rather than a narrowed scope.
ic_launcher_monochrome is the only entry: the launcher tints it from the system
palette, so a night copy would be a second source of truth for one shape. A
scope that only listed today's eight illustrations would not cover tomorrow's,
and the defect this guards against is a file somebody forgot.
Proved four ways, because prove-guard.sh cannot drive this one — it replaces a
string inside a file, and this guard's failure mode is a file that is not there,
in a set that is all .webp. GUARDS.md gains section 9 for that class of guard,
and the manual recipe from section 1 was run instead:
- a night twin deleted -> exactly 1 violation, naming art_welcome
- a dark-only asset added -> exactly 1 violation, naming art_orphan
- both restored -> green, 64 resources across 5 folder pairs
- roots pointed at a folder
that does not exist -> "no drawables were found ... not a pass"
The fourth is the one worth copying. Refusing to report a pass over an empty
observation is itself a thing to prove: a guard that finds nothing and says
"clean" is the failure GUARDS.md was written after, and two guards in this
project have done exactly that.
closes #42
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:37:43 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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",
|
2026-08-20 02:37:29 -05:00
|
|
|
"ic_launcher_incognito_foreground" to
|
|
|
|
|
"neutral activity-alias launcher artwork — Android renders the adaptive icon " +
|
|
|
|
|
"outside the app theme, so duplicating it in drawable-night would be a second source of truth",
|
|
|
|
|
"ic_launcher_incognito_monochrome" to
|
|
|
|
|
"themed monochrome activity-alias vector — the launcher tints it from the " +
|
|
|
|
|
"system palette, so a night copy would be a second source of truth for one shape",
|
feat: the status bar shows this app's mark, not a framework glyph
Both builders in PeriodNotifier called setSmallIcon(android.R.drawable.ic_dialog_info).
Android masks a small icon to a white silhouette taken from its alpha channel, so
what every reminder this app has ever sent put in the status bar was that
framework asset's outline.
ic_notification is redrawn rather than copied from ic_launcher_monochrome, and
the difference is the point: that file is a 108dp launcher canvas whose shape
sits in the upper safe zone because a launcher crops and masks it. Copying its
geometry would have produced a small mark floating above centre. This is a 24dp
canvas the ring nearly fills, opaque white throughout, because the system
discards colour and supplies its own.
Both call sites, not one. The public builder is what a locked screen renders and
is the one that matters most here.
## Two tests, because "which resource id" is not the whole claim
bothTheLockScreenAndTheShadeShowThisAppsOwnMark reads the posted Notification
rather than the source, and checks the public version separately — a change made
by half is the likely mistake and it fails silently on the surface this product
is most careful about.
theStatusBarMarkRendersAsAReadableSilhouette renders the vector and measures
alpha coverage. Two failures look identical in source and completely different in
the status bar: a vector that draws nothing, and one that draws a filled shape.
Neither is caught by asserting a resource id.
Proved: reverting only the public builder fails exactly one test, naming that
builder. prove-guard exit 0.
## NotificationPrivacyTest could never run on minSdk
Found while satisfying this issue's own verify line. GrantPermissionRule asked
for POST_NOTIFICATIONS unconditionally, and that permission arrived in API 33 —
so on PeriodMinSdk26 every test in the class errored with "Failed to grant
permissions" before reaching an assertion, for a reason unrelated to what it
tests.
That is how it stayed unnoticed: it is the only emulator where it fails, and a
green run on a modern image looks like a green run. The class guards what a
LOCKED SCREEN shows. "Passes on the newest device" was never the claim worth
having. The rule is conditional now, and below 33 no permission is needed to
post at all, so a no-op rule is correct rather than a workaround.
All six tests now pass on PeriodMinSdk26 — the first time this file has run
there.
## Not verified
The API 36 instrumented run. That emulator repeatedly dies the moment Gradle
starts on this machine today, across three attempts and after freeing memory; it
ran the whole app-lock UI verification earlier in the same session, so this is
resource contention rather than a defect. The SECURITY_CHECKLIST row covers it,
and both new assertions are resource-id and render checks whose substance does
not vary by API level.
closes #43
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:51:54 -05:00
|
|
|
"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",
|
feat: a guard that every drawable has a night twin
The light/dark pairing was held by a @Preview and nothing else. A preview fails
no build and nothing runs it, and the KDoc on OnboardingPreviews.kt already said
why that matters: "a set of eight where seven have a night variant looks
completely fine in light mode."
The failure mode is what makes this worth a guard. A missing drawable-night file
does not crash, does not warn, and does not fall back to nothing — Android
resolves the light drawable and draws it on a dark screen. The only other way to
find it is to open that one screen in that one theme, which is how #44 was found
and how it sat unnoticed until somebody looked.
checkThemedDrawables walks both directions: a light asset with no night twin,
and a night asset with no light one. The second is the same defect from the
other side and renders as nothing rather than as the wrong picture.
Exemptions are a named map with a reason each, rather than a narrowed scope.
ic_launcher_monochrome is the only entry: the launcher tints it from the system
palette, so a night copy would be a second source of truth for one shape. A
scope that only listed today's eight illustrations would not cover tomorrow's,
and the defect this guards against is a file somebody forgot.
Proved four ways, because prove-guard.sh cannot drive this one — it replaces a
string inside a file, and this guard's failure mode is a file that is not there,
in a set that is all .webp. GUARDS.md gains section 9 for that class of guard,
and the manual recipe from section 1 was run instead:
- a night twin deleted -> exactly 1 violation, naming art_welcome
- a dark-only asset added -> exactly 1 violation, naming art_orphan
- both restored -> green, 64 resources across 5 folder pairs
- roots pointed at a folder
that does not exist -> "no drawables were found ... not a pass"
The fourth is the one worth copying. Refusing to report a pass over an empty
observation is itself a thing to prove: a guard that finds nothing and says
"clean" is the failure GUARDS.md was written after, and two guards in this
project have done exactly that.
closes #42
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:37:43 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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.",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Health data never reaches shared storage — PRODUCT_PLAN.md §45
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// §45 forbids writing health history to shared external storage, and until now
|
|
|
|
|
// that rule was enforced by nobody having typed it. `checkPermissions` cannot
|
|
|
|
|
// see this: it matches `<uses-permission>` only, so a `<provider>` declaring
|
|
|
|
|
// androidx's FileProvider merges green — and FileProvider is already on the
|
|
|
|
|
// classpath through core-ktx, so adding one is a manifest entry away.
|
|
|
|
|
//
|
|
|
|
|
// The pattern this exists to stop is the obvious way to build an export: write
|
|
|
|
|
// the file to Downloads or to cacheDir, then hand somebody a path. #35 names it
|
|
|
|
|
// directly — "writing to Downloads/ and then sharing a path is exactly the
|
|
|
|
|
// pattern that rule exists to prevent". The Storage Access Framework needs none
|
|
|
|
|
// of these calls, so their absence is checkable.
|
|
|
|
|
|
|
|
|
|
val forbiddenStorageCalls: List<String> = listOf(
|
|
|
|
|
"Environment.getExternalStorage",
|
|
|
|
|
"Environment.DIRECTORY_",
|
|
|
|
|
"getExternalFilesDir",
|
|
|
|
|
"getExternalCacheDir",
|
|
|
|
|
"MediaStore.Downloads",
|
|
|
|
|
"MediaStore.Files",
|
|
|
|
|
"FileProvider",
|
|
|
|
|
"Intent.ACTION_SEND",
|
|
|
|
|
"ACTION_SEND_MULTIPLE",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
tasks.register("checkNoSharedStorageWrites") {
|
|
|
|
|
group = "verification"
|
|
|
|
|
description = "No module that can see a cycle date may write to shared storage (§45)."
|
|
|
|
|
|
|
|
|
|
val roots = modulesSeeingHealthData.map { layout.projectDirectory.dir(it).asFile }
|
|
|
|
|
val forbidden = forbiddenStorageCalls
|
|
|
|
|
val repoRoot = layout.projectDirectory.asFile
|
|
|
|
|
|
|
|
|
|
doLast {
|
|
|
|
|
// Comments first, for the reason GUARDS.md §2 gives: the KDoc on
|
|
|
|
|
// DataExporter explains why FileProvider and ACTION_SEND are absent, and
|
|
|
|
|
// a naive scan would report the explanation as the violation.
|
|
|
|
|
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" }
|
|
|
|
|
.filterNot { it.path.contains("/build/") || it.path.contains("/bin/") }
|
|
|
|
|
// Test sources may name these to assert they are NOT used.
|
|
|
|
|
.filterNot { it.path.contains("/src/test/") || it.path.contains("/src/androidTest/") }
|
|
|
|
|
.forEach { file ->
|
|
|
|
|
scanned++
|
|
|
|
|
codeOf(file.readText()).lines().forEachIndexed { i, line ->
|
|
|
|
|
forbidden.forEach { pattern ->
|
|
|
|
|
if (line.contains(pattern)) {
|
|
|
|
|
hits += "${file.relativeTo(repoRoot)}:${i + 1} $pattern"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scanned == 0) {
|
|
|
|
|
throw GradleException(
|
|
|
|
|
"no Kotlin sources found, so no storage call was checked. This is not a pass.",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hits.isNotEmpty()) {
|
|
|
|
|
logger.error("")
|
|
|
|
|
logger.error("PRODUCT_PLAN.md §45: health data must never reach shared storage.")
|
|
|
|
|
logger.error("")
|
|
|
|
|
hits.forEach { logger.error(" - $it") }
|
|
|
|
|
logger.error("")
|
|
|
|
|
logger.error("A file written to Downloads, or shared by path through a FileProvider,")
|
|
|
|
|
logger.error("has left this app's sandbox and is readable by anything with storage")
|
|
|
|
|
logger.error("access. The Storage Access Framework needs none of these: the user")
|
|
|
|
|
logger.error("picks a document and the app writes into it, with no copy in between.")
|
|
|
|
|
throw GradleException("${hits.size} shared-storage call(s) where health data is visible.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.lifecycle("shared storage: $scanned Kotlin file(s) checked, no shared-storage writes.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
// 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"))
|
feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
|
|
|
if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions"))
|
feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
|
|
|
dependsOn(rootProject.tasks.named("checkNoHealthLogging"))
|
feat: a guard that every drawable has a night twin
The light/dark pairing was held by a @Preview and nothing else. A preview fails
no build and nothing runs it, and the KDoc on OnboardingPreviews.kt already said
why that matters: "a set of eight where seven have a night variant looks
completely fine in light mode."
The failure mode is what makes this worth a guard. A missing drawable-night file
does not crash, does not warn, and does not fall back to nothing — Android
resolves the light drawable and draws it on a dark screen. The only other way to
find it is to open that one screen in that one theme, which is how #44 was found
and how it sat unnoticed until somebody looked.
checkThemedDrawables walks both directions: a light asset with no night twin,
and a night asset with no light one. The second is the same defect from the
other side and renders as nothing rather than as the wrong picture.
Exemptions are a named map with a reason each, rather than a narrowed scope.
ic_launcher_monochrome is the only entry: the launcher tints it from the system
palette, so a night copy would be a second source of truth for one shape. A
scope that only listed today's eight illustrations would not cover tomorrow's,
and the defect this guards against is a file somebody forgot.
Proved four ways, because prove-guard.sh cannot drive this one — it replaces a
string inside a file, and this guard's failure mode is a file that is not there,
in a set that is all .webp. GUARDS.md gains section 9 for that class of guard,
and the manual recipe from section 1 was run instead:
- a night twin deleted -> exactly 1 violation, naming art_welcome
- a dark-only asset added -> exactly 1 violation, naming art_orphan
- both restored -> green, 64 resources across 5 folder pairs
- roots pointed at a folder
that does not exist -> "no drawables were found ... not a pass"
The fourth is the one worth copying. Refusing to report a pass over an empty
observation is itself a thing to prove: a guard that finds nothing and says
"clean" is the failure GUARDS.md was written after, and two guards in this
project have done exactly that.
closes #42
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:37:43 -05:00
|
|
|
dependsOn(rootProject.tasks.named("checkThemedDrawables"))
|
feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
|
|
|
dependsOn(rootProject.tasks.named("checkNoSharedStorageWrites"))
|
feat: module boundary guard, and two API-level bugs it uncovered
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes #7
2026-08-18 03:00:11 -05:00
|
|
|
}
|
chore: adopt the project template and add the Kotlin/Compose skeleton
Period was a bare directory holding one 2,527-line specification, with no git
repository, no tracker and no documentation convention. This is the adoption
from Projects/Template/START-HERE-New-Project.md, plus a project that compiles
so the hooks and future guards have something real to run against.
Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to
docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the
capitalised Docs/ is gone. Every scaffolded document was filled in for Period.
docs/OPERATIONS.md deleted — an offline app is not a deployed service.
DOC_TRUST_MAP.md written last, describing what is actually here, including what
this project deliberately does not have.
Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm")
and cannot see the Android SDK, so the engine is testable without an emulator —
17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51.
BaselinePredictionEngine is a robust-median prototype and explicitly not the
product; it exists so Batch 02's replacement can be shown to be better rather
than merely different.
Versions verified against their official sources today rather than inherited
from the specification's own numbers, which that document asks for: Kotlin
2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt
2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no
longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk
stays 36, Play's floor from 2026-08-31, and the difference is deliberate.
Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md.
Three hooks in .githooks/, with pre-commit adapted to Gradle.
closes #1
closes #2
2026-08-18 02:16:47 -05:00
|
|
|
}
|