A dated one-shot whose time was moved later while it was snoozed rang a second time that day, because closing the cycle only asked whether the date was still ahead. It now keeps the alarm on only for a date after the day that rang, and a one-shot switched off as its cycle closes lets its date go. The date pickers accept every year a stored date can have, so a far date from a backup can no longer crash the editor. The widget formats its date with the list's own formatter, and a backup's leftover date on the wrong kind of alarm is ignored instead of rejecting the file.
171 lines
7.6 KiB
Markdown
171 lines
7.6 KiB
Markdown
# Clockula backup format
|
|
|
|
`clockula-backup-v1.json` is 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": 2,
|
|
"exportedAt": 1700000000000,
|
|
"alarms": [ … ],
|
|
"timers": [ … ],
|
|
"worldClocks": [ … ],
|
|
"settings": { … }
|
|
}
|
|
```
|
|
|
|
- `format`: must be exactly `"clockula-backup"`.
|
|
- `version`: `2` on export. Import accepts `1` and `2`; any other value
|
|
rejects the whole file, since Clockula does not guess at a schema it has not
|
|
implemented. Version 2 adds an alarm's `scheduled_date`, `pause_start` and
|
|
`pause_end`; a version 1 file is a version 2 file without them, and imports
|
|
with every alarm unscheduled and unpaused. The bump is what keeps an older
|
|
Clockula from importing a v2 file and silently turning a dated alarm into
|
|
tomorrow's, or a paused one back on. The file name stays
|
|
`clockula-backup-v1.json` so an existing backup folder keeps working.
|
|
- `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` or dates, 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, …) 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 alarm's own `snooze_minutes`, `snooze_limit` and `volume_ramp_seconds`
|
|
are clamped at parse time to the same ranges as the defaults they
|
|
override, and a timer's `duration_millis` to the setup range (1 second to
|
|
24 hours). Room has no clamp of its own, so this is where it happens.
|
|
- A missing `timer_presets` keeps the built-in presets. An empty list is a
|
|
user who removed them all, and is imported as such.
|
|
- 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 and export are limited to 8 MiB. Import also rejects more than 10,000
|
|
alarms, 10,000 timers, 1,000 world clocks, oversized labels or ringtone URIs,
|
|
and more than 1,000 timer presets. These limits keep a picked document from
|
|
exhausting memory or creating an impractical number of rows.
|
|
|
|
## 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. Settings are written in one DataStore transaction;
|
|
the three Room tables are replaced in one database transaction. The live ring
|
|
and timer services are paused while the tables change. If the database replace
|
|
fails, the old rows remain, the previous settings are restored, and the ring
|
|
and timer registrations are resumed. Scheduling is reconciled after either
|
|
outcome. An imported timer always starts idle.
|
|
|
|
Export writes a complete temporary document before removing an older backup.
|
|
If the provider refuses the final rename, the complete temporary document is
|
|
kept in the chosen folder and the app reports the failure with its filename.
|
|
|
|
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. |
|
|
| `scheduled_date` | String? | v2. ISO-8601 local date (`2026-10-28`) a one-shot alarm rings on. Ignored on a repeating alarm. |
|
|
| `pause_start` | String? | v2. First day of a repeating alarm's pause, ISO-8601. Ignored on a one-shot alarm. |
|
|
| `pause_end` | String? | v2. Last day of the pause, inclusive. Present exactly when `pause_start` is. |
|
|
|
|
A date that does not parse, lies outside 2000-01-01..9999-12-31, a pause with
|
|
only one end, or a pause ending before it starts rejects the whole file, like
|
|
an out-of-range `hour`. A date on a repeating alarm or a pause on a one-shot is
|
|
ignored unread, valid or not, which is what the editor would do with it.
|
|
|
|
**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",
|
|
"home_zone_id": null,
|
|
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
|
|
}
|
|
```
|
|
|
|
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
|
files may also carry `default_timer_duration_millis`, from a default timer
|
|
length setting that has since been removed; it is ignored on import like any
|
|
other unknown field. Every
|
|
numeric/duration value is clamped on *apply* using the same ranges and encoding
|
|
as the existing preference 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.
|