A private Android period tracker that learns your cycle. Kotlin, Jetpack Compose, offline-first.
Go to file
null 1d8d7cc688 feat: app lock, with no way to reset a forgotten PIN
§45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has
existed since Batch 01 with nothing outside its own module reading it; this
wires it, and adds the rest.

The recovery question was the reason #34 sat open, and it is decided: there is
no recovery. A backdoor into a period tracker's lock would be used by exactly
the person the lock exists to stop. Everything below follows from that.

## The gate

AppLockGate wraps the whole composition rather than being a screen inside it.
Today, Calendar and Insights each start collecting from CycleRepository the
moment they compose, so a lock implemented as a nav destination would already
have read the history before the user proved anything. content() is invoked
only in the unlocked branch.

Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings
and a permission dialog. Two guards on top: isChangingConfigurations, or
rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM
biometric overlay that stops the activity produces a lock that can never be
opened. No grace period: SECURITY.md leads with "someone who picks up an
unlocked phone", which is the window a grace period covers.

The unlock flag lives in a @Singleton, never in saved state. rememberSaveable
looks like the obvious home and would restore a background-killed app already
unlocked — the single most likely way to meet the lock screen would be the one
path that skipped it.

## What is stored is not the PIN

  mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k))

Two layers because they defend different things. The Keystore MAC is what makes
a six-digit PIN safe at all — a million candidates is nothing to an attacker who
can compute the hash, and impossible for one who cannot get the key off the
device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a
domain-separation tag; the lockout counter is MACed under 0x02.

The key omits six builder calls and the KDoc names every one. setUserAuthenti-
cationRequired is the important absence: it would bind the key to the device
lock, so changing a passcode would destroy it — and under no-recovery that is
somebody's whole history gone for an unrelated reason. It would also be a
bypass, since SECURITY.md already names "someone who knows the unlock PIN" as
an adversary. The biometric key is separate and takes the opposite policy,
where invalidation correctly degrades to "use your PIN".

## Wrong PINs cost time, never data

Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and
no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a
pocket destroy a history while knowing nothing. Both clock bypasses are closed —
the wait is the longer of a wall-clock and a monotonic deadline, and a reboot
re-applies it in full, detected by elapsedRealtime going backwards.

## Two writes that had to move

Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on
the phone's own lock screen, reachable by anybody, so the action is now parked
in AppLockController and applied only after an unlock — dropped if the session
never unlocks. Behaviour is unchanged when the lock is off.

The erase behind "Forgot your PIN?" deletes health data, then the Keystore key,
then the lock store. Skipping the middle step leaves the user erased AND still
locked out; prove-guard mutates that line out and requires exactly one red.

## Found by testing, not by review

  - A fresh install began in a 15-minute lockout: "no counter yet" and "counter
    was tampered with" were the same value. They are now distinct.
  - Setting a PIN locked you out of the session you set it in. Found on the
    emulator, not in a test.
  - Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice.

## Verified

244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and
PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice
here with no margin, and that the key is not auth-bound on either.

