Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/navigation/RootViewModel.kt

48 lines
1.9 KiB
Kotlin
Raw Normal View History

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
package dev.privacyllc.period.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
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 kotlinx.coroutines.launch
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
import javax.inject.Inject
enum class RootState { Loading, Onboarding, Ready }
@HiltViewModel
class RootViewModel @Inject constructor(
preferences: UserPreferencesRepository,
) : ViewModel() {
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
// Notification actions used to be applied here. They moved to
// AppLockViewModel, which is the only place that knows whether the session
// has been unlocked — the write they perform must not happen for somebody
// who tapped a reminder button and never proved who they were. The
// repository call itself is unchanged and still shared with the in-app
// button, which is what keeps the two meaning the same thing.
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
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
/**
* Set when onboarding finishes, so the switch happens immediately rather
* than waiting for the preference write to travel back through DataStore
* which is fast, but not instant, and a visible flicker on the last tap of
* onboarding is a poor first impression.
*/
private val finishedThisSession = MutableStateFlow(false)
val state: StateFlow<RootState> =
combine(
preferences.preferences.map { it.onboardingCompleted },
finishedThisSession,
) { completed, finished ->
if (completed || finished) RootState.Ready else RootState.Onboarding
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RootState.Loading)
fun onboardingFinished() { finishedThisSession.value = true }
}