Skip to content

Feat: auto offline sync on device connect#5916

Merged
mdmohsin7 merged 44 commits intomainfrom
feat/auto-offline-sync
Mar 26, 2026
Merged

Feat: auto offline sync on device connect#5916
mdmohsin7 merged 44 commits intomainfrom
feat/auto-offline-sync

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

Summary

  • Adds automatic offline sync when an Omi device connects with pending audio files on its SD/LittleFS storage
  • Implements the new multi-file firmware protocol (CMD_LIST_FILES 0x10, CMD_READ_FILE 0x11, CMD_DELETE_FILE 0x12) from PR Feat - refactor SD card, improve bluetooth throughput, remove wifi feature, fix issues #5897
  • Creates a new StorageSyncImpl alongside the existing SDCardWalSync — old firmware devices continue using the legacy sync unchanged
  • On device connect: reads storage status → if files exist → triggers syncProvider.syncWals() automatically (no user action needed)
  • Downloads each file individually from device to phone local storage, then hands to LocalWalSync for upload to backend via existing /v1/sync-local-files endpoint

Flow

Device connects → read storage status (16 bytes)
  → fileCount > 0 → auto-trigger syncWals()
  → Phase 0: StorageSync downloads files via BLE (new protocol)
  → Phase 2: LocalWalSync uploads to backend (unchanged)

What's new

  • StorageStatus / StorageFileInfo models in device_connection.dart
  • performGetStorageFileStats(), performListStorageFiles(), performDeleteStorageFile() in omi_connection.dart
  • StorageSyncImpl in new storage_sync.dart — multi-file BLE sync
  • StorageSync interface in wal_interfaces.dart
  • Phase 0 in WalSyncs.syncAll() before legacy SD card sync
  • _checkAndStartAutoSync() in DeviceProvider._onDeviceConnected()
  • Auto-sync callback wiring in home page

What's unchanged

  • sdcard_wal_sync.dart — legacy firmware sync untouched
  • local_wal_sync.dart — phone→backend upload untouched
  • Backend /v1/sync-local-files — same endpoint
  • Native BLE managers — no changes needed
  • BLE UUIDs — same service/characteristics

Test plan

  • Connect new firmware device with offline files → verify auto-sync triggers and files download
  • Connect old firmware device → verify legacy manual sync still works, no auto-sync attempt
  • Start realtime recording → verify offline sync runs simultaneously without audio drops
  • Kill app mid-sync → reconnect → verify files are re-detected and re-synced
  • Swipe app away → bring device in range → verify background wake triggers auto-sync

Depends on firmware PR #5897

🤖 Generated with Claude Code

…col methods

