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.
7.6 KiB
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
{
"format": "clockula-backup",
"version": 2,
"exportedAt": 1700000000000,
"alarms": [ … ],
"timers": [ … ],
"worldClocks": [ … ],
"settings": { … }
}
format: must be exactly"clockula-backup".version:2on export. Import accepts1and2; any other value rejects the whole file, since Clockula does not guess at a schema it has not implemented. Version 2 adds an alarm'sscheduled_date,pause_startandpause_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 staysclockula-backup-v1.jsonso 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'shour/minuteor dates, a world clock'szone_id, a timer'sduration_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/SettingsPrefsalready 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_limitandvolume_ramp_secondsare clamped at parse time to the same ranges as the defaults they override, and a timer'sduration_millisto the setup range (1 second to 24 hours). Room has no clamp of its own, so this is where it happens. - A missing
timer_presetskeeps 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_ challengeortheme_modefrom a future version, say) degrades to that field's default, the same forgiving-read postureAlarmMapperalready 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
"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.