Commit Graph

5 Commits

Author SHA1 Message Date
null cf09c7ab97 fix: apply a reminder's answer to the day it asked about
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>
2026-08-21 00:20:29 -05:00
null d31bfbd0e7 fix: step back through onboarding and the erase confirmation
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>
2026-08-21 00:19:35 -05:00
null ff3cbe89ad fix: let the app actually stop asking
§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>
2026-08-20 21:59:18 -05:00
null a12d8e5c49 fix: give every settings page a way out
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>
2026-08-20 21:49:57 -05:00
null ca7a187520 fix: make changing the PIN require knowing it
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 #60
closes #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:35:48 -05:00