- Kotlin 97.8%
- Python 2.2%
When two or more doses are due now (overdue and unresolved), show one button that takes them all in a single tap — the common case of a morning handful. It loops over DoseActions.ensureLog + take, the same path each cards "Ta nå" uses, so inventory decrement and escalation cancel stay consistent; take() is idempotent so a stale row cannot double-decrement. Scoped to due-now only, so a later dose cannot be taken early by accident. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| app | ||
| docs | ||
| gradle | ||
| tools | ||
| .gitignore | ||
| build.gradle.kts | ||
| CLAUDE.md | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| med-det-samme-build-prompt.md | ||
| README.md | ||
| settings.gradle.kts | ||
| THIRD_PARTY_LICENSES.md | ||
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!» — 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 (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 v1 file (scrypt/passphrase mode),
uploaded nightly to a self-hosted S3-compatible bucket
(Garage) 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
PdfDocumentand 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, plusSCHEDULE_EXACT_ALARMon API 31–32) withsetExactAndAllowWhileIdle, always gated oncanScheduleExactAlarms()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:
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) is verified in unit
tests against the 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.
./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.