Commit Graph

5 Commits

Author SHA1 Message Date
null 9b2b332a98 feat: give the lock a method, and every old install the one it had
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>
2026-08-21 01:32:37 -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 93ec5b7f91 ui: add discreet launcher alias
closes #31
2026-08-20 02:37:29 -05:00
null 99dbc36802 feat: reminders that stay quiet on a lock screen
§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.

TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS

A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.

And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.

§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.

WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.

ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.

188 unit tests and 6 instrumented, all passing. ./gradlew check green.

closes #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
null 8d7a7252cb feat: DataStore-backed UserPreferences, separate from the cycle database
core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification
privacy, reminder time, the three reminder toggles, biometric lock, theme, the
ads entitlement and whether onboarding finished.

Two defaults are decisions, and each has a test whose job is to stop it being
changed by accident:

  - notification privacy defaults to DISCREET (§28). A default of DIRECT would
    put menstrual detail on a lock screen before the user has been asked a
    single question, and a notification read over a shoulder is the likeliest
    real privacy breach in this product.
  - fertility reminders default to off. Most users are not tracking fertility
    and an unrequested ovulation notification is an unpleasant surprise.

An unrecognised stored value falls back to the SAFE option rather than to
whatever enum entry happens to be first — a rollback or a hand-edited file must
not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an
out-of-range reminder time.

This is a separate store rather than two more Room tables, and the reason is a
deletion semantic: Delete My Data removes the health history and must leave the
settings alone. Handing a user back a weaker privacy setting at the exact
moment they are exercising a privacy control is the worst possible time to do
it, and separate stores make the correct behaviour the easy one.

The repository takes a DataStore rather than a Context, so its 10 tests run on
the JVM against a temporary file — no emulator, no Robolectric. The Android
instance is supplied by DI at the app layer, the only place that should know
where a file lives.

41 tests across the project, all passing.

closes #4
2026-08-18 02:34:47 -05:00