Skip to content

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, SyncSession
  • SyncSettings for the synced settings (see below)
  • TombstoneRecord for deletions
  • BackupManifest with schemaVersion, appVersion, exportedAt, deviceId, syncScope, and mediaMode

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 metadata
  • data/meditations.json, data/bells.json, data/tags.json, ... - data JSON
  • media/<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 whose syncToWatch flag is true. 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-watch watchSubset bundle from a watch-to-phone delta bundle.
  • includeMedia — whether non-built-in sound files should be attached to the bundle.
  • incrementalMedia — whether to skip media files that have not changed since lastSyncAt.
  • includeSessions — whether to include session records (true for Data Layer sync, false for 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.

  • LocalFileSyncProvider reads/writes an unzipped bundle in the configured sync directory, creating manifest.json, data/, and media/.
  • WebDAVSyncProvider uses WebDAV MKCOL, PUT, GET, and PROPFIND against the configured WebDAV directory. an optional directory is appended to baseUrl so users can sync inside a server subfolder.
  • DataLayerSyncProvider sends 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 by SyncBackend.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 / importFromFile
  • backUpNow - export fixed-name bundle to a backup directory
  • maybeAutoBackup - run a daily backup if enabled and enough time has passed
  • sync - manual two-way sync with a specific provider
  • syncAllEnabled - 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 first
  • importWearSessionQueue - import cached watch sessions from the Data Layer
  • attach / detach - connectivity listener management
  • onAppResumed / 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 now buttons 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_at
  • local_sync_enabled, local_sync_directory
  • webdav_sync_enabled, webdav_url, webdav_username
  • sync_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.