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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user