On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on
the lock screen, turning the lock off requires the current PIN, and
`adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real.

androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx
never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed
checkPermissions until they were allowed on purpose, and it drags fragment to
1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity.

closes #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
.githooks fix: the pre-commit hook refused every deletion-only commit 2026-08-18 15:00:03 -05:00
app feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00
core feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00
docs feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00
domain feat: guard §45's logging rules, and stop the leak that needed no log call 2026-08-18 22:01:33 -05:00
gradle feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00
scripts feat: Room database for cycle history, and the schema guard that actually works 2026-08-18 02:32:07 -05:00
.gitignore chore: ignore .kotlin/, the compiler's session directory 2026-08-18 14:57:07 -05:00
README.md feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00
build.gradle.kts feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00
gradle.properties chore: adopt the project template and add the Kotlin/Compose skeleton 2026-08-18 02:16:47 -05:00
gradlew chore: adopt the project template and add the Kotlin/Compose skeleton 2026-08-18 02:16:47 -05:00
gradlew.bat chore: adopt the project template and add the Kotlin/Compose skeleton 2026-08-18 02:16:47 -05:00
settings.gradle.kts feat: app lock, with no way to reset a forgotten PIN 2026-08-19 04:02:47 -05:00

README.md

Privacy: Period Tracker

Status: Draft
Owner: _null
Last reviewed: 2026-08-18
Governs: README.md as the project-facing overview for Privacy: Period Tracker
Review trigger: The first buildable feature release; any change to the stack, the
                privacy promise, or how the project is built and run

A private Android period tracker that learns your cycle rather than the average person's — and never sells your data.

What it is | Status | Build and run | Repository map | Project docs | Agent notes

{ Android / Google Play | Kotlin + Jetpack Compose | Room, offline-first | Personalized prediction with an honest window | Discreet notifications | No account, no server, no data sale }

The core loop is:

Track → Learn → Predict → Remind → Learn again

What it is

A focused tracker that answers one question well: when is my next period likely to start? It records confirmed period dates, learns the individual's pattern from them, and produces a most-likely date with a window and a confidence label — never a bare date presented as fact. From that it estimates ovulation and the fertile window, and it asks discreetly whether the period started, using both yes and not yet to improve the next forecast.

Two promises hold the product up:

Your period. Better predicted. — once there is enough personal history, the app must not fall back to a generic 28-day cycle. A user recording 34, 35, 36, 34, 35 who is predicted 28 is a core product defect, not a tuning issue.

Your cycle belongs to you. We will never sell period history, fertility information, ovulation estimates, cycle predictions or personally identifiable information — to advertisers, data brokers or anyone else.

The second is held structurally rather than remembered, and the rule was written before the code it constrains: checkModuleBoundaries in the root build.gradle.kts already carries ":core:ads" to emptySet(), so when the ads module arrives in Batch 07 it may declare no project dependency at all — stricter than "nothing from the cycle database or the prediction domain". It is not in settings.gradle.kts yet, so today the rule matches no module, which is why the guard is proved against injected violations rather than trusted.

What it deliberately is not — no community, no pregnancy mode, no chatbot, no article feed, no symptom encyclopedia, and not contraception — is in docs/planning/PROJECT_PLAN.md.

Status

Five of the eight batches are done. Foundation, prediction engine, core UX, fertility and notifications have landed; privacy and security (Batch 06) is the next work. Every row below cites the file or test that proves it.

Surface Status Evidence
Documentation tree and tracker convention Adopted this tree; milestones and issues in the tracker
Gradle / Kotlin / Compose project Builds ./gradlew assembleDebug and assembleRelease both pass
Material 3 theme and tokens Built core/designsystem
Four-tab navigation shell Built app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt — Today, Calendar, Insights, Settings, each wired to its real screen
Cycle model and interval derivation Built domain/cycle (13 tests); domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/IntervalAnalysis.kt (12 tests)
Prediction engine Built — personalized PersonalPredictionEngine, wired in app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt; the 12 acceptance tests from PRODUCT_PLAN §51 run against both engines (PersonalAcceptanceTest, BaselineAcceptanceTest), and EngineComparisonTest scores the personal engine against the retained baseline every build
Room persistence, DataStore Built core/database (schema v1 exported, SchemaTest + scripts/schema-guard.sh), core/datastore, core/data
Onboarding, Today, Calendar, Insights Built app/src/main/kotlin/dev/privacyllc/period/feature/ — onboarding, today, calendar, insights; OnboardingViewModelTest, TodayViewModelTest, TodayUiStateTest
Fertility window and ovulation estimate Built domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt, 11 tests; shown on Today and Calendar
Discreet notifications Built core/notifications (24 JVM tests), instrumented NotificationPrivacyTest
App lock (PIN + fingerprint) Built core/security (Keystore-backed verifier, lockout policy), app/src/main/kotlin/dev/privacyllc/period/lock gate; 28 JVM tests plus KeystoreVerifierTest run on PeriodMinSdk26 and PeriodQA
Export, monetization Not built Batches 0607
QA Round 3 run, partial docs/qa/ClaudeReport.md — partial at 0451fbe; TalkBack, text scaling, minSdk and a real locked screen still unreached

BaselinePredictionEngine is still in the tree, but it stopped being the product in Batch 02. DataModule provides PersonalPredictionEngine — the recency-weighted, trend-aware, "not yet"-conditioned engine PRODUCT_PLAN §12 specifies — and the baseline is now the control: EngineComparisonTest scores both over the §51 fixtures every build, so "the new engine is better" is a number rather than an opinion.

Build and run

Prerequisites: a JDK 21 and the Android SDK with platform 37 and build-tools 37.0.0 installed, ANDROID_HOME set.

git config core.hooksPath .githooks   # per clone, every clone — see below

./gradlew test                        # JVM suites, no emulator, ~1s
./gradlew assembleDebug               # app/build/outputs/apk/debug/
./gradlew assembleRelease             # minified, unsigned

compileSdk is 37 because the current AndroidX libraries require it; targetSdk is 36, Google Play's floor for new apps from 2026-08-31. They are deliberately different — raising targetSdk opts the app into runtime behaviour changes and is a tested decision, not a build fix.

git config core.hooksPath .githooks is per clone. Without it the hooks are not installed and fail silently, which is the failure mode they exist to prevent. post-commit pushes; see docs/architecture/githooks/README.md.

Repository map

app/                     application module, MainActivity, navigation shell, DI
core/designsystem/       Material 3 theme and colour tokens
core/database/           Room entities, DAOs, converters, the exported schema
core/datastore/          user preferences that are not health history
core/data/               CycleRepository — the only module that touches a DAO
core/notifications/      reminder copy, privacy modes, WorkManager scheduling
domain/cycle/            pure Kotlin — period records, cycle derivation
domain/prediction/       pure Kotlin — the forecast, window and confidence
scripts/                 six from the template, plus schema-guard.sh, our own
.githooks/               pre-commit, commit-msg, post-commit
docs/                    product, architecture, design, brand, QA, security, history

domain/* are kotlin("jvm") modules and cannot see the Android SDK. That is on purpose: the prediction engine is the product, so it needs the most tests, and tests that need an emulator are tests that do not get run.

Project docs

Detailed procedures belong in docs; open work belongs in the tracker, not in this README.

Doc Purpose
docs/DOC_TRUST_MAP.md Which document owns which answer, and which source wins when records disagree. Read this first.
docs/planning/PRODUCT_PLAN.md The full V1 specification — prediction requirements, screens, copy, compliance
docs/planning/PROJECT_PLAN.md The short vision, the stack and its reasons, and what this is deliberately not
docs/WORK_CYCLE.md What happens at the end of a piece of work
docs/architecture/README.md Modules, boundaries, data shapes, migrations
docs/design/README.md Tone, and the four rules that settle design arguments
docs/security/SECURITY.md Threat model, the advertising boundary, logging rules
docs/TOOLS.md Which script to run, and which ones can stop you

Where the tracker is

Milestones are batches, issues are deliverables, and severity labels are exactly P0, P1, P2 and release-blocker. Credentials come from the environment, never from this repository:

set -a; . ~/.openclaw/docker-registry.env; set +a
python3 scripts/forgejo-issue.py list

Two traps that cost an hour each otherwise: Cloudflare 1010-blocks clients that do not look like a browser or curl, so every request needs User-Agent: curl/8.5.0forgejo-issue.py sends it and anything new must too. And /issues returns pull requests unless type=issues is passed.

Agent notes

  • Product truth comes from the code and the tracker before prose.
  • Do not keep a work list in this README or anywhere else in docs/.
  • Finish with docs/WORK_CYCLE.md, every time: close what you finished with the evidence, close the milestone if the batch landed, update the documents the change triggered in the same commit, push, log the entry, then reconcile and write the summary and next action.
  • Nothing on privacyllc.dev writes itself except the tracker counts and the pushed docs.
  • Do not claim a feature is built unless you can cite the file, test or screenshot that proves it. The status table above is the standard.
  • Verify current stable versions before changing the toolchain. The ones in gradle/libs.versions.toml were checked against the official sources on 2026-08-18; PRODUCT_PLAN.md asks for that check rather than for its own numbers to be trusted.
  • Never put a cycle date, a prediction or a fertility state into a log, an analytics event, or an ad request.