Feat: auto offline sync on device connect#5916
Conversation
…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.
Greptile SummaryThis PR introduces automatic offline sync when an Omi device connects with pending audio files on its LittleFS storage. It adds Confidence Score: 3/5Not 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
Sequence DiagramsequenceDiagram
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
|
|
|
||
| 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( |
There was a problem hiding this comment.
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 always ≤ totalBytes (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 > 1048576 → false → 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.
| 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"); |
There was a problem hiding this comment.
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;
}| ); | ||
|
|
||
| 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'); |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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:
| // 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.
Summary
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 #5897StorageSyncImplalongside the existingSDCardWalSync— old firmware devices continue using the legacy sync unchangedsyncProvider.syncWals()automatically (no user action needed)LocalWalSyncfor upload to backend via existing/v1/sync-local-filesendpointFlow
What's new
StorageStatus/StorageFileInfomodels indevice_connection.dartperformGetStorageFileStats(),performListStorageFiles(),performDeleteStorageFile()inomi_connection.dartStorageSyncImplin newstorage_sync.dart— multi-file BLE syncStorageSyncinterface inwal_interfaces.dartWalSyncs.syncAll()before legacy SD card sync_checkAndStartAutoSync()inDeviceProvider._onDeviceConnected()What's unchanged
sdcard_wal_sync.dart— legacy firmware sync untouchedlocal_wal_sync.dart— phone→backend upload untouched/v1/sync-local-files— same endpointTest plan
Depends on firmware PR #5897
🤖 Generated with Claude Code