Commit Graph

17 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 f43d580cc7 fix: make a reminder arrive when the user asked for it
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>
2026-08-20 22:42:09 -05:00
null 65e8e574b7 fix: stop counting reminders the app could not send
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>
2026-08-20 22:08:44 -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
null bde6528547 fix: stop "Ended" from recording the start of a period
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>
2026-08-20 21:28:47 -05:00
null 93ec5b7f91 ui: add discreet launcher alias
closes #31
2026-08-20 02:37:29 -05:00
null 9a0a644fdb feat: export my data, as one plaintext file the user places
"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>
2026-08-19 22:13:07 -05:00
null 4456f351ac feat: the privacy promise appears in Settings, from one string
§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>
2026-08-19 22:00:06 -05:00
null 1d8d7cc688 feat: app lock, with no way to reset a forgotten PIN
§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>
2026-08-19 04:02:47 -05:00
null 424a513336 fix: a throw in a background flow no longer kills the process
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>
2026-08-19 03:06:10 -05:00
null 8807951553 feat: Delete My Data, and fix a privacy option nobody could tap
closes #36

The deletion has existed since Batch 01 with an instrumented test and no way to
reach it. This adds the Privacy & Security section, a confirmation that says
what goes and what stays, and PrivacyViewModelTest.

A PRIVACY CONTROL WHOSE LABEL DID NOTHING

The confirmation promises "your reminder settings are unchanged". Proving that
meant changing a setting first, so I tapped "Maximum privacy" on a device and
nothing happened: PrivacyRow and onboarding's PrivacyOption both put onClick on
the RadioButton and left the row inert. The option that decides what a lock
screen shows could only be changed by hitting a 20dp circle — in both places a
user ever chooses it.

Modifier.selectable on the row, onClick = null on the radio. That is Material's
documented pattern and it also merges the semantics, so TalkBack announces one
selectable option instead of a radio button and two loose strings.

Found by trying to verify a different claim, which is the argument for verifying
claims rather than asserting them. The setting does survive deletion — set to
Maximum privacy, deleted everything, still Maximum privacy.

A DESIGN THAT WAS WRONG BEFORE IT WAS WRITTEN

The first draft cancelled the reminder schedule on delete. Seems obviously
right; is not. ReminderWorker reads the forecast each run and NoData maps to no
decision, so scheduled work already does nothing while there is nothing to say —
and scheduling only happens from ReminderCoordinator and the settings screen, so
cancelling would have left reminders silently off until the user next toggled
something, long after logging a new period. Checked the call sites instead of
reasoning from the name.

Delete touches health data only. UserPreferences is a separate store precisely
so a privacy action cannot reset a choice somebody made, and there is no undo —
§45 says irreversible, and an undo snackbar keeps the data alive for its
timeout.

Round 4 recorded in docs/qa/. 194 tests pass; ./gradlew check, schema-guard and
doc-claims all pass. Driven on PeriodMinSdk26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:03:16 -05:00
null 035a9d4162 feat: two-tap period logging, and spotting that cannot reset the cycle
§23's path exactly: "Started period" opens a sheet, "Yes — today" closes it and
the forecast has already moved. No symptoms, no mood, no notes, no survey — §23
lists all four as things not to force, and each one is a reason somebody stops
logging at all. Confirmed on a device: two taps, then "Logged ✓ Your predictions
have been updated."

Spotting sits in the same sheet rather than behind another tap, because it is
the answer to the same question the user just asked themselves, and one more tap
is how it stops being recorded.

§25's question, and both halves of it

The paragraph that permits "was this your period or spotting?" also says not to
over-question. Both are tests: a one-day entry asks, a five-day entry does not,
and a dismissed question is not asked again for that record. Answering "Period"
is a complete answer — there is no "ask me later", which is the option that
turns one question into three.

Reclassifying deletes the period record and keeps the day as spotting, and the
test that matters asserts the FORECAST is unchanged either way. That is §25's
real requirement — spotting must not reset the cycle — and a forecast is the
only thing that can prove it. Verified on a device too: the screen went straight
back to cycle day 26 with the same 21 August forecast it had before.

§24's "Updated ✓" acknowledgement on ending a period, because a silent write
reads as a failed tap.

135 tests, all passing. ./gradlew check green.

closes #18
2026-08-18 03:56:52 -05:00
null 2479a1ddf4 feat: onboarding, and the Surface that dark mode was missing
Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous
history, the privacy promise, notification privacy, first forecast. Verified end
to end on a device — the flow produces a forecast, the record persists, and a
relaunch goes straight to Today.

Three decisions with tests behind them:

  - Nothing is written until the final step. Somebody who abandons onboarding
    halfway has not asked this app to remember anything about them.
  - Notification privacy is Discreet before the user touches anything (§28), and
    Direct is last and never pre-selected. Checked on the device, not only in a
    unit test.
  - "Still going" and "I'm not sure" both mean no end date. §24: never invent
    one. The date picker refuses future dates by not offering them rather than
    by rejecting a tap it allowed.

DARK MODE WAS BROKEN FOR ALL OF BATCH 01

PeriodTheme never wrapped its content in a Surface, so every Text without an
explicit colour inherited Material's default — black — and the app background
never painted. In light mode that looked correct by accident, because dark text
on cream is what was wanted anyway. In dark mode the onboarding headings
rendered near-black on charcoal.

No test caught it and no test easily would have. It was found by opening the
app on a device and looking at it.

The Surface now lives in the theme, so a screen without a Scaffold cannot
forget, and every illustration has a light/dark preview pair. A preview is not
a test, but it is the cheapest thing that puts the failure in front of whoever
is editing the screen.

closes #16
2026-08-18 03:44:46 -05:00
null adc50751d8 feat: period CRUD end to end, and stop a double tap killing the app
The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device:
launch, log a period, it is stored, the forecast recalculates, edit or delete it
and the forecast moves again. Hilt wiring, a TodayViewModel exposing one
immutable state, and a working surface that says "Batch 01 · working surface" at
the top so nobody mistakes it for the designed Today screen, which is Batch 03.

THE DEFECT THIS FOUND, ON A DEVICE

Tapping "Started today" twice on the same day killed the app:

  FATAL EXCEPTION: main
  android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed:
  period_records.startDate

Not a hypothetical — the crash was reproduced on emulator-5580, the fix
applied, and the same two taps then produced "That day is already logged." with
the process still alive and zero FATAL lines in logcat.

The constraint is right: a duplicate must not overwrite the original row and
lose its createdAt and source. The API around it was wrong. Repeating a tap
when you are not sure the first one registered is an ordinary thing for a person
to do, not a fault, and it must never be an exception. So the period writes
return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound —
and only genuine faults still throw.

editPeriod had the same hole: moving a record onto a date another record holds.
That is refused rather than merged, because merging would delete a period the
user entered and only they can settle it.

The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what was just entered, and a
message somebody can read beats a process that vanished. The message carries the
exception type and never a record's contents (§45).

Four regression tests pin all of it, plus two instrumented tests on a real
file-backed database that close and reopen it — what a force-stop actually does,
and something an in-memory database cannot fail.

70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB.

closes #6
2026-08-18 02:52:35 -05:00