Files
clockula/docs/BACKUP.md
T
makiolaj 412e54db09 feat(settings): let alarms and timers choose when they silence themselves
A ringing alarm and a finished timer used to stop after a fixed ten
minutes. Settings > Alarms and Settings > Timers now each have a "Silence
after" row with Google Clock's choices: 1, 5, 10, 15, 20 or 25 minutes,
or Never. Ten minutes stays the default, so nothing changes on upgrade.
The value is app-wide; a per-alarm override would need a new column, a
migration and an editor row, and is left out.

"Never" rings for an hour, then stops. The backstop is also what keeps a
phone left at home from ringing its battery flat, what bounds the ring
services' wake locks, and what stops a reboot hours later from resuming a
ring nobody was there for. A truly unbounded ring would give all of that
up, so Never maps to a one-hour hard stop, longer than any finite choice,
and the settings hint says so. Stored values clamp to 0..60 on read, on
write and from a backup.

The window is read everywhere the fixed constant was: the resolver's
ringing and late-snooze checks (which now take it without a default), the
backstop onFire arms, how late a snooze fire may arrive, whether an
interrupted ring is resumed after a reboot, upcoming(), the timer ring
policy, the lapsed-session check and the timer ring screen. Both ring
services' wake-lock timeouts now derive from the hard stop instead of a
hard-coded fifteen minutes.

When the resolver closes a ring (the window was shortened mid-ring, the
clock jumped, the alarm was switched off), the engine now also stops the
ring service and cancels the backstop. Before, it only cleared
ringingSince and left the sound to a backstop armed for the old window.

Both values are backed up as default_alarm_silence_after_minutes and
default_timer_silence_after_minutes; a file without them imports as ten
minutes.

Fixes #17
2026-10-21 18:52:25 +02:00

166 lines
7.0 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": 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, …) 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. |
**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,
"default_alarm_silence_after_minutes": 10,
"default_timer_silence_after_minutes": 10,
"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.
`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. Out-of-range values clamp to 0..60.
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.