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:
2026-10-01 22:06:44 +02:00
parent 7253445c4e
commit 616bf7f67f
22 changed files with 1657 additions and 5 deletions
@@ -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)
}
}
@@ -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<Unit>
/** Reads whatever text is at [documentUri]. */
suspend fun read(documentUri: String): Result<String>
}
@@ -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<Unit> = 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<String> = 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) }
}
}
}
@@ -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<BackupAlarm>()
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<BackupTimer>()
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<BackupWorldClock>()
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,
),
)
}
}
@@ -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<AlarmEntity>)
@Insert
abstract suspend fun insertTimers(timers: List<TimerEntity>)
@Insert
abstract suspend fun insertWorldClocks(worldClocks: List<WorldClockEntity>)
/**
* 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<AlarmEntity>,
timers: List<TimerEntity>,
worldClocks: List<WorldClockEntity>,
) {
deleteAllTimers()
if (timers.isNotEmpty()) insertTimers(timers)
deleteAllWorldClocks()
if (worldClocks.isNotEmpty()) insertWorldClocks(worldClocks)
deleteAllAlarms()
if (alarms.isNotEmpty()) insertAlarms(alarms)
}
}
@@ -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<AlarmDto> = emptyList(),
val timers: List<TimerDto> = emptyList(),
val worldClocks: List<WorldClockDto> = 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<Long> = emptyList(),
)
@@ -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<BackupAlarm>
suspend fun timersSnapshot(): List<BackupTimer>
suspend fun worldClocksSnapshot(): List<BackupWorldClock>
/** Deletes every alarm, timer and world clock and re-creates them from the given config, atomically. */
suspend fun replaceAll(
alarms: List<BackupAlarm>,
timers: List<BackupTimer>,
worldClocks: List<BackupWorldClock>,
)
}
@@ -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<BackupAlarm> =
alarms.alarms().first().map { it.toBackup() }
override suspend fun timersSnapshot(): List<BackupTimer> =
timers.timers().first().map { it.toBackup() }
override suspend fun worldClocksSnapshot(): List<BackupWorldClock> =
worldClocks.worldClocks().first().map { it.toBackup() }
override suspend fun replaceAll(
alarms: List<BackupAlarm>,
timers: List<BackupTimer>,
worldClocks: List<BackupWorldClock>,
) {
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)
@@ -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"
@@ -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
}
@@ -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()
}
@@ -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
}
@@ -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<BackupAlarm>,
val timers: List<BackupTimer>,
val worldClocks: List<BackupWorldClock>,
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<Duration>,
)
@@ -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<String?>(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))
}
},
)
}
}
@@ -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
}
@@ -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<Boolean> = _busy.asStateFlow()
private val _events = Channel<BackupEvent>(Channel.CONFLATED)
internal val events: Flow<BackupEvent> = _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)
}
}
+14
View File
@@ -280,4 +280,18 @@
<string name="settings_version">Version</string>
<string name="settings_version_value" translatable="false">%1$s</string>
<string name="settings_ringtone_custom">Custom sound</string>
<!-- M10: backup -->
<string name="settings_backup_section">Back up &amp; restore</string>
<string name="settings_backup_export">Export backup</string>
<string name="settings_backup_export_summary">Choose a folder to save clockula-backup-v1.json in</string>
<string name="settings_backup_import">Import backup</string>
<string name="settings_backup_import_summary">Replaces all your alarms, timers and world clocks</string>
<string name="settings_backup_export_succeeded">Backup saved</string>
<string name="settings_backup_export_failed">Couldn\'t save the backup</string>
<string name="settings_backup_import_succeeded">Backup restored</string>
<string name="settings_backup_import_failed">Couldn\'t restore the backup</string>
<string name="settings_backup_import_confirm_title">Replace everything?</string>
<string name="settings_backup_import_confirm_message">This replaces all your current alarms, timers, world clocks and settings with what\'s in the backup file. This can\'t be undone.</string>
<string name="settings_backup_import_confirm_action">Replace</string>
</resources>
@@ -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)
}
}
+137
View File
@@ -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.