diff --git a/app/lib/pages/capture/widgets/limitless_sync_presentation.dart b/app/lib/pages/capture/widgets/limitless_sync_presentation.dart new file mode 100644 index 00000000000..01839b76ffa --- /dev/null +++ b/app/lib/pages/capture/widgets/limitless_sync_presentation.dart @@ -0,0 +1,26 @@ +final class LimitlessSyncPresentation { + const LimitlessSyncPresentation._({ + required this.isVisible, + required this.canOffloadDevice, + required this.showsCloudProgress, + required this.showsFlashDrainProgress, + }); + + final bool isVisible; + final bool canOffloadDevice; + final bool showsCloudProgress; + final bool showsFlashDrainProgress; + + factory LimitlessSyncPresentation.resolve({ + required bool hasPendingFlashPages, + required bool isCloudUploading, + required bool isFlashDraining, + }) { + return LimitlessSyncPresentation._( + isVisible: hasPendingFlashPages || isCloudUploading || isFlashDraining, + canOffloadDevice: hasPendingFlashPages && !isFlashDraining, + showsCloudProgress: isCloudUploading, + showsFlashDrainProgress: isFlashDraining, + ); + } +} diff --git a/app/lib/pages/capture/widgets/limitless_sync_widget.dart b/app/lib/pages/capture/widgets/limitless_sync_widget.dart index b19119c9805..cfad3cad78e 100644 --- a/app/lib/pages/capture/widgets/limitless_sync_widget.dart +++ b/app/lib/pages/capture/widgets/limitless_sync_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/pages/capture/widgets/limitless_sync_presentation.dart'; import 'package:omi/providers/device_provider.dart'; import 'package:omi/providers/sync_provider.dart'; import 'package:omi/services/wals.dart'; @@ -26,11 +27,16 @@ class LimitlessSyncCardWidget extends StatelessWidget { final pendingFlashPages = syncProvider.allWals.where((w) => w.storage == WalStorage.flashPage && w.status == WalStatus.miss).toList(); - if (pendingFlashPages.isEmpty && !syncProvider.isSyncing) { + final presentation = LimitlessSyncPresentation.resolve( + hasPendingFlashPages: pendingFlashPages.isNotEmpty, + isCloudUploading: syncProvider.isSyncing, + isFlashDraining: syncProvider.isFlashPageSyncing, + ); + + if (!presentation.isVisible) { return const SizedBox(); } - final isSyncing = syncProvider.isSyncing; final progress = syncProvider.walsSyncedProgress; return Container( @@ -49,14 +55,34 @@ class LimitlessSyncCardWidget extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - isSyncing ? context.l10n.syncingYourRecordings : context.l10n.syncYourRecordings, + presentation.showsCloudProgress + ? context.l10n.syncingYourRecordings + : context.l10n.syncYourRecordings, style: const TextStyle(color: Colors.white, fontSize: 16), ), ), - if (!isSyncing) + if (presentation.showsCloudProgress) + Text( + '${(progress * 100).toInt()}%', + style: const TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w600), + ), + if (presentation.showsCloudProgress && + (presentation.showsFlashDrainProgress || presentation.canOffloadDevice)) + const SizedBox(width: 12), + if (presentation.showsFlashDrainProgress) + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70), + ) + else if (presentation.canOffloadDevice) ElevatedButton( onPressed: () async { - if (await confirmSyncForCustomStt(context) && context.mounted) syncProvider.syncWals(); + if (presentation.showsCloudProgress) { + await syncProvider.offloadLimitlessFlash(); + } else if (await confirmSyncForCustomStt(context) && context.mounted) { + await syncProvider.syncWals(); + } }, style: ElevatedButton.styleFrom( backgroundColor: Colors.deepPurple, @@ -65,16 +91,11 @@ class LimitlessSyncCardWidget extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: Text(context.l10n.syncNow), - ) - else - Text( - '${(progress * 100).toInt()}%', - style: const TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w600), ), ], ), // Progress bar when syncing - if (isSyncing) ...[ + if (presentation.showsCloudProgress) ...[ const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(4), diff --git a/app/lib/providers/sync_provider.dart b/app/lib/providers/sync_provider.dart index 195f9d2136e..8c2ca19a6ea 100644 --- a/app/lib/providers/sync_provider.dart +++ b/app/lib/providers/sync_provider.dart @@ -291,6 +291,7 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy int _totalWalsToProcess = 0; int _walsProcessedCount = 0; bool _isDisposed = false; + bool _isOffloadingLimitlessFlash = false; late bool _rateLimitWasActive; // Computed properties for backward compatibility @@ -319,7 +320,7 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy SyncMethod? get currentSyncMethod => _syncState.syncMethod; // Flash page (Limitless) sync state - bool get isFlashPageSyncing => _walService.getSyncs().isFlashPageSyncing; + bool get isFlashPageSyncing => _isOffloadingLimitlessFlash || _walService.getSyncs().isFlashPageSyncing; /// Get a WAL by ID from the current list Wal? getWalById(String walId) { @@ -436,7 +437,7 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy Future _drainEligibleWals() async { if (_isDisposed || _syncState.isProcessing) return const RecordingTransferDrainResult.contended(); - if (_walService.getSyncs().isStorageSyncing || _walService.getSyncs().isSdCardSyncing) { + if (_walService.getSyncs().isStorageSyncing || _walService.getSyncs().isSdCardSyncing || isFlashPageSyncing) { return const RecordingTransferDrainResult.contended(); } @@ -549,6 +550,26 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy ); } + /// Copies Limitless flash pages to the phone without waiting for, or + /// interfering with, the cloud-upload lane. + Future offloadLimitlessFlash() async { + if (_isDisposed || isFlashPageSyncing) return; + _isOffloadingLimitlessFlash = true; + notifyListeners(); + try { + await _walService.getSyncs().offloadFlashPages(); + } finally { + _isOffloadingLimitlessFlash = false; + if (!_isDisposed) { + try { + await refreshWals(); + } finally { + if (!_isDisposed) notifyListeners(); + } + } + } + } + Future syncWal(Wal wal) async { // UI Sync/Auto Sync still call syncWal for a single row, but must not // race a coordinator drain (or device download) on the same WAL stack. @@ -579,9 +600,12 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy bool _isTransferSeamBusy() { if (_syncState.isProcessing) return true; final syncs = _walService.getSyncs(); - return syncs.isStorageSyncing == true || syncs.isSdCardSyncing == true; + return syncs.isStorageSyncing == true || syncs.isSdCardSyncing == true || isFlashPageSyncing; } + @visibleForTesting + Future drainEligibleWalsForTesting() => _drainEligibleWals(); + Future _performSync({ required Future Function() operation, required String context, diff --git a/app/lib/services/wals/wal_syncs.dart b/app/lib/services/wals/wal_syncs.dart index 86661f4313a..5067814909f 100644 --- a/app/lib/services/wals/wal_syncs.dart +++ b/app/lib/services/wals/wal_syncs.dart @@ -138,6 +138,16 @@ class WalSyncs implements IWalSync { await _flashPageSync.refreshWalsFromDevice(); } + /// Drains Limitless flash pages to the phone without entering the cloud + /// upload phase. This remains independent from [syncAll], so device storage + /// can be freed while an earlier recording uploads. + Future offloadFlashPages() async { + if (_flashPageSync.isSyncing) return; + await _flashPageSync.refreshWalsFromDevice(); + final missing = (await _flashPageSync.getMissingWals()).where((wal) => wal.status == WalStatus.miss); + if (missing.isNotEmpty) await _flashPageSync.syncAll(); + } + Future getWalStats() async { final allWals = await getAllWals(); int phoneFiles = 0; diff --git a/app/test/providers/sync_provider_flash_stall_test.dart b/app/test/providers/sync_provider_flash_stall_test.dart index f3b0306992b..bdfc7cf6176 100644 --- a/app/test/providers/sync_provider_flash_stall_test.dart +++ b/app/test/providers/sync_provider_flash_stall_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:omi/backend/http/api/conversations.dart'; +import 'dart:async'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/providers/sync_provider.dart'; @@ -14,10 +14,24 @@ import 'package:shared_preferences/shared_preferences.dart'; class _FakeSyncs { FlashSyncStallReason flashStallReason = FlashSyncStallReason.none; SyncLocalFilesResponse? syncAllResult; + bool isStorageSyncing = false; + bool isSdCardSyncing = false; + bool isFlashPageSyncing = false; + int syncAllCalls = 0; + int offloadFlashPagesCalls = 0; + Completer? offloadCompleter; Future> getAllWals() async => []; - Future syncAll({IWalSyncProgressListener? progress}) async => syncAllResult; + Future syncAll({IWalSyncProgressListener? progress}) async { + syncAllCalls++; + return syncAllResult; + } + + Future offloadFlashPages() async { + offloadFlashPagesCalls++; + await offloadCompleter?.future; + } } class _FakeWalService implements IWalService { @@ -115,4 +129,49 @@ void main() { expect(provider.syncState.hasError, isFalse); provider.dispose(); }); + + test('device-only offload does not enter the cloud sync state machine', () async { + final walService = _FakeWalService(); + final provider = SyncProvider(walService: walService, uploadGate: _hermeticGate(), startBackgroundSync: false); + await provider.initialized; + + await provider.offloadLimitlessFlash(); + + expect(walService.syncs.offloadFlashPagesCalls, 1); + expect(provider.syncState.isSyncing, isFalse); + provider.dispose(); + }); + + test('device-only offload is single-flight before the WAL service reports syncing', () async { + final walService = _FakeWalService(); + walService.syncs.offloadCompleter = Completer(); + final provider = SyncProvider(walService: walService, uploadGate: _hermeticGate(), startBackgroundSync: false); + await provider.initialized; + + final first = provider.offloadLimitlessFlash(); + final second = provider.offloadLimitlessFlash(); + + expect(provider.isFlashPageSyncing, isTrue); + expect(walService.syncs.offloadFlashPagesCalls, 1); + walService.syncs.offloadCompleter!.complete(); + await Future.wait([first, second]); + expect(provider.isFlashPageSyncing, isFalse); + provider.dispose(); + }); + + test('coordinator drain stays contended for the full device-only offload', () async { + final walService = _FakeWalService(); + walService.syncs.offloadCompleter = Completer(); + final provider = SyncProvider(walService: walService, uploadGate: _hermeticGate(), startBackgroundSync: false); + await provider.initialized; + + final offload = provider.offloadLimitlessFlash(); + final drain = await provider.drainEligibleWalsForTesting(); + + expect(drain.contended, isTrue); + expect(walService.syncs.syncAllCalls, 0); + walService.syncs.offloadCompleter!.complete(); + await offload; + provider.dispose(); + }); } diff --git a/app/test/providers/sync_provider_sync_wal_wake_test.dart b/app/test/providers/sync_provider_sync_wal_wake_test.dart index d795847306e..4a903afaa36 100644 --- a/app/test/providers/sync_provider_sync_wal_wake_test.dart +++ b/app/test/providers/sync_provider_sync_wal_wake_test.dart @@ -28,6 +28,7 @@ class _FakeSyncs { bool get isStorageSyncing => false; bool get isSdCardSyncing => false; + bool get isFlashPageSyncing => false; SyncLocalFilesResponse? get accumulatedResponse => null; diff --git a/app/test/unit/limitless_sync_presentation_test.dart b/app/test/unit/limitless_sync_presentation_test.dart new file mode 100644 index 00000000000..6950cb6aa35 --- /dev/null +++ b/app/test/unit/limitless_sync_presentation_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/pages/capture/widgets/limitless_sync_presentation.dart'; + +void main() { + test('cloud upload does not hide or disable device offload', () { + final state = LimitlessSyncPresentation.resolve( + hasPendingFlashPages: true, + isCloudUploading: true, + isFlashDraining: false, + ); + + expect(state.isVisible, isTrue); + expect(state.canOffloadDevice, isTrue); + expect(state.showsCloudProgress, isTrue); + }); + + test('only an active flash drain disables the offload action', () { + final state = LimitlessSyncPresentation.resolve( + hasPendingFlashPages: true, + isCloudUploading: true, + isFlashDraining: true, + ); + + expect(state.canOffloadDevice, isFalse); + expect(state.showsFlashDrainProgress, isTrue); + }); + + test('card hides when neither device work nor upload work exists', () { + final state = LimitlessSyncPresentation.resolve( + hasPendingFlashPages: false, + isCloudUploading: false, + isFlashDraining: false, + ); + + expect(state.isVisible, isFalse); + }); +}