Files
clockula/docs/BACKUP.md
T
makiolaj 7d65b2b206 feat(world-clock): an optional compact home clock
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.
2026-11-02 19:29:16 +01:00

231 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.