diff --git a/CLAUDE.md b/CLAUDE.md index da317cc..d2d9323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,4 +91,4 @@ All gradlew calls need `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64` — system - The phone (Pixel 7 Pro) has a RELEASE-signed install: `adb install` of debug builds is rejected on signature; use release builds for the phone, emulator for debug. - +See @README.md for architecture detail. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8962da --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Med det samme + +A local-first Android medication reminder. *«Med det samme»* — "right away" — is +the design brief in three words: it nags you to take the dose **now**. + +Built as a personal replacement for MyTherapy: no ads, no account, no +analytics, no cloud service. Single user, single device. Reminders are the +entire point, so reliability beats polish wherever the two trade off. + +## Features + +- **Escalating reminders** — a due dose posts a notification and re-nags every + 10 minutes (capped at ~6) until you tap *Tatt* or *Utsett*. Snooze defers + 15 minutes and keeps nagging. The nag counter lives in the database, so a + reboot mid-escalation resumes where it left off. +- **Flexible schedules** — time-of-day + weekday mask (different times on + different days) or every-N-days from an anchor date. All schedule math is a + pure, unit-tested engine. +- **Inventory** — physical stock is its own entity with one-tap package + restock. Taking a dose decrements stock; refill warnings are *derived* from + stock ÷ scheduled consumption, never manually scheduled. Prescription + renewal (expiry, reiterutleveringer) is tracked separately from stock — you + can have pills and a dead e-resept, or vice versa. +- **Offline drug autocomplete** — a slim extract of + [FEST](https://www.dmp.no/om-oss/distribusjon-av-legemiddeldata/fest) + (the open national dataset from DMP) is bundled in the APK and searched + offline. Release builds auto-refresh the bundle when it's >30 days old. +- **Encrypted backups you can leave with** — versioned JSON, encrypted as a + standard [age](https://age-encryption.org) v1 file (scrypt/passphrase mode), + uploaded nightly to a self-hosted S3-compatible bucket + ([Garage](https://garagehq.deuxfleurs.fr/)) with a hand-rolled SigV4 signer. + Any backup decrypts on any machine with plain `age -d` — no app lock-in. +- **Doctor summary** — a one-page PDF (current meds, schedules, supply, rx + status, 30-day adherence) generated with the platform `PdfDocument` and + shared via the system sheet. Human-readable, distinct from the + machine-readable backup. + +## Reliability design + +Android actively fights background work; a medication app has to fight back: + +- **Exact alarms** (`USE_EXACT_ALARM`, plus `SCHEDULE_EXACT_ALARM` on API + 31–32) with `setExactAndAllowWhileIdle`, always gated on + `canScheduleExactAlarms()` with an inexact fallback. +- **Re-arm on boot and app update** (`BOOT_COMPLETED`, `MY_PACKAGE_REPLACED`) + — AlarmManager state is wiped on both; every alarm is rebuilt from the DB. +- **No alarm drift by construction** — the alarm layer holds at most one + pending alarm per dose-time and always *recomputes* from the database via + the pure engine. Reboot, update and "Taken" all just re-ask. PendingIntent + identity (stable requestCode + `FLAG_UPDATE_CURRENT`) makes stacking + impossible at the OS level. +- **One-time battery-optimisation exemption prompt** — the biggest cause of + dropped reminders on aggressive OEMs. +- **One implementation of Taken/Snooze/Skip** (`DoseActions`), shared by the + notification actions and the UI, so the two can never disagree about + inventory or escalation state. + +## Data model + +``` +InventoryItem — physical stock: name, strength, unit, form, ATC, + package size, stock, low-stock lead, rx expiry/refills +Medication — a dosing regimen referencing an item (RESTRICT FK: + deleting stock can never erase adherence history) +DoseTime — minute-of-day + weekday mask (bit 0 = Sunday) or + every-N-days from an anchor epoch day +DoseLog — one scheduled occurrence and its outcome + (PENDING/TAKEN/SKIPPED/SNOOZED), amount snapshotted +ScheduleEngine — pure: next occurrence, daily consumption, + days-of-supply, needs-refill, needs-renewal, adherence +``` + +Room with exported schemas committed under `app/schemas/`; migrations are +proven by instrumented `MigrationTestHelper` tests before they touch real +data. Google Auto Backup is disabled (`allowBackup=false` + full +`dataExtractionRules` opt-out) — health data leaves the device only through +the app's own encrypted backup. + +## Backup format + +`{"version": 2, "inventoryItems": [...], "medications": [...], "doseTimes": +[...], "doseLogs": [...]}` — pretty-printed JSON, then encrypted. Decrypt +anywhere: + +```sh +age -d backup-20260610T020000Z.json.age | jq . +``` + +Version 1 files (pre inventory split) still import. The JCE baseline format +(`MDS1` header, PBKDF2-HMAC-SHA256 → AES-256-GCM) remains readable; new +backups are always age. The age implementation +([kage](https://github.com/android-password-store/kage)) is verified in unit +tests against the [C2SP CCTV](https://github.com/C2SP/CCTV) scrypt vectors. + +## FEST dataset + +`tools/fest_flatten.py` (stdlib Python) downloads DMP's M30 Rekvirent extract, +streams the ~115 MB XML, and flattens ~9 000 active human-use preparations +into `app/src/main/assets/fest/slim.json`. `assembleRelease` runs it +automatically: fresh file → fast skip; download failure → keep the old file +(an offline release build never breaks). See `docs/fest-dataset.md` for the +JSON contract. Felleskatalogen is deliberately not used: licensed editorial +content, no open API. + +## Building + +Requirements: JDK 17 and an Android SDK with platform 37. + +```sh +./gradlew assembleDebug # debug build +./gradlew test # unit tests (engine, crypto, SigV4, FEST search) +./gradlew connectedDebugAndroidTest # migration tests (needs an emulator/device) +./gradlew assembleRelease # signed if keystore.properties exists, else unsigned +``` + +Release signing reads a gitignored `keystore.properties` at the repo root +(`storeFile`/`storePassword`/`keyAlias`/`keyPassword`); without it the release +build is unsigned rather than failing. + +- minSdk 26, targetSdk 35, compileSdk 37 +- Kotlin + Jetpack Compose (Material3), Room (+KSP), WorkManager, OkHttp, + kotlinx-serialization, kage — Gradle Kotlin DSL with a version catalog, + no DI framework, no AWS SDK. + +## Out of scope + +Caregiver alerts, multi-profile, Health Connect, streaks/gamification, +symptom diary. This is one person's medication reminder, not a platform.