Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/lib/pages/capture/widgets/limitless_sync_presentation.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
43 changes: 32 additions & 11 deletions app/lib/pages/capture/widgets/limitless_sync_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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),
Expand Down
30 changes: 27 additions & 3 deletions app/lib/providers/sync_provider.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:async';

Check warning on line 1 in app/lib/providers/sync_provider.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/providers/sync_provider.dart is 994 lines; consider splitting files over 800 lines.

import 'package:flutter/foundation.dart';

Expand Down Expand Up @@ -291,6 +291,7 @@
int _totalWalsToProcess = 0;
int _walsProcessedCount = 0;
bool _isDisposed = false;
bool _isOffloadingLimitlessFlash = false;
late bool _rateLimitWasActive;

// Computed properties for backward compatibility
Expand Down Expand Up @@ -319,7 +320,7 @@
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) {
Expand Down Expand Up @@ -436,7 +437,7 @@

Future<RecordingTransferDrainResult> _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();
}

Expand Down Expand Up @@ -549,6 +550,26 @@
);
}

/// Copies Limitless flash pages to the phone without waiting for, or
/// interfering with, the cloud-upload lane.
Future<void> 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<void> 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.
Expand Down Expand Up @@ -579,9 +600,12 @@
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<RecordingTransferDrainResult> drainEligibleWalsForTesting() => _drainEligibleWals();

Future<SyncLocalFilesResponse?> _performSync({
required Future<SyncLocalFilesResponse?> Function() operation,
required String context,
Expand Down
10 changes: 10 additions & 0 deletions app/lib/services/wals/wal_syncs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<WalStats> getWalStats() async {
final allWals = await getAllWals();
int phoneFiles = 0;
Expand Down
63 changes: 61 additions & 2 deletions app/test/providers/sync_provider_flash_stall_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void>? offloadCompleter;

Future<List<Wal>> getAllWals() async => [];

Future<SyncLocalFilesResponse?> syncAll({IWalSyncProgressListener? progress}) async => syncAllResult;
Future<SyncLocalFilesResponse?> syncAll({IWalSyncProgressListener? progress}) async {
syncAllCalls++;
return syncAllResult;
}

Future<void> offloadFlashPages() async {
offloadFlashPagesCalls++;
await offloadCompleter?.future;
}
}

class _FakeWalService implements IWalService {
Expand Down Expand Up @@ -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<void>();
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<void>();
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();
});
}
1 change: 1 addition & 0 deletions app/test/providers/sync_provider_sync_wal_wake_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

bool get isStorageSyncing => false;
bool get isSdCardSyncing => false;
bool get isFlashPageSyncing => false;

SyncLocalFilesResponse? get accumulatedResponse => null;

Expand Down Expand Up @@ -68,7 +69,7 @@
dynamic getSyncs() => syncs;
}

void main() {

Check warning on line 72 in app/test/providers/sync_provider_sync_wal_wake_test.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

void main() is 219 lines; consider extracting focused helpers over 150 lines.
TestWidgetsFlutterBinding.ensureInitialized();

test('syncWal 202 wakes the transfer coordinator for reconciliation', () async {
Expand Down
38 changes: 38 additions & 0 deletions app/test/unit/limitless_sync_presentation_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading