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>
The application scope in PeriodApplication was built with SupervisorJob and
no CoroutineExceptionHandler, and ReminderCoordinator launchIns two Room
flows on it. SupervisorJob stops a failing child cancelling its siblings; it
does not stop the exception, which reaches the thread's default handler and
ends the process.
That scope is the one that runs with nobody watching. Application.onCreate
runs in every process, including the ones WorkManager starts after a reboot
and at the daily reminder — no Activity, no screen, nothing to show an error.
Both ViewModels already install a handler; the one place a crash is invisible
did not.
The trigger is real rather than theoretical: repository.forecast runs the
prediction engine inside the flow, and Prediction's init block enforces its
window invariants with require.
Three layers, outermost last:
- ReminderCoordinator catches per chain, so one failing collection cannot
take the other down. Doing nothing on failure is deliberate — cancelling
the schedule would turn a failed read into reminders silently switched
off until the user next touched a notification setting.
- ReminderWorker returns success and posts nothing when it cannot read what
it needs, which is already its behaviour with no history. Cancellation is
rethrown rather than swallowed.
- The scope handler is a backstop whose only job is that the process lives.
It cannot log: checkNoHealthLogging covers this module, and an exception
message here can carry a date derived from a cycle.
The chains moved into internal functions taking flows so the catch is
reachable from a test. CycleRepository is final with an internal constructor,
which is right for a data boundary and wrong for faking, and adding a mocking
library to reach one catch would have been the worse trade.
Proved to fail, per GUARDS.md §1: removing the handler fails exactly one test
(ApplicationScopeTest.kt:69), and removing either catch fails exactly its own.
GUARDS.md gains §8. prove-guard.sh decides a guard caught the mutation from
the runner's exit code, and cannot tell a broken test from a malformed
command. Its first use here reported a clean catch when Gradle had actually
rejected `:app:test --tests` as an unknown option and run nothing. The same
tool's line-counting fallback also means the three documented boundary proofs
in architecture/README.md have been exiting 3 rather than 0 since they were
written; they now carry the fail pattern that makes them exit 0.
closes#45
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§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#24closes#25closes#26closes#27