feat(backup): JSON export/import through SAF, whole-state replace

clockula-backup-v1.json, schema documented in docs/BACKUP.md per
PLAN.md §7: alarms, timers and world clocks as configuration only —
ring state and a timer's running anchors never leave the device, since
neither means anything on another one. Unknown fields are ignored at
any level; a malformed known field rejects the whole file rather than
importing it half-way.

Import is whole-state replacement, not a merge, and the backup screen
says so before the user confirms. Before replacing: a ringing alarm is
dismissed and every running timer is deleted, so the import can never
strand a live ring session; after it, AlarmEngine and TimerEngine are
told to re-resolve so the imported state is live immediately.

BackupDao adds delete-all/insert-many queries under one transaction —
no schema or version change, queries only.
This commit is contained in:
makiolaj committed 2026-10-01 22:06:44 +02:00
1 parent 7253445c4e
commit 616bf7f67f
22 files changed
+1657 -5

No files matched your search

@@ -0,0 +1,162 @@
package de.jeanlucmakiola.clockula.backup
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.RingEvent
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.backupHarness
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* [BackupEngine] over the real engines and repositories: proves export then
* import round-trips configuration, that ring state is never part of it, that
* a ringing alarm and running timers are cleared before the replace, and that
* both engines are told to re-resolve afterwards.
*/
class BackupEngineTest {
@Test
fun `export then import reproduces the same alarms, timers, world clocks and settings`(
@TempDir tempDir: Path,
) = runTest {
val source = backupHarness(tempDir.resolve("source").also { it.toFile().mkdirs() })
source.givenAlarm(
alarmAt(id = 1L, hour = 7, minute = 30, repeatDays = RepeatDays.WEEKDAYS, label = "Gym"),
)
source.givenTimer(idleTimer(id = 1L, duration = 10.minutes, label = "Pasta"))
source.givenWorldClock(WorldClock(id = 1L, zoneId = "Europe/Berlin", label = "Home", sortOrder = 0))
source.settings.setThemeMode(ThemeMode.DARK)
source.settings.setSnoozeMinutes(7)
source.settings.setHomeZoneId("America/New_York")
val exported = source.backupEngine.export()
// A fresh install: a different harness, empty of everything the export carries.
val target = backupHarness(tempDir.resolve("target").also { it.toFile().mkdirs() })
target.backupEngine.import(exported)
val alarm = target.storedAlarms().single()
assertThat(alarm.time).isEqualTo(TimeOfDay(7, 30))
assertThat(alarm.repeatDays).isEqualTo(RepeatDays.WEEKDAYS)
assertThat(alarm.label).isEqualTo("Gym")
val timer = target.storedTimers().single()
assertThat(timer.label).isEqualTo("Pasta")
assertThat(timer.duration).isEqualTo(10.minutes)
assertThat(timer.state).isEqualTo(TimerState.IDLE)
assertThat(timer.remaining).isEqualTo(10.minutes)
val worldClock = target.storedWorldClocks().single()
assertThat(worldClock.zoneId).isEqualTo("Europe/Berlin")
assertThat(worldClock.label).isEqualTo("Home")
assertThat(target.settings.themeMode.first()).isEqualTo(ThemeMode.DARK)
assertThat(target.settings.currentDefaults().snoozeMinutes).isEqualTo(7)
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
}
@Test
fun `import never carries ring state`(@TempDir tempDir: Path) = runTest {
val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() })
source.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY))
source.givenRinging(1L, instantAt(BERLIN, "2026-06-10T07:00"))
assertThat(source.alarmEngine.ringSession()).isNotNull()
val exported = source.backupEngine.export()
assertThat(exported.alarms.single()).isInstanceOf(BackupAlarm::class.java)
val target = backupHarness(tempDir.resolve("target2").also { it.toFile().mkdirs() })
target.backupEngine.import(exported)
assertThat(target.alarmEngine.ringSession()).isNull()
}
@Test
fun `import dismisses a ringing alarm before replacing it`(@TempDir tempDir: Path) = runTest {
val harness = backupHarness(tempDir.resolve("ringing").also { it.toFile().mkdirs() })
harness.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY))
harness.givenRinging(1L, instantAt(BERLIN, "2026-06-10T07:00"))
assertThat(harness.alarmEngine.ringSession()?.alarm?.id).isEqualTo(1L)
val replacement = harness.backupEngine.export().copy(alarms = emptyList())
harness.backupEngine.import(replacement)
assertThat(harness.ring.events).contains(RingEvent.Stopped(1L))
assertThat(harness.alarmEngine.ringSession()).isNull()
}
@Test
fun `import deletes running timers before replacing them`(@TempDir tempDir: Path) = runTest {
val harness = backupHarness(tempDir.resolve("runningTimer").also { it.toFile().mkdirs() })
harness.givenTimer(runningTimerWith(id = 1L, left = 2.minutes, elapsedNow = 1_000.seconds))
assertThat(harness.storedTimers().single().state).isEqualTo(TimerState.RUNNING)
val replacement = harness.backupEngine.export().copy(timers = emptyList())
harness.backupEngine.import(replacement)
assertThat(harness.storedTimers()).isEmpty()
}
@Test
fun `import reschedules alarms with the system`(@TempDir tempDir: Path) = runTest {
val harness = backupHarness(
tempDir.resolve("reschedule").also { it.toFile().mkdirs() },
now = instantAt(BERLIN, "2026-06-10T05:00"),
)
val content = harness.backupEngine.export().copy(
alarms = listOf(
BackupAlarm(
time = TimeOfDay(7, 0),
label = "Wake up",
enabled = true,
repeatDays = RepeatDays.NONE,
skipNextOccurrence = false,
ringtoneUri = null,
vibrate = null,
snoozeMinutes = null,
snoozeLimit = null,
volumeRampSeconds = null,
dismissChallenge = null,
deleteAfterUse = false,
),
),
)
harness.backupEngine.import(content)
assertThat(harness.alarmScheduler.next).isNotNull()
assertThat(harness.alarmScheduler.next?.fireAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00"))
}
@Test
fun `settings timer presets and dynamic colour round trip`(@TempDir tempDir: Path) = runTest {
val source = backupHarness(tempDir.resolve("presetsSource").also { it.toFile().mkdirs() })
source.settings.setDynamicColor(false)
source.settings.setTimerPresets(listOf(1.minutes, 3.minutes, 9.minutes))
val exported = source.backupEngine.export()
assertThat(exported.settings.dynamicColor).isFalse()
assertThat(exported.settings.timerPresets).containsExactly(1.minutes, 3.minutes, 9.minutes).inOrder()
val target = backupHarness(tempDir.resolve("presetsTarget").also { it.toFile().mkdirs() })
target.backupEngine.import(exported)
assertThat(target.settings.dynamicColor.first()).isFalse()
assertThat(target.settings.timerPresets.first())
.containsExactly(1.minutes, 3.minutes, 9.minutes).inOrder()
}
}
@@ -0,0 +1,232 @@
package de.jeanlucmakiola.clockula.data.backup
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.floret.prefs.ThemeMode
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
class BackupCodecTest {
private fun sampleContent(): BackupContent = BackupContent(
exportedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
alarms = listOf(
BackupAlarm(
time = TimeOfDay(7, 30),
label = "Gym",
enabled = true,
repeatDays = RepeatDays.WEEKDAYS,
skipNextOccurrence = true,
ringtoneUri = "content://media/42",
vibrate = false,
snoozeMinutes = 7,
snoozeLimit = 2,
volumeRampSeconds = 30,
dismissChallenge = DismissChallenge.MATH,
deleteAfterUse = false,
),
),
timers = listOf(
BackupTimer(
label = "Pasta",
duration = 5.minutes,
ringtoneUri = "content://media/7",
sortOrder = 3,
deleteAfterUse = false,
),
),
worldClocks = listOf(
BackupWorldClock(zoneId = "Europe/Berlin", label = "Home", sortOrder = 2),
),
settings = BackupSettings(
themeMode = ThemeMode.DARK,
dynamicColor = false,
defaults = ClockDefaults(
snoozeMinutes = 9,
snoozeLimit = 4,
vibrate = false,
volumeRampSeconds = 20,
alarmRingtoneUri = "content://media/1",
timerRingtoneUri = "content://media/2",
dismissChallenge = DismissChallenge.HOLD,
timerDuration = 10.minutes,
homeZoneId = "America/New_York",
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
),
)
@Test
fun `round trip is identical`() {
val original = sampleContent()
val decoded = BackupCodec.decode(BackupCodec.encode(original))
assertThat(decoded).isEqualTo(BackupResult.Success(original))
}
@Test
fun `round trip of an empty backup is identical`() {
val original = BackupContent(
exportedAt = Instant.fromEpochMilliseconds(0L),
alarms = emptyList(),
timers = emptyList(),
worldClocks = emptyList(),
settings = BackupSettings(
themeMode = ThemeMode.SYSTEM,
dynamicColor = true,
defaults = ClockDefaults(),
timerPresets = emptyList(),
),
)
val decoded = BackupCodec.decode(BackupCodec.encode(original))
assertThat(decoded).isEqualTo(BackupResult.Success(original))
}
@Test
fun `an unknown top-level field is ignored`() {
val decoded = BackupCodec.decode(minimalDocument(""" "unknownTopLevelField": 123,"""))
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
}
@Test
fun `an unknown nested field is ignored`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [ { "hour": 7, "minute": 0, "somethingElse": true } ],
"timers": [],
"worldClocks": [],
"settings": { "someFutureSetting": "x" }
}
""".trimIndent()
val decoded = BackupCodec.decode(json)
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
}
@Test
fun `wrong format rejects the whole file`() {
val decoded = BackupCodec.decode(minimalDocument(format = "something-else"))
assertThat(decoded).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a future version rejects the whole file`() {
val decoded = BackupCodec.decode(minimalDocument(version = 2))
assertThat(decoded).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `an out-of-range hour rejects the whole file`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [ { "hour": 25, "minute": 0 } ],
"timers": [],
"worldClocks": [],
"settings": {}
}
""".trimIndent()
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `an out-of-range minute rejects the whole file`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [ { "hour": 7, "minute": 60 } ],
"timers": [],
"worldClocks": [],
"settings": {}
}
""".trimIndent()
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a non-IANA zone id rejects the whole file`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [],
"timers": [],
"worldClocks": [ { "zone_id": "Not/AZone" } ],
"settings": {}
}
""".trimIndent()
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `a negative timer duration rejects the whole file`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [],
"timers": [ { "duration_millis": -1 } ],
"worldClocks": [],
"settings": {}
}
""".trimIndent()
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `an unrecognised dismiss challenge degrades rather than rejecting`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [ { "hour": 7, "minute": 0, "dismiss_challenge": "SOME_FUTURE_CHALLENGE" } ],
"timers": [],
"worldClocks": [],
"settings": {}
}
""".trimIndent()
val result = BackupCodec.decode(json)
assertThat(result).isInstanceOf(BackupResult.Success::class.java)
val content = (result as BackupResult.Success).content
assertThat(content.alarms.single().dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `garbage text fails rather than throwing`() {
assertThat(BackupCodec.decode("not json at all")).isInstanceOf(BackupResult.Failure::class.java)
}
private fun minimalDocument(
extraTopLevel: String = "",
format: String = "clockula-backup",
version: Int = 1,
): String = """
{
"format": "$format",
"version": $version,
$extraTopLevel
"exportedAt": 0,
"alarms": [],
"timers": [],
"worldClocks": [],
"settings": {}
}
""".trimIndent()
}
@@ -0,0 +1,153 @@
package de.jeanlucmakiola.clockula.testing
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.backup.BackupEngine
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
import de.jeanlucmakiola.clockula.data.backup.BackupRepositoryImpl
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerMapper
import de.jeanlucmakiola.clockula.data.timers.TimerRepositoryImpl
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockMapper
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepositoryImpl
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import java.nio.file.Path
import java.time.ZoneId
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* [BackupEngine] over both real engines, the real repositories, the fake DAOs
* and one DataStore — the same shape [InteropHarness] builds for M9, extended
* with world clocks for M10's backup.
*/
class BackupHarness(
tempDir: Path,
storeScope: CoroutineScope,
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
zone: ZoneId = BERLIN,
uptime: Duration = 1_000.seconds,
) {
val alarmDao: FakeAlarmDao = FakeAlarmDao()
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao(alarms = alarmDao)
val timerDao: FakeTimerDao = FakeTimerDao()
val worldClockDao: FakeWorldClockDao = FakeWorldClockDao()
val backupDao: FakeBackupDao = FakeBackupDao(alarmDao, timerDao, worldClockDao)
val wallClock: FakeWallClock = FakeWallClock(now)
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
val zones: FakeZoneProvider = FakeZoneProvider(zone)
val alarmScheduler: FakeAlarmScheduler = FakeAlarmScheduler()
val ring: FakeRingCoordinator = FakeRingCoordinator()
val notifier: FakeAlarmNotifier = FakeAlarmNotifier()
val timerScheduler: FakeTimerScheduler = FakeTimerScheduler()
val timerService: FakeTimerServiceHandle = FakeTimerServiceHandle()
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
val store: PrefStore = PrefStore(
PreferenceDataStoreFactory.create(
scope = storeScope,
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
)
val settings: SettingsPrefs = SettingsPrefs(store)
val ringState: TimerRingStateStore = TimerRingStateStore(store)
val alarms: AlarmRepositoryImpl = AlarmRepositoryImpl(alarmDao, wallClock)
val states: AlarmStateRepositoryImpl = AlarmStateRepositoryImpl(stateDao)
val timers: TimerRepositoryImpl = TimerRepositoryImpl(timerDao, elapsed, wallClock)
val worldClocks: WorldClockRepositoryImpl = WorldClockRepositoryImpl(worldClockDao)
val backupRepository: BackupRepositoryImpl =
BackupRepositoryImpl(alarms, timers, worldClocks, backupDao, wallClock)
val alarmEngine: AlarmEngine = AlarmEngine(
alarms = alarms,
states = states,
settings = settings,
scheduler = alarmScheduler,
ring = ring,
notifier = notifier,
rebootRepair = rebootRepair,
wallClock = wallClock,
zones = zones,
)
val timerEngine: TimerEngine = TimerEngine(
timers = timers,
settings = settings,
ringState = ringState,
scheduler = timerScheduler,
service = timerService,
alarmRing = alarmRing,
rebootRepair = rebootRepair,
elapsed = elapsed,
wall = wallClock,
)
val backupEngine: BackupEngine = BackupEngine(
repository = backupRepository,
settings = settings,
timers = timers,
alarmEngine = alarmEngine,
timerEngine = timerEngine,
wallClock = wallClock,
)
init {
alarmDao.onDeleted = { stateDao.deleteByAlarmId(it) }
}
suspend fun givenAlarm(alarm: Alarm): Alarm {
alarmDao.insert(AlarmMapper.toEntity(alarm))
return alarm
}
suspend fun givenTimer(timer: Timer): Timer {
timerDao.insert(TimerMapper.toEntity(timer))
return timer
}
suspend fun givenWorldClock(clock: WorldClock): WorldClock {
worldClockDao.insert(WorldClockMapper.toEntity(clock))
return clock
}
/** Rings [alarmId] through the engine, exactly as a fire broadcast would. */
suspend fun givenRinging(alarmId: Long, at: Instant) {
wallClock.instant = at
alarmEngine.onFire(alarmId, at)
}
suspend fun storedAlarms(): List<Alarm> = alarmDao.stored.map(AlarmMapper::toDomain)
suspend fun storedTimers(): List<Timer> = timerDao.stored.map(TimerMapper::toDomain)
suspend fun storedWorldClocks(): List<WorldClock> = worldClockDao.stored.map(WorldClockMapper::toDomain)
}
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
fun TestScope.backupHarness(
tempDir: Path,
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
zone: ZoneId = BERLIN,
): BackupHarness = BackupHarness(
tempDir = tempDir,
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
now = now,
zone = zone,
)
@@ -4,6 +4,7 @@ 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.backup.BackupDao
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.data.timers.TimerDao
@@ -236,3 +237,42 @@ class FakeAlarmStateDao(
private fun sorted(rows: List<AlarmStateEntity>): List<AlarmStateEntity> = rows.sortedBy { it.alarmId }
}
/**
* Delegates to the real [FakeAlarmDao]/[FakeTimerDao]/[FakeWorldClockDao]
* instances it is given, rather than holding its own storage — so a backup
* test can read the same tables through [FakeAlarmDao.stored] etc. Deletes go
* through each dao's own `deleteById`, so [FakeAlarmDao]'s `onDeleted` cascade
* hook (wired to the state dao, exactly as [InteropHarness] wires it) still
* fires for every alarm a replace removes.
*/
class FakeBackupDao(
private val alarmDao: FakeAlarmDao,
private val timerDao: FakeTimerDao,
private val worldClockDao: FakeWorldClockDao,
) : BackupDao() {
override suspend fun deleteAllAlarms() {
for (row in alarmDao.stored) alarmDao.deleteById(row.id)
}
override suspend fun deleteAllTimers() {
for (row in timerDao.stored) timerDao.deleteById(row.id)
}
override suspend fun deleteAllWorldClocks() {
for (row in worldClockDao.stored) worldClockDao.deleteById(row.id)
}
override suspend fun insertAlarms(alarms: List<AlarmEntity>) {
for (alarm in alarms) alarmDao.insert(alarm)
}
override suspend fun insertTimers(timers: List<TimerEntity>) {
for (timer in timers) timerDao.insert(timer)
}
override suspend fun insertWorldClocks(worldClocks: List<WorldClockEntity>) {
for (clock in worldClocks) worldClockDao.insert(clock)
}
}