A reminder can be switched on and still never arrive: the permission
denied, notifications off for the whole app, or the channel blocked.
notify() returns false in all three cases, and both call sites discarded
it while still counting the check-in. So with notifications denied the
counter climbed to §30's limit and the app stopped asking -- permanently,
having never once asked.
Counted only when posted now. canPost() also asks whether notifications
are enabled at all and whether this mode's channel is blocked; below API
33 the permission is granted by definition, so an app whose notifications
the user had switched off posted into nothing and called it asking.
The settings screen says so, in one row above the toggles, with a button
to the system setting that would fix it -- and re-reads on resume, so
somebody who leaves to switch notifications back on is believed when she
returns. Revocation after the fact was previously undetectable:
hasPermission() was called from nowhere in main.
A denial does not switch the toggle back off. That is the tempting fix and
it is wrong: she said she wants the reminder, and rewriting her answer
means a later grant changes nothing and she has to find the toggle again
to discover that. The preference records what she asked for; the row
records what the system is doing about it.
ReminderWorker now takes a ReminderNotifier rather than building one from
the application context, which is what made it testable. It had no test of
any kind -- the class that reads the history, applies the rules, posts, and
counts -- and every defect in this batch lived in that gap. Six now, over
a real repository and a real preference store with only the notifier
faked, since what is asserted is precisely what the worker does with the
notifier's answer.
Two things the prove-guard discipline caught that a green suite did not.
The posted-and-counts guard reddened nothing at first, because the worker
had no tests to redden -- the fix was unproven until the harness existed.
And an assertion of mine read vm.state.value, which is
stateIn(WhileSubscribed): with nobody collecting, it sits on the defaults,
where every reminder is already true. That test could not have failed. It
reads the store now.
closes#71
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