Adds new models and abstract methods to DeviceConnection for the new
firmware's multi-file storage protocol (CMD_LIST_FILES, CMD_READ_FILE,
CMD_DELETE_FILE). Default no-op implementations allow old device types
to remain unchanged.
Implements getStorageFileStats (16-byte LE status), listStorageFiles
(CMD 0x10 with completer pattern), and deleteStorageFile (CMD 0x12)
for the new firmware's LittleFS multi-file storage protocol.
New sync implementation that downloads individual files from device
LittleFS storage via BLE, saves to phone disk, then registers with
LocalWalSync for cloud upload. Separate from legacy sdcard_wal_sync.
Adds storage sync as Phase 0 before legacy SD card sync. Wires into
all lifecycle methods (start, stop, cancel, delete, getMissingWals).
New firmware devices sync via StorageSync, old firmware falls through
to existing SDCardWalSync unchanged.
Reads storage status after BLE connection. If device has offline files,
fires onOfflineDataDetected callback to trigger sync automatically
without user action.
Connects DeviceProvider.onOfflineDataDetected to SyncProvider.syncWals()
so offline sync starts automatically when device connects with pending
files. Cleans up callback on dispose.
Change >= vs > in packed opus parsing to match sdcard_wal_sync,
preventing corrupt frames at 440-byte block boundaries. Use
codec-specific FPS for chunk sizing instead of hardcoded 100.
Refined pipeline card with proper visual hierarchy, borders, and
color system. Added storage settings section (save to phone/cloud).
Improved recording list with grouped containers. Cleaner info sheet
with numbered steps and tips.
Use AutoSyncPage for new multi-file firmware, SyncPage for legacy.
Add stopStorageSync, listStorageFiles, deleteStorageFile,
getStorageFileStats for new multi-file firmware protocol.
Add CMD_LIST_FILES (0x10), CMD_READ_FILE (0x11), CMD_DELETE_FILE
(0x12), CMD_STOP_SYNC (0x03) for new LittleFS firmware protocol.
Run multi-file storage sync as Phase 0 before legacy SD card sync.
Include storage WALs in getAllWals and missing WAL queries.
Allow StorageSync to register downloaded files with LocalWalSync
for cloud upload. Add legacy Limitless file migration.
Check getStorageFileStats on connect to detect new firmware.
Expose supportsMultiFileSync flag and onOfflineDataDetected callback.
Register onOfflineDataDetected callback to auto-start sync on
device connect. Route to AutoSyncPage for new firmware users.
Fix tier states so downloading shows device=check phone=spinner.
Use app design tokens (0xFF1C1C1E cards, borderRadius 20). Replace
history toggle with Pending/Synced filter chips. Make WAL items
tappable for playback via WalItemDetailPage. Remove redundant
source badges, checkmarks, and status text. Add storage settings.
Rename tiers to Omi's Storage, Phone Storage, Cloud Storage.
19 new keys: omisStorage, phoneStorage, cloudStorage,
howSyncingWorks, syncFailed, keepSyncing, and others.
Real translations for omisStorage, phoneStorage, cloudStorage,
syncFailed, keepSyncing, cancelSyncQuestion, and 13 other keys.
# Conflicts:
#	app/lib/l10n/app_ar.arb
#	app/lib/l10n/app_bg.arb
#	app/lib/l10n/app_ca.arb
#	app/lib/l10n/app_cs.arb
#	app/lib/l10n/app_da.arb
#	app/lib/l10n/app_de.arb
#	app/lib/l10n/app_el.arb
#	app/lib/l10n/app_en.arb
#	app/lib/l10n/app_es.arb
#	app/lib/l10n/app_et.arb
#	app/lib/l10n/app_fi.arb
#	app/lib/l10n/app_fr.arb
#	app/lib/l10n/app_hi.arb
#	app/lib/l10n/app_hu.arb
#	app/lib/l10n/app_id.arb
#	app/lib/l10n/app_it.arb
#	app/lib/l10n/app_ja.arb
#	app/lib/l10n/app_ko.arb
#	app/lib/l10n/app_localizations.dart
#	app/lib/l10n/app_localizations_ar.dart
#	app/lib/l10n/app_localizations_bg.dart
#	app/lib/l10n/app_localizations_ca.dart
#	app/lib/l10n/app_localizations_cs.dart
#	app/lib/l10n/app_localizations_da.dart
#	app/lib/l10n/app_localizations_de.dart
#	app/lib/l10n/app_localizations_el.dart
#	app/lib/l10n/app_localizations_en.dart
#	app/lib/l10n/app_localizations_es.dart
#	app/lib/l10n/app_localizations_et.dart
#	app/lib/l10n/app_localizations_fi.dart
#	app/lib/l10n/app_localizations_fr.dart
#	app/lib/l10n/app_localizations_hi.dart
#	app/lib/l10n/app_localizations_hu.dart
#	app/lib/l10n/app_localizations_id.dart
#	app/lib/l10n/app_localizations_it.dart
#	app/lib/l10n/app_localizations_ja.dart
#	app/lib/l10n/app_localizations_ko.dart
#	app/lib/l10n/app_localizations_lt.dart
#	app/lib/l10n/app_localizations_lv.dart
#	app/lib/l10n/app_localizations_ms.dart
#	app/lib/l10n/app_localizations_nl.dart
#	app/lib/l10n/app_localizations_no.dart
#	app/lib/l10n/app_localizations_pl.dart
#	app/lib/l10n/app_localizations_pt.dart
#	app/lib/l10n/app_localizations_ro.dart
#	app/lib/l10n/app_localizations_ru.dart
#	app/lib/l10n/app_localizations_sk.dart
#	app/lib/l10n/app_localizations_sv.dart
#	app/lib/l10n/app_localizations_th.dart
#	app/lib/l10n/app_localizations_tr.dart
#	app/lib/l10n/app_localizations_uk.dart
#	app/lib/l10n/app_localizations_vi.dart
#	app/lib/l10n/app_localizations_zh.dart
#	app/lib/l10n/app_lt.arb
#	app/lib/l10n/app_lv.arb
#	app/lib/l10n/app_ms.arb
#	app/lib/l10n/app_nl.arb
#	app/lib/l10n/app_no.arb
#	app/lib/l10n/app_pl.arb
#	app/lib/l10n/app_pt.arb
#	app/lib/l10n/app_ro.arb
#	app/lib/l10n/app_ru.arb
#	app/lib/l10n/app_sk.arb
#	app/lib/l10n/app_sv.arb
#	app/lib/l10n/app_th.arb
#	app/lib/l10n/app_tr.arb
#	app/lib/l10n/app_uk.arb
#	app/lib/l10n/app_vi.arb
#	app/lib/l10n/app_zh.arb
Add onDone handler to BLE stream to complete immediately on
disconnect. Return transfer status from _syncSingleFile so
syncAll breaks the loop on incomplete transfer instead of
trying remaining files. Remove all print() debug logging.
Wrap multipart request stream to report bytes sent, total bytes,
and upload speed via UploadProgressCallback.
Track file-level progress during BLE download for pipeline UI.
Device tier shows "2 of 4 files" during BLE download. Phone tier
shows BLE speed during download. Cloud tier shows upload speed.
Remove misleading WAL file counts. Add sync restart button.
Refresh WAL list after conversation processing completes so home screen
cloud icon updates. Pass new upload tracking fields through provider.
…ches

