Changing the reminder time did not move the reminder. WorkManager's
UPDATE policy carries the previous request's lastEnqueueTime and
periodCount forward, and a periodic request computes its next run as
periodCount == 0 ? lastEnqueueTime + initialDelay : lastEnqueueTime +
interval. Both halves bit:
After the first run, a new initial delay is ignored entirely -- Morning to
Evening did nothing at all. Before it, the delay is applied to the
ORIGINAL enqueue time, and the coordinator reschedules at every process
start, so asking for 19:00 at 09:00 on work enqueued at 08:00 produced
18:00, with every later period anchored off that.
The scheduler reads the existing work first: KEEP when nothing is
scheduled, leave an overdue run alone -- moving it skips today's reminder
entirely -- leave a run already within five minutes alone, and otherwise
UPDATE with an explicit setNextScheduleTimeOverride, which is the only way
to say WHEN rather than how long from a moment WorkManager has its own
opinion about. CANCEL_AND_REENQUEUE is wrong for a subtler reason: this
runs at every process start including the one WorkManager started to run
the worker, and cancelling the unique work there cancels the worker.
A time zone or clock change now re-aims it. The delay was computed once,
from the zone in force then, so flying east left the reminder arriving at
the old wall-clock time indefinitely. WorkManager's own RescheduleReceiver
declares BOOT_COMPLETED and nothing else -- which is why ClockChangeReceiver
exists for the other two broadcasts, and why it does not duplicate boot.
Unexported, no permission, checkPermissions still green.
No flex window was added, and the screen's copy changed instead. Flex
would have made "a few minutes either side" true and placed the first run
nearly a full period out, skipping the reminder on the day the user set
it -- to keep a sentence. It now says Android may deliver a few minutes
after, never before, which is what actually happens.
schedule() had no test; only the arithmetic beneath it did. Nine now,
against WorkManager's own recorded next-run time, sharing one clock with
it -- a test that fixes only the scheduler's measures a 2026 delay against
a real System.currentTimeMillis().
Writing them was necessary rather than tidy: the first version of the
change-the-time test passed with the defect still in place, because both
schedules happened at the same instant and the bug only bites once time
has moved. The test that catches it advances the clock an hour between
them, which is what a real second process start does.
closes#72
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
§30 says the app stops asking "did your period start?" after a few
unanswered check-ins, and starts again next cycle. It could not.
The count was reset by a flow watching the newest confirmed start.
distinctUntilChanged only dedups within one collection, so its first
emission always passes -- and PeriodApplication starts the coordinator in
every process, including the one WorkManager spawns to run the reminder.
The count was wiped moments before the worker read it. A user who ignored
the check-ins kept being asked, daily, which is the behaviour §30 exists
to prevent and the kind people uninstall over.
The count now carries the row id of the period it was asked about and
reads as zero for any other. A new period starts it over by arithmetic
rather than by an event that has to fire at the right moment in the right
process. The reset chain is deleted outright -- there is nothing to race
and nothing for a second process to get wrong -- and the notification
handler no longer resets anything either.
An id, never a date. The rule that keeps dates out of
PeriodRecord.toString() applies to anything at rest a backup or a crash
reporter could pick up: an id says a record exists, a date says when
somebody bled. Delete My Data leaves an id matching nothing, which is
correctly no count at all.
The coordinator test that covered the deleted chain is replaced by one
that starts the coordinator twice on the same history and asserts the
tally survives -- the regression itself, rather than the machinery that
used to cause it.
Also raises the app-lock test's await budget from 5s to 30s. It went red
once in a full parallel run and passed alone: each PIN there costs a real
210,000-iteration PBKDF2 derivation, and one test asks for three. The
budget is for catching a hang, not for measuring the crypto -- and a flaky
guard is one people learn to ignore.
Proved with prove-guard, one red each: dropping the period from countFor,
and letting recordCheckIn increment across periods.
closes#70
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The period-end check-in asks "Is your period over?" and offers Ended and
Still going. Tapping Ended inserted a NEW period record starting today,
in the middle of the period it was asking about. Still going filed a
censoring observation against a forecast that had already arrived.
The labels were chosen in NotificationCopy and the writes were attached
in ReminderWorker by position -- index 0 to "started", index 1 to "not
yet", for every kind of reminder. That holds while every reminder asks
the same question. It stopped holding the moment one did not.
It corrupted the health record and every forecast built on it, and the
user had no way to see it happen.
A button is now one thing carrying both halves: NotificationCopy.buttons
returns the label and the action together, and nothing downstream is
allowed to pair them up again. ENDED closes the period that is running
through setPeriodEnd -- the same call the Today screen makes -- and never
opens one. STILL_GOING deliberately writes nothing: it is the state the
record is already in, and the in-app equivalent is a no-op that would
still move updatedAt and read, in the history, as an edit she never made.
A start confirmed from a notification is now sourced
NOTIFICATION_CONFIRMATION rather than MANUAL. How a record arrived is
part of the record.
Actions travel as their enum name, and anything unrecognised -- including
the strings used before this change -- writes nothing. A notification
sitting in somebody's shade across the upgrade still opens the app; it
just does not guess what she meant. The extra key is now declared once in
core/notifications and read by MainActivity rather than repeated as a
literal on both sides.
The handler had no test at all, which is how this survived: it owns the
only two writes reachable from a locked phone. It has eight now, and the
first is not about a write -- it asserts the two halves agree, in every
privacy mode, as a property.
Proved: mutating the already-closed guard out reddens exactly one test
(scripts/prove-guard.sh). Reverting ENDED to its old write reddens three,
which is the whole ENDED semantics and not a coincidence.
closes#68
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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