Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt

135 lines
5.4 KiB
Kotlin
Raw Normal View History

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
package dev.privacyllc.period.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
fix: make the lock fail towards opening, not towards a locked-out app 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>
2026-08-20 21:53:51 -05:00
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
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
import androidx.datastore.preferences.core.Preferences
fix: make the lock fail towards opening, not towards a locked-out app 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>
2026-08-20 21:53:51 -05:00
import androidx.datastore.preferences.core.emptyPreferences
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
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
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
import dev.privacyllc.period.core.notifications.ReminderScheduler
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
import dev.privacyllc.period.core.security.AppLockRepository
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
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
import dev.privacyllc.period.domain.prediction.PredictionEngine
import dev.privacyllc.period.launcher.AndroidLauncherAliasSwitcher
import dev.privacyllc.period.launcher.LauncherAliasSwitcher
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
import java.time.Clock
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
import javax.inject.Qualifier
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
import javax.inject.Singleton
/**
* Where the app learns what a file path is, and the only place it does.
*
* Note what is absent: no `PeriodDatabase`, no DAO, no Room import anywhere in
* this module or anywhere above it. `CycleData.repository` hands back a
* repository and keeps the storage to itself see
* docs/architecture/README.md.
*/
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
/**
* Singleton because Room and DataStore both are: two instances over one
* file is a corruption bug that only shows up under concurrency.
*/
@Provides
@Singleton
fun cycleRepository(
@ApplicationContext context: Context,
engine: PredictionEngine,
clock: Clock,
): CycleRepository = CycleData.repository(context, engine, clock)
@Provides
@Singleton
fun preferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile("user_preferences")
}
@Provides
@Singleton
fun userPreferencesRepository(store: DataStore<Preferences>) = UserPreferencesRepository(store)
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
/**
* The app lock's own store, in its own file.
*
* Qualified because it is a second `DataStore<Preferences>` and Hilt would
* otherwise have two bindings for one type but the separate *file* is the
* real point, not the qualifier. `UserPreferencesRepository.resetToDefaults()`
* is `edit { it.clear() }`; a verifier record sharing that store would be one
* future caller away from silent removal, and removing it locks the user out
* of their own history with no way back.
*/
@Provides
@Singleton
@AppLockStore
fun appLockDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
fix: make the lock fail towards opening, not towards a locked-out app 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>
2026-08-20 21:53:51 -05:00
PreferenceDataStoreFactory.create(
// A corrupt lock file is replaced rather than thrown at every
// caller. Without this, one bad write leaves the app unable to read
// OR write the file: the lock can never be set again, and the
// "Forgot your PIN?" erase — the only way back in — fails too,
// because it writes to this same store.
//
// Empty means "no lock", which is deliberate and matches how
// AppLockRepository reads an unreadable file. The reasoning is
// there, and in docs/security/SECURITY.md.
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
) {
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
context.preferencesDataStoreFile("app_lock")
}
@Provides
@Singleton
fun appLockRepository(@AppLockStore store: DataStore<Preferences>): AppLockRepository =
AppLockRepository(store)
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
/**
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
* The engine, and the one line that decides which one the product ships.
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
*
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
* Everything else depends on [PredictionEngine] rather than an
* implementation, which is what made this swap a single line and what makes
* the next one a single line too.
*
* `BaselinePredictionEngine` stays in the tree. It stopped being the product
* and became the control: `EngineComparisonTest` scores both over the §51
* fixtures every build, so "the new engine is better" is a number rather
* than an opinion. At the swap it was **mean absolute error 0.67 against
* 1.00, and the window contained the actual start 9 times out of 9 against
* 7** the second mattering more, because a window that misses is a broken
* promise rather than a tight forecast.
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
*/
@Provides
@Singleton
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
fun predictionEngine(): PredictionEngine = PersonalPredictionEngine()
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
/** Injected rather than read from the environment, so §50's date edge cases stay testable. */
@Provides
@Singleton
fun clock(): Clock = Clock.systemDefaultZone()
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
@Provides
@Singleton
fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler =
ReminderScheduler(context)
@Provides
@Singleton
fun launcherAliasSwitcher(switcher: AndroidLauncherAliasSwitcher): LauncherAliasSwitcher = switcher
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
}
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
/** Distinguishes the lock's store from the settings store; they are different files. */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AppLockStore