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:
+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