Per request: re-enable allowBackup but scope it to ONE file via include-rules — files/gbackup/latest.json.age, an age-encrypted export written only while a backup passphrase is set (never plaintext to Google; DB/prefs stay excluded). On a new device the OS restores the blob, the Today screen detects an empty DB + present blob and offers 'Gjenopprett' — which demands the passphrase typed, since the Keystore key never migrates. Clearing the passphrase deletes the blob. scrypt work factor dropped 18→15: age's desktop default needs a 256 MiB working set and OOM-crashed on-device (found the hard way); 15 = 32 MiB, still real brute-force cost, and largeHeap covers decrypting foreign files made at 18. Verified by instrumented round-trip tests (write → empty DB → restore; wrong passphrase keeps DB empty; cleared passphrase removes blob) — UI automation couldn't drive the multi-field dialog reliably, so the proof lives in the test instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.8 KiB
6.8 KiB
«Med det samme» — Claude Code project memory
Local-first Android medication reminder. My personal MyTherapy replacement: no ads, no account, no analytics, no cloud service of yours. Single user, single device. Reminders are the entire point — reliability beats polish wherever they trade off. The name says the brief: nag me to take the dose now.
- Package / applicationId:
no.naiv.meddetsamme - Display name:
Med det samme(instrings.xml, referenced from the manifest) - minSdk 26, targetSdk 35, JVM 17, Kotlin + Compose (Material3)
- Stack: Room (+KSP), WorkManager, OkHttp, kotlinx-serialization, kage (age crypto). Gradle Kotlin DSL + version catalog. No AWS SDK, no DI framework.
Working agreement
- Senior dev, Linux host. Be concise; explain why for non-obvious choices in a line or two (XAI, not essays). 24h clocks everywhere.
- Never claim it builds without running the build. Verify; don't assert.
- Verify current stable library versions yourself before bumping — don't trust memory.
- Smallest change that works. No speculative abstraction.
- Ask before anything irreversible (schema migrations, data deletion).
- This is a medication app: wrong schedule/dose math is a real harm. Keep schedule logic pure and unit-tested before anything depends on it.
Decisions — don't relitigate (flag and wait if you think one's wrong)
- Native, not PWA — PWA notification reliability is unacceptable for meds.
- Exact alarms (
USE_EXACT_ALARM), always gated oncanScheduleExactAlarms()with an inexact fallback;setExactAndAllowWhileIdleso Doze doesn't eat doses. - Re-arm every alarm on
BOOT_COMPLETED+MY_PACKAGE_REPLACED(state is wiped then). - Surface a one-time battery-optimisation exemption prompt — biggest cause of dropped reminders on aggressive OEMs.
- Google Auto Backup carries ONLY the app's own age-encrypted blob (
files/gbackup/, include-rules in dataExtractionRules/fullBackupContent exclude everything else). Blob exists only while a passphrase is set; restore on a new device requires typing it (Keystore never migrates). Amended 2026-06 from a full opt-out, on user request. - Own backup: versioned JSON → encrypted → S3-compatible PUT to self-hosted Garage, path-style, hand-rolled SigV4. One serializer for export, import, and auto-backup.
- Crypto target is age (passphrase/scrypt) so backups are
age -d-decryptable and never lock me into this app. JCE AES-GCM is the baseline behind an interface; verify age against the age test vectors before trusting it. - FEST, not Felleskatalogen, and not as a live API: its open Rekvirent extract
is a SOAP/WCF M30 XML dump.
tools/fest_flatten.py(in-repo, amended 2026-06) flattens it into a slim JSON bundled as an APK asset; release builds auto-refresh it when >30 days old. Autocomplete is offline; a downloaded file via Settings can override the bundle. The ban covers Felleskatalogen as a data source — browser links to felleskatalogen.no for human reading are fine (added 2026-06). - Refill is derived from inventory + consumption. Prescription renewal
(
rxExpiryEpochDay,refillsRemaining) is tracked separately from stock.
Toolchain (verified 2026-06, re-verify before bumping)
- AGP 9 has built-in Kotlin: applying
org.jetbrains.kotlin.androidis an ERROR. The compose/serialization plugin versions must match AGP's bundled KGP (2.3.10 for AGP 9.2), not the newest Kotlin. - Kotlin stays 2.3.x until KSP supports 2.4 (KSP versions standalone since 2.3.0: plain "2.3.9").
- compileSdk 37 (AndroidX hard-requires), targetSdk 35 — separate knobs, keep both.
- Room 3 (
androidx.room3) is alpha — stay on Room 2.x.room-ktxis merged intoroom-runtime. - androidx security-crypto is fully deprecated — SettingsStore is our own Keystore wrapper instead.
- No appcompat:
?attr/colorControlNormaldoesn't exist; Compose tints icons via LocalContentColor.
Load-bearing — change with care
- The reminder subsystem is the reliability core. After any change, re-verify: alarms re-arm on boot/update, escalation cancels on Taken, the next occurrence is always armed, Taken decrements inventory.
- Escalation contract: due dose → PENDING log + notify, re-nag every 10 min (cap ~6 / ~1 h) until Taken or Snooze; Snooze pushes the next nag 15 min and stays PENDING.
- The S3 SigV4 signer: if a PUT fails it's almost always clock skew or a non-path-style endpoint, not the maths. Don't "fix" the signer first.
- The schedule engine is pure — add tests here before touching the math.
- DoseActions is the ONLY implementation of Taken/Snooze/Skip/Undo, shared by the notification receiver and UI. Never add a second path — they'd disagree about inventory.
- Backup DTO field names AND enum constant names (MedForm/DoseStatus) are part of the backup-file contract; renaming breaks restore of old backups. Bump BackupFile.VERSION instead.
- kage is trusted only because CctvScryptVectorTest passes — keep the vendored CCTV vectors green when bumping kage.
- Doctor PDF formatting is locale-pinned to nb; the default locale prints English month names.
Out of scope — don't add without asking
Caregiver/"Team" alerts, multi-profile, Health Connect, streaks/gamification, injection-site tracking, symptom/mood diary. (Modelling "trackable" generically is fine if cheap; build no diary UI.)
Commands
All gradlew calls need JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 — system java is a JDK 25 EA.
- Build debug:
./gradlew assembleDebug - Unit tests:
./gradlew test - Lint:
./gradlew lint - Release:
./gradlew assembleRelease— signs via gitignoredkeystore.properties(keystore in~/.keystores/; file absent → unsigned build, don't "fix" that) - adb/emulator are not on PATH:
~/android-sdk/platform-tools/adb,~/android-sdk/emulator/emulator - Emulator verify: create a throwaway AVD from
system-images;android-35;google_apis;x86_64, delete after. NEVER use the personaltilfluktsromAVD. - Inspect device DB:
adb shell run-as no.naiv.meddetsamme sqlite3 databases/med-det-samme.db "'SQL'" - Verify alarms:
adb shell dumpsys alarm | grep meddetsamme—window=0means exact - The phone (Pixel 7 Pro) has a RELEASE-signed install:
adb installof debug builds is rejected on signature; use release builds for the phone, emulator for debug. - INCIDENT RULE (2026-06-10, data was lost): never run a device-touching command
unscoped. Every
adbcall gets-s <serial>; connected tests getANDROID_SERIAL=emulator-<n>. The build fails connected* tasks without an emulator serial — that guard stays. The phone is never a test target.
See @README.md for architecture detail.