feat(data): schema v3 — an alarm or a timer that deletes itself after use
The `AlarmClock` contract says twice that an alarm or a timer created with `SKIP_UI` should be removed once it has been dismissed. Without somewhere to record that, a voice user's alarm list becomes a graveyard of one-shots nobody asked to keep. One column on each of `alarms` and `timers`, defaulting to 0, and a migration that adds them. Existing rows keep the behaviour they have always had.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "b41398764e44a3f293815797eec1658d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "alarms",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `label` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `repeat_days` INTEGER NOT NULL, `skip_next_occurrence` INTEGER NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER, `snooze_minutes` INTEGER, `snooze_limit` INTEGER, `volume_ramp_seconds` INTEGER, `dismiss_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hour",
|
||||
"columnName": "hour",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minute",
|
||||
"columnName": "minute",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "enabled",
|
||||
"columnName": "enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatDays",
|
||||
"columnName": "repeat_days",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "skipNextOccurrence",
|
||||
"columnName": "skip_next_occurrence",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ringtoneUri",
|
||||
"columnName": "ringtone_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "vibrate",
|
||||
"columnName": "vibrate",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozeMinutes",
|
||||
"columnName": "snooze_minutes",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozeLimit",
|
||||
"columnName": "snooze_limit",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volumeRampSeconds",
|
||||
"columnName": "volume_ramp_seconds",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "dismissChallenge",
|
||||
"columnName": "dismiss_challenge",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleteAfterUse",
|
||||
"columnName": "delete_after_use",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_alarms_enabled",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"enabled"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_enabled` ON `${TABLE_NAME}` (`enabled`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "alarm_states",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `snoozed_until` INTEGER, `snooze_count` INTEGER NOT NULL, `ringing_since` INTEGER, `handled_occurrence` INTEGER, `skipped_occurrence` INTEGER, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "alarmId",
|
||||
"columnName": "alarm_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozedUntil",
|
||||
"columnName": "snoozed_until",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snoozeCount",
|
||||
"columnName": "snooze_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ringingSince",
|
||||
"columnName": "ringing_since",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "handledOccurrence",
|
||||
"columnName": "handled_occurrence",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "skippedOccurrence",
|
||||
"columnName": "skipped_occurrence",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"alarm_id"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "alarms",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"alarm_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "timers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_millis` INTEGER NOT NULL, `state` TEXT NOT NULL, `remaining_millis` INTEGER NOT NULL, `started_at_elapsed_realtime_millis` INTEGER, `ends_at_elapsed_realtime_millis` INTEGER, `ends_at_wall_clock_millis` INTEGER, `ringtone_uri` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationMillis",
|
||||
"columnName": "duration_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "state",
|
||||
"columnName": "state",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remainingMillis",
|
||||
"columnName": "remaining_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "startedAtElapsedRealtimeMillis",
|
||||
"columnName": "started_at_elapsed_realtime_millis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endsAtElapsedRealtimeMillis",
|
||||
"columnName": "ends_at_elapsed_realtime_millis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "endsAtWallClockMillis",
|
||||
"columnName": "ends_at_wall_clock_millis",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "ringtoneUri",
|
||||
"columnName": "ringtone_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleteAfterUse",
|
||||
"columnName": "delete_after_use",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_timers_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_timers_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "world_clocks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `label` TEXT, `sort_order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoneId",
|
||||
"columnName": "zone_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_world_clocks_zone_id",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"zone_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_world_clocks_zone_id` ON `${TABLE_NAME}` (`zone_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_world_clocks_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_world_clocks_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "stopwatch_laps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lap_index` INTEGER NOT NULL, `split_millis` INTEGER NOT NULL, `cumulative_millis` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lapIndex",
|
||||
"columnName": "lap_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "splitMillis",
|
||||
"columnName": "split_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cumulativeMillis",
|
||||
"columnName": "cumulative_millis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stopwatch_laps_lap_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"lap_index"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stopwatch_laps_lap_index` ON `${TABLE_NAME}` (`lap_index`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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, 'b41398764e44a3f293815797eec1658d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,15 @@ class MigrationTest {
|
||||
1700000000000, 1700000060000)
|
||||
""".trimIndent()
|
||||
|
||||
private val insertTimer = """
|
||||
INSERT INTO timers (
|
||||
id, label, duration_millis, state, remaining_millis,
|
||||
started_at_elapsed_realtime_millis, ends_at_elapsed_realtime_millis,
|
||||
ends_at_wall_clock_millis, ringtone_uri, sort_order, created_at, updated_at
|
||||
) VALUES (1, 'Pasta', 300000, 'IDLE', 300000, NULL, NULL, NULL, NULL, 0,
|
||||
1700000000000, 1700000060000)
|
||||
""".trimIndent()
|
||||
|
||||
private fun migratedDatabase(): ClockulaDatabase {
|
||||
val db = Room.databaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
@@ -102,6 +111,33 @@ class MigrationTest {
|
||||
assertThat(runBlocking { db.alarmStateDao().findByAlarmId(1L) }).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSecondMigrationValidatesAgainstTheExportedVersionThreeSchema() {
|
||||
helper.createDatabase(DB_NAME, 2).close()
|
||||
|
||||
val migrated = helper.runMigrationsAndValidate(DB_NAME, 3, true, Migrations.MIGRATION_2_3)
|
||||
|
||||
assertThat(migrated.version).isEqualTo(3)
|
||||
migrated.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowsWrittenAtVersionTwoSurviveAsPermanentOnes() {
|
||||
helper.createDatabase(DB_NAME, 2).apply {
|
||||
execSQL(insertAlarm)
|
||||
execSQL(insertTimer)
|
||||
close()
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(DB_NAME, 3, true, Migrations.MIGRATION_2_3).close()
|
||||
|
||||
val db = migratedDatabase()
|
||||
val alarm = runBlocking { db.alarmDao().findById(1L) }
|
||||
val timer = runBlocking { db.timerDao().findById(1L) }
|
||||
assertThat(alarm!!.deleteAfterUse).isFalse()
|
||||
assertThat(timer!!.deleteAfterUse).isFalse()
|
||||
}
|
||||
|
||||
private fun ringStateFor(alarmId: Long) = AlarmStateEntity(
|
||||
alarmId = alarmId,
|
||||
snoozedUntil = 1_700_000_100_000L,
|
||||
|
||||
@@ -27,4 +27,5 @@ data class AlarmEntity(
|
||||
@ColumnInfo(name = "dismiss_challenge") val dismissChallenge: String?,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
@ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ object AlarmMapper {
|
||||
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
||||
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
|
||||
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
|
||||
deleteAfterUse = entity.deleteAfterUse,
|
||||
)
|
||||
|
||||
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
|
||||
@@ -47,6 +48,7 @@ object AlarmMapper {
|
||||
dismissChallenge = alarm.dismissChallenge?.name,
|
||||
createdAt = alarm.createdAt.toEpochMilliseconds(),
|
||||
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
|
||||
deleteAfterUse = alarm.deleteAfterUse,
|
||||
)
|
||||
|
||||
/** id = 0, createdAt = updatedAt = [now]. */
|
||||
@@ -66,5 +68,6 @@ object AlarmMapper {
|
||||
dismissChallenge = draft.dismissChallenge?.name,
|
||||
createdAt = now.toEpochMilliseconds(),
|
||||
updatedAt = now.toEpochMilliseconds(),
|
||||
deleteAfterUse = draft.deleteAfterUse,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
|
||||
/**
|
||||
* Clockula's own storage. Version 2; the schema is exported to `app/schemas/`
|
||||
* Clockula's own storage. Version 3; the schema is exported to `app/schemas/`
|
||||
* and committed, so every later migration is reviewable and testable.
|
||||
*
|
||||
* No `TypeConverters` — every column is a primitive and the whole translation
|
||||
@@ -29,7 +29,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
||||
WorldClockEntity::class,
|
||||
LapEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class ClockulaDatabase : RoomDatabase() {
|
||||
|
||||
@@ -29,5 +29,20 @@ object Migrations {
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2)
|
||||
/** v2 → v3: `delete_after_use` on `alarms` and on `timers` (M9 D10). */
|
||||
val MIGRATION_2_3: Migration = object : Migration(2, 3) {
|
||||
override fun migrate(connection: SQLiteConnection) {
|
||||
// `DEFAULT 0` matches the entities' own `defaultValue`, so the
|
||||
// migrated table validates against the exported schema — and every
|
||||
// row written before M9 reads as the permanent alarm it was.
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `alarms` ADD COLUMN `delete_after_use` INTEGER NOT NULL DEFAULT 0",
|
||||
)
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `timers` ADD COLUMN `delete_after_use` INTEGER NOT NULL DEFAULT 0",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ data class TimerEntity(
|
||||
@ColumnInfo(name = "sort_order") val sortOrder: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
@ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ object TimerMapper {
|
||||
sortOrder = entity.sortOrder,
|
||||
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
|
||||
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
|
||||
deleteAfterUse = entity.deleteAfterUse,
|
||||
)
|
||||
|
||||
fun toDomain(entities: List<TimerEntity>): List<Timer> = entities.map(::toDomain)
|
||||
@@ -39,6 +40,7 @@ object TimerMapper {
|
||||
sortOrder = timer.sortOrder,
|
||||
createdAt = timer.createdAt.toEpochMilliseconds(),
|
||||
updatedAt = timer.updatedAt.toEpochMilliseconds(),
|
||||
deleteAfterUse = timer.deleteAfterUse,
|
||||
)
|
||||
|
||||
fun toEntity(draft: TimerDraft, sortOrder: Int, now: Instant): TimerEntity {
|
||||
@@ -56,6 +58,7 @@ object TimerMapper {
|
||||
sortOrder = sortOrder,
|
||||
createdAt = now.toEpochMilliseconds(),
|
||||
updatedAt = now.toEpochMilliseconds(),
|
||||
deleteAfterUse = draft.deleteAfterUse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ data class Alarm(
|
||||
val dismissChallenge: DismissChallenge?,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
/** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */
|
||||
val deleteAfterUse: Boolean,
|
||||
)
|
||||
|
||||
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
|
||||
@@ -105,6 +107,7 @@ data class AlarmDraft(
|
||||
val snoozeLimit: Int? = null,
|
||||
val volumeRampSeconds: Int? = null,
|
||||
val dismissChallenge: DismissChallenge? = null,
|
||||
val deleteAfterUse: Boolean = false,
|
||||
)
|
||||
|
||||
/** An alarm's overrides resolved against the app defaults — no nullables left. */
|
||||
|
||||
@@ -22,12 +22,15 @@ data class Timer(
|
||||
val sortOrder: Int,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
/** A transient timer the `AlarmClock` contract asked for: deleted once it is dismissed (M9 D10). */
|
||||
val deleteAfterUse: Boolean,
|
||||
)
|
||||
|
||||
data class TimerDraft(
|
||||
val duration: Duration,
|
||||
val label: String = "",
|
||||
val ringtoneUri: String? = null,
|
||||
val deleteAfterUse: Boolean = false,
|
||||
)
|
||||
|
||||
/** What a [Timer] reads as *right now*. [remaining] is never negative. */
|
||||
|
||||
@@ -39,6 +39,7 @@ class AlarmMapperTest {
|
||||
dismissChallenge = DismissChallenge.MATH,
|
||||
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||
updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L),
|
||||
deleteAfterUse = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -133,4 +134,29 @@ class AlarmMapperTest {
|
||||
|
||||
assertThat(roundTripped.ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient row reads back as a transient alarm`() {
|
||||
val alarm = AlarmMapper.toDomain(fullAlarmEntity().copy(deleteAfterUse = true))
|
||||
|
||||
assertThat(alarm.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient alarm writes the column back`() {
|
||||
val entity = AlarmMapper.toEntity(fullAlarm().copy(deleteAfterUse = true))
|
||||
|
||||
assertThat(entity.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft carries the flag, and an ordinary draft does not`() {
|
||||
val flags = listOf(
|
||||
AlarmMapper.toEntity(AlarmDraft(TimeOfDay(6, 0), deleteAfterUse = true), T0).deleteAfterUse,
|
||||
AlarmMapper.toEntity(AlarmDraft(TimeOfDay(6, 0)), T0).deleteAfterUse,
|
||||
)
|
||||
|
||||
assertThat(flags).containsExactly(true, false).inOrder()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -228,4 +228,24 @@ class AlarmRepositoryTest {
|
||||
|
||||
assertThat(repository.find(id)!!.ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient draft is stored and read back as transient`() = runTest {
|
||||
val repository = repository(FakeAlarmDao())
|
||||
|
||||
val id = repository.create(AlarmDraft(TimeOfDay(7, 0), deleteAfterUse = true))
|
||||
|
||||
assertThat(repository.find(id)!!.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing an alarm leaves the transient flag alone`() = runTest {
|
||||
val repository = repository(FakeAlarmDao())
|
||||
val id = repository.create(AlarmDraft(TimeOfDay(7, 0), deleteAfterUse = true))
|
||||
|
||||
repository.edit(id) { it.copy(label = "x") }
|
||||
|
||||
assertThat(repository.find(id)!!.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -40,21 +40,35 @@ class SchemaExportTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the previous version is never regenerated away`() {
|
||||
val schemas = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase")
|
||||
fun `the version 3 schema is exported and committed`() {
|
||||
val schema = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/3.json")
|
||||
|
||||
assertThat(schemas.list()?.toList()).containsAtLeast("1.json", "2.json")
|
||||
assertThat(schema.isFile).isTrue()
|
||||
val text = schema.readText()
|
||||
assertThat(text).contains("\"version\": 3")
|
||||
assertThat(text).contains("delete_after_use")
|
||||
assertThat(text).contains("\"tableName\": \"alarms\"")
|
||||
assertThat(text).contains("\"tableName\": \"timers\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one migration ships, and it reaches the exported schema version`() {
|
||||
fun `the previous version is never regenerated away`() {
|
||||
val schemas = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase")
|
||||
|
||||
assertThat(schemas.list()?.toList()).containsAtLeast("1.json", "2.json", "3.json")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the migrations form an unbroken chain up to the exported schema version`() {
|
||||
val exported = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase")
|
||||
.list()!!.map { it.removeSuffix(".json").toInt() }
|
||||
val migrations = Migrations.ALL.toList()
|
||||
|
||||
assertThat(exported.max()).isEqualTo(2)
|
||||
assertThat(Migrations.ALL.toList()).hasSize(1)
|
||||
assertThat(Migrations.ALL.single().startVersion).isEqualTo(1)
|
||||
assertThat(Migrations.ALL.single().endVersion).isEqualTo(2)
|
||||
assertThat(Migrations.ALL.single()).isEqualTo(Migrations.MIGRATION_1_2)
|
||||
assertThat(exported.max()).isEqualTo(3)
|
||||
assertThat(migrations).hasSize(2)
|
||||
assertThat(migrations.map { it.startVersion to it.endVersion })
|
||||
.containsExactly(1 to 2, 2 to 3).inOrder()
|
||||
assertThat(migrations).containsExactly(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3)
|
||||
.inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,4 +94,29 @@ class TimerMapperTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient row reads back as a transient timer`() {
|
||||
val timer = TimerMapper.toDomain(runningTimerEntity().copy(deleteAfterUse = true))
|
||||
|
||||
assertThat(timer.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient timer writes the column back`() {
|
||||
val entity = TimerMapper.toEntity(runningTimer().copy(deleteAfterUse = true))
|
||||
|
||||
assertThat(entity.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft carries the flag, and an ordinary draft does not`() {
|
||||
val flags = listOf(
|
||||
TimerMapper.toEntity(TimerDraft(90.seconds, deleteAfterUse = true), 0, T0).deleteAfterUse,
|
||||
TimerMapper.toEntity(TimerDraft(90.seconds), 0, T0).deleteAfterUse,
|
||||
)
|
||||
|
||||
assertThat(flags).containsExactly(true, false).inOrder()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -534,4 +534,24 @@ class TimerRepositoryTest {
|
||||
|
||||
assertThat(repository.find(id)).isEqualTo(before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient draft is stored and read back as transient`() = runTest {
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta", deleteAfterUse = true))
|
||||
|
||||
assertThat(repository.find(id)!!.deleteAfterUse).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetting a transient timer returns it to idle without deleting it`() = runTest {
|
||||
val id = repository.create(TimerDraft(5.minutes, "Pasta", deleteAfterUse = true))
|
||||
repository.start(id)
|
||||
repository.markExpired(id)
|
||||
|
||||
repository.reset(id)
|
||||
|
||||
val timer = repository.find(id)
|
||||
assertThat(timer).isNotNull()
|
||||
assertThat(timer!!.state).isEqualTo(TimerState.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class AlarmSettingsTest {
|
||||
dismissChallenge = dismissChallenge,
|
||||
createdAt = T0,
|
||||
updatedAt = T0,
|
||||
deleteAfterUse = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -35,6 +35,7 @@ class TimerSnapshotTest {
|
||||
sortOrder = 0,
|
||||
createdAt = T0,
|
||||
updatedAt = T0,
|
||||
deleteAfterUse = false,
|
||||
)
|
||||
|
||||
private fun idleTimer(state: TimerState, remaining: Duration) = timer(
|
||||
|
||||
@@ -62,6 +62,12 @@ class AlarmEngineHarness(
|
||||
zones = zones,
|
||||
)
|
||||
|
||||
init {
|
||||
// `ON DELETE CASCADE`, as the schema declares it (M3) and as M9's
|
||||
// transient alarms rely on: deleting the alarm deletes its ring state.
|
||||
alarmDao.onDeleted = { stateDao.deleteByAlarmId(it) }
|
||||
}
|
||||
|
||||
/** Inserts an alarm straight into storage, bypassing the engine. */
|
||||
suspend fun givenAlarm(
|
||||
id: Long = 1L,
|
||||
@@ -73,6 +79,7 @@ class AlarmEngineHarness(
|
||||
label: String = "Wake up",
|
||||
snoozeMinutes: Int? = null,
|
||||
snoozeLimit: Int? = null,
|
||||
deleteAfterUse: Boolean = false,
|
||||
): Alarm {
|
||||
val alarm = alarmAt(
|
||||
id = id,
|
||||
@@ -84,6 +91,7 @@ class AlarmEngineHarness(
|
||||
label = label,
|
||||
snoozeMinutes = snoozeMinutes,
|
||||
snoozeLimit = snoozeLimit,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
)
|
||||
alarmDao.insert(AlarmMapper.toEntity(alarm))
|
||||
return alarm
|
||||
|
||||
@@ -32,6 +32,15 @@ open class FakeAlarmDao(
|
||||
/** The stored rows, for asserting that a call wrote nothing. */
|
||||
val stored: List<AlarmEntity> get() = sorted(rows.value)
|
||||
|
||||
/**
|
||||
* `alarm_states.alarm_id` is declared `ON DELETE CASCADE`, so a deleted
|
||||
* alarm takes its ring state with it. The fake cannot infer that from a
|
||||
* schema it does not have, so the harness wires it here — otherwise a test
|
||||
* about a deleted alarm would leave an orphan row the real database cannot
|
||||
* hold.
|
||||
*/
|
||||
var onDeleted: (suspend (Long) -> Unit)? = null
|
||||
|
||||
override fun observeAll(): Flow<List<AlarmEntity>> = rows.map(::sorted)
|
||||
|
||||
override fun observeById(id: Long): Flow<AlarmEntity?> =
|
||||
@@ -56,6 +65,7 @@ open class FakeAlarmDao(
|
||||
|
||||
override suspend fun deleteById(id: Long) {
|
||||
rows.update { rows -> rows.filterNot { it.id == id } }
|
||||
onDeleted?.invoke(id)
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long) {
|
||||
|
||||
@@ -62,6 +62,7 @@ fun fullAlarm(): Alarm = Alarm(
|
||||
dismissChallenge = DismissChallenge.MATH,
|
||||
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||
updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L),
|
||||
deleteAfterUse = false,
|
||||
)
|
||||
|
||||
fun runningTimerEntity(): TimerEntity = TimerEntity(
|
||||
@@ -92,6 +93,7 @@ fun runningTimer(): Timer = Timer(
|
||||
sortOrder = 3,
|
||||
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||
updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L),
|
||||
deleteAfterUse = false,
|
||||
)
|
||||
|
||||
fun worldClockEntity(): WorldClockEntity = WorldClockEntity(
|
||||
@@ -156,6 +158,7 @@ fun alarmAt(
|
||||
snoozeMinutes: Int? = null,
|
||||
snoozeLimit: Int? = null,
|
||||
label: String = "Wake up",
|
||||
deleteAfterUse: Boolean = false,
|
||||
): Alarm = Alarm(
|
||||
id = id,
|
||||
time = TimeOfDay(hour, minute),
|
||||
@@ -171,6 +174,7 @@ fun alarmAt(
|
||||
dismissChallenge = null,
|
||||
createdAt = T0,
|
||||
updatedAt = T0,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
)
|
||||
|
||||
/** Ring state, defaulted to the record an alarm with no stored row reads back as. */
|
||||
@@ -236,6 +240,7 @@ fun timerAt(
|
||||
endsAtWallClock: Instant? = null,
|
||||
ringtoneUri: String? = null,
|
||||
sortOrder: Int = 0,
|
||||
deleteAfterUse: Boolean = false,
|
||||
): Timer = Timer(
|
||||
id = id,
|
||||
label = label,
|
||||
@@ -249,6 +254,7 @@ fun timerAt(
|
||||
sortOrder = sortOrder,
|
||||
createdAt = T0,
|
||||
updatedAt = T0,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
)
|
||||
|
||||
fun idleTimer(
|
||||
@@ -257,6 +263,7 @@ fun idleTimer(
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
deleteAfterUse: Boolean = false,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
@@ -265,6 +272,7 @@ fun idleTimer(
|
||||
remaining = duration,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
)
|
||||
|
||||
/** RUNNING with all three anchors derived from one reading of both clocks, [left] from now. */
|
||||
@@ -277,6 +285,7 @@ fun runningTimerWith(
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
deleteAfterUse: Boolean = false,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
@@ -288,6 +297,7 @@ fun runningTimerWith(
|
||||
endsAtWallClock = wallNow + left,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
)
|
||||
|
||||
fun pausedTimer(
|
||||
@@ -313,6 +323,7 @@ fun expiredTimer(
|
||||
label: String = "Pasta",
|
||||
sortOrder: Int = 0,
|
||||
ringtoneUri: String? = null,
|
||||
deleteAfterUse: Boolean = false,
|
||||
): Timer = timerAt(
|
||||
id = id,
|
||||
label = label,
|
||||
@@ -321,4 +332,5 @@ fun expiredTimer(
|
||||
remaining = Duration.ZERO,
|
||||
sortOrder = sortOrder,
|
||||
ringtoneUri = ringtoneUri,
|
||||
deleteAfterUse = deleteAfterUse,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user