Batch loop has overlapping index ranges causing file counts to exceed
total. Count actual synced WALs from list instead of accumulating per
batch. Remove broken byte-level HTTP progress tracking.
…ails

- Completed sync shows all tiers green regardless of remaining device files
- Idle state differentiates device WALs vs phone WALs
- Cloud upload shows file count progress instead of broken byte display
- Phone tier shows files waiting to upload when idle
- Sync button available when phone has files to upload (no device needed)
Ensures correct sync page is shown even when device is disconnected.
Shows a snackbar instead of navigating to the transfer screen, preventing
users from starting a conflicting single-file transfer during batch sync.
When the app starts and there are files on phone waiting to upload,
automatically upload them to cloud if no device sync is in progress.
Skips if device is actively syncing or a sync is already running.
@mdmohsin7 mdmohsin7 marked this pull request as ready for review March 26, 2026 12:56
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 26, 2026

Greptile Summary

This PR introduces automatic offline sync when an Omi device connects with pending audio files on its LittleFS storage. It adds StorageSyncImpl for the new multi-file firmware protocol (CMD_LIST_FILES 0x10, CMD_READ_FILE 0x11, CMD_DELETE_FILE 0x12), wires it as Phase 0 in WalSyncs.syncAll() ahead of the legacy SD-card sync, and exposes a new AutoSyncPage UI. The overall architecture follows the proven patterns from the SD-card sync path and is well-structured.\n\nThree concrete bugs need attention before merging:\n\n- Unreliable firmware-version heuristic (omi_connection.dart): The guard fileCount > totalBytes intended to detect old firmware almost never triggers — a byte offset is always ≤ total used bytes — so old-firmware devices are misidentified as new, wasting 10 s per connect on a CMD_LIST_FILES timeout and persisting deviceSupportsMultiFileSync = true incorrectly.\n- Audio duplication on BLE disconnect mid-transfer (storage_sync.dart): _syncSingleFile flushes partial frames to disk and registers them with LocalWalSync regardless of whether transferComplete is true. On next reconnect the same file is re-downloaded from offset 0, creating duplicate audio chunks uploaded to the cloud.\n- Subscription leak on write failure (omi_connection.dart): In performListStorageFiles (and performDeleteStorageFile), sub.cancel() is placed after — not in a finally block around — writeCharacteristic, leaving a live BLE notification listener if the write throws.

