The Insights screen's whole job is showing what the app has learned, and
its cycle history was a column of numbers -- the least legible form of the
one pattern a user actually wants to see.
A bar per cycle now, Compose-drawn with no chart library, scaled to the
longest with a floor so a single cycle does not fill the card and read as
"long". A cycle outside her usual range is filled differently AND says so
in its label, and the range itself is stated in words underneath.
The number never leaves. A bar chart is exactly where §43's rule -- that
nothing is legible only as a shape or only as a colour -- gets forgotten,
because the picture reads so well to whoever built it that the number
feels redundant. The bars annotate the numbers; they do not replace them.
A screen reader hears one sentence per cycle, and it is the same sentence
a sighted user reads beside the bar.
With no usual range established, nothing is called unusual: one cycle is
not a pattern, and this card must not imply it is.
Proved: dropping the "outside your usual range" half of the label reddens
exactly one test.
closes#59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The App lock screen now asks how the app should unlock and offers four
answers: off, a PIN just for this app, a fingerprint or face, or either.
Each carries the sentence a person cannot work out for herself. The PIN
option says it is separate from the phone's, because somebody who assumes
otherwise will assume they can reset it the way they reset a phone PIN --
and by the time they find out, the only way back in is to erase
everything. The fingerprint option says who it lets in: anyone enrolled on
this phone, including anyone who enrols later.
An option that cannot work is disabled with its reason rather than hidden.
A choice that is simply absent reads as a feature the app does not have,
and this one is absent for something she can fix in her phone's settings.
The screen renders the ViewModel's stage and cannot advance it. It used to
keep its own step in remembered state and move it in the same breath as
asking, which is how a wrong PIN reached "choose a PIN"; two state
machines for one flow is the shape that produced that, so there is one
now.
Verified on the emulator: the four options render, the PIN detail reads as
intended, and both biometric options are correctly disabled with "this
phone has no fingerprint or face set up" -- which is true of that image.
SECURITY.md gains the three-methods bullet and the fingerprint-only
caveat. The checklist gains four device rows, including the one that
matters most: removing every fingerprint must leave the app closed, not
open it.
Part of #63
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screens and the rules behind the choice. The storage and the
migration landed in the previous commit.
The state machine lives in the ViewModel and only a verified outcome
advances it. That is not tidiness: the old screen advanced its own step in
the same breath as asking, without waiting for the answer, and any four
digits reached "choose a PIN". Keeping the stage where the verification
happens removes the class of bug rather than one instance of it.
Three rules, each with a test:
Any change authenticates with the method that is on NOW -- otherwise
whoever is holding the unlocked phone simply switches the lock off, which
is the adversary SECURITY.md names first. Turning it on from nothing needs
no proof, because there is nothing yet to prove.
A fingerprint is never turned on without one working on this phone.
Consent, then a successful scan, and only then is anything written. A
cancelled prompt after a PIN has been committed leaves a working PIN lock
rather than nothing, and says so.
Moving to fingerprint-only drops the PIN only once a scan has worked, and
then drops it properly -- the verifier and the Keystore key both go, so a
stale secret is not left in the file.
The lock screen in fingerprint-only mode is one line, one button and a
notice, with no PIN field: a disabled text box on a screen that will never
accept a PIN is an invitation to try. An unavailable sensor there must NOT
switch the lock off -- that would let anyone who knows the phone's own PIN
remove the enrolled fingerprints and walk in -- so the notice names the way
back instead: enrol again, or erase.
All of it says what it means. The PIN option states that it belongs to
this app and not the phone, because somebody who assumes otherwise will
assume they can reset it the way they reset a phone PIN. The fingerprint
option says who it lets in: anyone enrolled on this phone, now or later.
LockSettingsCopyTest holds both of those, and holds the whole surface to
the same no-sensitive-words, no-"secure" rules the lock screen has.
Proved: a failed scan committing nothing reddens exactly one test.
Removing the authenticate-first rule or the scan-before-commit rule
reddens several, which is that rule's whole surface rather than a
coincidence.
Part of #63
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for offering a PIN, a fingerprint, or either. This commit is
the storage and the migration; the screens follow.
LockMethod is an enum rather than two booleans because BIOMETRIC is the
state with no fallback, and everything that has to be careful -- the
migration, the settings transitions, what happens when a sensor stops
working -- is careful specifically about the absence of a PIN. A boolean
pair spreads that condition across two fields nothing stops disagreeing.
It lives in the lock's own DataStore, not UserPreferences, and that
placement is the point: resetToDefaults() there is edit { clear() }, so a
fingerprint-only lock recorded beside the theme would be one "reset my
settings" away from silently vanishing.
resolve() decides what the stored state actually means, and each of its
three rules closes a way somebody could be locked out or wrongly let in. A
verifier with no recorded method reads as PIN, so the gate is shut from
the first frame of an old install rather than waiting for a migration. A
method needing a PIN with no verifier reads as NONE -- the same policy
VerifierRecord.decode already applies, because a lock nobody can open is
worse than no lock when the way out is behind it. An unrecognised name
falls back to the verifier, so a newer build's value cannot brick an older
one.
A PIN is never checked for a method that does not use one, even with a
stale record in the file.
The migration can never produce BIOMETRIC. recordMigratedMethod refuses it
at the API, and both its conditions -- nothing recorded, verifier agrees --
are evaluated inside the DataStore transaction, so a migration racing an
erase cannot resurrect a lock the user just removed. Getting this wrong
does not show a wrong number on a screen: it locks somebody out of their
own history on an update they did not ask for.
The migration lives in app because it reads two stores and core/security
depends on nothing.
24 repository tests and 9 migration tests, including all four legacy
combinations asserting the never-PIN-less invariant, proved by removing
the require and watching exactly one go red.
Part of #63
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two specification gaps that were filed as deferred and turned out to be
mostly affordable now.
§30 asks the follow-up reminder to pin down the day: Yes Today, Yes
Yesterday, Choose Date, Not Yet. It offers three of those. Android
reliably renders three notification actions, and a fourth that appears on
some devices and not others is worse than one that never appears -- a
feature the user learns and then loses. The one left out is Choose Date,
which was never really a button: it needs a screen, and logging an older
start in the app is the route to that today.
"Yes, yesterday" is cheap only because an answer now carries the day it
was about. It means the day before the QUESTION, never the day before the
tap, or it drifts exactly like the defect dated actions fixed.
§29 lists a custom reminder time beside the three presets. The preference
has always stored an arbitrary minute; only the screen insisted on one of
three, and "around dinner" is not the same hour in every house. The
keyboard-entry toggle stays on: a dial is quick with a thumb and miserable
with a screen reader, and this is a control somebody may set once.
One test had pinned the follow-up's exact button list, so adding a third
broke it. It asserts the property now -- a question about a start never
offers an ending -- which is what it was there to protect and does not
have to be edited every time the copy grows.
closes#75
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every throw in the worker became Result.success(). A repository failing
every day produced no reminders, no retries, no logs -- §45 forbids them
here -- and nothing a user or a developer could tell apart from a cycle
with nothing to say.
resultFor splits the two kinds. A file or database that could not be read
this minute is worth asking again, three times with WorkManager's backoff;
past that it gives up quietly, because a reminder that finally posts after
a day of retries answers a question nobody is still asking. Anything else
-- most likely a Prediction invariant -- fails identically every time, so
retrying would spin. Extracted and internal because the plumbing that
produces each throw is a Room handle or a corrupt file, and neither is
worth constructing to assert a when.
The catch is Exception, not Throwable, deliberately: an OOM is neither
retryable nor a success, and WorkManager recording it as a failure is
right.
An answered reminder now leaves the shade. setAutoCancel only covers the
body being tapped, so answering with a button left it sitting there to be
answered again, and confirming in the app left it asking something already
settled.
And the stop-asking notice goes through NotificationCopy. Built inline in
the worker, it sat outside every test in that file -- both suites iterate
ReminderKind and it is not one -- and hardcoded PRIVATE visibility, so a
user who explicitly asked to be told plainly got less than her mode
promises.
Also records the spotting decision in HISTORY.md: recorded, never
predicted. §25 keeps spotting from driving the cycle, so predicting it
would hand it influence the specification withholds -- and it is optional
and sparsely logged, so a forecast built on it would be a pattern invented
from whichever cycles she happened to log.
One diagnosis worth keeping. Adding the clock generation to the schedule
key made that flow infinite, which is right in production and hung the
whole app suite for an hour in tests -- a collect that waits for
completion never gets one. A thread dump named the line. The tests now
pass a finite generation flow, and the suite runs in eight seconds.
closes#73
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes#69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two screens outside the navigation graph kept the old behaviour: the
system gesture left them entirely while the on-screen control beside it
stepped back.
Onboarding has a Back button and the gesture ignored it, so back from the
middle of onboarding exited the whole flow -- the app's first impression
of what its own controls mean. It now steps, and deliberately does not
intercept on the first step: there, back means leaving the app, which is
its ordinary meaning everywhere else. Silently skipping onboarding would
not be.
"Forgot your PIN?" is not a destination -- the lock screen swaps it in on
a remembered flag -- so the gesture left the lock screen from the one
place where the only other control on screen erases everything. It returns
to the PIN entry now, like its Cancel always did.
closes#67
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SECURITY.md and AndroidKeyStoreMacProvider's KDoc both described a second,
biometric-bound Keystore key taking "the opposite policy" and invalidating
on enrolment. There is no such key: grep -rn BiometricKey found only those
two sentences. A successful fingerprint flips an in-memory session flag --
no CryptoObject, no key unwrap -- so the biometric path is a gate, not a
second secret.
A security document that describes protection the code does not have is
worse than one admitting the gap, because the next reader trusts it.
The absent version is not obviously wrong, so both places now say why it
is filed rather than built (#63): binding a key to biometric enrolment
means a partner adding their own fingerprint invalidates it, which is
exactly the protection somebody would want -- but it only degrades safely
where a PIN sits behind it. In a fingerprint-only lock, a key destroyed by
an ordinary enrolment is the no-recovery policy turned against its owner.
Also adds the six device rows the development log has claimed since Batch
06 were "rows in SECURITY_CHECKLIST.md rather than memory". They were
never added: a grep for biometric, fingerprint, TalkBack, rotation,
fontScale and flicker over that file returned nothing. A release gate that
silently skips half its checks is the failure the file exists to prevent.
closes#66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Development log entry per WORK_CYCLE step 6, covering what the three
explorations found beyond the bug that was reported -- including that
ExportHost has almost certainly never been visible to anyone, and that the
app-lock entry's "setting a PIN locked you out" fix closed the common
ordering but not the race.
Next action and blockers named at the end, as the file's own rules ask.
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>
Three ways the lock could fail badly, all in its storage.
hasPin had no catch, unlike the equivalent flow in
UserPreferencesRepository, and it is collected on the startup path in two
places that cannot handle a throw: the gate's stateIn, which launches
outside its own exception handler, and MainActivity's FLAG_SECURE
collector. A corrupt app_lock file crashed the app before any UI existed
to say why.
It now reads as no lock. That is a decision, not a shrug: whoever can
corrupt that file has the app's private storage and therefore reads the
database this lock does not encrypt anyway, while the alternative is an
app that can be neither opened nor erased -- because the way out is behind
the lock that is broken. It is the rule VerifierRecord.decode already
applies to a single corrupt record, extended to the file.
The store also gains a corruption handler. Without one, a single bad write
leaves DataStore unable to read OR write it: the lock can never be set
again, and the "Forgot your PIN?" erase fails too, since it writes here.
And a wrong PIN now costs time even when the counter cannot be signed.
Signing needs the Keystore, which can be briefly unavailable; the write
returned early, so guessing was free for as long as that lasted -- the one
direction this must not fail in. The unwritten counter is held in memory
and read back whenever it is the longer wait. Not persisted: it is the
delay actually earned rather than the maximum a tampered counter earns,
and it is forgotten on process death, which is the bound a reboot already
gives an attacker.
Writing that test found the honest boundary. If the key is gone entirely
the PIN cannot be checked either, so the app reports that it does not know
rather than charging for a guess it could not read; the failure worth
defending against is the counter alone failing while the verifier still
works. The fake now tells those apart by the domain-separation tag the two
uses already carry.
Proved with prove-guard, one red each: dropping the in-memory counter, and
removing the catch.
closes#64
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: "on the settings page, if you go to app lock you can't
unnavigate out of it." Two causes, both real.
The bottom nav saved and restored per-tab back stacks. From App lock,
tapping the Settings tab popped [settings, settings/lock], saved it, and
restored it in the same breath -- landing back on App lock. The saved
stack survived visiting other tabs, so the Settings tab stayed pinned to
App lock for the rest of the process. The KDoc above the NavHost claimed
the opposite.
And no screen in the app had a back arrow. A grep for TopAppBar,
navigationIcon, BackHandler and popBackStack across app/, core/ and
domain/ returned nothing at all. App lock had a headline styled like a
bar without being one, so the affordance a user reaches for was a label,
and after setting a PIN the only button on screen -- "Done" -- cleared a
message and navigated nowhere.
Settings is now a nested graph. Its children are inside the tab's
hierarchy, so the tab renders as selected on App lock rather than looking
unselected and inviting the tap that trapped you; and re-tapping the tab
you are already on pops to its root, which is the gesture people reach
for. Leaving Settings pops without saving, so there is nothing to
restore. Today, Calendar and Insights keep their place exactly as before.
One SettingsSubpage component carries the bar for all three children.
Three copies would drift -- one would get the ellipsis for long titles at
font scale 2.0 and the others would wrap mid-word, which is a defect the
tab labels already shipped once.
App lock's steps are remembered state, not destinations, so its back is
step-aware: inside a step the arrow and the system gesture both return to
the overview, and at the overview the handler is disabled so the gesture
falls through and pops the destination, exactly as the arrow does. Two
controls a hand's width apart now do the same thing.
ExportHost moves into the Scaffold's topBar. It was a sibling emitted
BEFORE the Scaffold inside PeriodTheme's Surface -- a Box, where later
siblings draw over earlier ones -- so an opaque Scaffold was painted on
top of it. It has almost certainly never been visible to anyone.
Compose UI tests run on the JVM under Robolectric; nothing in this project
could assert a navigation behaviour before. Proved with prove-guard, one
red each: unwiring the arrow, and removing the step-aware BackHandler.
Verified on the emulator: from App lock, tapping Settings now lands on the
settings tree, the Settings tab is highlighted while on a child, and the
arrow returns.
closes#61
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change PIN asked for the current one, and then moved to "Choose a PIN"
without waiting for the answer. The check is asynchronous; the screen
reassigned its step outside the result and passed an empty callback. Any
four digits reached the replacement screen, and setPin enrolls without
verifying anything.
So anyone holding the phone while it was unlocked could change the app's
PIN. Under the no-recovery policy the owner's only way back into her own
history is to erase all of it. The file's own KDoc says this must not be
possible.
The step now advances from inside the verified callback, as "turn the
lock off" already did -- and the ViewModel refuses a replacement that no
successful check authorised. Two guards for one rule on purpose: a screen
is the kind of file that gets rewritten by somebody who has not read the
one behind it, and the place that writes the PIN is the place that has to
refuse. Cancelling withdraws the permission; a successful write spends it.
"Turn the lock off" had the same advance-before-answer shape. It was safe
-- the work was already inside the callback -- but its wrong-PIN message
landed on a screen that had gone, so ConfirmPin.wrong was dead code. Fixed
symmetrically.
Also fixes the lock-out race in the same function (#62). setPin wrote the
PIN and then unlocked the session; the gate is combine(hasPin, unlocked)
and closes on (true, false), so DataStore's emission could arrive in
between. AppLockGate disposes the whole app subtree when it closes, this
ViewModel is scoped to a destination inside it, and the unlock was
cancelled with the scope -- the user was thrown to the lock screen to type
the PIN she had chosen a second earlier. The development log records the
common case as fixed; the fix lived in the scope the race destroyed.
Unlocking first makes the bad pair unobservable: unlock() sets a
MutableStateFlow synchronously on this thread, before the write begins,
and combine always emits with the latest of both. If the write fails there
is no PIN and Unlocked is correct anyway.
The settings ViewModel had no test at all, which is how a wrong PIN
reaching the replacement screen went unnoticed. It has five now, against
the real repository over a host-JVM signing key -- core/security gains a
small public two-argument constructor for that, since AndroidKeyStore
cannot be reached off-device and faking the repository would prove nothing
about it.
Proved with scripts/prove-guard.sh, one red each: spending the
authorisation, and the old write-then-unlock order. Removing the
write-site guard entirely reddens three, which is that guard's whole
surface rather than a coincidence.
closes#60closes#62
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>
ClaudeQACoverage has carried "long-horizon accuracy is tested only at the
unit level" as a standing gap for four rounds. The simulated-history half
is now closed by LearningCurveTest, and the entry says so -- along with
what it found, since a harness that changed nothing would be a weaker
claim than one that caught two real faults.
The other half stays open and is stated plainly: a simulation exercises
the engine, not the app. Nothing here proves a real install accumulates
those cycles correctly over a year, that snapshots survive updates and
time-zone changes, or that the accuracy card shows what the engine
computed. That needs elapsed time on a device.
Development log entry per WORK_CYCLE step 6, including the two
corrections this batch made to its own plan: the coverage dip was a
40-seed artifact, and a swept constant shipped one value away from the
measured one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Will I have my period the week of the wedding?" is a core prediction
question the app could not answer. The calendar marked only the next
forecast window and stopped browsing one month out, so a textbook-regular
user paged forward into blank months.
CycleProjection reaches a year. It is a separate type from Prediction,
deliberately: a forecast is something the app scores itself on -- the
period arrives inside the window or it does not, and PredictionRecord
writes down which -- while a projection eleven cycles out is never
scored, never learned from, and will have been replaced four times before
its date arrives. Separate types mean a projection cannot reach the
accuracy figures by accident.
Three rules keep it honest, each with a test named after it.
Uncertainty grows as sqrt(n), because cycle lengths are near-independent
draws: eleven cycles out is about three times as uncertain as one, not
eleven times. Real cycles do correlate, so that is the optimistic edge --
which is the second reason the assumption sentence is mandatory.
It declines rather than stretching. Past ten days either side a
projection stops being an answer, so the projection ends and reports that
it ended -- FertilityEstimate's precedent, which already refuses to show
a seventeen-day fertile window. The caller can tell "no period due here"
from "cannot see this far", and only the second invites her to log more.
The assumption is stated, not implied. Any month showing projected marks
carries "If your cycles continue as they have, this is the forecast" with
the confidence for that distance beside it. A year-ahead date drawn
without that is the clearest overstatement this app could make.
Cycle 1 is the engine's own forecast copied through unchanged, so the
calendar and the Today screen cannot disagree about the next period. The
typical cycle length comes from her own history and never a population
default -- projecting 29 days for a 35-day user is §3's core defect
repeated twelve times over.
PROJECTED_PERIOD is its own mark: the same dashed ring as a prediction,
thinner and finer. The family resemblance is the message -- still a
period, weaker claim -- and it survives greyscale, which an opacity
difference would not. Screen readers get the distance in the label, since
they cannot see that a square is months away.
Browsing now reaches a year; logging still stops at today.
ConfidenceRow and readable() move to feature/common and are shared with
the Today screen rather than copied. Two of them drifting apart is a
screen saying Medium in dots and Low in words.
closes#57
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PersonalPredictionEngine has thirty constants and no way to tell which
kind each is. Both are argued for in comments, in the same voice, so the
difference is invisible: MAD_TO_SCALE was tuned against fixtures and can
be retuned by anyone with a measurement, while FERTILE_DAYS_BEFORE_-
OVULATION is a claim about how long sperm survive and cannot.
SCIENCE.md is that line, with a verdict and a citation per constant:
supported, needs-a-caveat, contradicted, or not-a-scientific-claim. The
last category is listed deliberately, so nobody demands a citation for a
tuning parameter and nobody mistakes one for physiology and refuses to
tune it.
It is a sibling of architecture/README rather than a section in it. That
document's subject is the module graph, and it fires its review trigger on
a new Gradle module or a changed Room entity; the evidence for a luteal
phase changes when somebody publishes. Two subjects with unrelated review
cycles in one document means the one nobody is looking at goes stale
behind the one they are.
One value changed as a result. POPULATION_DEFAULT_DAYS -- the single
length assumed for a user with one confirmed period and no interval yet --
moves 28.0 to 29.0. Twenty-eight is the number everyone knows and the
large app-data studies agree it is not the average; the mean sits near 29
and only a minority of women average 28. The app's one unavoidable guess
is now the population's actual centre rather than folklore's.
BaselinePredictionEngine keeps 28.0: it is the frozen control, and moving
it would move the yardstick.
The luteal phase is filed rather than changed (#56). Fourteen is above the
measured mean of 12-13, but moving it shifts what the fertility screen
shows every user -- a product decision with its own evidence and QA, not a
line inside a calibration change.
Also fixes the one user-facing claim that outran its measurements. The
Insights banner told a user one cycle in that "a few more and the
predictions get noticeably better" -- an accuracy promise made before a
single forecast had been scored, and for a woman whose cycles genuinely
vary, one the engine can never keep: her forecast gets more honest, not
more precise. It now promises the mechanism, which is true by
construction and is the standard the rest of that screen already meets.
closes#54closes#55
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The window promises to hold the actual start four times in five. For a
user whose cycles genuinely disagree it was delivering closer to three in
four, and the missing piece was structural rather than a mistuned knob:
the spread estimate measured how far her cycles fall from a centre and
never asked how well that centre is known. With four intervals it is not
known well.
The correction is the textbook one -- a future draw around an ESTIMATED
centre is wider than around the true centre by sqrt(1 + 1/n) -- taken over
the effective sample size, because recency weighting means eight intervals
do not contribute eight intervals' worth of evidence.
Applied only to the branch that actually estimates from a sample. The
zero- and one-interval branches are hand-tuned constants standing in for
total uncertainty when there is nothing to estimate from, and multiplying
those would have counted the same doubt twice -- measured, that mistake
widened a first-cycle window by a day and a half and bought nothing.
Not a knob: 1.22x at two intervals, 1.06x at eight, 1.03x at fifteen,
fixed by the arithmetic rather than chosen to reach a number.
The issue this closes was filed on a wrong measurement, and that is worth
recording. A 40-seed run showed mid-learning coverage collapsing to 57.5%;
at 400 seeds the same cells sit at 73-85%, and the collapse was one
unlucky cell rather than a defect. What survived the larger sample is
smaller and real, and is what this fixes: variable-user coverage 75.6% ->
76.8% late, 72.2% -> 73.3% mid-learning.
The floor in the guard stays below the promise on purpose. For an
intrinsically unpredictable cycle the last few points would cost width she
cannot afford, and the width ceiling in the same file is the other jaw of
that vice.
Fixture window returns to 4.56 days, exactly where it was before this
batch -- so across the three calibration commits the engine now predicts
the §51 fleet at MAE 0.44 instead of 0.67, with 9/9 coverage, for the same
window it always had.
closes#52
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user whose cycle lengthens steadily was tracked to within 1.2 days and
told the app was losing confidence: her window grew from 3.1 to 5.1 days
and read Low 31% of the time, the longer it followed her correctly.
The spread estimate measured every interval's distance from a single
static centre. Under a real trend those distances grow with the length of
the history however well the trend is being followed, so the engine was
reading its own success as her variability. Residuals are now measured
against the line the centre was built on. The slope is zero unless a
trend actually fires, so every history without a drift in it is
arithmetically the previous computation -- which is what protects the §51
fixtures rather than a promise to be careful.
Two things fell out of that, and both are the same discovery.
Detrending alone took drift coverage from 96% to 77%: honest residuals
around a line cannot see that the engine deliberately under-follows the
slope, so its centre lags by design. A scale floor applies while
extrapolating a trend -- following one is still an extrapolation.
And the damping itself was paying for the measurement error. While spread
came from a static centre, following a trend inflated the number that
decided how uncertain the forecast was, so the engine had to under-follow
to stay honest. With residuals read against the line that tax is gone:
raising damping 0.50 -> 0.70 halves the error on both §51 drift fixtures
(2 days -> 1), takes fleet MAE from 0.67 to 0.44, returns window coverage
to 9/9, and *narrows* the mean window from 4.56 to 4.33.
Everything improved at once, which is the signature of removing a
distortion rather than trading one fault for another.
Measured at 400 seeds, drifting user at ten or more cycles: MAE 1.19 ->
0.97, window 5.06 -> 3.31 days, coverage 96.1% -> 94.2%, High 6.6% ->
68.1%, Low 31% -> 0.2%. The guard now pins the narrower window together
with a coverage floor, because a tighter window that stopped holding the
answer would be the trade this must not make.
closes#53
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit shipped 0.44. The sweep that chose the threshold left
its last tried value in the file, and 0.44 was the one measured and
rejected: it pushes the drifting user back toward Low 21% of the time for
a cycle the engine tracks to within a day and a quarter -- telling her the
app has lost the thread of something it is following well.
0.40 is the measured choice. At 400 seeds and eight or more cycles:
stable reads High 88.5% and 88.1%, variable reads Low 93.1% and High
0.1%, drifting keeps Medium with Low at 3.3%.
Both values pass the suite, which is why this needed the numbers rather
than a green tick. The rationale is now in the constant, including the
AGREEMENT_SENSITIVITY alternative that was tried across three values and
why it cannot work: agreement multiplies into every score, so quietening
the variable user always costs the stable one her High.
Part of #51
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user whose cycles run 28 +/- 1 was predicted to within a day and told,
five times in six, that the app was only moderately sure. Measured: High
appeared in 15.7% of her forecasts once she had eight cycles logged.
The cause was an asymmetry in confidence(). With no scored forecasts the
accuracy term was a flat 0.92 and she scored 0.64 -- High. The moment
real errors arrived, averaging 0.9 days against a cycle that varies by a
day, the term fell to 0.735 and she scored 0.51 -- Medium, and Medium
from then on. Being demonstrably as accurate as her cycle allows rated
worse than never having been checked, which is §16's "your predictions
are getting better" contradicted by the label printed next to it.
So the yardstick is now what the forecast already admits it does not
know. For a Laplace distribution E|X| = b: the mean error of a perfectly
calibrated forecast IS its scale, so errors inside the scale are the
model working and only the excess counts. The constant is 1.0 by that
identity, not by tuning.
Real inaccuracy is still punished and still visible: it arrives through
agreement, because scored error already feeds the scale. A run of bad
forecasts widens the window and drops the confidence beside it, together,
for the same reason.
MEDIUM_THRESHOLD moves 0.30 -> 0.40 as part of the same change, not as a
second opinion. The old thresholds sat where they had to sit while every
score was deflated; with scores no longer deflated, 0.30 stopped
separating anything and the §51 variable user began reading Medium beside
an eleven-day window. Tightening AGREEMENT_SENSITIVITY was tried first
across three values and rejected -- agreement multiplies into every
score, so every value that quietened the variable user also took High
from the stable one, and 1.0 broke §51 outright.
Measured at 400 seeds, stable users k>=8: High 15.7% -> 88.5%. Variable
user stays Low 93% of the time and reads High in 0.1%. Accuracy and
window widths are untouched by this commit -- it changes what the app
says about its forecasts, not the forecasts.
modelVersion -> personal-2, so §16's history spans the change rather than
comparing two engines' errors as though one engine made them.
closes#51
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/qa/ClaudeQACoverage.md has carried this as a standing gap for four
rounds: whether predictions measurably improve at 3, 6 and 12 confirmed
cycles cannot be reached by a QA round, because it needs a simulated
history or a year of elapsed time. So the product's headline claim was
tested only one hand-picked fixture at a time.
This walks whole histories forward the way a device does -- predict,
score against what happened, feed the error back, predict again -- for
four simulated users across 400 seeds each, and asserts on aggregates.
Both directions are pinned. Accuracy floors alone would be satisfied by
an engine that answered "some time this month", so every accuracy row is
paired with a width ceiling and a confidence-honesty row.
Landing it before the calibration work it exists to guard, at thresholds
the current engine already meets, so each fix can be watched turning its
own row from red to green. A guard never seen failing is not known to
guard anything.
Two things the sample size taught, both worth keeping:
400 seeds rather than 40 costs 1.2 seconds and changes the conclusions.
The mid-learning coverage dip that a 40-seed run showed at 57% turns out
to be sampling noise -- the aggregate sits at 76-86% -- which is why the
assertions quantify over ranges of cycle-counts and never a single cell.
The variable user reads High twice in ~6,400 predictions, so this asserts
a rate ceiling rather than zero. Those two are not the failure §15
describes: the engine judges the evidence in front of it, not the process
behind it, and six cycles that happen to land within a day of each other
are a consistent history on the evidence. Asserting zero would have been
a guard that passed at 40 seeds and failed the first time anyone looked
harder.
Part of #50
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tracing the confirm -> score -> feed-back loop end to end turned up four
ways it lost or falsified its own evidence. None of them looked broken:
each produced plausible accuracy figures with something missing.
A forecast's generatedAt is now the date its LINEAGE began, not the date
its row was written. Every "Not yet", edit and delete revises the answer
to one standing question, so the replacement inherits the origin instead
of restamping today. Restamping moved the goalposts of the backfill
guard: a "Not yet" on the 29th, then a period logged on the 30th as
having started on the 28th, tripped the guard and the forecast the user
was actually shown was deleted unscored. The app learned nothing from
precisely the cycle it got wrong.
The standing snapshot is retired before the engine is consulted rather
than after, so a lineage dies with its history instead of waiting to be
scored against an unrelated one.
Scores now follow the period they are facts about. Editing a start
re-scores every snapshot recorded against it -- predictedStartDate stays
immutable, so a correction worsens the figure as readily as it improves
one -- and the same rule that refuses to score backfill retracts a score
whose start has moved behind its lineage, so an edit cannot smuggle in a
measurement the guard would have turned away. Deleting the period
retracts outright.
A confirm that becomes the newest start clears every "not yet", not just
the older ones: they all censor the same question. A date-bounded clear
left observations dated after a retroactively logged start alive to
depress the next cycle's confidence for a question already answered.
Also removes CycleData.repository's engine default. Nobody relied on it,
which is the point -- a caller who omitted the argument would compile
cleanly and ship the baseline prototype §11 calls unacceptable.
No schema change: all four fixes are queries and call order.
Six of the seven new tests were observed red against the unfixed source.
The seventh -- a deep backfill does not clear the observations censoring
the standing question -- passes both sides deliberately, pinning against
overshooting the not-yet fix.
closes#46closes#47closes#48closes#49
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 6 of WORK_CYCLE. Next action is filing Batch 07's issues: Monetization is
an open milestone with zero issues, so the largest remaining body of code is
described nowhere and is missing from the denominator the project screen
reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Storage Access Framework path is covered by Robolectric — the intent
contract, the bytes reaching the stream, a failing stream never reported as
done, and cacheDir empty afterwards — but no test reaches a real document
provider, and the picker UI itself has not been driven on a device.
Recorded as three checklist rows rather than claimed. The only emulator running
on this machine belongs to another project, and starting one on top of it was
not worth the contention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"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>
§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>
androidx.hilt.navigation.compose.hiltViewModel is deprecated — it moved to
androidx.hilt.lifecycle.viewmodel.compose, and every one of the ten files
calling it was importing the old path, so the whole app module compiled with a
deprecation warning on the single most-used composable helper in it.
hilt-lifecycle-viewmodel-compose was already resolving onto the classpath
transitively at the same 1.4.0. It is declared explicitly now rather than leaned
on, because it is called directly in ten files and a transitive that quietly
stops being pulled is a compile error with a confusing cause.
The app module now compiles with zero deprecation warnings.
Checked while auditing library currency: every artifact in the catalog, all four
plugins and the Gradle wrapper are already at their newest stable. The only
version that looks old is androidx.biometric 1.1.0, which IS the newest stable —
1.4.0 is alpha-only and biometric-ktx has never shipped a stable release.
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 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>
Step 6 of WORK_CYCLE. Next action is #35, export with the file encrypted — the
only artifact that leaves the sandbox, and the precondition SECURITY.md records
for ever revisiting database encryption.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AppLockViewModel.kt landed in 1d8d7cc with a single 0x00 at offset 5468, inside
the char literal of pin.fill(...). It compiled — Kotlin accepted the raw byte as
a character — and every test passed, so nothing caught it except the commit
summary reporting "Bin 0 -> 7069 bytes" where every other file showed a line
count.
That matters more than a stray byte usually would. A source file git treats as
binary produces no diff, so this file would have been unreviewable in every
future change — and it is the file that decides whether the app is locked.
It is written as an explicit unicode escape now, which is what was meant. The
two fills in LockSettingsViewModel used a space, blanking a PIN rather than
zeroing it, and now use the same escape: the whole point of the call is to leave
nothing behind.
Verified: `file` reports UTF-8 text, and no tracked source contains a NUL byte —
only the images and the Gradle wrapper jar do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§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>
Step 6 of WORK_CYCLE. Next action is #34, the app lock — the recovery
decision is settled and the design is on the issue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asked whether encryption at rest could be added. It can, and it should not be
yet — so the reasoning goes in the document that owns the claim, before it is
forgotten and re-derived from scratch.
The architecture decides it. ReminderCoordinator collects Room flows at every
process start, including processes started with no Activity after a reboot,
and ReminderWorker reads the forecast on a schedule. The key would have to be
readable with no user present, which is the availability condition the
platform key already has. A key anything running as this app can reach
unattended defends a file, not a process — and against every adversary this
document already lists, it adds nothing.
What it would genuinely add is narrow: a file copied off the device and read
elsewhere, a phone with no screen lock, and crypto-shredding on delete. The
cost is about +1 MB downloaded and +2 MB installed on a 2 MB app, and a new
class of total loss — a Keystore key can be lost in the field, platform backup
is off, and there is no telemetry that would tell us it happened. Today a
database that will not open is a bug. Afterwards it would be somebody's
history.
The condition that reverses it is written down too: a key bound to the app
lock's user secret, once export has given the user a copy they control.
BRAND_GUIDE carried an artwork brief reading "explaining encrypted local data
storage" — a false claim waiting for somebody to draw it, on the one subject
where a padlock reads as a promise. Replaced with what is true and is a better
story anyway: the data never leaves, so there is no server to breach and
nothing to sell. SECURITY_CHECKLIST gains the row that would have caught it.
Nothing shipped ever claimed encryption; grep over app, core and domain for
strings and illustrations comes back empty. This closes the gap between that
and the documents.
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>
Privacy: Period Tracker is the only Privacy LLC product without a page on
privacyllc.dev — /work/bill-tracker, /work/closer-couples and /work/fruit-fall
exist, /work/privacy-period-tracker returns 404 — and until now no screenshot of
this app existed anywhere in the repository.
Eleven frames covering all seven onboarding steps plus Today, Calendar, Insights
and Settings, in the house style those pages use: portrait, captioned, no device
frames, no decorative background.
SEEDED, NOT REAL
Six invented starts — 8 Mar, 5 Apr, 3 May, 31 May, 28 Jun, 26 Jul 2026 — giving
gaps of 28/29/28/28/29 days. On a 19 August clock that is cycle day 25, and the
engine earns "period likely in 4 days, most likely 23 August, expected 22-24
August, confidence High". Nothing shown is real data, which #32 states is not a
nicety: a screenshot of a period tracker is a screenshot of health data.
Today was reshot once. The first seeding landed the capture day on the estimated
ovulation date, so the hero read "estimated ovulation in 0 days" — true, and a
poor lead. Shifting the history ten days gives the period countdown, which is the
screen the app is built around.
CHECKED, NOT ASSUMED
The status bar is SysUI demo mode so all eleven are identical, and the top strip
of every frame was cropped and compared in one image to confirm what #32 warns
about: no notification appears in any of them. The not-contraception line is
visible in the Today and Calendar frames. Light mode throughout, which also
avoids #44's wrong dark Welcome illustration.
520 KB for the set, 1080x2400 WebP, each far under the 512 KB ceiling.
The README carries the seeded dates, the demo-mode and text-input-picker recipe,
and the rules every frame must satisfy — so the next capture reproduces this one
rather than re-deriving it. Also corrects a stale `splash.png` in the trust map,
which became splash.webp when it was converted.
These are not the three marks privacyllc.dev renders for the project card; that
folder says plainly that screenshots do not belong in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#40, closes#41
The illustrations span the screen and fade into the page, at roughly a third of
its height, and the seven hand-rolled step layouts became one component.
A UNIFIED STEP
Every step wrote out Spacer / Illustration(size) / Spacer / Heading / controls,
in three different spacer rhythms with a reason recorded for only two. StepBody
holds the rhythm; a step declares what is different about it. artHeight stays
per-step: the two carrying three or four controls take a shorter hero so the
primary button is reachable without scrolling, which is the constraint the old
104dp values existed for.
The screen's padding moved from the scrolling column to the content inside each
step. Compose has no negative padding, so a full-bleed child cannot live inside
a padded parent.
THE CAP IS WHAT MAKES LANDSCAPE SURVIVE
A landscape phone is 914x411dp. A fixed 280dp hero would be 68% of it and every
step would break on rotation — nothing in this app uses WindowSizeClass and
onboarding does not lock orientation. Illustration caps at a third of the screen.
It reads LocalConfiguration.screenHeightDp rather than BoxWithConstraints: inside
a verticalScroll column a child is measured with an INFINITE height constraint,
so maxHeight there is Dp.Infinity and the cap would silently never apply. A guard
that looks right and does nothing.
DENSITY BUCKETS, FIXING A LIVE DEFECT
The old set sat in a density-less drawable/, treated as mdpi and pre-scaled at
decode — roughly 17 MB of heap for one bitmap on an xxxhdpi device, and this
artwork is far larger. Now four widths per theme, 617 to 1644 px, and no plain
drawable/ at all. 2.3 MB across every bucket; a release is an AAB and Play splits
by density, so a device downloads 130-500 KB.
That is also the error-handling fix: painterResource decodes inside composition
and there is no boundary anywhere, so an OOM would crash onboarding on first
launch. Sizing the bitmaps removes the failure rather than hiding it.
The clip is gone with the corners it existed for, and the bottom fade is drawn
from the theme background rather than baked into the art. Titles move to
headlineLarge, which is what 280dp of artwork above them needs.
Driven on PeriodMinSdk26: all steps, both themes, font scale 1.0 and 2.0, and
landscape. No crash, no OOM. ./gradlew check green.
KNOWN, FILED SEPARATELY: dark 01 Welcome is a forecast scene, so dark mode shows
a calendar on step 1. The artwork is wrong, not the wiring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#39
Sixteen files, 3:2 landscape, composed natively for the wide frame rather than
cropped or padded from the portrait originals.
THE FIRST ATTEMPT MET THE CANVAS AND NOT THE BRIEF
It reached 2048x1365 by upscaling the old portrait art ~2.8x and padding the
sides with a blurred copy of itself. The sharp region was 1024x1365 — aspect
0.750 against the originals' 0.748, the same composition — and measured edge
detail fell 2.1x-8.8x in the outer quarters on 15 of 16 files. At real hero size
a hard seam showed around the sharp centre.
This set is natively wide: real painted content to all four edges, checked by
measuring detail across each image and looking for the step change a pasted
rectangle leaves. None has one.
A DUPLICATE THAT WOULD HAVE SHIPPED
07_Forecast dark was byte-identical to 01_Welcome dark. Light was correct, so
light mode looked perfect and only dark mode showed the wrong picture, on the
last onboarding step — the silent failure OnboardingPreviews.kt exists to expose
and that #42 would fail a build over. Replaced; all sixteen hashes are unique.
Dark 07 is 1536x1024 where the rest are 2048x1365 — the same 3:2 to within
0.02%, 274.00dp against 273.93dp at a 411dp hero, and ample resolution at every
density the app ships to.
The set still carries no text, no step numbers, and a forecast illustration that
names no fields. Recorded in dist/README.md, along with two things deliberately
left unmatched: light and dark 07 are different compositions, and eight files
have a busy bottom quarter under the fade.
Sources only. 4.2 MB here; the shipped copies are downscaled into density
buckets, which is #41.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#28
The owner supplied a redrawn lockup, docs/design/dist/splash.webp, which sets
the name as "Privacy: Period Tracker" — with the space after the colon.
NECESSARY AND NOT SUFFICIENT
The tempting move was to look at the new artwork and close the issue. #28's
Verify names the two DERIVED assets, not the lockup: the wordmark in
docs/data/img/logo.webp and banner.webp had to match app_full_name, and both
still carried the old spaceless form because they were generated from the
earlier source. Regenerated from the new lockup and checked by cropping the
colon out of each finished webp and reading it at 1350px wide.
THE BANNER IS COMPOSED, NEVER CROPPED
The lockup is square and the banner is 3:1, so a crop takes the ends off the
wordmark — the trap the first pass at these assets hit. A 660px square card
centred on 2176x725, matching how the existing asset did it. No flood-fill this
time either: splash.webp has real transparency where the earlier sources
rendered their rounded corners against black.
icon.webp is deliberately untouched. It carries no wordmark, it is the same
visual family, and #28 is about the name — regenerating it would be churn
dressed as consistency.
BRAND_GUIDE.md §10 carried a callout stating the drawn mark disagreed with the
written name. That is now false, so it records what happened instead and marks
lockup-light-source.png and lockup-dark-source.png as superseded rather than
deleting them; they are the owner's files.
Also corrects a path in the previous log entry, which named splash.png in
backticks — doc-claims reads that as a claim the file is present, and it is now
splash.webp. Named bare, per the convention DOC_TRUST_MAP.md documents for a
path that deliberately does not exist. Second time this session; the lesson is
that converting a file means grepping the docs for its old name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
closes#30
The last placeholder. It was blocked on a real constraint rather than taste: an
adaptive icon masks roughly a quarter of the canvas away, and the first emblem
had content hard against its edges, so dropping it in would have cropped the
shield.
THE NEW EMBLEM DID NOT NEED REDRAWING, IT NEEDED PLACING
It puts the meaning in the middle — calendar, shield, padlock — with the ring and
botanicals as outer decoration. Dropped in full-bleed it still crops: simulating
the circular and rounded-square masks slices the ring apart and cuts the crescent
in half. Scaled into the 66dp safe circle on §18's Deep Plum it survives both
masks whole.
Both were checked by rendering the masks, and then by installing and looking at
the launcher rather than trusting the simulation.
Foreground ships as a density ladder, mdpi through xxxhdpi, 44 KB for all five.
Release APK 2.03 -> 2.08 MB.
THE MONOCHROME LAYER STAYS THE SIMPLIFIED VECTOR
Renamed to ic_launcher_monochrome.xml, which is now what it is. A themed icon is
a silhouette — Android discards colour and tints the alpha — so the emblem's
overlapping calendar, shield, padlock, crescent and leaves would flatten into an
unreadable blob. A ring with one dot still reads at 48dp in a single colour,
which is the whole job of that layer.
Source kept at docs/design/brand/icon.webp: same 1254px, 128 KB instead of 896.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>