feat(data): ring state in a new alarm_states table at schema v2

Snooze, the handled-occurrence watermark and the skip watermark need somewhere
to live that survives a process death. They go in their own table rather than
as columns on alarms: a missing row is the initial record, and the FK cascade
means deleting an alarm cannot leave orphaned ring state behind.

Schema v2 is exported and committed alongside v1, so MIGRATION_1_2 is
reviewable and testable rather than taken on faith.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:04:26 +02:00
co-authored by Claude Opus 5
parent 5ea3c798b4
commit f4b7c1a16c
17 changed files with 1105 additions and 8 deletions
@@ -0,0 +1,370 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "5e5fd3a9d31d77a7aa57306836ab19ad",
"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)",
"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
}
],
"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)",
"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
}
],
"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, '5e5fd3a9d31d77a7aa57306836ab19ad')"
]
}
}
@@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@@ -110,7 +111,45 @@ class ClockulaDatabaseTest {
}
@Test
fun theDatabaseOpensAtVersionOne() {
assertThat(db.openHelper.readableDatabase.version).isEqualTo(1)
fun upsertingTheSameAlarmStateTwiceLeavesOneRow() = runBlocking {
val id = db.alarmDao().insert(alarm(hour = 7, enabled = true))
db.alarmStateDao().upsert(ringState(alarmId = id, snoozeCount = 1))
db.alarmStateDao().upsert(ringState(alarmId = id, snoozeCount = 2))
assertThat(db.alarmStateDao().observeAll().first()).hasSize(1)
assertThat(db.alarmStateDao().findByAlarmId(id)!!.snoozeCount).isEqualTo(2)
}
@Test
fun ringingReturnsTheRowThatStartedRingingLast() = runBlocking {
val first = db.alarmDao().insert(alarm(hour = 7, enabled = true))
val second = db.alarmDao().insert(alarm(hour = 8, enabled = true))
db.alarmStateDao().upsert(ringState(alarmId = first, ringingSince = 1_700_000_000_000L))
db.alarmStateDao().upsert(ringState(alarmId = second, ringingSince = 1_700_000_060_000L))
val ringing = db.alarmStateDao().ringing()
assertThat(ringing!!.alarmId).isEqualTo(second)
}
@Test
fun theDatabaseOpensAtVersionTwoAndTheNewDaoEmits() = runBlocking {
assertThat(db.openHelper.readableDatabase.version).isEqualTo(2)
assertThat(db.alarmStateDao().observeAll().first()).isEmpty()
}
private fun ringState(
alarmId: Long,
snoozeCount: Int = 0,
ringingSince: Long? = null,
) = AlarmStateEntity(
alarmId = alarmId,
snoozedUntil = null,
snoozeCount = snoozeCount,
ringingSince = ringingSince,
handledOccurrence = null,
skippedOccurrence = null,
)
}
@@ -0,0 +1,117 @@
package de.jeanlucmakiola.clockula.data.db
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The only proof the v1 → v2 migration works. A migration that fails eats the
* user's alarms, so it is validated against the exported schema rather than
* eyeballed. Device only; the build gate compiles it.
*/
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
ClockulaDatabase::class.java,
)
private val insertAlarm = """
INSERT INTO alarms (
id, hour, minute, label, enabled, repeat_days, skip_next_occurrence, ringtone_uri,
vibrate, snooze_minutes, snooze_limit, volume_ramp_seconds, dismiss_challenge,
created_at, updated_at
) VALUES (1, 7, 5, 'Gym', 1, 31, 0, 'content://media/42', 0, 7, 2, 30, 'MATH',
1700000000000, 1700000060000)
""".trimIndent()
private fun migratedDatabase(): ClockulaDatabase {
val db = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
ClockulaDatabase::class.java,
DB_NAME,
).addMigrations(*Migrations.ALL).build()
helper.closeWhenFinished(db)
return db
}
@Test
fun theMigrationValidatesAgainstTheExportedVersionTwoSchema() {
helper.createDatabase(DB_NAME, 1).close()
val migrated = helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2)
assertThat(migrated.version).isEqualTo(2)
migrated.close()
}
@Test
fun alarmsWrittenAtVersionOneSurviveTheMigrationUnchanged() {
helper.createDatabase(DB_NAME, 1).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2).close()
val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) }
assertThat(alarm).isNotNull()
assertThat(alarm!!.label).isEqualTo("Gym")
assertThat(alarm.hour).isEqualTo(7)
assertThat(alarm.repeatDays).isEqualTo(31)
}
@Test
fun ringStateIsAcceptedForAnExistingAlarmAndRefusedForOneThatIsNotThere() {
helper.createDatabase(DB_NAME, 1).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2).close()
val db = migratedDatabase()
runBlocking { db.alarmStateDao().upsert(ringStateFor(alarmId = 1L)) }
assertThat(runBlocking { db.alarmStateDao().findByAlarmId(1L) }).isNotNull()
runCatching { runBlocking { db.alarmStateDao().upsert(ringStateFor(alarmId = 404L)) } }
.also { assertThat(it.isFailure).isTrue() }
}
@Test
fun deletingAnAlarmCascadesItsRingStateAway() {
helper.createDatabase(DB_NAME, 1).apply {
execSQL(insertAlarm)
close()
}
helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2).close()
val db = migratedDatabase()
runBlocking { db.alarmStateDao().upsert(ringStateFor(alarmId = 1L)) }
runBlocking { db.alarmDao().deleteById(1L) }
assertThat(runBlocking { db.alarmStateDao().findByAlarmId(1L) }).isNull()
}
private fun ringStateFor(alarmId: Long) = AlarmStateEntity(
alarmId = alarmId,
snoozedUntil = 1_700_000_100_000L,
snoozeCount = 1,
ringingSince = 1_700_000_200_000L,
handledOccurrence = 1_700_000_300_000L,
skippedOccurrence = null,
)
private companion object {
const val DB_NAME = "clockula-migration-test.db"
}
}
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface AlarmStateDao {
@Query("SELECT * FROM alarm_states")
fun observeAll(): Flow<List<AlarmStateEntity>>
@Query("SELECT * FROM alarm_states WHERE alarm_id = :alarmId LIMIT 1")
suspend fun findByAlarmId(alarmId: Long): AlarmStateEntity?
@Query("SELECT * FROM alarm_states WHERE ringing_since IS NOT NULL ORDER BY ringing_since DESC LIMIT 1")
suspend fun ringing(): AlarmStateEntity?
@Upsert
suspend fun upsert(state: AlarmStateEntity)
@Query("DELETE FROM alarm_states WHERE alarm_id = :alarmId")
suspend fun deleteByAlarmId(alarmId: Long)
@Query("DELETE FROM alarm_states")
suspend fun deleteAll()
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
/**
* The `alarm_states` row (schema v2): one row per alarm, volatile ring state
* only. Cascades away with its alarm, so deleting an alarm takes its ring state
* with it and M10's backup never sees it.
*/
@Entity(
tableName = "alarm_states",
foreignKeys = [
ForeignKey(
entity = AlarmEntity::class,
parentColumns = ["id"],
childColumns = ["alarm_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class AlarmStateEntity(
@PrimaryKey @ColumnInfo(name = "alarm_id") val alarmId: Long,
@ColumnInfo(name = "snoozed_until") val snoozedUntil: Long?,
@ColumnInfo(name = "snooze_count") val snoozeCount: Int,
@ColumnInfo(name = "ringing_since") val ringingSince: Long?,
@ColumnInfo(name = "handled_occurrence") val handledOccurrence: Long?,
@ColumnInfo(name = "skipped_occurrence") val skippedOccurrence: Long?,
)
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import kotlin.time.Instant
/**
* Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never
* throws. An absent column and a stored epoch-zero are different facts and stay
* different — null means "never happened".
*/
object AlarmStateMapper {
fun toDomain(entity: AlarmStateEntity): AlarmRingState = AlarmRingState(
alarmId = entity.alarmId,
snoozedUntil = entity.snoozedUntil?.toInstant(),
snoozeCount = entity.snoozeCount.coerceAtLeast(0),
ringingSince = entity.ringingSince?.toInstant(),
handledOccurrence = entity.handledOccurrence?.toInstant(),
skippedOccurrence = entity.skippedOccurrence?.toInstant(),
)
fun toDomain(entities: List<AlarmStateEntity>): List<AlarmRingState> = entities.map(::toDomain)
fun toEntity(state: AlarmRingState): AlarmStateEntity = AlarmStateEntity(
alarmId = state.alarmId,
snoozedUntil = state.snoozedUntil?.toEpochMilliseconds(),
snoozeCount = state.snoozeCount,
ringingSince = state.ringingSince?.toEpochMilliseconds(),
handledOccurrence = state.handledOccurrence?.toEpochMilliseconds(),
skippedOccurrence = state.skippedOccurrence?.toEpochMilliseconds(),
)
private fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this)
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import kotlinx.coroutines.flow.Flow
interface AlarmStateRepository {
fun states(): Flow<List<AlarmRingState>>
/** Never null: an alarm with no row reads back as [AlarmRingState.initial]. Writes nothing. */
suspend fun state(alarmId: Long): AlarmRingState
suspend fun save(state: AlarmRingState)
/** The single ringing alarm, if any. Most-recent `ringingSince` wins if two rows disagree. */
suspend fun ringing(): AlarmRingState?
suspend fun clear(alarmId: Long)
suspend fun clearAll()
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* The ring-state table as the engine sees it. A missing row is a fact rather
* than an error — an alarm that has never rung reads back as the initial record
* and nothing is written until there is something to write.
*/
@Singleton
class AlarmStateRepositoryImpl @Inject constructor(
private val dao: AlarmStateDao,
) : AlarmStateRepository {
override fun states(): Flow<List<AlarmRingState>> = dao.observeAll().map(AlarmStateMapper::toDomain)
override suspend fun state(alarmId: Long): AlarmRingState =
dao.findByAlarmId(alarmId)?.let(AlarmStateMapper::toDomain) ?: AlarmRingState.initial(alarmId)
override suspend fun save(state: AlarmRingState) = dao.upsert(AlarmStateMapper.toEntity(state))
override suspend fun ringing(): AlarmRingState? = dao.ringing()?.let(AlarmStateMapper::toDomain)
override suspend fun clear(alarmId: Long) = dao.deleteByAlarmId(alarmId)
override suspend fun clearAll() = dao.deleteAll()
}
@@ -4,6 +4,8 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.data.timers.TimerDao
@@ -12,7 +14,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
/**
* Clockula's own storage. Version 1; the schema is exported to `app/schemas/`
* Clockula's own storage. Version 2; 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
@@ -22,15 +24,17 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
@Database(
entities = [
AlarmEntity::class,
AlarmStateEntity::class,
TimerEntity::class,
WorldClockEntity::class,
LapEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
)
abstract class ClockulaDatabase : RoomDatabase() {
abstract fun alarmDao(): AlarmDao
abstract fun alarmStateDao(): AlarmStateDao
abstract fun timerDao(): TimerDao
abstract fun worldClockDao(): WorldClockDao
abstract fun lapDao(): LapDao
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.data.db
import androidx.room.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
/**
* Every migration this database has ever needed, in one place, so the list a
* builder is handed is the list the tests assert on. The statements are copied
* from the exported schema's `createSql` verbatim: `runMigrationsAndValidate`
* compares the migrated database against that JSON column for column.
*/
object Migrations {
/** v1 → v2: `CREATE TABLE alarm_states` — the snooze and ring state (M3). */
val MIGRATION_1_2: Migration = object : Migration(1, 2) {
override fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `alarm_states` (" +
"`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 )",
)
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2)
}
@@ -8,14 +8,16 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao
import de.jeanlucmakiola.clockula.data.db.ClockulaDatabase
import de.jeanlucmakiola.clockula.data.db.Migrations
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
import de.jeanlucmakiola.clockula.data.timers.TimerDao
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
import javax.inject.Singleton
/**
* The database and its four DAOs. No destructive-migration fallback: docs/PLAN.md
* The database and its five DAOs. No destructive-migration fallback: docs/PLAN.md
* §12 wants every schema change to arrive with a tested migration, and a fallback
* would quietly drop a user's alarms instead of failing loudly in review.
*/
@@ -26,11 +28,16 @@ object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): ClockulaDatabase =
Room.databaseBuilder(context, ClockulaDatabase::class.java, ClockulaDatabase.NAME).build()
Room.databaseBuilder(context, ClockulaDatabase::class.java, ClockulaDatabase.NAME)
.addMigrations(*Migrations.ALL)
.build()
@Provides
fun provideAlarmDao(database: ClockulaDatabase): AlarmDao = database.alarmDao()
@Provides
fun provideAlarmStateDao(database: ClockulaDatabase): AlarmStateDao = database.alarmStateDao()
@Provides
fun provideTimerDao(database: ClockulaDatabase): TimerDao = database.timerDao()
@@ -6,6 +6,8 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepositoryImpl
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
@@ -14,7 +16,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepositoryImpl
import javax.inject.Singleton
/** The four interfaces the rest of the app sees — and the only ones it may see. */
/** The five interfaces the rest of the app sees — and the only ones it may see. */
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@@ -23,6 +25,10 @@ abstract class RepositoryModule {
@Singleton
abstract fun bindAlarmRepository(impl: AlarmRepositoryImpl): AlarmRepository
@Binds
@Singleton
abstract fun bindAlarmStateRepository(impl: AlarmStateRepositoryImpl): AlarmStateRepository
@Binds
@Singleton
abstract fun bindTimerRepository(impl: TimerRepositoryImpl): TimerRepository
@@ -0,0 +1,110 @@
package de.jeanlucmakiola.clockula.data.alarms
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** Entity ↔ domain for the v2 ring-state table. A read must degrade, never throw. */
class AlarmStateMapperTest {
private fun fullEntity() = AlarmStateEntity(
alarmId = 42L,
snoozedUntil = 1_700_000_100_000L,
snoozeCount = 2,
ringingSince = 1_700_000_200_000L,
handledOccurrence = 1_700_000_300_000L,
skippedOccurrence = 1_700_000_400_000L,
)
@Test
fun `every column lands on the field that belongs to it`() {
val state = AlarmStateMapper.toDomain(fullEntity())
assertThat(state).isEqualTo(
AlarmRingState(
alarmId = 42L,
snoozedUntil = Instant.fromEpochMilliseconds(1_700_000_100_000L),
snoozeCount = 2,
ringingSince = Instant.fromEpochMilliseconds(1_700_000_200_000L),
handledOccurrence = Instant.fromEpochMilliseconds(1_700_000_300_000L),
skippedOccurrence = Instant.fromEpochMilliseconds(1_700_000_400_000L),
),
)
}
@Test
fun `domain to entity and back is the same value`() {
val state = AlarmStateMapper.toDomain(fullEntity())
assertThat(AlarmStateMapper.toDomain(AlarmStateMapper.toEntity(state))).isEqualTo(state)
}
@Test
fun `a negative stored snooze count reads back as none`() {
val degraded = AlarmStateMapper.toDomain(fullEntity().copy(snoozeCount = -3))
assertThat(degraded.snoozeCount).isEqualTo(0)
// An implausibly high count is not a user-facing setting, so it is left alone.
assertThat(AlarmStateMapper.toDomain(fullEntity().copy(snoozeCount = 900)).snoozeCount)
.isEqualTo(900)
}
@Test
fun `an absent column and an epoch-zero one are different facts`() {
val absent = AlarmStateMapper.toDomain(fullEntity().copy(snoozedUntil = null))
val epochZero = AlarmStateMapper.toDomain(fullEntity().copy(snoozedUntil = 0L))
assertThat(absent.snoozedUntil).isNull()
assertThat(epochZero.snoozedUntil).isEqualTo(Instant.fromEpochMilliseconds(0L))
}
@Test
fun `the initial record is all absences and no snoozes`() {
val initial = AlarmRingState.initial(42L)
assertThat(initial).isEqualTo(AlarmRingState(alarmId = 42L))
assertThat(
listOf(
initial.snoozedUntil,
initial.ringingSince,
initial.handledOccurrence,
initial.skippedOccurrence,
),
).containsExactly(null, null, null, null)
assertThat(initial.snoozeCount).isEqualTo(0)
}
@Test
fun `the initial record maps to a row of nulls`() {
val entity = AlarmStateMapper.toEntity(AlarmRingState.initial(42L))
assertThat(entity).isEqualTo(
AlarmStateEntity(
alarmId = 42L,
snoozedUntil = null,
snoozeCount = 0,
ringingSince = null,
handledOccurrence = null,
skippedOccurrence = null,
),
)
}
@Test
fun `no combination of null columns makes the mapper throw`() {
val combinations = (0 until 32).map { bits ->
fullEntity().copy(
snoozedUntil = fullEntity().snoozedUntil.takeIf { bits and 1 == 0 },
ringingSince = fullEntity().ringingSince.takeIf { bits and 2 == 0 },
handledOccurrence = fullEntity().handledOccurrence.takeIf { bits and 4 == 0 },
skippedOccurrence = fullEntity().skippedOccurrence.takeIf { bits and 8 == 0 },
snoozeCount = if (bits and 16 == 0) 2 else -1,
)
}
val mapped = AlarmStateMapper.toDomain(combinations)
assertThat(mapped).hasSize(32)
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.clockula.data.alarms
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
import de.jeanlucmakiola.clockula.testing.FakeAlarmStateDao
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.fullAlarmEntity
import de.jeanlucmakiola.clockula.testing.ringState
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.time.Duration.Companion.minutes
/** The ring-state table as the engine sees it: a missing row is a fact, not an error. */
class AlarmStateRepositoryTest {
private val alarmDao = FakeAlarmDao()
private val dao = FakeAlarmStateDao(alarms = alarmDao)
private val repository = AlarmStateRepositoryImpl(dao)
private suspend fun givenAlarm(id: Long): Long = alarmDao.insert(fullAlarmEntity().copy(id = id))
@Test
fun `an alarm with no row reads back as the initial record, writing nothing`() = runTest {
givenAlarm(1L)
val state = repository.state(1L)
assertThat(state).isEqualTo(AlarmRingState.initial(1L))
assertThat(dao.writes).isEqualTo(0)
}
@Test
fun `a saved state reads back`() = runTest {
givenAlarm(1L)
val state = ringState(alarmId = 1L, snoozedUntil = T0 + 10.minutes, snoozeCount = 1)
repository.save(state)
assertThat(repository.state(1L)).isEqualTo(state)
}
@Test
fun `saving twice replaces the row rather than adding one`() = runTest {
givenAlarm(1L)
repository.save(ringState(alarmId = 1L, snoozeCount = 1))
repository.save(ringState(alarmId = 1L, snoozeCount = 2))
assertThat(dao.stored).hasSize(1)
assertThat(repository.state(1L).snoozeCount).isEqualTo(2)
}
@Test
fun `states re-emits after a save`() = runTest {
givenAlarm(1L)
repository.states().test {
assertThat(awaitItem()).isEmpty()
repository.save(ringState(alarmId = 1L, snoozeCount = 1))
assertThat(awaitItem()).containsExactly(ringState(alarmId = 1L, snoozeCount = 1))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `ringing finds the ringing row and is null when there is none`() = runTest {
givenAlarm(1L)
assertThat(repository.ringing()).isNull()
repository.save(ringState(alarmId = 1L, ringingSince = T0))
assertThat(repository.ringing()?.alarmId).isEqualTo(1L)
}
@Test
fun `two ringing rows resolve to the one that started later`() = runTest {
givenAlarm(1L)
givenAlarm(2L)
repository.save(ringState(alarmId = 1L, ringingSince = T0))
repository.save(ringState(alarmId = 2L, ringingSince = T0 + 1.minutes))
val ringing = repository.ringing()
assertThat(ringing?.alarmId).isEqualTo(2L)
}
@Test
fun `ring state for an alarm that no longer exists is refused by the foreign key`() = runTest {
assertThrows<IllegalStateException> { repository.save(ringState(alarmId = 404L)) }
}
}
@@ -27,4 +27,34 @@ class SchemaExportTest {
assertThat(text).contains("\"tableName\": \"world_clocks\"")
assertThat(text).contains("\"tableName\": \"stopwatch_laps\"")
}
@Test
fun `the version 2 schema is exported and committed`() {
val schema = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/2.json")
assertThat(schema.isFile).isTrue()
val text = schema.readText()
assertThat(text).isNotEmpty()
assertThat(text).contains("\"version\": 2")
assertThat(text).contains("\"tableName\": \"alarm_states\"")
}
@Test
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")
}
@Test
fun `exactly one migration ships, and it reaches the exported schema version`() {
val exported = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase")
.list()!!.map { it.removeSuffix(".json").toInt() }
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)
}
}
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.data.timers.TimerDao
@@ -40,7 +42,10 @@ class FakeAlarmDao(
override suspend fun enabled(): List<AlarmEntity> = sorted(rows.value).filter { it.enabled }
override suspend fun insert(alarm: AlarmEntity): Long {
val id = if (alarm.id == 0L) nextRowId++ else alarm.id
val id = if (alarm.id == 0L) nextRowId else alarm.id
// SQLite hands out `max(rowid) + 1`, so a row inserted with an explicit
// id moves the allocator past it — the next generated id never collides.
nextRowId = maxOf(nextRowId, id + 1L)
rows.update { it + alarm.copy(id = id) }
return id
}
@@ -83,6 +88,8 @@ open class FakeTimerDao(initial: List<TimerEntity> = emptyList()) : TimerDao() {
override suspend fun findById(id: Long): TimerEntity? = rows.value.firstOrNull { it.id == id }
override suspend fun running(): List<TimerEntity> = sorted(rows.value).filter { it.state == "RUNNING" }
override suspend fun maxSortOrder(): Int? = rows.value.maxOfOrNull { it.sortOrder }
override suspend fun insert(timer: TimerEntity): Long {
@@ -168,3 +175,52 @@ class FakeLapDao(initial: List<LapEntity> = emptyList()) : LapDao() {
rows.value = emptyList()
}
}
/**
* The `alarm_states` table in memory. When it is given the alarm table it
* enforces the same foreign key SQLite would, so a test cannot write ring state
* for an alarm that is no longer there and call it passing.
*/
class FakeAlarmStateDao(
initial: List<AlarmStateEntity> = emptyList(),
private val alarms: FakeAlarmDao? = null,
) : AlarmStateDao {
private val rows = MutableStateFlow(initial)
/** Every write this DAO has seen, so "a read wrote nothing" is assertable. */
var writes: Int = 0
private set
val stored: List<AlarmStateEntity> get() = sorted(rows.value)
override fun observeAll(): Flow<List<AlarmStateEntity>> = rows.map(::sorted)
override suspend fun findByAlarmId(alarmId: Long): AlarmStateEntity? =
rows.value.firstOrNull { it.alarmId == alarmId }
override suspend fun ringing(): AlarmStateEntity? =
rows.value.filter { it.ringingSince != null }.maxByOrNull { it.ringingSince!! }
override suspend fun upsert(state: AlarmStateEntity) {
alarms?.let { table ->
check(table.stored.any { it.id == state.alarmId }) {
"FOREIGN KEY constraint failed: no alarm ${state.alarmId}"
}
}
writes++
rows.update { rows -> rows.filterNot { it.alarmId == state.alarmId } + state }
}
override suspend fun deleteByAlarmId(alarmId: Long) {
writes++
rows.update { rows -> rows.filterNot { it.alarmId == alarmId } }
}
override suspend fun deleteAll() {
writes++
rows.value = emptyList()
}
private fun sorted(rows: List<AlarmStateEntity>): List<AlarmStateEntity> = rows.sortedBy { it.alarmId }
}
@@ -10,6 +10,11 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
@@ -99,3 +104,85 @@ fun lapEntity(): LapEntity = LapEntity(
splitMillis = 1_234L,
cumulativeMillis = 5_678L,
)
// --- M3: zones, instants and alarm fixtures ---
/** Berlin 2026: spring forward 2026-03-29 02:00→03:00, fall back 2026-10-25 03:00→02:00. */
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
/** New York 2026: spring forward 2026-03-08, fall back 2026-11-01. */
val NEW_YORK: ZoneId = ZoneId.of("America/New_York")
/** Lord Howe 2026: a **30-minute** gap on 2026-10-04 at 02:00. */
val LORD_HOWE: ZoneId = ZoneId.of("Australia/Lord_Howe")
/** Apia 2011: the calendar day 2011-12-30 never happened. */
val APIA: ZoneId = ZoneId.of("Pacific/Apia")
val UTC_ZONE: ZoneId = ZoneId.of("UTC")
/**
* A local wall-clock reading resolved in [zone] the way `java.time` resolves
* it. Use it for *inputs*; spell expectations with [offsetInstant] so an
* assertion never restates the behaviour under test.
*/
fun instantAt(zone: ZoneId, iso: String): Instant =
ZonedDateTime.of(LocalDateTime.parse(iso), zone).toInstant().toKotlinInstant()
/** An absolute instant, spelled with the offset it is expected to carry: `"2026-03-29T03:30+02:00"`. */
fun offsetInstant(iso: String): Instant = OffsetDateTime.parse(iso).toInstant().toKotlinInstant()
/** What [this] reads as on a wall clock in [zone]. */
fun Instant.localAt(zone: ZoneId): LocalDateTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDateTime()
/** The offset [zone] was on at [this]. */
fun Instant.offsetAt(zone: ZoneId): java.time.ZoneOffset =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).offset
private fun java.time.Instant.toKotlinInstant(): Instant = Instant.fromEpochMilliseconds(toEpochMilli())
/** An alarm with only the fields M3 reasons about spelled out. */
fun alarmAt(
id: Long = 1L,
hour: Int,
minute: Int = 0,
repeatDays: RepeatDays = RepeatDays.NONE,
enabled: Boolean = true,
skipNextOccurrence: Boolean = false,
snoozeMinutes: Int? = null,
snoozeLimit: Int? = null,
label: String = "Wake up",
): Alarm = Alarm(
id = id,
time = TimeOfDay(hour, minute),
label = label,
enabled = enabled,
repeatDays = repeatDays,
skipNextOccurrence = skipNextOccurrence,
ringtoneUri = null,
vibrate = null,
snoozeMinutes = snoozeMinutes,
snoozeLimit = snoozeLimit,
volumeRampSeconds = null,
dismissChallenge = null,
createdAt = T0,
updatedAt = T0,
)
/** Ring state, defaulted to the record an alarm with no stored row reads back as. */
fun ringState(
alarmId: Long = 1L,
snoozedUntil: Instant? = null,
snoozeCount: Int = 0,
ringingSince: Instant? = null,
handledOccurrence: Instant? = null,
skippedOccurrence: Instant? = null,
): AlarmRingState = AlarmRingState(
alarmId = alarmId,
snoozedUntil = snoozedUntil,
snoozeCount = snoozeCount,
ringingSince = ringingSince,
handledOccurrence = handledOccurrence,
skippedOccurrence = skippedOccurrence,
)