A new setting in Settings > Alarms: while the ring screen shows, the volume buttons change the volume (the default, and what they did before), snooze, dismiss, or do nothing. It is app-wide, stored next to the clock defaults, and included in the backup; a backup without it restores "change the volume". A press is made without looking, so VolumeButtonPolicy keeps it from ending an alarm more finally than the ring screen would. Dismiss with a dismiss challenge set snoozes instead, so the challenge still stands. With no snoozes left, Snooze (and Dismiss behind a challenge) falls back to changing the volume, never to the engine's refused-snooze path, which dismisses. The key's snooze also asks the engine to keep ringing if the limit turns out to be reached after all (AlarmEngine.snooze's new dismissIfRefused). The ring screen decides whether a press is its own on key down and keeps that for the release, leaves the keys alone once another alarm has taken over the ring, and ignores a second snooze or dismiss while the first is still running. Only the ring screen handles the keys. While the ring is a heads-up over another app, a background app gets no key events, and the one way around that does not hold up: a remote-playback MediaSession whose VolumeProvider takes the keys. On Android 13 and later MediaSessionRecord.canHandleVolumeKey refuses such a session from an app without a MediaRouter2 routing session, which Clockula's would be, so on current Pixels the keys never reach it. Where it does work, it loses to whatever app is in front or played last, shows a cast-volume slider and a phantom media player, and takes media buttons while the alarm rings. AOSP DeskClock and BlackyHawky's Clock also handle the keys in their alarm activity only. ARCHITECTURE.md records the limitation. Fixes #18
161 lines
6.8 KiB
Markdown
161 lines
6.8 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,
|
|
"volume_buttons_while_ringing": "CONTROL_VOLUME",
|
|
"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. `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. 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.
|