feat(data): a transactional alarm edit, and the audio source policy

The editor and the engine write to the same row from different threads, so an
edit is a transaction: read, transform, write back, with the id and the
creation stamp forced from the stored row. Without it the editor would
re-enable an alarm the engine had just resolved as disabled, or move a row's
history.

`AudioSourcePolicy` is where the silent sentinel becomes an empty source list
— the audio player then fails to start by design, and the ring falls through
to vibration instead of to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:43:33 +02:00
co-authored by Claude Opus 5
parent 52204d3e9c
commit 8002651e3f
5 changed files with 118 additions and 10 deletions
@@ -3,35 +3,48 @@ package de.jeanlucmakiola.clockula.data.alarms
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface AlarmDao {
abstract class AlarmDao {
@Query("SELECT * FROM alarms ORDER BY hour, minute, id")
fun observeAll(): Flow<List<AlarmEntity>>
abstract fun observeAll(): Flow<List<AlarmEntity>>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
fun observeById(id: Long): Flow<AlarmEntity?>
abstract fun observeById(id: Long): Flow<AlarmEntity?>
@Query("SELECT * FROM alarms WHERE id = :id LIMIT 1")
suspend fun findById(id: Long): AlarmEntity?
abstract suspend fun findById(id: Long): AlarmEntity?
@Query("SELECT * FROM alarms WHERE enabled = 1 ORDER BY hour, minute, id")
suspend fun enabled(): List<AlarmEntity>
abstract suspend fun enabled(): List<AlarmEntity>
@Insert
suspend fun insert(alarm: AlarmEntity): Long
abstract suspend fun insert(alarm: AlarmEntity): Long
@Update
suspend fun update(alarm: AlarmEntity)
abstract suspend fun update(alarm: AlarmEntity)
@Query("DELETE FROM alarms WHERE id = :id")
suspend fun deleteById(id: Long)
abstract suspend fun deleteById(id: Long)
@Query("UPDATE alarms SET enabled = :enabled, updated_at = :updatedAt WHERE id = :id")
suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long)
abstract suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long)
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
abstract suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
/**
* Reads the row, applies [transform] and writes the result back inside one
* transaction. A missing row, or a null result, writes nothing. [transform]
* must be pure and must not touch the database.
*/
@Transaction
open suspend fun updateWithin(id: Long, transform: (AlarmEntity) -> AlarmEntity?) {
val current = findById(id) ?: return
val next = transform(current) ?: return
update(next)
}
}
@@ -14,4 +14,11 @@ interface AlarmRepository {
suspend fun delete(id: Long)
suspend fun setEnabled(id: Long, enabled: Boolean)
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean)
/**
* Transactional read-modify-write. Stamps `updated_at` from the wall clock
* and keeps the id, so a concurrent `setEnabled` from the engine cannot be
* clobbered by a stale whole-row write. A missing id writes nothing.
*/
suspend fun edit(id: Long, transform: (Alarm) -> Alarm)
}
@@ -41,4 +41,15 @@ class AlarmRepositoryImpl @Inject constructor(
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds())
override suspend fun edit(id: Long, transform: (Alarm) -> Alarm) {
val updatedAt = wallClock.now().toEpochMilliseconds()
dao.updateWithin(id) { entity ->
// The id is forced back on, so a transform can change a field but
// never move the row; the read and the write are one transaction, so
// a concurrent `setEnabled` from the engine is not clobbered.
AlarmMapper.toEntity(transform(AlarmMapper.toDomain(entity)))
.copy(id = entity.id, createdAt = entity.createdAt, updatedAt = updatedAt)
}
}
}
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.fullAlarm
@@ -124,4 +125,12 @@ class AlarmMapperTest {
),
)
}
@Test
fun `the silent sentinel round-trips, because blank-to-null does not eat it`() {
val entity = fullAlarmEntity().copy(ringtoneUri = Ringtones.SILENT_URI)
val roundTripped = AlarmMapper.toEntity(AlarmMapper.toDomain(entity))
assertThat(roundTripped.ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.alarms
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
import de.jeanlucmakiola.clockula.testing.FakeWallClock
@@ -160,4 +161,71 @@ class AlarmRepositoryTest {
cancel()
}
}
// --- M5: the transactional read-modify-write (D25) ---
@Test
fun `editing changes the one field the transform touched`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
val original = repository.find(id)!!
repository.edit(id) { it.copy(label = "Gym") }
assertThat(repository.find(id)).isEqualTo(original.copy(label = "Gym"))
}
@Test
fun `editing stamps the wall clock's current value`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
wallClock.advance(5.minutes)
repository.edit(id) { it.copy(label = "Gym") }
assertThat(repository.find(id)!!.updatedAt).isEqualTo(T0 + 5.minutes)
}
@Test
fun `editing an id no alarm has writes nothing and throws nothing`() = runTest {
val dao = FakeAlarmDao()
val repository = repository(dao)
repository.create(AlarmDraft(TimeOfDay(7, 0)))
val before = dao.stored
repository.edit(999L) { it.copy(label = "Gym") }
assertThat(dao.stored).isEqualTo(before)
}
@Test
fun `a transform cannot move a row to another id`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
repository.edit(id) { it.copy(id = id + 100L, label = "Gym") }
assertThat(repository.find(id)!!.label).isEqualTo("Gym")
}
@Test
fun `two edits to different fields both survive`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
repository.edit(id) { it.copy(snoozeLimit = 1) }
repository.edit(id) { it.copy(time = TimeOfDay(6, 45)) }
val alarm = repository.find(id)!!
assertThat(alarm.snoozeLimit to alarm.time).isEqualTo(1 to TimeOfDay(6, 45))
}
@Test
fun `the silent sentinel survives a create and an edit`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0), ringtoneUri = Ringtones.SILENT_URI))
repository.edit(id) { it.copy(label = "Gym") }
assertThat(repository.find(id)!!.ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
}
}