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>
142 lines
6.7 KiB
Markdown
142 lines
6.7 KiB
Markdown
# 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
|
||
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 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.
|