Backup is automatic for the database and manual for the media
Context
All data lives on the device, so losing the phone means losing everything unless the user acts. There is no server and there will not be one, so backup has to cost nothing to run.
Android Auto Backup covers this for free — but with two properties that shape the whole design:
- A per-app quota of about 25 MB, and Android rejects the entire over-quota dataset. It does not back up partially. So a pile of photos does not cost “just the photos”: it silently takes the database down with it, and the user believes they have a backup when they have none.
- It runs unattended, on the OS’s schedule, with no UI and nobody present. Nothing can be surfaced at backup time.
And one that is easy to miss: Room runs in WAL mode, so the most recent committed writes may live
only in the -wal sidecar. Copying the .db alone hands over a file missing the very data the user
asked to keep — and copying the sidecar alongside it is the other half of the same trap, because a
-wal captured mid-write restores corrupt.
Decision
Two levels.
Automatic, through a custom BackupAgent: the database, checkpointed, plus the small media. It
is an agent rather than backup_rules.xml because static include/exclude XML cannot checkpoint the
WAL, and there is no way to express “flush first” in it. Registered with fullBackupOnly="true" —
declaring an agent without it puts the app on the key/value path, where the no-op onBackup is what
runs and the app silently backs up nothing.
Manual, in the app: a single zip the user keeps wherever they like, at a chosen scope. Records only (small enough to mail yourself) or everything (which can be hundreds of megabytes).
The archive carries a manifest, read first on restore. Without one, the only way to find out an archive is from a newer version of the app is to open its database and have Room throw — by which point the live file has already been replaced.
A restore extracts into a staging directory and swaps in only once the extraction has completed. Extracting straight over the live files — which is where they are going anyway — turns every interrupted restore into total data loss.
Alternatives
Include photos in Auto Backup. Trades a reliable small backup for an unreliable large one, and the failure is silent.
backup_rules.xml and no agent. Cannot checkpoint. Produces backups that appear to work.
Restore in place. See above. A user restoring a backup is already having a bad day.
A cloud sync of our own. A server, a bill, a privacy policy with something in it, and an account system — for a feature the platform already provides.
Consequences
- The export is written to a
.partand renamed on success, so an interrupted export leaves no file that looks shareable and is not. - A restore deletes the
-waland-shmsidecars before copying the new database in. Leaving a log belonging to a database that no longer exists is a corrupt open on next launch. - The restorer guards against zip slip — an entry named
../../databases/other.db. The archive arrives from the user’s storage, not from the app, so it is never trusted even though the app wrote the format. - Media is merged rather than replaced on restore: filenames are UUIDs, so a collision means the same file, and keeping files the archive does not mention costs disk where deleting them costs photos.
Amendment — 2026-10-27: binky’s design, ported at phase 6’s checkpoint E
Photos made backup matter (phase-6.md § 7), so Kozica takes the design binky
grew from this ADR (maksforge/binky-app, its ADR-0005), with two changes.
- The agent’s file set is a pure function over files. The database, checkpointed, and the
preferences always; then thumbnails newest first up to 20 MiB in all, under the ~25 MB quota with
room to spare, since an over-quota dataset is rejected whole. Photos never, except on a
phone-to-phone transfer (
FLAG_DEVICE_TO_DEVICE_TRANSFER, API 28 and up, not 30 as binky says), which has no quota. The preferences join the backup, so the map’s last centre does too. - A marker file outside the database records the last automatic backup and how many thumbnails it left out. It is never in the backup set and is cleared at restore. Backup settings shows it in three states, stale past 14 days, and says in words that the photos are not in it.
- Two export scopes, Records (database, preferences, thumbnails) and Everything (and the photos), each with a manifest that is the authority on what the archive holds. Binky’s third scope separates media Kozica does not have. An export goes to the share sheet or to a remembered folder (a persisted tree grant, checked on every read), falling back to the share sheet.
- Restore stages, migrates on the copy and swaps. A Records export of the current state goes to
preserved/first and is listed under Saved copies with Restore, Share and Delete, which is how a bad restore is undone. A newer schema is refused, and only entries of a known shape are extracted: an allowlist rather than a zip-slip check. The copy is opened with the destructive fallback pinned off, and checked as a SQLite file first, because Room’s default handler deletes a corrupt file and would swap an empty one in. The restored preferences wait for the next process start, where the live DataStore cannot write over them. - Left out: binky’s recurring export reminder and its exclusion notification, which are scheduled work or a notification outside a recording (ADR-0003’s amendments). The Hikes tab’s banner says the same inside the app.