diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 18cfc0d..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Ole-Morten Duesund - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index c7497ad..4033bce 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,3 @@ build is unsigned rather than failing. 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cddc67a..a1010f4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { targetSdk = 35 // Bump both for every release installed on the phone — versionName for // humans, versionCode so dumpsys/install history can tell builds apart. - versionCode = 6 - versionName = "0.6.0" + versionCode = 3 + versionName = "0.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json b/app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json deleted file mode 100644 index 45f17c4..0000000 --- a/app/schemas/no.naiv.meddetsamme.data.MedDatabase/4.json +++ /dev/null @@ -1,389 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 4, - "identityHash": "56932b6862c83744cc926f80ab7875e0", - "entities": [ - { - "tableName": "inventory_item", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `strength` TEXT NOT NULL, `unit` TEXT NOT NULL, `form` TEXT NOT NULL, `atcCode` TEXT, `packageSize` REAL, `stockUnits` REAL NOT NULL, `lowStockLeadDays` INTEGER NOT NULL, `rxExpiryEpochDay` INTEGER, `refillsRemaining` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "strength", - "columnName": "strength", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "unit", - "columnName": "unit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "form", - "columnName": "form", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "atcCode", - "columnName": "atcCode", - "affinity": "TEXT" - }, - { - "fieldPath": "packageSize", - "columnName": "packageSize", - "affinity": "REAL" - }, - { - "fieldPath": "stockUnits", - "columnName": "stockUnits", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "lowStockLeadDays", - "columnName": "lowStockLeadDays", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "rxExpiryEpochDay", - "columnName": "rxExpiryEpochDay", - "affinity": "INTEGER" - }, - { - "fieldPath": "refillsRemaining", - "columnName": "refillsRemaining", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "medication", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `itemId` INTEGER NOT NULL, `withFood` INTEGER NOT NULL, `notes` TEXT NOT NULL, `active` INTEGER NOT NULL, FOREIGN KEY(`itemId`) REFERENCES `inventory_item`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "withFood", - "columnName": "withFood", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "notes", - "columnName": "notes", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "active", - "columnName": "active", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_medication_itemId", - "unique": false, - "columnNames": [ - "itemId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_medication_itemId` ON `${TABLE_NAME}` (`itemId`)" - } - ], - "foreignKeys": [ - { - "table": "inventory_item", - "onDelete": "RESTRICT", - "onUpdate": "NO ACTION", - "columns": [ - "itemId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "dose_time", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `minuteOfDay` INTEGER NOT NULL, `amount` REAL NOT NULL, `daysOfWeekMask` INTEGER NOT NULL, `intervalDays` INTEGER NOT NULL, `anchorEpochDay` INTEGER, `cycleActiveDays` INTEGER NOT NULL DEFAULT 0, `cycleLengthDays` INTEGER NOT NULL DEFAULT 0, `startEpochDay` INTEGER, `endEpochDay` INTEGER, `seasonStartMmdd` INTEGER NOT NULL DEFAULT 0, `seasonEndMmdd` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "medId", - "columnName": "medId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "minuteOfDay", - "columnName": "minuteOfDay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "daysOfWeekMask", - "columnName": "daysOfWeekMask", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "intervalDays", - "columnName": "intervalDays", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "anchorEpochDay", - "columnName": "anchorEpochDay", - "affinity": "INTEGER" - }, - { - "fieldPath": "cycleActiveDays", - "columnName": "cycleActiveDays", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "cycleLengthDays", - "columnName": "cycleLengthDays", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "startEpochDay", - "columnName": "startEpochDay", - "affinity": "INTEGER" - }, - { - "fieldPath": "endEpochDay", - "columnName": "endEpochDay", - "affinity": "INTEGER" - }, - { - "fieldPath": "seasonStartMmdd", - "columnName": "seasonStartMmdd", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonEndMmdd", - "columnName": "seasonEndMmdd", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_dose_time_medId", - "unique": false, - "columnNames": [ - "medId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_time_medId` ON `${TABLE_NAME}` (`medId`)" - } - ], - "foreignKeys": [ - { - "table": "medication", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "medId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "dose_log", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `medId` INTEGER NOT NULL, `doseTimeId` INTEGER, `scheduledAtMillis` INTEGER NOT NULL, `amount` REAL NOT NULL, `status` TEXT NOT NULL, `actionedAtMillis` INTEGER, `nagCount` INTEGER NOT NULL, FOREIGN KEY(`medId`) REFERENCES `medication`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`doseTimeId`) REFERENCES `dose_time`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "medId", - "columnName": "medId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "doseTimeId", - "columnName": "doseTimeId", - "affinity": "INTEGER" - }, - { - "fieldPath": "scheduledAtMillis", - "columnName": "scheduledAtMillis", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "actionedAtMillis", - "columnName": "actionedAtMillis", - "affinity": "INTEGER" - }, - { - "fieldPath": "nagCount", - "columnName": "nagCount", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_dose_log_medId", - "unique": false, - "columnNames": [ - "medId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_medId` ON `${TABLE_NAME}` (`medId`)" - }, - { - "name": "index_dose_log_doseTimeId", - "unique": false, - "columnNames": [ - "doseTimeId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId` ON `${TABLE_NAME}` (`doseTimeId`)" - }, - { - "name": "index_dose_log_doseTimeId_scheduledAtMillis", - "unique": false, - "columnNames": [ - "doseTimeId", - "scheduledAtMillis" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_doseTimeId_scheduledAtMillis` ON `${TABLE_NAME}` (`doseTimeId`, `scheduledAtMillis`)" - }, - { - "name": "index_dose_log_status", - "unique": false, - "columnNames": [ - "status" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dose_log_status` ON `${TABLE_NAME}` (`status`)" - } - ], - "foreignKeys": [ - { - "table": "medication", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "medId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "dose_time", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "doseTimeId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '56932b6862c83744cc926f80ab7875e0')" - ] - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt index 0a46990..e57272e 100644 --- a/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt +++ b/app/src/androidTest/java/no/naiv/meddetsamme/data/MigrationTest.kt @@ -108,35 +108,6 @@ class MigrationTest { } } - @Test - fun migrate3To4_addsSeasonColumnsDefaultingToNoSeason() { - helper.createDatabase(TEST_DB, 3).use { db -> - db.execSQL( - "INSERT INTO inventory_item (id, name, strength, unit, form, stockUnits, " + - "lowStockLeadDays) VALUES (1, 'Cetirizin', '10 mg', 'tablett', 'TABLET', 30.0, 7)", - ) - db.execSQL("INSERT INTO medication (id, itemId, withFood, notes, active) VALUES (1, 1, 0, '', 1)") - db.execSQL( - "INSERT INTO dose_time (id, medId, minuteOfDay, amount, daysOfWeekMask, " + - "intervalDays, anchorEpochDay, cycleActiveDays, cycleLengthDays) " + - "VALUES (3, 1, 480, 1.0, 127, 0, NULL, 0, 0)", - ) - } - - // Validates the migrated schema against 4.json AND checks the new columns - // default to "no season" while the existing row survives. - helper.runMigrationsAndValidate(TEST_DB, 4, true, MedDatabase.MIGRATION_3_4).use { db -> - db.query( - "SELECT amount, seasonStartMmdd, seasonEndMmdd FROM dose_time WHERE id = 3", - ).use { c -> - assertTrue(c.moveToFirst()) - assertEquals(1.0, c.getDouble(0), 0.0) - assertEquals(0, c.getInt(1)) // seasonStartMmdd - assertEquals(0, c.getInt(2)) // seasonEndMmdd - } - } - } - private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt deleted file mode 100644 index d3df921..0000000 --- a/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package no.naiv.meddetsamme.domain - -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.runBlocking -import no.naiv.meddetsamme.alarm.AlarmScheduler -import no.naiv.meddetsamme.data.DoseLog -import no.naiv.meddetsamme.data.DoseStatus -import no.naiv.meddetsamme.data.DoseTime -import no.naiv.meddetsamme.data.InventoryItem -import no.naiv.meddetsamme.data.MedDatabase -import no.naiv.meddetsamme.data.MedForm -import no.naiv.meddetsamme.data.Medication -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -/** - * The grouped-notification contract at the data layer: co-timed doses (an - * identical scheduledAtMillis) form one occurrence, "Tatt alle" resolves them - * together, and "Ikke forstyrr alle" drops them off the card without marking - * them skipped. Drives DoseActions directly — the single Taken/Snooze/Skip path - * — since the notification surface itself can't be driven deterministically. - */ -@RunWith(AndroidJUnit4::class) -class DoseActionsGroupTest { - - private val context = ApplicationProvider.getApplicationContext() - private val db get() = MedDatabase.get(context) - private val actions = DoseActions(context) - - /** A fixed PAST instant both doses share — that shared value *is* the group. */ - private val occurrence = 1_600_000_000_000L - - @Before fun clear() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) } - @After fun cleanup() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) } - - /** One med + one dose log due at [occurrence]; returns (logId, itemId). */ - private suspend fun seedDose(name: String, stock: Double, amount: Double): Pair { - val itemId = db.inventoryDao().insert( - InventoryItem(name = name, strength = "", unit = "tablett", form = MedForm.TABLET, stockUnits = stock), - ) - val medId = db.medicationDao().insert(Medication(itemId = itemId)) - val doseTimeId = db.doseTimeDao().insert(DoseTime(medId = medId, minuteOfDay = 480, amount = amount)) - val logId = db.doseLogDao().insert( - DoseLog(medId = medId, doseTimeId = doseTimeId, scheduledAtMillis = occurrence, amount = amount), - ) - return logId to itemId - } - - private suspend fun active() = - db.doseLogDao().getActiveAtOccurrence(occurrence, AlarmScheduler.NAG_CAP) - - @Test fun takeAllResolvesEveryCoTimedDoseAndDecrementsEach() = runBlocking { - val (logA, _) = seedDose("A", stock = 10.0, amount = 1.0) - val (logB, _) = seedDose("B", stock = 20.0, amount = 2.0) - assertEquals("both due at the same instant are one occurrence", 2, active().size) - - actions.takeAll(occurrence) - - assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logA)!!.status) - assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logB)!!.status) - assertTrue("nothing left to nag", active().isEmpty()) - val stock = db.inventoryDao().getAll().associate { it.name to it.stockUnits } - assertEquals(9.0, stock["A"]!!, 1e-9) - assertEquals(18.0, stock["B"]!!, 1e-9) - } - - @Test fun muteAllStopsNaggingButKeepsDosesPending() = runBlocking { - seedDose("A", 10.0, 1.0) - seedDose("B", 20.0, 1.0) - - actions.muteAll(occurrence) - - val logs = db.doseLogDao().getRange(occurrence, occurrence + 1) - assertTrue("muted doses stay unresolved, not skipped", logs.all { it.status == DoseStatus.PENDING }) - assertTrue("nagCount above the cap = won't re-nag, won't resume on reboot", logs.all { it.nagCount > AlarmScheduler.NAG_CAP }) - assertTrue("muted doses drop off the card", active().isEmpty()) - } - - @Test fun mutedDoseLeavesCoTimedSiblingActive() = runBlocking { - val (logA, _) = seedDose("A", 10.0, 1.0) - val (logB, _) = seedDose("B", 10.0, 1.0) - - // Mute only A (its nagCount above the cap); B is untouched. - db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP + 1) - - val remaining = active() - assertEquals(1, remaining.size) - assertEquals("B still nags; A is silenced", logB, remaining.single().id) - } - - @Test fun naturallyCappedDoseStaysActionableOnCard() = runBlocking { - val (logA, _) = seedDose("A", 10.0, 1.0) - - // Reached the nag cap on its own — still shown (== cap), unlike a mute. - db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP) - - assertEquals(1, active().size) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dba3ffc..0895d71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,7 +63,6 @@ -