From 616bf7f67fb9d93e6b0c2c0d8cbfaf16c8d941a2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 1 Oct 2026 22:06:44 +0200 Subject: [PATCH] feat(backup): JSON export/import through SAF, whole-state replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../clockula/backup/BackupEngine.kt | 93 +++++++ .../clockula/backup/BackupFiles.kt | 17 ++ .../clockula/backup/android/SafBackupFiles.kt | 48 ++++ .../clockula/data/backup/BackupCodec.kt | 194 +++++++++++++++ .../clockula/data/backup/BackupDao.kt | 57 +++++ .../clockula/data/backup/BackupDto.kt | 72 ++++++ .../clockula/data/backup/BackupRepository.kt | 19 ++ .../data/backup/BackupRepositoryImpl.kt | 122 +++++++++ .../clockula/data/db/ClockulaDatabase.kt | 3 + .../clockula/data/di/BackupModule.kt | 19 ++ .../clockula/data/di/DatabaseModule.kt | 4 + .../clockula/data/di/RepositoryModule.kt | 6 + .../clockula/domain/backup/BackupContent.kt | 59 +++++ .../clockula/ui/settings/BackupScreen.kt | 128 +++++++++- .../clockula/ui/settings/BackupUiState.kt | 9 + .../clockula/ui/settings/BackupViewModel.kt | 74 ++++++ app/src/main/res/values/strings.xml | 14 ++ .../clockula/backup/BackupEngineTest.kt | 162 ++++++++++++ .../clockula/data/backup/BackupCodecTest.kt | 232 ++++++++++++++++++ .../clockula/testing/BackupHarness.kt | 153 ++++++++++++ .../clockula/testing/FakeDaos.kt | 40 +++ docs/BACKUP.md | 137 +++++++++++ 22 files changed, 1657 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupFiles.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/backup/android/SafBackupFiles.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepository.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/di/BackupModule.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt create mode 100644 docs/BACKUP.md diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt new file mode 100644 index 0000000..1118894 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.clockula.backup + +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.backup.BackupRepository +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.backup.BackupContent +import de.jeanlucmakiola.clockula.domain.backup.BackupSettings +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.timer.TimerEngine +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Orchestrates a whole-state export/import, the same way [AlarmEngine] and + * [TimerEngine] orchestrate their own domains: plain Kotlin, no Android + * import, every write going through the repositories and the two engines + * rather than around them. `ui/settings/BackupViewModel.kt` is the only + * caller; it owns the SAF file picking ([BackupFiles] does the I/O). + */ +@Singleton +class BackupEngine @Inject constructor( + private val repository: BackupRepository, + private val settings: SettingsPrefs, + private val timers: TimerRepository, + private val alarmEngine: AlarmEngine, + private val timerEngine: TimerEngine, + private val wallClock: WallClock, +) { + + /** The current state, as a document ready for [de.jeanlucmakiola.clockula.data.backup.BackupCodec.encode]. */ + suspend fun export(): BackupContent = BackupContent( + exportedAt = wallClock.now(), + alarms = repository.alarmsSnapshot(), + timers = repository.timersSnapshot(), + worldClocks = repository.worldClocksSnapshot(), + settings = settingsSnapshot(), + ) + + /** + * Replaces every alarm, timer and world clock, and every setting the + * backup carries. Before the replace: a ringing alarm is dismissed (the + * same `stopRingIfRinging` step `AlarmsViewModel` takes before disabling + * one) and every running timer is deleted through [TimerEngine], so the + * import can never strand a live ring session mid-replace. After it: + * both engines are told to re-resolve, so the imported state is live + * immediately rather than waiting for the next unrelated trigger. + */ + suspend fun import(content: BackupContent) { + stopRingingAlarmIfAny() + deleteRunningTimers() + + repository.replaceAll(content.alarms, content.timers, content.worldClocks) + applySettings(content.settings) + + alarmEngine.reschedule() + timerEngine.resync() + } + + private suspend fun settingsSnapshot(): BackupSettings = BackupSettings( + themeMode = settings.themeMode.first(), + dynamicColor = settings.dynamicColor.first(), + defaults = settings.currentDefaults(), + timerPresets = settings.timerPresets.first(), + ) + + private suspend fun applySettings(backup: BackupSettings) { + settings.setThemeMode(backup.themeMode) + settings.setDynamicColor(backup.dynamicColor) + settings.setSnoozeMinutes(backup.defaults.snoozeMinutes) + settings.setSnoozeLimit(backup.defaults.snoozeLimit) + settings.setVibrate(backup.defaults.vibrate) + settings.setVolumeRampSeconds(backup.defaults.volumeRampSeconds) + settings.setAlarmRingtoneUri(backup.defaults.alarmRingtoneUri) + settings.setTimerRingtoneUri(backup.defaults.timerRingtoneUri) + settings.setDismissChallenge(backup.defaults.dismissChallenge) + settings.setTimerDuration(backup.defaults.timerDuration) + settings.setHomeZoneId(backup.defaults.homeZoneId) + settings.setTimerPresets(backup.timerPresets) + } + + private suspend fun stopRingingAlarmIfAny() { + val ringingId = alarmEngine.ringSession()?.alarm?.id ?: return + alarmEngine.dismiss(ringingId) + } + + private suspend fun deleteRunningTimers() { + val runningIds = timers.timers().first().filter { it.state == TimerState.RUNNING }.map { it.id } + for (id in runningIds) timerEngine.delete(id) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupFiles.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupFiles.kt new file mode 100644 index 0000000..4ffd1f5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupFiles.kt @@ -0,0 +1,17 @@ +package de.jeanlucmakiola.clockula.backup + +/** The fixed filename a backup is written to inside the user-chosen folder (`docs/PLAN.md` §7). */ +const val BACKUP_FILE_NAME: String = "clockula-backup-v1.json" + +/** + * The on-device side of export/import: writing/reading the JSON text through + * SAF. [BackupEngine] does not know this is SAF — it only knows these two + * verbs, so the engine stays JVM-testable with a fake. + */ +interface BackupFiles { + /** Writes [content] as [BACKUP_FILE_NAME] inside the folder [treeUri] names, overwriting any existing one. */ + suspend fun write(treeUri: String, content: String): Result + + /** Reads whatever text is at [documentUri]. */ + suspend fun read(documentUri: String): Result +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/android/SafBackupFiles.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/android/SafBackupFiles.kt new file mode 100644 index 0000000..68381ef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/android/SafBackupFiles.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.clockula.backup.android + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.backup.BACKUP_FILE_NAME +import de.jeanlucmakiola.clockula.backup.BackupFiles +import de.jeanlucmakiola.floret.di.IoDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * SAF, through [DocumentFile] — the family's own export-module shape + * (`docs/PLAN.md` §7), read and write both as blocking I/O on + * [IoDispatcher], the same posture `SystemRingtoneCatalog` already uses for + * a raw provider/content call. + */ +@Singleton +class SafBackupFiles @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val io: CoroutineDispatcher, +) : BackupFiles { + + override suspend fun write(treeUri: String, content: String): Result = withContext(io) { + runCatching { + val tree = DocumentFile.fromTreeUri(context, treeUri.toUri()) + ?: error("could not open the chosen folder") + tree.findFile(BACKUP_FILE_NAME)?.delete() + val file = tree.createFile("application/json", BACKUP_FILE_NAME) + ?: error("could not create $BACKUP_FILE_NAME") + val stream = context.contentResolver.openOutputStream(file.uri) + ?: throw IOException("could not open an output stream for ${file.uri}") + stream.use { it.write(content.toByteArray(Charsets.UTF_8)) } + } + } + + override suspend fun read(documentUri: String): Result = withContext(io) { + runCatching { + val stream = context.contentResolver.openInputStream(documentUri.toUri()) + ?: throw IOException("could not open an input stream for $documentUri") + stream.use { it.readBytes().toString(Charsets.UTF_8) } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt new file mode 100644 index 0000000..f87fcb0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -0,0 +1,194 @@ +package de.jeanlucmakiola.clockula.data.backup + +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.Zones +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 de.jeanlucmakiola.floret.prefs.toEnum +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** The result of [BackupCodec.decode]: either a validated [BackupContent], or why not. */ +sealed interface BackupResult { + data class Success(val content: BackupContent) : BackupResult + data class Failure(val reason: String) : BackupResult +} + +/** + * The JSON wire format ↔ [BackupContent]. Pure Kotlin — no Android import — + * so the round trip and every rejection path are JVM-unit-testable. + * See `docs/BACKUP.md` for the schema this implements, including exactly + * which fields are tolerant of being unknown/malformed and which reject the + * whole file. + */ +object BackupCodec { + + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + } + + fun encode(content: BackupContent): String = + json.encodeToString(BackupDocument.serializer(), content.toDocument()) + + fun decode(text: String): BackupResult { + val document = try { + json.decodeFromString(BackupDocument.serializer(), text) + } catch (e: SerializationException) { + return BackupResult.Failure("not a valid backup file: ${e.message}") + } catch (e: IllegalArgumentException) { + return BackupResult.Failure("not a valid backup file: ${e.message}") + } + return document.toContent() + } + + // --- encode --- + + private fun BackupContent.toDocument(): BackupDocument = BackupDocument( + format = BACKUP_FORMAT, + version = BACKUP_VERSION, + exportedAtMillis = exportedAt.toEpochMilliseconds(), + alarms = alarms.map { it.toDto() }, + timers = timers.map { it.toDto() }, + worldClocks = worldClocks.map { it.toDto() }, + settings = settings.toDto(), + ) + + private fun BackupAlarm.toDto() = AlarmDto( + hour = time.hour, + minute = time.minute, + label = label, + enabled = enabled, + repeatDays = repeatDays.mask, + skipNextOccurrence = skipNextOccurrence, + ringtoneUri = ringtoneUri, + vibrate = vibrate, + snoozeMinutes = snoozeMinutes, + snoozeLimit = snoozeLimit, + volumeRampSeconds = volumeRampSeconds, + dismissChallenge = dismissChallenge?.name, + deleteAfterUse = deleteAfterUse, + ) + + private fun BackupTimer.toDto() = TimerDto( + label = label, + durationMillis = duration.inWholeMilliseconds, + ringtoneUri = ringtoneUri, + sortOrder = sortOrder, + deleteAfterUse = deleteAfterUse, + ) + + private fun BackupWorldClock.toDto() = WorldClockDto( + zoneId = zoneId, + label = label, + sortOrder = sortOrder, + ) + + private fun BackupSettings.toDto() = SettingsDto( + themeMode = themeMode.name, + dynamicColor = dynamicColor, + snoozeMinutes = defaults.snoozeMinutes, + snoozeLimit = defaults.snoozeLimit, + vibrate = defaults.vibrate, + volumeRampSeconds = defaults.volumeRampSeconds, + alarmRingtoneUri = defaults.alarmRingtoneUri, + timerRingtoneUri = defaults.timerRingtoneUri, + dismissChallenge = defaults.dismissChallenge.name, + timerDurationMillis = defaults.timerDuration.inWholeMilliseconds, + homeZoneId = defaults.homeZoneId, + timerPresets = timerPresets.map { it.inWholeMilliseconds }, + ) + + // --- decode + validate --- + + private fun BackupDocument.toContent(): BackupResult { + if (format != BACKUP_FORMAT) return BackupResult.Failure("unknown format: '$format'") + if (version != BACKUP_VERSION) return BackupResult.Failure("unsupported backup version: $version") + + val alarms = mutableListOf() + for ((index, dto) in this.alarms.withIndex()) { + if (dto.hour !in 0..23 || dto.minute !in 0..59) { + return BackupResult.Failure( + "alarms[$index]: hour/minute out of range (${dto.hour}:${dto.minute})", + ) + } + alarms += BackupAlarm( + time = TimeOfDay(dto.hour, dto.minute), + label = dto.label, + enabled = dto.enabled, + repeatDays = RepeatDays.fromMask(dto.repeatDays), + skipNextOccurrence = dto.skipNextOccurrence, + ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() }, + vibrate = dto.vibrate, + snoozeMinutes = dto.snoozeMinutes, + snoozeLimit = dto.snoozeLimit, + volumeRampSeconds = dto.volumeRampSeconds, + dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE), + deleteAfterUse = dto.deleteAfterUse, + ) + } + + val timers = mutableListOf() + for ((index, dto) in this.timers.withIndex()) { + if (dto.durationMillis < 0) { + return BackupResult.Failure("timers[$index]: negative duration") + } + timers += BackupTimer( + label = dto.label, + duration = dto.durationMillis.milliseconds, + ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() }, + sortOrder = dto.sortOrder, + deleteAfterUse = dto.deleteAfterUse, + ) + } + + val worldClocks = mutableListOf() + for ((index, dto) in this.worldClocks.withIndex()) { + if (!Zones.isValid(dto.zoneId)) { + return BackupResult.Failure("worldClocks[$index]: not an IANA zone id: '${dto.zoneId}'") + } + worldClocks += BackupWorldClock( + zoneId = dto.zoneId, + label = dto.label?.takeIf { it.isNotBlank() }, + sortOrder = dto.sortOrder, + ) + } + + val settingsDto = settings + val settingsContent = BackupSettings( + themeMode = settingsDto.themeMode.toEnum(ThemeMode.SYSTEM), + dynamicColor = settingsDto.dynamicColor, + defaults = ClockDefaults( + snoozeMinutes = settingsDto.snoozeMinutes, + snoozeLimit = settingsDto.snoozeLimit, + vibrate = settingsDto.vibrate, + volumeRampSeconds = settingsDto.volumeRampSeconds, + alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() }, + timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() }, + dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), + timerDuration = settingsDto.timerDurationMillis.milliseconds, + homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), + ), + timerPresets = settingsDto.timerPresets.map { it.milliseconds }, + ) + + return BackupResult.Success( + BackupContent( + exportedAt = Instant.fromEpochMilliseconds(exportedAtMillis), + alarms = alarms, + timers = timers, + worldClocks = worldClocks, + settings = settingsContent, + ), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDao.kt new file mode 100644 index 0000000..6ea00eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDao.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.clockula.data.backup + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.timers.TimerEntity +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity + +/** + * The one bulk operation a backup import needs: replace every alarm, timer + * and world clock in a single transaction. `alarms`' delete cascades its + * `alarm_states` rows away with it (the FK on [AlarmEntity] is `ON DELETE + * CASCADE`), which is exactly right — a fresh import has no ring state to + * carry over (`docs/BACKUP.md`). + */ +@Dao +abstract class BackupDao { + + @Query("DELETE FROM alarms") + abstract suspend fun deleteAllAlarms() + + @Query("DELETE FROM timers") + abstract suspend fun deleteAllTimers() + + @Query("DELETE FROM world_clocks") + abstract suspend fun deleteAllWorldClocks() + + @Insert + abstract suspend fun insertAlarms(alarms: List) + + @Insert + abstract suspend fun insertTimers(timers: List) + + @Insert + abstract suspend fun insertWorldClocks(worldClocks: List) + + /** + * Timers and world clocks first, alarms last — order only matters for the + * alarms/`alarm_states` cascade, which this ensures happens cleanly before + * the fresh alarms (with their fresh, autogenerated ids) land. + */ + @Transaction + open suspend fun replaceAll( + alarms: List, + timers: List, + worldClocks: List, + ) { + deleteAllTimers() + if (timers.isNotEmpty()) insertTimers(timers) + deleteAllWorldClocks() + if (worldClocks.isNotEmpty()) insertWorldClocks(worldClocks) + deleteAllAlarms() + if (alarms.isNotEmpty()) insertAlarms(alarms) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt new file mode 100644 index 0000000..9ae8d52 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -0,0 +1,72 @@ +package de.jeanlucmakiola.clockula.data.backup + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** `docs/BACKUP.md`'s top-level constants. */ +const val BACKUP_FORMAT: String = "clockula-backup" +const val BACKUP_VERSION: Int = 1 + +/** The wire shape of `clockula-backup-v1.json`. See `docs/BACKUP.md`. */ +@Serializable +data class BackupDocument( + val format: String, + val version: Int, + @SerialName("exportedAt") val exportedAtMillis: Long, + val alarms: List = emptyList(), + val timers: List = emptyList(), + val worldClocks: List = emptyList(), + val settings: SettingsDto, +) + +/** Config only — never the `alarm_states` ring-state table. Field names mirror the `alarms` columns. */ +@Serializable +data class AlarmDto( + val hour: Int, + val minute: Int, + val label: String = "", + val enabled: Boolean = true, + @SerialName("repeat_days") val repeatDays: Int = 0, + @SerialName("skip_next_occurrence") val skipNextOccurrence: Boolean = false, + @SerialName("ringtone_uri") val ringtoneUri: String? = null, + val vibrate: Boolean? = null, + @SerialName("snooze_minutes") val snoozeMinutes: Int? = null, + @SerialName("snooze_limit") val snoozeLimit: Int? = null, + @SerialName("volume_ramp_seconds") val volumeRampSeconds: Int? = null, + @SerialName("dismiss_challenge") val dismissChallenge: String? = null, + @SerialName("delete_after_use") val deleteAfterUse: Boolean = false, +) + +/** Config only — never state, the running anchors, or `ends_at_wall_clock`. */ +@Serializable +data class TimerDto( + val label: String = "", + @SerialName("duration_millis") val durationMillis: Long, + @SerialName("ringtone_uri") val ringtoneUri: String? = null, + @SerialName("sort_order") val sortOrder: Int = 0, + @SerialName("delete_after_use") val deleteAfterUse: Boolean = false, +) + +@Serializable +data class WorldClockDto( + @SerialName("zone_id") val zoneId: String, + val label: String? = null, + @SerialName("sort_order") val sortOrder: Int = 0, +) + +/** Field names mirror `ClockPrefs`'/`SettingsPrefs`' on-disk keys. */ +@Serializable +data class SettingsDto( + @SerialName("theme_mode") val themeMode: String = "SYSTEM", + @SerialName("dynamic_color") val dynamicColor: Boolean = true, + @SerialName("default_snooze_minutes") val snoozeMinutes: Int = 10, + @SerialName("default_snooze_limit") val snoozeLimit: Int = 3, + @SerialName("default_vibrate") val vibrate: Boolean = true, + @SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15, + @SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null, + @SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null, + @SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE", + @SerialName("default_timer_duration_millis") val timerDurationMillis: Long = 300_000L, + @SerialName("home_zone_id") val homeZoneId: String? = null, + @SerialName("timer_presets") val timerPresets: List = emptyList(), +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepository.kt new file mode 100644 index 0000000..c24aec3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepository.kt @@ -0,0 +1,19 @@ +package de.jeanlucmakiola.clockula.data.backup + +import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm +import de.jeanlucmakiola.clockula.domain.backup.BackupTimer +import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock + +/** Reads/writes the three Room-backed record types a backup carries — no settings, no ring state. */ +interface BackupRepository { + suspend fun alarmsSnapshot(): List + suspend fun timersSnapshot(): List + suspend fun worldClocksSnapshot(): List + + /** Deletes every alarm, timer and world clock and re-creates them from the given config, atomically. */ + suspend fun replaceAll( + alarms: List, + timers: List, + worldClocks: List, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt new file mode 100644 index 0000000..d9a9556 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupRepositoryImpl.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.clockula.data.backup + +import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository +import de.jeanlucmakiola.clockula.data.timers.TimerEntity +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.domain.Alarm +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm +import de.jeanlucmakiola.clockula.domain.backup.BackupTimer +import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock +import de.jeanlucmakiola.clockula.domain.time.WallClock +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reads through the three repository interfaces, same as any other caller — + * no need to touch a DAO just to look at the current state. Writing a whole + * new set is a different shape (one atomic replace, fresh ids and + * timestamps) that none of the three repositories' narrower verbs offer, so + * that side goes straight to [BackupDao]. + */ +@Singleton +class BackupRepositoryImpl @Inject constructor( + private val alarms: AlarmRepository, + private val timers: TimerRepository, + private val worldClocks: WorldClockRepository, + private val backupDao: BackupDao, + private val wallClock: WallClock, +) : BackupRepository { + + override suspend fun alarmsSnapshot(): List = + alarms.alarms().first().map { it.toBackup() } + + override suspend fun timersSnapshot(): List = + timers.timers().first().map { it.toBackup() } + + override suspend fun worldClocksSnapshot(): List = + worldClocks.worldClocks().first().map { it.toBackup() } + + override suspend fun replaceAll( + alarms: List, + timers: List, + worldClocks: List, + ) { + val now = wallClock.now().toEpochMilliseconds() + backupDao.replaceAll( + alarms = alarms.map { it.toEntity(now) }, + timers = timers.map { it.toEntity(now) }, + worldClocks = worldClocks.map { it.toEntity() }, + ) + } +} + +private fun Alarm.toBackup() = BackupAlarm( + time = time, + label = label, + enabled = enabled, + repeatDays = repeatDays, + skipNextOccurrence = skipNextOccurrence, + ringtoneUri = ringtoneUri, + vibrate = vibrate, + snoozeMinutes = snoozeMinutes, + snoozeLimit = snoozeLimit, + volumeRampSeconds = volumeRampSeconds, + dismissChallenge = dismissChallenge, + deleteAfterUse = deleteAfterUse, +) + +private fun Timer.toBackup() = BackupTimer( + label = label, + duration = duration, + ringtoneUri = ringtoneUri, + sortOrder = sortOrder, + deleteAfterUse = deleteAfterUse, +) + +private fun WorldClock.toBackup() = BackupWorldClock(zoneId = zoneId, label = label, sortOrder = sortOrder) + +/** Fresh id (autogenerate), fresh timestamps — a backup carries neither (`docs/BACKUP.md`). */ +private fun BackupAlarm.toEntity(nowMillis: Long) = AlarmEntity( + id = 0L, + hour = time.hour, + minute = time.minute, + label = label, + enabled = enabled, + repeatDays = repeatDays.mask, + skipNextOccurrence = skipNextOccurrence, + ringtoneUri = ringtoneUri, + vibrate = vibrate, + snoozeMinutes = snoozeMinutes, + snoozeLimit = snoozeLimit, + volumeRampSeconds = volumeRampSeconds, + dismissChallenge = dismissChallenge?.name, + createdAt = nowMillis, + updatedAt = nowMillis, + deleteAfterUse = deleteAfterUse, +) + +/** Imported timers always land IDLE with the full configured duration (`docs/BACKUP.md`). */ +private fun BackupTimer.toEntity(nowMillis: Long) = TimerEntity( + id = 0L, + label = label, + durationMillis = duration.inWholeMilliseconds, + state = TimerState.IDLE.name, + remainingMillis = duration.inWholeMilliseconds, + startedAtElapsedRealtimeMillis = null, + endsAtElapsedRealtimeMillis = null, + endsAtWallClockMillis = null, + ringtoneUri = ringtoneUri, + sortOrder = sortOrder, + createdAt = nowMillis, + updatedAt = nowMillis, + deleteAfterUse = deleteAfterUse, +) + +private fun BackupWorldClock.toEntity() = WorldClockEntity(id = 0L, zoneId = zoneId, label = label, sortOrder = sortOrder) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt index cda9b3b..a80afe8 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt @@ -6,6 +6,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 @@ -38,6 +39,8 @@ abstract class ClockulaDatabase : RoomDatabase() { abstract fun timerDao(): TimerDao abstract fun worldClockDao(): WorldClockDao abstract fun lapDao(): LapDao + /** M10's backup import: no schema change, only new queries over existing tables. */ + abstract fun backupDao(): BackupDao companion object { const val NAME: String = "clockula.db" diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/BackupModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/BackupModule.kt new file mode 100644 index 0000000..0a162aa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/BackupModule.kt @@ -0,0 +1,19 @@ +package de.jeanlucmakiola.clockula.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.clockula.backup.BackupFiles +import de.jeanlucmakiola.clockula.backup.android.SafBackupFiles +import javax.inject.Singleton + +/** The one Android-backed seam [de.jeanlucmakiola.clockula.backup.BackupEngine] reaches through. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class BackupModule { + + @Binds + @Singleton + abstract fun bindBackupFiles(impl: SafBackupFiles): BackupFiles +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt index a0eb267..e2c0292 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt @@ -9,6 +9,7 @@ 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.backup.BackupDao import de.jeanlucmakiola.clockula.data.db.ClockulaDatabase import de.jeanlucmakiola.clockula.data.db.Migrations import de.jeanlucmakiola.clockula.data.stopwatch.LapDao @@ -46,4 +47,7 @@ object DatabaseModule { @Provides fun provideLapDao(database: ClockulaDatabase): LapDao = database.lapDao() + + @Provides + fun provideBackupDao(database: ClockulaDatabase): BackupDao = database.backupDao() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt index e3b85c8..60f82f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt @@ -8,6 +8,8 @@ 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.backup.BackupRepository +import de.jeanlucmakiola.clockula.data.backup.BackupRepositoryImpl import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepositoryImpl import de.jeanlucmakiola.clockula.data.timers.TimerRepository @@ -40,4 +42,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindStopwatchRepository(impl: StopwatchRepositoryImpl): StopwatchRepository + + @Binds + @Singleton + abstract fun bindBackupRepository(impl: BackupRepositoryImpl): BackupRepository } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt new file mode 100644 index 0000000..eac440d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.clockula.domain.backup + +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.floret.prefs.ThemeMode +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * Everything a backup carries — configuration only, never ring state or a + * timer's running anchors (`docs/BACKUP.md`). The codec and the repository + * both speak this shape; neither the JSON wire format nor the Room entities + * leak into it. + */ +data class BackupContent( + val exportedAt: Instant, + val alarms: List, + val timers: List, + val worldClocks: List, + val settings: BackupSettings, +) + +data class BackupAlarm( + val time: TimeOfDay, + val label: String, + val enabled: Boolean, + val repeatDays: RepeatDays, + val skipNextOccurrence: Boolean, + val ringtoneUri: String?, + val vibrate: Boolean?, + val snoozeMinutes: Int?, + val snoozeLimit: Int?, + val volumeRampSeconds: Int?, + val dismissChallenge: DismissChallenge?, + val deleteAfterUse: Boolean, +) + +data class BackupTimer( + val label: String, + val duration: Duration, + val ringtoneUri: String?, + val sortOrder: Int, + val deleteAfterUse: Boolean, +) + +data class BackupWorldClock( + val zoneId: String, + val label: String?, + val sortOrder: Int, +) + +data class BackupSettings( + val themeMode: ThemeMode, + val dynamicColor: Boolean, + val defaults: ClockDefaults, + val timerPresets: List, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt index e996d56..0a1d029 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt @@ -1,24 +1,142 @@ package de.jeanlucmakiola.clockula.ui.settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CloudUpload +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.Position +import kotlinx.coroutines.launch /** - * Placeholder for backup export/import — the real screen lands with M10's - * backup phase. Same route, same call shape, so swapping it in is a one-file - * change. + * Export writes a fresh `clockula-backup-v1.json` into a folder the user + * picks; import reads one back after an explicit confirmation, since it + * **replaces** every alarm, timer and world clock rather than merging + * (`docs/BACKUP.md`). */ @Composable -fun BackupScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { +fun BackupScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: BackupViewModel = hiltViewModel(), +) { + val busy by viewModel.busy.collectAsStateWithLifecycle() + val snackbarHost = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + var pendingImportUri by rememberSaveable { mutableStateOf(null) } + + val pickExportFolder = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri -> viewModel.onExportFolderPicked(uri?.toString()) } + + val pickImportFile = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> if (uri != null) pendingImportUri = uri.toString() } + + val exportSucceeded = stringResource(R.string.settings_backup_export_succeeded) + val exportFailed = stringResource(R.string.settings_backup_export_failed) + val importSucceeded = stringResource(R.string.settings_backup_import_succeeded) + val importFailed = stringResource(R.string.settings_backup_import_failed) + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + val message = when (event) { + BackupEvent.ExportSucceeded -> exportSucceeded + is BackupEvent.ExportFailed -> "$exportFailed: ${event.reason}" + BackupEvent.ImportSucceeded -> importSucceeded + is BackupEvent.ImportFailed -> "$importFailed: ${event.reason}" + } + scope.launch { snackbarHost.showSnackbar(message) } + } + } + CollapsingScaffold( title = stringResource(R.string.settings_backup_title), onBack = onBack, modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHost) { Snackbar(it) } }, ) { - Text(stringResource(R.string.settings_coming_soon)) + GroupedSectionHeader(stringResource(R.string.settings_backup_section)) + GroupedRow( + title = stringResource(R.string.settings_backup_export), + summary = stringResource(R.string.settings_backup_export_summary), + position = Position.Top, + leading = { Icon(Icons.Rounded.CloudUpload, contentDescription = null) }, + dimmed = busy, + onClick = { if (!busy) pickExportFolder.launch(null) }, + ) + GroupedRow( + title = stringResource(R.string.settings_backup_import), + summary = stringResource(R.string.settings_backup_import_summary), + position = Position.Bottom, + leading = { Icon(Icons.Rounded.CloudDownload, contentDescription = null) }, + dimmed = busy, + onClick = { if (!busy) pickImportFile.launch(arrayOf("application/json", "*/*")) }, + ) + + if (busy) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator() + } + } + } + + pendingImportUri?.let { uri -> + AlertDialog( + onDismissRequest = { pendingImportUri = null }, + title = { Text(stringResource(R.string.settings_backup_import_confirm_title)) }, + text = { Text(stringResource(R.string.settings_backup_import_confirm_message)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.onImportFilePicked(uri) + pendingImportUri = null + }, + ) { + Text(stringResource(R.string.settings_backup_import_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = { pendingImportUri = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupUiState.kt new file mode 100644 index 0000000..ac23d5b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupUiState.kt @@ -0,0 +1,9 @@ +package de.jeanlucmakiola.clockula.ui.settings + +/** A one-off result the screen shows as a snackbar and then forgets. */ +internal sealed interface BackupEvent { + data object ExportSucceeded : BackupEvent + data class ExportFailed(val reason: String) : BackupEvent + data object ImportSucceeded : BackupEvent + data class ImportFailed(val reason: String) : BackupEvent +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupViewModel.kt new file mode 100644 index 0000000..3fc6eae --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupViewModel.kt @@ -0,0 +1,74 @@ +package de.jeanlucmakiola.clockula.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.backup.BackupEngine +import de.jeanlucmakiola.clockula.backup.BackupFiles +import de.jeanlucmakiola.clockula.data.backup.BackupCodec +import de.jeanlucmakiola.clockula.data.backup.BackupResult +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Export writes [BackupEngine.export] through [BackupCodec] and [BackupFiles]; + * import reads the same path backwards. Neither [BackupFiles] (plain text + * I/O) nor [BackupEngine] (plain [de.jeanlucmakiola.clockula.domain.backup.BackupContent]) + * know about the other — the codec in between is the only place that knows + * the JSON shape, same split `data/backup` keeps internally. + */ +@HiltViewModel +class BackupViewModel @Inject internal constructor( + private val engine: BackupEngine, + private val files: BackupFiles, +) : ViewModel() { + + private val _busy = MutableStateFlow(false) + internal val busy: StateFlow = _busy.asStateFlow() + + private val _events = Channel(Channel.CONFLATED) + internal val events: Flow = _events.receiveAsFlow() + + /** `null` means the folder picker was cancelled — a silent no-op. */ + fun onExportFolderPicked(treeUri: String?) { + if (treeUri == null) return + viewModelScope.launch { + _busy.value = true + val json = BackupCodec.encode(engine.export()) + val event = files.write(treeUri, json).fold( + onSuccess = { BackupEvent.ExportSucceeded }, + onFailure = { BackupEvent.ExportFailed(it.message ?: "unknown error") }, + ) + _busy.value = false + _events.trySend(event) + } + } + + /** `null` means the file picker was cancelled — a silent no-op. Call only after the user has confirmed the replace. */ + fun onImportFilePicked(documentUri: String?) { + if (documentUri == null) return + viewModelScope.launch { + _busy.value = true + val event = files.read(documentUri).fold( + onSuccess = { text -> applyImport(text) }, + onFailure = { BackupEvent.ImportFailed(it.message ?: "unknown error") }, + ) + _busy.value = false + _events.trySend(event) + } + } + + private suspend fun applyImport(text: String): BackupEvent = when (val decoded = BackupCodec.decode(text)) { + is BackupResult.Success -> { + engine.import(decoded.content) + BackupEvent.ImportSucceeded + } + is BackupResult.Failure -> BackupEvent.ImportFailed(decoded.reason) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fcc7e8d..1638d35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,4 +280,18 @@ Version %1$s Custom sound + + + Back up & restore + Export backup + Choose a folder to save clockula-backup-v1.json in + Import backup + Replaces all your alarms, timers and world clocks + Backup saved + Couldn\'t save the backup + Backup restored + Couldn\'t restore the backup + Replace everything? + This replaces all your current alarms, timers, world clocks and settings with what\'s in the backup file. This can\'t be undone. + Replace diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt new file mode 100644 index 0000000..acd4274 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt new file mode 100644 index 0000000..3ef6cc8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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() +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt new file mode 100644 index 0000000..d3823ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt @@ -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 = alarmDao.stored.map(AlarmMapper::toDomain) + + suspend fun storedTimers(): List = timerDao.stored.map(TimerMapper::toDomain) + + suspend fun storedWorldClocks(): List = 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, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt index 51a4056..8a0782f 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -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): List = 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) { + for (alarm in alarms) alarmDao.insert(alarm) + } + + override suspend fun insertTimers(timers: List) { + for (timer in timers) timerDao.insert(timer) + } + + override suspend fun insertWorldClocks(worldClocks: List) { + for (clock in worldClocks) worldClockDao.insert(clock) + } +} diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..9447366 --- /dev/null +++ b/docs/BACKUP.md @@ -0,0 +1,137 @@ +# Clockula backup format + +`clockula-backup-v1.json` — a documented, versioned JSON export of Clockula's +own data, written to (and read from) a user-chosen folder via SAF +(`docs/PLAN.md` §7). This file is the schema's source of truth; the codec +(`data/backup/BackupDto.kt`, `BackupCodec.kt`) implements exactly what is +written here. + +## Top level + +```json +{ + "format": "clockula-backup", + "version": 1, + "exportedAt": 1700000000000, + "alarms": [ … ], + "timers": [ … ], + "worldClocks": [ … ], + "settings": { … } +} +``` + +- `format` — must be exactly `"clockula-backup"`. +- `version` — must be exactly `1`. Any other value rejects the whole file: + Clockula does not guess at a schema it has not implemented. +- `exportedAt` — epoch milliseconds, informational only (not re-imported). + +## Parsing rules + +- **Tolerant of unknown fields, at any level.** An older or newer client's + extra field is ignored, never an error. +- **Strict about known fields.** A malformed value in a field this schema + defines — `format`, `version`, an alarm's `hour`/`minute`, a world clock's + `zone_id`, a timer's `duration_millis` — rejects the **whole file** with a + specific reason. Nothing is imported half-way. +- Settings values (snooze minutes, volume ramp, timer duration, …) are + **not** range-checked at parse time; they pass through the same clamps + `ClockPrefs`/`SettingsPrefs` already apply on every write, so a value from + a future, more permissive version degrades to the nearest valid one instead + of failing the import. +- An enum field with a name this version does not recognise (a `dismiss_ + challenge` or `theme_mode` from a future version, say) degrades to that + field's default, the same forgiving-read posture `AlarmMapper` already uses + for a corrupt Room row — it does not fail the import. + +## Import semantics + +Import **replaces all state** — alarms, timers and world clocks are deleted +and re-created from the file, and every setting the file carries is written +over the current value. This is not a merge, and the backup screen says so +before the user confirms. A ringing alarm is dismissed and any running timer +is deleted *before* the replace, so the import never leaves an orphaned ring +session; alarm/timer/stopwatch scheduling is re-resolved immediately after. + +Every imported alarm and timer gets a **fresh id and fresh timestamps** — +the backup carries no id and no `created_at`/`updated_at`, since those are +local-database facts, not portable ones. + +## Alarms + +One object per row of `alarms`, config only: + +| Field | Type | Notes | +|---|---|---| +| `hour` | Int | 0..23. Out of range rejects the whole file. | +| `minute` | Int | 0..59. Out of range rejects the whole file. | +| `label` | String | | +| `enabled` | Boolean | | +| `repeat_days` | Int | The 7-bit mask (`RepeatDays`); bit *n* is ISO day *n+1*. | +| `skip_next_occurrence` | Boolean | | +| `ringtone_uri` | String? | Exported as-is. See "Ringtone URIs" below. | +| `vibrate` | Boolean? | `null` = inherit the app default. | +| `snooze_minutes` | Int? | `null` = inherit. | +| `snooze_limit` | Int? | `null` = inherit. | +| `volume_ramp_seconds` | Int? | `null` = inherit. | +| `dismiss_challenge` | String? | `NONE`/`MATH`/`HOLD`, or `null` = inherit. | +| `delete_after_use` | Boolean | A transient, contract-created alarm. | + +**Not exported:** the `alarm_states` ring-state table (snooze/ringing/handled- +occurrence bookkeeping) — it is volatile, per-device state, not configuration, +and `docs/ARCHITECTURE.md` §4/§6 are explicit that it must never appear here. + +## Timers + +One object per row of `timers`, config only: + +| Field | Type | Notes | +|---|---|---| +| `label` | String | | +| `duration_millis` | Long | ≥ 0. Negative rejects the whole file. | +| `ringtone_uri` | String? | | +| `sort_order` | Int | | +| `delete_after_use` | Boolean | | + +**Not exported:** `state`, `remaining`, the elapsed-realtime anchors, or +`ends_at_wall_clock` — a timer's running state is meaningless on another +device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands +`IDLE`, with `remaining` equal to `duration`. + +## World clocks + +| Field | Type | Notes | +|---|---|---| +| `zone_id` | String | Must be a zone id the *exporting* device's IANA tzdata knows (`Zones.isValid`). A bad id rejects the whole file. | +| `label` | String? | | +| `sort_order` | Int | | + +## Settings + +```json +"settings": { + "theme_mode": "SYSTEM", + "dynamic_color": true, + "default_snooze_minutes": 10, + "default_snooze_limit": 3, + "default_vibrate": true, + "default_volume_ramp_seconds": 15, + "default_alarm_ringtone_uri": null, + "default_timer_ringtone_uri": null, + "default_dismiss_challenge": "NONE", + "default_timer_duration_millis": 300000, + "home_zone_id": null, + "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] +} +``` + +Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Every +numeric/duration value is clamped on *apply*, through the existing pref +setters — never on parse. + +## Ringtone URIs + +Exported exactly as stored (a `content://` URI, typically). On import the +URI is written back unchanged; if the *importing* device cannot resolve it +at ring/preview time, the existing ringtone-resolution fallback already used +elsewhere in the app applies (falls through to the device default) — this is +not special-cased in the backup path itself.