Confidence Score: 3/5

Not safe to merge as-is — the partial-transfer data-duplication bug and unreliable firmware heuristic can cause real user-facing issues (duplicate cloud conversations, wrong UI for legacy devices).

Two P1 bugs affect correctness on both old and new firmware paths: the firmware-detection heuristic means old-firmware users see the wrong sync page and waste 10 s on every connect, while the incomplete-transfer flush path can silently produce duplicate audio in the cloud. The subscription leak is an additional reliability issue. The overall design is solid and the fixes are targeted, so confidence would jump to 4-5 once those three issues are resolved.

app/lib/services/devices/omi_connection.dart (firmware heuristic + subscription leak) and app/lib/services/wals/storage_sync.dart (partial-transfer flush)

Important Files Changed

Filename Overview
app/lib/services/wals/storage_sync.dart New StorageSyncImpl for LittleFS multi-file BLE protocol; partial-transfer data is flushed to disk before the transfer-complete check, risking audio duplication on retry
app/lib/services/devices/omi_connection.dart New CMD_LIST_FILES/READ/DELETE BLE commands added; firmware-version heuristic (fileCount > totalBytes) is unreliable and stream subscription is leaked if writeCharacteristic throws
app/lib/providers/device_provider.dart Adds _checkAndStartAutoSync on connect and onOfflineDataDetected callback; unawaited future should be explicitly marked with unawaited()
app/lib/providers/sync_provider.dart Adds _autoUploadPendingPhoneFiles with a 3 s delay that can race with device auto-sync and silently skip device file downloads
app/lib/services/wals/wal_syncs.dart Phase 0 (StorageSync) correctly wired before legacy SD-card phase; cancellation and WAL list aggregation look correct
app/lib/pages/conversations/auto_sync_page.dart New AutoSyncPage with three-tier pipeline visualization (Device → Phone → Cloud); UI logic looks sound
app/lib/pages/home/page.dart Auto-sync callback registered/cleaned up correctly; cloud icon routes to AutoSyncPage or SyncPage based on firmware capability flag

Sequence Diagram

sequenceDiagram
    participant Device as Omi Device (LittleFS)
    participant DP as DeviceProvider
    participant SP as SyncProvider
    participant SS as StorageSyncImpl
    participant LS as LocalWalSyncImpl
    participant Cloud as Backend /v1/sync-local-files

    Device->>DP: BLE connect
    DP->>Device: getStorageFileStats() → [totalBytes, fileCount]
    alt new firmware (fileCount ≤ totalBytes)
        DP->>SP: onOfflineDataDetected(fileCount, totalBytes)
        SP->>SS: syncWals() → syncAll()
        SS->>Device: stopStorageSync() (STOP 0x03)
        SS->>Device: listStorageFiles() (CMD_LIST_FILES 0x10)
        Device-->>SS: [count][ts:4][sz:4]... file list
        loop each file
            SS->>Device: writeToStorage(fileNum, 0x11, 0) (CMD_READ_FILE)
            Device-->>SS: BLE notifications [ts:4][audio_440]...
            Device-->>SS: [0x64] end signal
            SS->>SS: _flushToDisk(frames)
            SS->>LS: addExternalWal(localWal)
            SS->>Device: deleteStorageFile(fileNum) (CMD_DELETE_FILE 0x12)
        end
        SS->>SP: syncAll() complete
        SP->>LS: phone.syncAll()
        LS->>Cloud: POST /v1/sync-local-files
        Cloud-->>SP: newConversationIds[]
    else old firmware (or no files)
        DP->>DP: supportsMultiFileSync = false
    end
