med-det-samme/README.md
Ole-Morten Duesund 7eac153740 Add MIT license
Release under MIT. Note that the bundled FEST extract stays under DMPs
own open-data terms, not MIT, since it is third-party data shipped in
the APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:00:24 +02:00

142 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**.
The name tips its hat to Arve Opsahl's 1962 classic
[*«Medisin, medisin med det samme!»*](https://sonichits.com/video/Arve_Opsahl/Medisin%2C_Medisin_Med_Det_Samme!)
— a man demanding his medicine *right away*, which is exactly what this app
does on his behalf.
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
3132) 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 carries exactly one file: the app's own
age-encrypted export (written only while a backup passphrase is set) —
include-rules keep the database and settings out, and restoring on a new
device requires typing the passphrase, since Keystore keys never migrate.
## 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.
## License
[MIT](LICENSE) © Ole-Morten Duesund.
The bundled FEST extract (`app/src/main/assets/fest/slim.json`) is derived from
DMP's openly published drug data and remains subject to DMP's own terms, not
the MIT license above.