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