Loading

Comments Outside Diff (1)

  1. app/lib/providers/sync_provider.dart, line 77-90 (link)

    P2 Auto-phone-upload can block device auto-sync on first connect

    _autoUploadPendingPhoneFiles delays 3 seconds then starts uploading stale phone WALs. _checkAndStartAutoSync in DeviceProvider awaits several BLE round-trips (storage-status read + stop command + list-files) before calling onOfflineDataDetected. If those BLE ops take more than ~3 s, the phone-upload is already marked isProcessing = true when the device-sync callback fires.

    The callback in _registerAutoSyncCallback guards with !syncProvider.isSyncing, so the device's offline files are silently skipped until the next disconnect-reconnect cycle.

    Consider checking whether a device is connected and has files before starting the phone-side upload, or give the device-sync path a higher priority.

Reviews (1): Last reviewed commit: "Auto-upload pending phone WALs to cloud ..." | Re-trigger Greptile

Comment on lines +254 to +286

sub = stream.listen((value) {
if (completer.isCompleted) return;
if (value.isEmpty) return;

int count = value[0];
int expectedLen = 1 + count * 8;

// Empty file list
if (count == 0 && value.length == 1) {
completer.complete([]);
return;
}

// Validate this looks like a file list response (not a data packet or status byte)
if (value.length >= expectedLen && count > 0 && count <= 128) {
List<StorageFileInfo> files = [];
for (int i = 0; i < count; i++) {
int base = 1 + i * 8;
if (base + 8 > value.length) break;
int timestamp = (value[base] << 24) | (value[base + 1] << 16) | (value[base + 2] << 8) | value[base + 3];
int size = (value[base + 4] << 24) | (value[base + 5] << 16) | (value[base + 6] << 8) | value[base + 7];
files.add(StorageFileInfo(index: i, timestamp: timestamp, sizeBytes: size));
}
Logger.debug('OmiDeviceConnection: Listed ${files.length} storage files');
completer.complete(files);
}
});

// Send CMD_LIST_FILES
await transport.writeCharacteristic(storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid, [0x10]);

