A "Compact home clock" switch under Settings > Clock swaps the World clock tab's analog face for a row: a home glyph, the city, "Home · <zone name>" and the time, in the seconds-aware style the city rows use and spoken to the minute like them. It is drawn the way the automatic local clock's row is, through one shared pinned-row composable, and the local row still sits directly under home. Off by default. Like show-seconds it is display-only, so it is its own preference and flow (world_clock_compact_home) rather than a clock default, and it is applied outside the tick so flipping it does not restart the ticker. Backups carry it; older files restore it as off. Closes #29.
231 lines
11 KiB
Markdown
231 lines
11 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 `default_timer_vibrate` (a file from before timers had their own
|
||
vibration setting) restores `default_vibrate` in its place: that is what
|
||
the exporting install's timers vibrated by. A missing
|
||
`default_timer_volume_ramp_seconds` restores 0, the ring timers had then.
|
||
- A missing `timer_presets` keeps the built-in presets. An empty list is a
|
||
user who removed them all, and is imported as such.
|
||
- A missing `quick_tile_duration_millis` (a file from before the Quick
|
||
Settings tiles), or one outside the quick-timer range of 1 second to 59:59,
|
||
keeps the quick-timer tile's default of five minutes — the same reading the
|
||
quick-timer widget gives its own stored duration.
|
||
- 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_timer_vibrate": false,
|
||
"default_timer_volume_ramp_seconds": 0,
|
||
"default_dismiss_challenge": "NONE",
|
||
"home_zone_id": null,
|
||
"default_alarm_silence_after_minutes": 10,
|
||
"default_timer_silence_after_minutes": 10,
|
||
"volume_buttons_while_ringing": "CONTROL_VOLUME",
|
||
"world_clock_show_seconds": false,
|
||
"world_clock_compact_home": false,
|
||
"automatic_local_clock": true,
|
||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||
"bedtime": {
|
||
"enabled": false,
|
||
"bedtime_minutes": 1350,
|
||
"wake_minutes": 420,
|
||
"days": 127,
|
||
"reminder_minutes": 0,
|
||
"grayscale": true,
|
||
"dim_wallpaper": true,
|
||
"dark_theme": true,
|
||
"hide_always_on_display": false
|
||
},
|
||
"screen_saver_style": "DIGITAL",
|
||
"screen_saver_night_mode": false,
|
||
"quick_tile_duration_millis": 300000
|
||
}
|
||
```
|
||
|
||
`bedtime` is the opt-in bedtime schedule (issue #1). Its times are minutes
|
||
since local midnight, `days` is the same 7-bit mask as an alarm's
|
||
`repeat_days` (the nights bedtime is for, named by their evening), and `reminder_minutes` 0 means no
|
||
reminder. A file without `bedtime` (any file written before it existed)
|
||
leaves the device's current bedtime settings as they are, the one setting a
|
||
restore does not replace when its file is silent about it. Like the other settings it
|
||
is clamped rather than rejected: times into a day, the reminder into 0–120
|
||
minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it;
|
||
the importing device creates its own (`docs/ARCHITECTURE.md` §18).
|
||
|
||
Field names mirror `ClockPrefs`'/`SettingsPrefs`'/`ScreenSaverPrefs`' own
|
||
on-disk keys. A file from before the screen saver has neither
|
||
`screen_saver_*` field and restores its defaults (digital, night mode off);
|
||
an unknown style degrades to `DIGITAL` rather than rejecting the file. 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. `volume_buttons_while_ringing` is one of
|
||
`CONTROL_VOLUME`/`SNOOZE`/`DISMISS`/`NOTHING`; a file without it (any backup made
|
||
before the setting existed) or with a name this version does not know restores
|
||
`CONTROL_VOLUME`, the behaviour that file's device had.
|
||
|
||
`default_alarm_silence_after_minutes` and `default_timer_silence_after_minutes`
|
||
are how long a ringing alarm or a finished timer keeps going before it stops on
|
||
its own: whole minutes, with `0` meaning "Never" (which still stops after an
|
||
hour). A file without them, from before the setting existed, imports as `10`,
|
||
the value it was made with. A value of 60 or more reads as 0, "Never" (which
|
||
rings for that long anyway); a negative one reads as the default, 10.
|
||
|
||
Files from before `world_clock_show_seconds` existed import with it off, its
|
||
default. It travels with the settings but is not a clock default: nothing an
|
||
alarm or timer inherits depends on it. The same goes for
|
||
`world_clock_compact_home` (#29), which a file without it restores as off. A file without `automatic_local_clock`
|
||
(one written before it existed) restores it as `true`, its default.
|
||
|
||
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.
|