backup and sync
this document describes the backup and sync system: bundle format, providers, conflict resolution, and the Wear OS Data Layer path.
goals
- export a full backup bundle that can be restored or shared.
- synchronize data across devices using a provider model.
- preserve stable sync ids and timestamps for conflict resolution.
- respect deletions through tombstones without losing history.
architecture
the sync stack is layered like this:
UI (BackupSyncScreen / WearSettingsScreen)
|
riverpod state (BackupSyncService / BackupSyncState)
|
BackupSyncScheduler (lifecycle + network triggers)
|
SyncProvider (local / webdav / Data Layer)
|
BundleArchive (manifest + data JSON + media, zipped for backups)
|
BackupMapper (database <-> bundle; the implementation splits into
backup_mapper_export.dart, backup_mapper_apply.dart, and
backup_mapper_media.dart, with shared settings helpers in
backup_mapper_shared.dart)
|
Drift / SQLite
data model
lib/data/models/sync/sync_models.dart defines the freezed/json-serializable
counterparts for every syncable entity:
SyncMeditation,SyncSegment,SyncMeditationSoundscape,SyncBackgroundRef,SyncSound,SyncImageBackground,SyncBinauralBeat,SyncGeneratedSoundscape,SyncBreathingExercise,SyncColorPreset,SyncTag,SyncList,SyncListItem,SyncReminder,SyncSessionSyncSettingsfor the synced settings (see below)TombstoneRecordfor deletionsBackupManifestwithschemaVersion,appVersion,exportedAt,deviceId,syncScope, andmediaMode
two serialization names are kept for back-compat with bundles written before
the respective renames: SyncReminder is serialized under the
notificationProfiles bundle key, and its message field under customText.
SyncSegment.breathingExerciseSyncId carries a breathing-exercise reference
by sync id — the earlier breathingExerciseId int field stored a device-local
row id that was meaningless on other devices; old bundles may still carry it
but new exports always write the sync id. when an incoming segment's reference
is missing or does not resolve on this device, applyBundle keeps the local
segment's existing selection (matched by orderIndex) instead of resetting
it. there is no user-configurable default breathing exercise: a segment whose
stored selection is empty or dangling plays the built-in box breathing preset
(BreathingExercisesRepository.resolveOrDefault).
BackupManifest.syncScope controls how applyBundle treats missing records:
full— a complete local/backup bundle; nothing is pruned.watchSubset— an authoritative phone-to-watch bundle; the watch removes meditations, sounds, lists, and tags that are no longer included. breathing exercises are not pruned: they carry no media, built-ins are shared, and unreferenced user-defined exercises simply remain on the watch.delta— a partial bundle (e.g. watch-to-phone sessions); only present records are merged and no pruning occurs.
BackupManifest.mediaMode is either all or incremental. incremental
bundles only attach media bytes for entities whose updatedAt has changed
since the last successful sync. sounds and image backgrounds carry their own
updatedAt; audio-list items carry one too (added to SyncListItem after
the fact, so older bundles ship it as null and are treated as changed).
ListsRepository.save() preserves an item's updatedAt when a save does not
touch its synced fields — body, filePath, orderIndex, or durationSeconds — so
editing one item re-sends only that item's audio.
SyncSettings is a key/value map over the settings-table rows listed in
syncableSettings (backup_mapper_shared.dart): meditation_defaults
(which itself
contains the session options and mood presets) and health_settings
(Android only — it is skipped on desktop exports where health sync is
meaningless). the mapper stores a snapshot of the exported map so
updatedAt only moves when a synced setting actually changes. easy mode
settings and Wear-only recitation input settings are excluded.
bundle format
a bundle is a collection of files and folders. it can be written as a zip
archive (for backups) using BundleArchive.writeToFile/toBytes or as an
unzipped directory (for sync) using BundleArchive.writeToDirectory.
manifest.json- bundle metadatadata/meditations.json,data/bells.json,data/tags.json, ... - data JSONmedia/<syncId>.<ext>- user sounds and images
BundleArchive.readFromFile parses a zip archive back into a BackupBundle
and a Map<String, Uint8List> of media files. BundleArchive.readFromDirectory
reads the same layout from a local directory and returns null when there is
no manifest.json.
database support
AppDatabase includes sync_id, updated_at, tombstones, and sync_log
tables. TombstoneService records deletions with device id and timestamp and
prunes tombstones older than 180 days.
BackupMapper reads every syncable table and produces a BackupBundle.
applyBundle merges a bundle into the database, creates missing records,
updates changed records, and resolves sound file paths from the media map.
bundle export options
lib/data/services/backup_sync/bundle_export_options.dart defines
BundleExportOptions, which BackupMapper.exportBundle() uses to decide
what should go into a bundle:
watchSync: true— export only active meditations whosesyncToWatchflag istrue. the bundle also contains only the bells, soundscapes, lists, and tags referenced by those meditations. reminders are omitted from the watch sync bundle; sessions are included in both directions.isWearable— whether the bundle is being exported from the Wear OS app. this distinguishes an authoritative phone-to-watchwatchSubsetbundle from a watch-to-phonedeltabundle.includeMedia— whether non-built-in sound files should be attached to the bundle.incrementalMedia— whether to skip media files that have not changed sincelastSyncAt.includeSessions— whether to include session records (truefor Data Layer sync,falsefor phone-to-watch if you need to keep the bundle small).
this keeps the Data Layer bundles as small as possible: the watch receives only what it needs, and large media files are transferred only when they have changed.
conflict resolution
ConflictResolver performs a last-write-wins merge. if a baseline bundle is
provided, it detects conflicts when both local and remote have newer
timestamps than the baseline. it merges every entity list in the bundle —
including binauralBeats, generatedSoundscapes, and breathingExercises,
which all sync by stable sync id — plus settings and tombstones, and reports a per-operation
conflictsCount on the resulting BackupSyncOperation.
tombstones are merged first. a tombstone wins if its deletedAt is at or
after the record's updatedAt; otherwise the record wins and the tombstone
is discarded. conflicts are reported per operation
(BackupSyncOperation.conflictsCount) and stored in the sync log.
BackupMapper.applyBundle follows the same rule against the tombstones in
the local database, for every entity type but sessions: a bundle record is
skipped when this device deleted the entity at or after the record's
updatedAt, and otherwise it is applied and the local tombstone is
removed. times are compared in whole seconds, which is what the database
stores. two exceptions: a deleted built-in (a builtin: sync id) is never
re-applied, because its updatedAt is the time its device seeded it and
not the time of an edit; and a locally deleted session is never
re-applied.
a backup restored from a file (applyBundle(restore: true)) is different:
the user asked for what the backup holds, so a record is applied even
where this device deleted the entity later. the local tombstone is
removed and the row is stamped as changed now, which keeps the next sync
from deleting it again on the strength of that deletion. sessions are
again the exception. the limits: a deletion made on another device that
this one has not received yet is not known here, so it still deletes the
restored item at the next sync; a restored built-in stays deleted on the
other devices that deleted it; and a meditation that another device
detached a deleted sound from keeps that sound only on the restoring
device until the meditation is next edited.
a binaural beat or a generated soundscape that arrives under a name
another of the user's has is stored with a number appended (drift (2))
and stamped as changed now, so the new name reaches the other devices.
the meditation editor and .med files refer to these by name (the
database and bundles use ids). names are judged against how things stand
once the bundle is applied, and among rows arriving under one name the
lowest sync id keeps it, so every device settles on the same names.
providers
SyncProvider is a small interface for upload, download, and availability.
each provider also exposes a SyncBackend (local, webdav, or
dataLayer) so the service and UI can report status per backend.
LocalFileSyncProviderreads/writes an unzipped bundle in the configured sync directory, creatingmanifest.json,data/, andmedia/.WebDAVSyncProvideruses WebDAVMKCOL,PUT,GET, andPROPFINDagainst the configured WebDAV directory. an optionaldirectoryis appended tobaseUrlso users can sync inside a server subfolder.DataLayerSyncProvidersends bundles to a paired Wear OS watch or phone over the Wearable Data Layer (DataClient/MessageClient). phone-to-watch bundles can include user-imported media; watch-to-phone bundles are small and contain data only. it is selected bySyncBackend.dataLayer.
backups still use kBundleFileName
(know_that_you_are_breathing_backup.zip) through
BundleArchive.writeToFile; sync providers use the unzipped directory
layout. the legacy filename know_you_are_breathing_backup.zip is still
recognized when importing an old backup created before the rebrand.
new providers only need to implement download, upload, and isAvailable.
service and state
BackupSyncService is a Riverpod notifier. it exposes BackupSyncState,
which keeps a separate BackupSyncOperation for the backup, local sync,
WebDAV sync, and Data Layer sync operations. each operation has its own
status, last message, last sync time, and conflict count. methods:
exportToFile/importFromFilebackUpNow- export fixed-name bundle to a backup directorymaybeAutoBackup- run a daily backup if enabled and enough time has passedsync- manual two-way sync with a specific providersyncAllEnabled- sync every enabled provider: local sync, WebDAV sync, and (on Android, when a paired watch is connected) the Data Layer provider, which also imports the queued watch sessions firstimportWearSessionQueue- import cached watch sessions from the Data Layerattach/detach- connectivity listener managementonAppResumed/onAppPaused/onAppDetached- lifecycle entry points
BackupSyncScheduler in
lib/data/services/backup_sync/backup_sync_scheduler.dart is a
WidgetsBindingObserver that calls the lifecycle methods. it is attached in
main() and cancels timers and listeners on app exit.
when the user is on BackupSyncScreen, all automatic sync triggers are
suppressed — syncAllEnabled() returns immediately while the screen is
open. manual sync (the test local sync / test webdav sync buttons)
still runs. because the sync with watch button lives on that same
screen, it must not go through syncAllEnabled (which would no-op); it
calls importWearSessionQueue() and sync(dataLayerProvider()) directly.
openSyncScreen() / closeSyncScreen() on BackupSyncService are called
from initState() / dispose() of BackupSyncScreen to track this.
sync triggers follow the pyggo model:
- app resume/foreground starts auto-backup and schedules a debounced sync
- app pause/exit runs an immediate sync
- network restore schedules a sync
- manual
sync nowbuttons bypass the debounce but respect single-flight - failures retry with exponential backoff, capped at ten minutes
settings are persisted through SettingsRepository:
auto_backup_enabled,auto_backup_directory,last_auto_backup_atlocal_sync_enabled,local_sync_directorywebdav_sync_enabled,webdav_url,webdav_usernamesync_debounce_seconds
webdav_password is stored in the platform secure storage (Keychain /
Keystore / Windows credential locker / Linux Secret Service) instead of the
settings table.
every sync operation writes a row to sync_log.
media file naming and rename semantics
imported and recorded sounds, background images, and audio-list recordings are stored with human-readable local file names. the format is:
<sanitized-display-name>_<5-char-disambiguator>.<ext>
- for sounds and images the disambiguator is the first five lowercase
alphanumeric characters of the entity's stable
syncId. this keeps the file name the same across devices and across renames. - for audio-list items, which do not have their own
syncId, the disambiguator is five random lowercase alphanumeric characters on local import. synced copies use<list-syncId-prefix>_<orderIndex>for disambiguation within a list.
when a user renames a sound, image, or audio-list label, the repository
moves the underlying file to the new name and updates the stored filePath.
the stable syncId does not change, so the next sync treats the item as an
update, not a new entity.
BackupMapper.applyBundle() writes media to the new human-readable local
path and deletes the previous local file when the path changes. this
prevents a rename from leaving an orphaned copy on the receiving device.
bundle media keys now use the same human-readable names as local files
(<sanitized-display-name>_<5-char-disambiguator>.<ext> for sounds and
images, <sanitized-body>_<list-syncId-prefix>_<orderIndex>.mp3 for
audio-list items). this makes files on the remote provider (WebDAV, local
sync folder, and Data Layer bundles) readable and predictable.
BackupMapper.applyBundle() still accepts the legacy
media/<syncId>.<ext> and
media/audio_list_<listSyncId>_<orderIndex>.mp3 keys so older bundles can
still be imported, but new bundles export with the human-readable keys.
the WebDAVSyncProvider deletes remote files that are no longer part of the
bundle after each upload, so renames and deletions do not leave stale
UUID-named files on the server.
watch cleanup and media sync feedback
BackupMapper.applyBundle() checks the incoming BackupManifest.syncScope.
when the scope is watchSubset, the watch removes any local meditations
that are no longer in the bundle, detaches their sessions by setting
meditationId to null, and deletes their segments, backgrounds, and tag
links. it then removes unreferenced non-built-in bells and soundscapes,
deletes their audio files from the watch's storage, removes unreferenced
user-created binaural beats and generated soundscapes (which have no files),
and removes any lists or tags that are no longer used.
BackupSyncService posts a BackupSyncNotification with messageKey:
'syncingMedia' when the media bytes in a Data Layer transfer total 2 MiB or
more (the 10 MiB kLargeTransferThresholdBytes mobile-data confirmation is a
separate, earlier gate). the watch re-downloads the phone's cached bundle on
every sync, so MediaNotificationGate fingerprints the payload and only
posts the notification once per changed payload — an unchanged bundle no
longer re-shows the snackbar. the WearSyncSnackbarListener in
lib/features/wear/wear_sync_snackbar_listener.dart listens for
BackupSyncState.notification and shows a matching snackbar on the watch so
the user knows a media transfer is in progress; the snackbar is dismissed
when the sync finishes.
Wear OS session relay
completed watch sessions are sent to the phone as small MessageClient
messages on the Wearable Data Layer path /sync/session. the phone's
DataLayerListenerService wakes the app process, calls the shared
HealthConnectService, writes to Health Connect, and caches the session in
DataLayerCache. the Dart BackupSyncService imports pending cached
sessions into SessionsTable on the next app start, and HealthRelayService
re-attempts any queued or newly-merged watch sessions to Health Connect
using the watch's stable clientRecordId. watch sessions are persisted with
origin_device = 'wear' and health_synced = false.
Wear OS do-not-disturb relay
Wear OS does not ship the notification-policy-access settings screen, so a
watch cannot grant itself permission to control dnd — the grant only exists
when someone runs adb shell cmd notification allow_dnd
com.wesorick.john.knowthatyouarebreathing on the watch.
WearSilencePhoneService
(lib/data/services/wear_silence_phone_service.dart) wraps the local
AndroidSilencePhoneService: when the watch has the grant it silences itself
as usual; otherwise it publishes a /sync/dnd data item
({enabled, expiresAtMillis}). the phone's DataLayerListenerService hands
the payload to WearDndRelay, which saves the current interruption filter,
sets INTERRUPTION_FILTER_PRIORITY, and arms an AlarmManager restore at
expiresAtMillis — the session's planned duration plus a margin, or an
eight-hour cap for unbounded sessions. watches that mirror the phone's dnd
state (pixel, samsung) silence themselves through the platform sync. the fdroid
flavor keeps the sendDndState/reconcileWearDnd channel methods as no-ops.
a data item rather than a MessageClient message is used so the state is
durable: enabled: false written while the devices are disconnected is
delivered on the next sync, and the peer always sees the latest state. restore
happens three ways — the off-write at session end, the expiry alarm through
WearDndRestoreReceiver, and a reconcile pass at app start and
BOOT_COMPLETED (the alarm is lost on reboot but the filter persists). a
phone already in a filtered mode is left alone so restore never clobbers a
user choice.
UI
BackupSyncScreen lives in lib/features/backup_sync/ and is reachable from
SettingsScreen through a backup & sync tile. it shows four separate
status lines: backup, local sync, WebDAV sync, and watch (Data Layer) sync.
each section has its own toggle (labelled on with a backend-specific
subtitle), directory/credential input, and a manual test local sync,
test webdav sync, or sync with watch button.
WearSettingsScreen in lib/features/wear/wear_settings_screen.dart shows
the paired-node status, a note that health sync is handled by the paired
phone, a silence watch row that reports whether sessions silence the
watch itself or the paired phone (tapping it explains the adb grant), the
last Data Layer sync time, and a sync now button.
the phone editor pushes changes to the watch immediately after saving a
meditation. MeditationEditorScreen calls
BackupSyncService.syncDataLayerIfPaired() after saveMeditation() returns,
so a newly enabled syncToWatch meditation is uploaded to the Wearable Data
Layer without waiting for the phone's next lifecycle sync.