final result = await completer.future.timeout(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unreliable old-vs-new firmware heuristic

The condition fileCount > totalBytes && totalBytes > 0 is meant to catch old firmware where field[1] is a byte offset. But a byte offset into the file is alwaystotalBytes (an offset cannot exceed the total used bytes), so this guard will almost never be true. As a result, any old-firmware device that returns two LE uint32 fields will be misclassified as new firmware, leading to the new LittleFS protocol being attempted against it.

For example, an old-firmware device with [totalUsedBytes=1048576, offset=524288] evaluates 524288 > 1048576false → incorrectly treated as new firmware. The subsequent CMD_LIST_FILES (0x10) times out after 10 s, wastes the user's time, and (due to SharedPreferencesUtil().deviceSupportsMultiFileSync = true) permanently shows AutoSyncPage for a device that doesn't support it.

Consider using a more robust signal — e.g., a distinct magic byte returned by the new firmware in the response, a firmware version characteristic, or a length check on the response payload that's unique to each protocol.

Comment on lines +281 to +295
wal.status = WalStatus.synced;

// If transfer was interrupted (BLE disconnect), save what we have and stop
if (!complete) {
Logger.debug('StorageSync: File ${wal.fileNum} incomplete (device disconnected), stopping download phase');
listener.onWalUpdated();
break;
}

// Delete the file from device after successful BLE transfer (per PR #5905)
if (wal.fileNum >= 0) {
try {
var connection = await ServiceManager.instance().device.ensureConnection(_device!.id);
if (connection != null) {
Logger.debug("StorageSync: Deleting file index ${wal.fileNum} after successful BLE sync");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial data flushed to disk before transfer-complete check, causing audio duplication on retry

_syncSingleFile always calls _flushToDisk + _registerWithLocalSync at the end with whatever frames were received, even for an incomplete transfer (transferComplete = false). Back in syncAll, the code sets wal.status = WalStatus.synced and then breaks but does not delete the file from the device.

On the next reconnect, refreshWalsFromDevice() discovers the file is still on device and creates a fresh WAL with storageOffset = 0. _syncSingleFile is then called again from the start, and the same audio frames are written to a new disk file and registered with LocalWalSync again — producing duplicate audio chunks uploaded to the cloud.

A simple guard is to skip _flushToDisk/_registerWithLocalSync when transferComplete is false:

// at the end of _syncSingleFile, before the flush loop:
if (!transferComplete && bytesData.isNotEmpty) {
  Logger.debug('StorageSync: Discarding ${bytesData.length} partial frames for file ${wal.fileNum}');
  return false;
}

Comment on lines +292 to +320
);

await sub.cancel();
return result;
} catch (e) {
Logger.debug('OmiDeviceConnection: Error listing storage files: $e');
return [];
}
}

@override
Future<bool> performDeleteStorageFile(int fileIndex) async {
try {
final completer = Completer<bool>();

final stream = transport.getCharacteristicStream(
storageDataStreamServiceUuid,
storageDataStreamCharacteristicUuid,
);

StreamSubscription? subscription;
Timer? timeout;

subscription = stream.listen((value) {
if (completer.isCompleted) return;
timeout?.cancel();
// Single-byte result: 0 = success
final result = value.isNotEmpty ? value[0] : 0xFF;
Logger.debug('OmiDeviceConnection: deleteStorageFile result=$result');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stream subscription leak when writeCharacteristic throws

sub is created before the writeCharacteristic call but is only cancelled in the happy path (after the timeout). If writeCharacteristic throws an exception, execution jumps to the outer catch block and sub.cancel() is never called, leaving a live BLE notification listener open.

Fix with a try/finally:

try {
  await transport.writeCharacteristic(
      storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid, [0x10]);
  final result = await completer.future.timeout(
    const Duration(seconds: 10),
    onTimeout: () {
      Logger.debug('OmiDeviceConnection: listFiles timeout');
      return <StorageFileInfo>[];
    },
  );
  return result;
} finally {
  await sub?.cancel();
}

The same pattern applies to performDeleteStorageFile where subscription.cancel() is also outside a finally.

@@ -426,6 +428,10 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption
// Wals
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unawaited future should be explicitly marked

_checkAndStartAutoSync(device) is fire-and-forget, which is intentional, but the Dart linter will warn about the unawaited Future. Mark it explicitly so the intent is clear and the warning is suppressed:

Suggested change
// Wals
unawaited(_checkAndStartAutoSync(device));

… uploads

When device connects while auto-upload is running, cancel the phone upload
first so the full device sync can take over without state corruption
- Fix old-vs-new firmware detection: field[1] > 1000 indicates a byte
  offset (old firmware), not a file count. Previous check was inverted.
- Wrap BLE subscriptions in try/finally in performListStorageFiles and
  performDeleteStorageFile to prevent stream leaks on exceptions.
Replace unreliable BLE response heuristic with firmware version check.
Firmware >= 3.0.17 supports the new LittleFS multi-file protocol.
Persists the flag so correct sync page shows even when device is
disconnected.
@mdmohsin7 mdmohsin7 merged commit 90285a4 into main Mar 26, 2026
2 checks passed
@mdmohsin7 mdmohsin7 deleted the feat/auto-offline-sync branch March 26, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant