From 1c25b1caebab76d801504a82076a64ed0517495b Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Sun, 22 Mar 2026 16:20:49 +0700 Subject: [PATCH 1/2] remove wifi sync and update new SD sync method for Omi CV1 --- app/lib/backend/http/api/conversations.dart | 11 +- app/lib/backend/http/shared.dart | 58 +- app/lib/backend/preferences.dart | 10 + app/lib/pages/conversations/sync_page.dart | 202 +---- .../wal_item_detail/wal_item_detail_page.dart | 249 ++---- app/lib/pages/settings/device_settings.dart | 69 ++ app/lib/providers/auth_provider.dart | 4 +- app/lib/providers/device_provider.dart | 11 + app/lib/providers/sync_provider.dart | 28 +- .../services/devices/device_connection.dart | 81 ++ app/lib/services/devices/models.dart | 25 + app/lib/services/devices/omi_connection.dart | 125 +++ app/lib/services/wals/local_wal_sync.dart | 50 +- app/lib/services/wals/sdcard_wal_sync.dart | 815 +++++++++--------- app/lib/services/wals/wal_syncs.dart | 38 +- 15 files changed, 964 insertions(+), 812 deletions(-) diff --git a/app/lib/backend/http/api/conversations.dart b/app/lib/backend/http/api/conversations.dart index 9a76adf860..611068de47 100644 --- a/app/lib/backend/http/api/conversations.dart +++ b/app/lib/backend/http/api/conversations.dart @@ -345,9 +345,16 @@ Future> sendStorageToBackend(File file, String sdCardDa } } -Future syncLocalFiles(List files) async { +Future syncLocalFiles( + List files, { + void Function(int sentBytes, int totalBytes, double? speedKBps)? onUploadProgress, +}) async { try { - var response = await makeMultipartApiCall(url: '${Env.apiBaseUrl}v1/sync-local-files', files: files); + var response = await makeMultipartApiCall( + url: '${Env.apiBaseUrl}v1/sync-local-files', + files: files, + onUploadProgress: onUploadProgress, + ); if (response.statusCode == 200) { Logger.debug('syncLocalFile Response body: ${jsonDecode(response.body)}'); diff --git a/app/lib/backend/http/shared.dart b/app/lib/backend/http/shared.dart index cc45b4cb93..a3d925dd7e 100644 --- a/app/lib/backend/http/shared.dart +++ b/app/lib/backend/http/shared.dart @@ -162,14 +162,52 @@ Future _buildMultipartRequest({ required Map fields, required String fileFieldName, required String method, + void Function(int sentBytes, int totalBytes, double? speedKBps)? onUploadProgress, + Duration progressUpdateInterval = const Duration(milliseconds: 200), }) async { var request = http.MultipartRequest(method, Uri.parse(url)); request.headers.addAll(headers); request.fields.addAll(fields); + final fileLengths = {}; + var totalFileBytes = 0; for (var file in files) { - var stream = http.ByteStream(file.openRead()); - var length = await file.length(); + final length = await file.length(); + fileLengths[file.path] = length; + totalFileBytes += length; + } + + var uploadedBytes = 0; + final speedStopwatch = Stopwatch()..start(); + DateTime? lastProgressEmission; + + for (var file in files) { + var length = fileLengths[file.path] ?? await file.length(); + var rawStream = file.openRead(); + var stream = http.ByteStream(rawStream.transform( + StreamTransformer, List>.fromHandlers( + handleData: (chunk, sink) { + uploadedBytes += chunk.length; + + if (onUploadProgress != null) { + final now = DateTime.now(); + final shouldEmit = lastProgressEmission == null || + now.difference(lastProgressEmission!) >= progressUpdateInterval || + uploadedBytes >= totalFileBytes; + + if (shouldEmit) { + lastProgressEmission = now; + final elapsedMs = speedStopwatch.elapsedMilliseconds; + final speedKBps = elapsedMs > 0 ? (uploadedBytes / 1024) / (elapsedMs / 1000) : null; + onUploadProgress(uploadedBytes, totalFileBytes, speedKBps); + } + } + + sink.add(chunk); + }, + ), + )); + var multipartFile = http.MultipartFile(fileFieldName, stream, length, filename: basename(file.path)); request.files.add(multipartFile); } @@ -184,6 +222,8 @@ Future makeMultipartApiCall({ Map fields = const {}, String fileFieldName = 'files', String method = 'POST', + void Function(int sentBytes, int totalBytes, double? speedKBps)? onUploadProgress, + Duration progressUpdateInterval = const Duration(milliseconds: 200), }) async { try { final bool requireAuthCheck = _isRequiredAuthCheck(url); @@ -196,6 +236,8 @@ Future makeMultipartApiCall({ fields: fields, fileFieldName: fileFieldName, method: method, + onUploadProgress: onUploadProgress, + progressUpdateInterval: progressUpdateInterval, ); var streamedResponse = await HttpPoolManager.instance.sendStreaming(request); @@ -213,6 +255,8 @@ Future makeMultipartApiCall({ fields: fields, fileFieldName: fileFieldName, method: method, + onUploadProgress: onUploadProgress, + progressUpdateInterval: progressUpdateInterval, ); streamedResponse = await HttpPoolManager.instance.sendStreaming(request); response = await http.Response.fromStream(streamedResponse); @@ -235,6 +279,16 @@ Future makeMultipartApiCall({ } } + if (onUploadProgress != null) { + var totalFileBytes = 0; + for (var file in files) { + totalFileBytes += await file.length(); + } + if (totalFileBytes > 0) { + onUploadProgress(totalFileBytes, totalFileBytes, null); + } + } + return response; } catch (e, stackTrace) { Logger.debug('Multipart HTTP request failed: $e, $stackTrace'); diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index afbaf01173..afda544cbb 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -78,6 +78,16 @@ class SharedPreferencesUtil { String get deviceName => getString('deviceName'); + bool getDeviceAutoSyncEnabled(String deviceId) { + if (deviceId.isEmpty) return true; + return getBool('deviceAutoSyncEnabled:$deviceId', defaultValue: true); + } + + Future setDeviceAutoSyncEnabled(String deviceId, bool enabled) async { + if (deviceId.isEmpty) return false; + return await saveBool('deviceAutoSyncEnabled:$deviceId', enabled); + } + bool get deviceIsV2 => getBool('deviceIsV2'); set deviceIsV2(bool value) => saveBool('deviceIsV2', value); diff --git a/app/lib/pages/conversations/sync_page.dart b/app/lib/pages/conversations/sync_page.dart index e9e02b16ea..a83badaa51 100644 --- a/app/lib/pages/conversations/sync_page.dart +++ b/app/lib/pages/conversations/sync_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -15,15 +13,11 @@ import 'package:omi/providers/user_provider.dart'; import 'package:omi/services/services.dart'; import 'package:omi/services/wals.dart'; import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; -import 'package:omi/pages/conversations/sync_widgets/wifi_connection_sheet.dart'; import 'package:omi/utils/device.dart'; import 'package:omi/utils/other/temp.dart'; import 'package:omi/utils/other/time_utils.dart'; -import 'fast_transfer_settings_page.dart'; import 'local_storage_page.dart'; import 'private_cloud_sync_page.dart'; -import 'sync_widgets/fast_transfer_suggestion_dialog.dart'; -import 'sync_widgets/location_permission_dialog.dart'; import 'synced_conversations_page.dart'; import 'wal_item_detail/wal_item_detail_page.dart'; @@ -369,47 +363,13 @@ class _SyncPageState extends State { ); } - Widget _buildSettingsCard({required bool showTransferMethod}) { + Widget _buildSettingsCard() { final isPhoneStorageOn = SharedPreferencesUtil().unlimitedLocalStorageEnabled; - final preferredSyncMethod = SharedPreferencesUtil().preferredSyncMethod; - final isFastTransfer = preferredSyncMethod == 'wifi'; return Container( decoration: BoxDecoration(color: const Color(0xFF1C1C1E), borderRadius: BorderRadius.circular(20)), child: Column( children: [ - if (showTransferMethod) ...[ - Builder( - builder: (context) { - return _buildSettingsItem( - icon: FontAwesomeIcons.bolt, - title: context.l10n.transferMethod, - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isFastTransfer ? Colors.blue.withOpacity(0.2) : const Color(0xFF2A2A2E), - borderRadius: BorderRadius.circular(100), - ), - child: Text( - isFastTransfer ? context.l10n.fast : context.l10n.ble, - style: TextStyle( - color: isFastTransfer ? Colors.blue : Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - showChevron: true, - onTap: () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => const FastTransferSettingsPage())) - .then((_) => setState(() {})); - }, - ); - }, - ), - const Divider(height: 1, color: Color(0xFF3C3C43)), - ], Builder( builder: (context) { return _buildSettingsItem( @@ -557,33 +517,6 @@ class _SyncPageState extends State { void _handleSyncWals(BuildContext context, SyncProvider syncProvider) async { final sdCardWals = syncProvider.missingWals.where((wal) => wal.storage == WalStorage.sdcard).toList(); - if (Platform.isIOS && sdCardWals.isNotEmpty) { - var preferredMethod = SharedPreferencesUtil().preferredSyncMethod; - final wifiSupported = await ServiceManager.instance().wal.getSyncs().sdcard.isWifiSyncSupported(); - - if (preferredMethod == 'ble' && wifiSupported) { - if (!context.mounted) return; - final result = await FastTransferSuggestionDialog.show(context); - if (result == null) { - return; - } else if (result == 'switch') { - SharedPreferencesUtil().preferredSyncMethod = 'wifi'; - preferredMethod = 'wifi'; - if (context.mounted) { - setState(() {}); - } - } - } - - if (preferredMethod == 'wifi' && wifiSupported) { - if (!context.mounted) return; - final hasPermission = await LocationPermissionHelper.checkAndRequest(context); - if (!hasPermission) { - return; - } - } - } - if (sdCardWals.isNotEmpty) { // Show SD card warning dialog if (context.mounted) { @@ -594,20 +527,8 @@ class _SyncPageState extends State { } } - bool _isWifiSyncError(String errorMessage) { - final lowerMessage = errorMessage.toLowerCase(); - return lowerMessage.contains('wifi') || - lowerMessage.contains('hotspot') || - lowerMessage.contains('ssid') || - lowerMessage.contains('password') || - lowerMessage.contains('tcp'); - } - String _formatErrorMessage(BuildContext context, String errorMessage) { // Clean up exception prefixes - if (errorMessage.startsWith('WifiSyncException: ')) { - errorMessage = errorMessage.substring('WifiSyncException: '.length); - } if (errorMessage.startsWith('Exception: ')) { errorMessage = errorMessage.substring('Exception: '.length); } @@ -620,30 +541,9 @@ class _SyncPageState extends State { lowerMessage.contains('packet length')) { return context.l10n.wifiEnableFailed; } - if (lowerMessage.contains('does not support wifi')) { - return context.l10n.deviceNoFastTransfer; - } - if (errorMessage.contains('Hotspot name must be') || errorMessage.contains('Password must be')) { - return errorMessage; - } - if (lowerMessage.contains('hotspot') && lowerMessage.contains('enable')) { - return context.l10n.enableHotspotMessage; - } - if (lowerMessage.contains('tcp server') || lowerMessage.contains('network server')) { - return context.l10n.transferStartFailed; - } if (lowerMessage.contains('timeout') || lowerMessage.contains('did not respond')) { return context.l10n.deviceNotResponding; } - if (lowerMessage.contains('credentials')) { - return context.l10n.invalidWifiCredentials; - } - if (lowerMessage.contains('connection') && lowerMessage.contains('fail')) { - return context.l10n.wifiConnectionFailed; - } - if (lowerMessage.contains('wifi') && lowerMessage.contains('fail')) { - return context.l10n.wifiConnectionFailed; - } return errorMessage; } @@ -673,7 +573,7 @@ class _SyncPageState extends State { TextButton( onPressed: () { Navigator.of(context).pop(); - _startSyncWithWifiSheet(context, syncProvider); + syncProvider.syncWals(); }, child: Text( context.l10n.process, @@ -685,40 +585,10 @@ class _SyncPageState extends State { ); } - /// Start sync and show WiFi connection sheet if using WiFi sync - Future _startSyncWithWifiSheet(BuildContext context, SyncProvider syncProvider) async { - final preferredMethod = SharedPreferencesUtil().preferredSyncMethod; - final wifiSupported = await ServiceManager.instance().wal.getSyncs().sdcard.isWifiSyncSupported(); - final hasSDCardWals = syncProvider.missingWals.any((w) => w.storage == WalStorage.sdcard); - - if (preferredMethod == 'wifi' && wifiSupported && hasSDCardWals && context.mounted) { - WifiConnectionListenerBridge? listener; - - final controller = await WifiConnectionSheet.show( - context, - deviceName: 'Omi', - onCancel: () { - syncProvider.cancelSync(); - }, - onRetry: () { - if (listener != null) { - syncProvider.syncWals(connectionListener: listener); - } - }, - ); - - listener = WifiConnectionListenerBridge(controller); - syncProvider.syncWals(connectionListener: listener); - } else { - syncProvider.syncWals(); - } - } - Widget _buildProcessCard(SyncProvider syncProvider) { // Error state if (syncProvider.syncError != null && syncProvider.failedWal == null) { final errorMessage = syncProvider.syncError!; - final isWifiError = _isWifiSyncError(errorMessage); return Container( decoration: BoxDecoration(color: const Color(0xFF1C1C1E), borderRadius: BorderRadius.circular(20)), @@ -733,7 +603,7 @@ class _SyncPageState extends State { width: 24, height: 24, child: _buildFaIcon( - isWifiError ? FontAwesomeIcons.wifi : FontAwesomeIcons.circleExclamation, + FontAwesomeIcons.circleExclamation, color: Colors.red, ), ), @@ -743,7 +613,7 @@ class _SyncPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isWifiError ? context.l10n.wifiSyncFailed : context.l10n.processingFailed, + context.l10n.processingFailed, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), ), const SizedBox(height: 2), @@ -789,16 +659,12 @@ class _SyncPageState extends State { // Syncing state if (syncProvider.isSyncing) { - final progress = syncProvider.walBasedProgress > 0 - ? syncProvider.walBasedProgress - : syncProvider.walsSyncedProgress; + final realtimeProgress = syncProvider.walsSyncedProgress.clamp(0.0, 1.0); + final walCountProgress = syncProvider.walBasedProgress.clamp(0.0, 1.0); + final progress = realtimeProgress > walCountProgress ? realtimeProgress : walCountProgress; final speedKBps = syncProvider.syncSpeedKBps; final phase = syncProvider.syncState.phase; - // Get sync method from the currently syncing WAL - final syncingWal = syncProvider.allWals.where((w) => w.isSyncing).firstOrNull; - final isWifiSync = syncingWal?.syncMethod == SyncMethod.wifi; - // Phase-aware display properties final String phaseText; final IconData phaseIcon; @@ -851,12 +717,12 @@ class _SyncPageState extends State { width: 36, height: 36, decoration: BoxDecoration( - color: (isWifiSync ? Colors.blue : phaseColor).withOpacity(0.2), + color: Colors.deepPurple.withOpacity(0.2), borderRadius: BorderRadius.circular(10), ), child: Icon( - isWifiSync ? Icons.bolt : phaseIcon, - color: isWifiSync ? Colors.blue : phaseColor, + Icons.bluetooth, + color: Colors.deepPurpleAccent, size: 20, ), ), @@ -878,17 +744,27 @@ class _SyncPageState extends State { ), if (showSpeed && speedKBps != null && speedKBps > 0) ...[ const SizedBox(height: 4), - Text( - '${speedKBps.toStringAsFixed(1)} KB/s', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + TweenAnimationBuilder( + tween: Tween(begin: speedKBps, end: speedKBps), + duration: const Duration(milliseconds: 600), + curve: Curves.easeOut, + builder: (context, animSpeed, _) => Text( + '${animSpeed.toStringAsFixed(1)} KB/s', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + ), ), ], ], ), ), - Text( - '${(progress * 100).toInt()}%', - style: TextStyle(color: Colors.grey.shade400, fontSize: 14, fontWeight: FontWeight.w500), + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: progress), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + builder: (context, animPct, _) => Text( + '${(animPct * 100).toStringAsFixed(1)}%', + style: TextStyle(color: Colors.grey.shade400, fontSize: 14, fontWeight: FontWeight.w500), + ), ), if (showCancel) ...[ const SizedBox(width: 12), @@ -907,13 +783,18 @@ class _SyncPageState extends State { ], ), const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: progress, - backgroundColor: const Color(0xFF3C3C43), - color: isWifiSync ? Colors.blue : phaseColor, - minHeight: 4, + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: progress), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + builder: (context, animProgress, _) => ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: animProgress, + backgroundColor: const Color(0xFF3C3C43), + color: Colors.deepPurpleAccent, + minHeight: 4, + ), ), ), ], @@ -1241,12 +1122,7 @@ class _SyncPageState extends State { _buildProcessCard(syncProvider), const SizedBox(height: 16), _buildConversationsCreatedCard(syncProvider), - FutureBuilder( - future: ServiceManager.instance().wal.getSyncs().sdcard.isWifiSyncSupported(), - builder: (context, wifiSnapshot) { - return _buildSettingsCard(showTransferMethod: wifiSnapshot.data ?? false); - }, - ), + _buildSettingsCard(), const SizedBox(height: 16), _buildStatusChips(syncProvider), const SizedBox(height: 16), diff --git a/app/lib/pages/conversations/wal_item_detail/wal_item_detail_page.dart b/app/lib/pages/conversations/wal_item_detail/wal_item_detail_page.dart index 19da02c675..04bd816ab5 100644 --- a/app/lib/pages/conversations/wal_item_detail/wal_item_detail_page.dart +++ b/app/lib/pages/conversations/wal_item_detail/wal_item_detail_page.dart @@ -1,22 +1,14 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:provider/provider.dart'; -import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/models/playback_state.dart'; -import 'package:omi/pages/conversations/sync_widgets/fast_transfer_suggestion_dialog.dart'; -import 'package:omi/pages/conversations/sync_widgets/location_permission_dialog.dart'; import 'package:omi/providers/sync_provider.dart'; -import 'package:omi/services/devices/wifi_sync_error.dart'; -import 'package:omi/services/services.dart'; import 'package:omi/services/wals.dart'; -import 'package:omi/services/wifi/wifi_network_service.dart'; import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; -import 'package:omi/pages/conversations/sync_widgets/wifi_connection_sheet.dart'; import 'package:omi/utils/device.dart'; import 'package:omi/utils/other/temp.dart'; import 'package:omi/utils/other/time_utils.dart'; @@ -95,12 +87,6 @@ class _WalItemDetailPageState extends State { ); } - void _showSnackBar(String message, [Color? backgroundColor]) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message), backgroundColor: backgroundColor)); - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -262,31 +248,60 @@ class _WalItemDetailPageState extends State { // Progress indicator if (isTransferring) ...[ const SizedBox(height: 32), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: transferProgress > 0 ? transferProgress : null, - backgroundColor: Colors.grey.shade800, - color: Colors.deepPurpleAccent, - minHeight: 6, + TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: transferProgress.clamp(0.0, 1.0), ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${(transferProgress * 100).toInt()}%', - style: TextStyle(color: Colors.grey.shade400, fontSize: 14, fontWeight: FontWeight.w500), - ), - if (transferSpeedKBps != null && transferSpeedKBps > 0) ...[ - const SizedBox(width: 16), - Text( - '${transferSpeedKBps.toStringAsFixed(1)} KB/s', - style: TextStyle(color: Colors.grey.shade500, fontSize: 14), - ), - ], - ], + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + builder: (context, animProgress, _) { + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: animProgress > 0 ? animProgress : null, + backgroundColor: Colors.grey.shade800, + color: Colors.deepPurpleAccent, + minHeight: 6, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${(animProgress * 100).toStringAsFixed(1)}%', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (transferSpeedKBps != null && transferSpeedKBps > 0) ...[ + const SizedBox(width: 16), + TweenAnimationBuilder( + tween: Tween( + begin: transferSpeedKBps, + end: transferSpeedKBps, + ), + duration: const Duration(milliseconds: 600), + curve: Curves.easeOut, + builder: (context, animSpeed, _) => Text( + '${animSpeed.toStringAsFixed(1)} KB/s', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + ), + ), + ], + ], + ), + ], + ); + }, ), if (transferEtaSeconds != null && transferEtaSeconds > 0) ...[ const SizedBox(height: 8), @@ -302,32 +317,6 @@ class _WalItemDetailPageState extends State { ), ), - // Transfer button - Padding( - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 42), - child: SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: isTransferring ? _handleCancelTransfer : _handleTransferToPhone, - style: ElevatedButton.styleFrom( - backgroundColor: isTransferring ? Colors.orange : Colors.deepPurpleAccent, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(isTransferring ? Icons.close : Icons.download, color: Colors.white, size: 22), - const SizedBox(width: 12), - Text( - isTransferring ? context.l10n.cancelTransfer : context.l10n.transferToPhone, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ), - ), ], ); }, @@ -485,120 +474,6 @@ class _WalItemDetailPageState extends State { ); } - Future _handleTransferToPhone() async { - final preferredMethod = SharedPreferencesUtil().preferredSyncMethod; - final wifiSupported = await ServiceManager.instance().wal.getSyncs().sdcard.isWifiSyncSupported(); - - bool wifiHardwareAvailable = false; - if (wifiSupported && widget.wal.storage == WalStorage.sdcard) { - wifiHardwareAvailable = await _checkWifiHardwareAvailable(); - if (!wifiHardwareAvailable && preferredMethod == 'wifi') { - SharedPreferencesUtil().preferredSyncMethod = 'ble'; - if (mounted) { - _showSnackBar(context.l10n.deviceDoesNotSupportWifiSwitchingToBle, Colors.orange); - } - } - } - - if (preferredMethod == 'ble' && wifiHardwareAvailable && widget.wal.storage == WalStorage.sdcard) { - if (!mounted) return; - final result = await FastTransferSuggestionDialog.show(context); - if (result == null) return; - - if (result == 'switch') { - // User wants to switch to Fast Transfer - SharedPreferencesUtil().preferredSyncMethod = 'wifi'; - if (!mounted) return; - _showSnackBar(context.l10n.switchedToFastTransfer, Colors.green); - } - } - - final currentMethod = SharedPreferencesUtil().preferredSyncMethod; - if (Platform.isIOS && widget.wal.storage == WalStorage.sdcard) { - if (currentMethod == 'wifi' && wifiHardwareAvailable) { - if (!mounted) return; - final hasPermission = await LocationPermissionHelper.checkAndRequest(context); - if (!hasPermission) { - return; - } - } - } - - if (!mounted) return; - - try { - final syncProvider = context.read(); - final currentMethod = SharedPreferencesUtil().preferredSyncMethod; - - // Show WiFi connection sheet if using WiFi for SD card transfer - if (currentMethod == 'wifi' && wifiHardwareAvailable && widget.wal.storage == WalStorage.sdcard && mounted) { - WifiConnectionListenerBridge? listener; - - final sheetController = await WifiConnectionSheet.show( - context, - deviceName: 'Omi', - onCancel: () { - syncProvider.cancelSync(); - }, - onRetry: () { - if (listener != null) { - syncProvider.transferWalToPhone(widget.wal, connectionListener: listener); - } - }, - ); - - listener = WifiConnectionListenerBridge(sheetController); - await syncProvider.transferWalToPhone(widget.wal, connectionListener: listener); - } else { - await syncProvider.transferWalToPhone(widget.wal); - } - - if (mounted) { - _showSnackBar(context.l10n.transferCompleteMessage, Colors.green); - Navigator.of(context).pop(); - } - } catch (e) { - if (mounted) { - _showSnackBar(context.l10n.transferFailedMessage(e.toString()), Colors.red); - } - } - } - - Future _checkWifiHardwareAvailable() async { - try { - final connection = await ServiceManager.instance().device.ensureConnection(widget.wal.device); - if (connection == null) { - return true; - } - - final ssid = WifiNetworkService.generateSsid(widget.wal.device); - final password = WifiNetworkService.generatePassword(widget.wal.device); - - final result = await connection.setupWifiSync(ssid, password); - - if (!result.success && result.errorCode == WifiSyncErrorCode.wifiHardwareNotAvailable) { - return false; - } - - if (result.success) { - await connection.stopWifiSync(); - } - - return true; - } catch (e) { - debugPrint('Error checking WiFi hardware: $e'); - return true; - } - } - - void _handleCancelTransfer() { - final syncProvider = context.read(); - syncProvider.cancelSync(); - _showSnackBar(context.l10n.transferCancelled, Colors.orange); - // Pop back since the WAL state will change - Navigator.of(context).pop(); - } - Future _handlePlayPause(SyncProvider syncProvider) async { await syncProvider.toggleWalPlayback(widget.wal); } @@ -632,23 +507,7 @@ class _WalItemDetailPageState extends State { _showFileDetailsDialog(context); }, ), - if (_needsTransfer) ...[ - ListTile( - leading: Icon(Icons.download, color: isTransferring ? Colors.grey : Colors.white), - title: Text( - isTransferring ? context.l10n.transferInProgress : context.l10n.transferToPhone, - style: Theme.of( - sheetContext, - ).textTheme.bodyMedium!.copyWith(color: isTransferring ? Colors.grey : Colors.white), - ), - onTap: isTransferring - ? null - : () { - Navigator.pop(sheetContext); - _handleTransferToPhone(); - }, - ), - ] else ...[ + if (!_needsTransfer) ...[ ListTile( leading: const FaIcon(FontAwesomeIcons.share, color: Colors.white, size: 18), title: Text(context.l10n.shareRecording, style: Theme.of(sheetContext).textTheme.bodyMedium), diff --git a/app/lib/pages/settings/device_settings.dart b/app/lib/pages/settings/device_settings.dart index b86f8785a7..95c345a348 100644 --- a/app/lib/pages/settings/device_settings.dart +++ b/app/lib/pages/settings/device_settings.dart @@ -40,6 +40,7 @@ class _DeviceSettingsState extends State { // WiFi sync state bool _isWifiSupported = false; + bool _autoSyncEnabled = true; Timer? _debounce; Timer? _micGainDebounce; @@ -131,15 +132,36 @@ class _DeviceSettingsState extends State { } final wifiSupported = await connection.isWifiSyncSupported(); + final autoSyncEnabled = SharedPreferencesUtil().getDeviceAutoSyncEnabled(deviceProvider.pairedDevice!.id); if (mounted) { setState(() { _isWifiSupported = wifiSupported; + _autoSyncEnabled = autoSyncEnabled; }); } } } } + Future _updateAutoSync(bool enabled) async { + final deviceProvider = context.read(); + final device = deviceProvider.pairedDevice; + if (device == null) { + return; + } + + await SharedPreferencesUtil().setDeviceAutoSyncEnabled(device.id, enabled); + + if (!enabled) { + try { + var connection = await ServiceManager.instance().device.ensureConnection(device.id); + await connection?.stopStorageSync(); + } catch (e) { + Logger.debug('DeviceSettings: failed to stop storage auto-sync: $e'); + } + } + } + void _updateDimRatio(double value) async { final deviceProvider = context.read(); if (deviceProvider.pairedDevice != null) { @@ -331,6 +353,41 @@ class _DeviceSettingsState extends State { ); } + Widget _buildProfileStyleSwitchItem({ + required IconData icon, + required String title, + required bool value, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + SizedBox(width: 24, height: 24, child: FaIcon(icon, color: const Color(0xFF8E8E93), size: 20)), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w400), + ), + ), + Text( + value ? context.l10n.on : context.l10n.off, + style: const TextStyle(color: Color(0xFF8E8E93), fontSize: 13, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 8), + Switch.adaptive( + value: value, + onChanged: onChanged, + activeColor: Colors.white, + activeTrackColor: const Color(0xFF2A2A2E), + inactiveTrackColor: const Color(0xFF2A2A2E), + ), + ], + ), + ); + } + String _getDoubleTapActionLabel(int action) { switch (action) { case 0: @@ -678,6 +735,18 @@ class _DeviceSettingsState extends State { chipValue: _getDoubleTapActionLabel(doubleTapAction), onTap: _showDoubleTapActionSheet, ), + const Divider(height: 1, color: Color(0xFF3C3C43)), + _buildProfileStyleSwitchItem( + icon: FontAwesomeIcons.arrowsRotate, + title: '${context.l10n.auto} ${context.l10n.sync}', + value: _autoSyncEnabled, + onChanged: (value) async { + setState(() { + _autoSyncEnabled = value; + }); + await _updateAutoSync(value); + }, + ), // LED Brightness if (_isDimRatioLoaded && _hasDimmingFeature == true) ...[ const Divider(height: 1, color: Color(0xFF3C3C43)), diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 593ac39755..a6122f3ebe 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -73,9 +73,7 @@ class AuthenticationProvider extends BaseProvider { }); } - bool isSignedIn() { - return _auth.currentUser != null && !_auth.currentUser!.isAnonymous; - } + bool isSignedIn() => _auth.currentUser != null && !_auth.currentUser!.isAnonymous; void setLoading(bool value) { _loading = value; diff --git a/app/lib/providers/device_provider.dart b/app/lib/providers/device_provider.dart index 646950405d..1b24a90558 100644 --- a/app/lib/providers/device_provider.dart +++ b/app/lib/providers/device_provider.dart @@ -474,6 +474,17 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption ServiceManager.instance().wal.getSyncs().sdcard.setDevice(device); ServiceManager.instance().wal.getSyncs().flashPage.setDevice(device); + final autoSyncEnabled = SharedPreferencesUtil().getDeviceAutoSyncEnabled(device.id); + if (!autoSyncEnabled) { + try { + final connection = await ServiceManager.instance().device.ensureConnection(device.id); + await connection?.stopStorageSync(); + Logger.debug('DeviceProvider: storage auto-sync disabled by app setting for ${device.id}'); + } catch (e) { + Logger.debug('DeviceProvider: failed to disable storage auto-sync on connect: $e'); + } + } + notifyListeners(); // Check firmware updates diff --git a/app/lib/providers/sync_provider.dart b/app/lib/providers/sync_provider.dart index b96349306f..f1642cbcf5 100644 --- a/app/lib/providers/sync_provider.dart +++ b/app/lib/providers/sync_provider.dart @@ -263,16 +263,7 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy String _formatSyncError(dynamic error, Wal? wal) { var baseMessage = error.toString().replaceAll('Exception: ', '').replaceAll('WifiSyncException: ', ''); - // Convert technical WiFi errors to user-friendly messages - if (baseMessage.toLowerCase().contains('internal error') || - baseMessage.toLowerCase().contains('invalidpacketlength') || - baseMessage.toLowerCase().contains('packet length')) { - baseMessage = 'Failed to enable WiFi on device'; - } else if (baseMessage.toLowerCase().contains('wifi') && baseMessage.toLowerCase().contains('setup')) { - baseMessage = 'Failed to enable WiFi on device'; - } else if (baseMessage.toLowerCase().contains('tcp') || baseMessage.toLowerCase().contains('socket')) { - baseMessage = 'Connection interrupted'; - } else if (baseMessage.toLowerCase().contains('timeout')) { + if (baseMessage.toLowerCase().contains('timeout')) { baseMessage = 'Device did not respond'; } else if (baseMessage.toLowerCase().contains('could not be processed')) { baseMessage = 'Audio file could not be processed'; @@ -423,24 +414,15 @@ class SyncProvider extends ChangeNotifier implements IWalServiceListener, IWalSy } } - /// Transfer a single WAL from device storage (SD card or flash page) to phone storage + /// Transfer device storage recordings to phone. + /// Per-file sync is no longer supported - this redirects to syncAll. Future transferWalToPhone(Wal wal, {IWifiConnectionListener? connectionListener}) async { if (wal.storage != WalStorage.sdcard && wal.storage != WalStorage.flashPage) { throw Exception('This recording is already on phone'); } - // Set sync state to syncing so progress updates are processed - _updateSyncState(_syncState.toSyncing()); - - try { - await _walService.getSyncs().syncWal(wal: wal, progress: this, connectionListener: connectionListener); - await refreshWals(); - _updateSyncState(_syncState.toIdle()); - } catch (e) { - await refreshWals(); - _updateSyncState(_syncState.toIdle()); - rethrow; - } + // Redirect to sync all files (no per-file sync) + await syncWals(connectionListener: connectionListener); } /// Check if SD card sync is in progress diff --git a/app/lib/services/devices/device_connection.dart b/app/lib/services/devices/device_connection.dart index ae2d40f6a7..a138974520 100644 --- a/app/lib/services/devices/device_connection.dart +++ b/app/lib/services/devices/device_connection.dart @@ -157,6 +157,13 @@ abstract class DeviceConnection { // Check connection await ping(); + // Sync device time on every successful connection (best-effort). + try { + await performSyncDeviceTime(); + } catch (e) { + Logger.debug('DeviceConnection: Time sync failed (non-fatal): $e'); + } + // Update device info device = await device.getDeviceInfo(this); } catch (e) { @@ -344,6 +351,68 @@ abstract class DeviceConnection { return Future.value(false); } + /// List audio files on the device's SD card. + /// Returns a list of [StorageFile] with index, timestamp, and size. + /// Uses CMD_LIST_FILES (0x10) protocol command. + Future> listFiles() async { + if (await isConnected()) { + return await performListFiles(); + } + _showDeviceDisconnectedNotification(); + return []; + } + + Future> performListFiles() async { + return []; + } + + /// Delete a specific file on the device's SD card by index. + /// Uses CMD_DELETE_FILE (0x12) protocol command. + Future deleteFile(int fileIndex) async { + if (await isConnected()) { + return await performDeleteFile(fileIndex); + } + _showDeviceDisconnectedNotification(); + return false; + } + + Future performDeleteFile(int fileIndex) async { + return false; + } + + /// Get persisted sync state from firmware when supported. + /// On current Omi firmware this is deprecated (0x13 is STOP sync), so most + /// implementations return an empty state. + Future getSyncState() async { + if (await isConnected()) { + return await performGetSyncState(); + } + return SyncStateInfo(timestamp: 0, offset: 0); + } + + Future performGetSyncState() async { + return SyncStateInfo(timestamp: 0, offset: 0); + } + + /// Stop any active storage sync (auto-sync or manual transfer). + /// Sends STOP_COMMAND (3) to the device. + Future stopStorageSync() async { + if (await isConnected()) { + return await performStopStorageSync(); + } + return false; + } + + Future performStopStorageSync() async { + try { + await transport.writeCharacteristic(storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid, [0x03]); + return true; + } catch (e) { + Logger.debug('Failed to send STOP command: $e'); + return false; + } + } + Future getBleStorageBytesListener({ required void Function(List) onStorageBytesReceived, }) async { @@ -462,6 +531,18 @@ abstract class DeviceConnection { Future performGetMicGain(); + Future syncDeviceTime() async { + if (await isConnected()) { + return await performSyncDeviceTime(); + } + _showDeviceDisconnectedNotification(); + return false; + } + + Future performSyncDeviceTime() async { + return false; + } + Future isWifiSyncSupported() async { if (await isConnected()) { return await performIsWifiSyncSupported(); diff --git a/app/lib/services/devices/models.dart b/app/lib/services/devices/models.dart index dc5a35ffa7..7cd04ad47b 100644 --- a/app/lib/services/devices/models.dart +++ b/app/lib/services/devices/models.dart @@ -68,6 +68,31 @@ const String limitlessServiceUuid = "632de001-604c-446b-a80f-7963e950f3fb"; const String limitlessTxCharUuid = "632de002-604c-446b-a80f-7963e950f3fb"; const String limitlessRxCharUuid = "632de003-604c-446b-a80f-7963e950f3fb"; +/// Represents a file on the device's SD card storage (returned by CMD_LIST_FILES) +class StorageFile { + final int index; // 0-based file index + final int timestamp; // Unix timestamp parsed from filename + final int size; // File size in bytes + + StorageFile({required this.index, required this.timestamp, required this.size}); + + @override + String toString() => 'StorageFile(index=$index, ts=$timestamp, size=$size)'; +} + +/// Persisted sync state from firmware (if supported by a specific device protocol) +class SyncStateInfo { + final int timestamp; // Unix timestamp of the file being synced + final int offset; // Byte offset within that file + + SyncStateInfo({required this.timestamp, required this.offset}); + + bool get isEmpty => timestamp == 0 && offset == 0; + + @override + String toString() => 'SyncStateInfo(ts=$timestamp, offset=$offset)'; +} + // OmiGlass OTA Service UUIDs const String omiGlassOtaServiceUuid = "19b10010-e8f2-537e-4f6c-d104768a1214"; const String omiGlassOtaControlCharacteristicUuid = "19b10011-e8f2-537e-4f6c-d104768a1214"; diff --git a/app/lib/services/devices/omi_connection.dart b/app/lib/services/devices/omi_connection.dart index c19cf29026..967fee97ed 100644 --- a/app/lib/services/devices/omi_connection.dart +++ b/app/lib/services/devices/omi_connection.dart @@ -266,6 +266,131 @@ class OmiDeviceConnection extends DeviceConnection { } } + @override + Future performSyncDeviceTime() async { + try { + final int epochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final payload = [ + epochSeconds & 0xFF, + (epochSeconds >> 8) & 0xFF, + (epochSeconds >> 16) & 0xFF, + (epochSeconds >> 24) & 0xFF, + ]; + + await transport.writeCharacteristic(timeSyncServiceUuid, timeSyncWriteCharacteristicUuid, payload); + Logger.debug('OmiDeviceConnection: Synced device time to epoch=$epochSeconds'); + return true; + } catch (e) { + Logger.debug('OmiDeviceConnection: Error syncing device time: $e'); + return false; + } + } + + /// Send CMD_LIST_FILES (0x10) and wait for the file list notification response. + /// Response format: [count:1][ts1:4 BE][sz1:4 BE][ts2:4 BE][sz2:4 BE]... + @override + Future> performListFiles() async { + try { + final completer = Completer>(); + StreamSubscription? sub; + + final stream = + transport.getCharacteristicStream(storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid); + + 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 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(StorageFile(index: i, timestamp: timestamp, size: size)); + } + Logger.debug('OmiDeviceConnection: Listed ${files.length} files'); + completer.complete(files); + } + }); + + // Send CMD_LIST_FILES + await transport.writeCharacteristic(storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid, [0x10]); + + final result = await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + Logger.debug('OmiDeviceConnection: listFiles timeout'); + return []; + }, + ); + + await sub.cancel(); + return result; + } catch (e) { + Logger.debug('OmiDeviceConnection: Error listing files: $e'); + return []; + } + } + + /// Send CMD_DELETE_FILE (0x12) and wait for the result notification. + @override + Future performDeleteFile(int fileIndex) async { + try { + final completer = Completer(); + StreamSubscription? sub; + + final stream = + transport.getCharacteristicStream(storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid); + + sub = stream.listen((value) { + if (completer.isCompleted) return; + if (value.length == 1) { + // 0 = success, other values = error codes + Logger.debug('OmiDeviceConnection: deleteFile result: ${value[0]}'); + completer.complete(value[0] == 0); + } + }); + + // Send CMD_DELETE_FILE [0x12, fileIndex] + await transport.writeCharacteristic( + storageDataStreamServiceUuid, storageDataStreamCharacteristicUuid, [0x12, fileIndex & 0xFF]); + + final result = await completer.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + Logger.debug('OmiDeviceConnection: deleteFile timeout'); + return false; + }, + ); + + await sub.cancel(); + return result; + } catch (e) { + Logger.debug('OmiDeviceConnection: Error deleting file: $e'); + return false; + } + } + + /// Deprecated for Omi firmware. + /// Command 0x13 is now STOP sync, so querying sync-state via BLE command is disabled. + @override + Future performGetSyncState() async { + Logger.debug('OmiDeviceConnection: getSyncState is deprecated on current firmware; returning empty state'); + return SyncStateInfo(timestamp: 0, offset: 0); + } + @override Future performCameraStartPhotoController() async { try { diff --git a/app/lib/services/wals/local_wal_sync.dart b/app/lib/services/wals/local_wal_sync.dart index a25efdaaab..9fea4fa8e3 100644 --- a/app/lib/services/wals/local_wal_sync.dart +++ b/app/lib/services/wals/local_wal_sync.dart @@ -430,6 +430,8 @@ class LocalWalSyncImpl implements LocalWalSync { } files.add(file); wal.isSyncing = true; + wal.syncStartedAt = DateTime.now(); + wal.syncSpeedKBps = null; } catch (e) { wal.status = WalStatus.corrupted; corruptedCount++; @@ -443,11 +445,35 @@ class LocalWalSyncImpl implements LocalWalSync { continue; } - progress?.onWalSyncedProgress(1.0 - (left).toDouble() / wals.length); + final batchSize = right - left + 1; + final baseProgress = (left).toDouble() / wals.length; + final batchWeight = batchSize.toDouble() / wals.length; + + progress?.onWalSyncedProgress(baseProgress); listener.onWalUpdated(); try { - var partialRes = await syncLocalFiles(files); + var partialRes = await syncLocalFiles( + files, + onUploadProgress: (sentBytes, totalBytes, speedKBps) { + final batchUploadProgress = totalBytes > 0 ? sentBytes / totalBytes : 0.0; + final overallProgress = (baseProgress + (batchWeight * batchUploadProgress)).clamp(0.0, 1.0); + + for (var idx = left; idx <= right; idx++) { + if (idx < wals.length) { + final syncWal = wals[idx]; + syncWal.syncSpeedKBps = speedKBps; + if (speedKBps != null && speedKBps > 0 && totalBytes > sentBytes) { + final bytesRemaining = totalBytes - sentBytes; + syncWal.syncEtaSeconds = ((bytesRemaining / 1024) / speedKBps).round(); + } + } + } + + progress?.onWalSyncedProgress(overallProgress, speedKBps: speedKBps); + listener.onWalUpdated(); + }, + ); resp.newConversationIds.addAll( partialRes.newConversationIds.where((id) => !resp.newConversationIds.contains(id)), @@ -467,6 +493,7 @@ class LocalWalSyncImpl implements LocalWalSync { wals[j].isSyncing = false; wals[j].syncStartedAt = null; wals[j].syncEtaSeconds = null; + wals[j].syncSpeedKBps = null; listener.onWalSynced(wal); } @@ -483,6 +510,7 @@ class LocalWalSyncImpl implements LocalWalSync { wals[j].isSyncing = false; wals[j].syncStartedAt = null; wals[j].syncEtaSeconds = null; + wals[j].syncSpeedKBps = null; } } } @@ -542,6 +570,8 @@ class LocalWalSyncImpl implements LocalWalSync { } else { walFile = file; wal.isSyncing = true; + wal.syncStartedAt = DateTime.now(); + wal.syncSpeedKBps = null; } } } catch (e) { @@ -552,7 +582,19 @@ class LocalWalSyncImpl implements LocalWalSync { listener.onWalUpdated(); try { - var partialRes = await syncLocalFiles([walFile]); + var partialRes = await syncLocalFiles( + [walFile], + onUploadProgress: (sentBytes, totalBytes, speedKBps) { + final progressPercent = totalBytes > 0 ? (sentBytes / totalBytes).clamp(0.0, 1.0) : 0.0; + walToSync.syncSpeedKBps = speedKBps; + if (speedKBps != null && speedKBps > 0 && totalBytes > sentBytes) { + final bytesRemaining = totalBytes - sentBytes; + walToSync.syncEtaSeconds = ((bytesRemaining / 1024) / speedKBps).round(); + } + progress?.onWalSyncedProgress(progressPercent, speedKBps: speedKBps); + listener.onWalUpdated(); + }, + ); resp.newConversationIds.addAll( partialRes.newConversationIds.where((id) => !resp.newConversationIds.contains(id)), @@ -567,6 +609,7 @@ class LocalWalSyncImpl implements LocalWalSync { walToSync.isSyncing = false; walToSync.syncStartedAt = null; walToSync.syncEtaSeconds = null; + walToSync.syncSpeedKBps = null; DebugLogManager.logInfo('Single WAL upload succeeded', {'walId': wal.id}); listener.onWalSynced(wal); @@ -576,6 +619,7 @@ class LocalWalSyncImpl implements LocalWalSync { walToSync.isSyncing = false; walToSync.syncStartedAt = null; walToSync.syncEtaSeconds = null; + walToSync.syncSpeedKBps = null; rethrow; } diff --git a/app/lib/services/wals/sdcard_wal_sync.dart b/app/lib/services/wals/sdcard_wal_sync.dart index 926b380e19..a9f2be343b 100644 --- a/app/lib/services/wals/sdcard_wal_sync.dart +++ b/app/lib/services/wals/sdcard_wal_sync.dart @@ -15,6 +15,7 @@ import 'package:omi/services/devices/device_connection.dart'; import 'package:omi/services/devices/transports/tcp_transport.dart'; import 'package:omi/services/devices/wifi_sync_error.dart'; import 'package:omi/services/services.dart'; +import 'package:omi/services/devices/models.dart'; import 'package:omi/services/wals/wal.dart'; import 'package:omi/services/wals/wal_interfaces.dart'; import 'package:omi/services/wifi/wifi_network_service.dart'; @@ -30,6 +31,8 @@ class SDCardWalSyncImpl implements SDCardWalSync { bool _isCancelled = false; bool _isSyncing = false; + String? _activeSyncDeviceId; + bool _firmwareStopRequested = false; TcpTransport? _activeTcpTransport; Completer? _activeTransferCompleter; @override @@ -65,6 +68,12 @@ class SDCardWalSyncImpl implements SDCardWalSync { _isCancelled = true; Logger.debug("SDCardWalSync: Cancel requested, actively tearing down connections"); + final storageSub = _storageStream; + if (storageSub != null) { + unawaited(storageSub.cancel()); + } + unawaited(_requestFirmwareStopSync()); + // Actively disconnect TCP transport to stop data flow immediately _activeTcpTransport?.disconnect(); @@ -75,9 +84,34 @@ class SDCardWalSyncImpl implements SDCardWalSync { } } + Future _requestFirmwareStopSync() async { + if (_firmwareStopRequested) return; + _firmwareStopRequested = true; + + final deviceId = _activeSyncDeviceId ?? _device?.id; + if (deviceId == null || deviceId.isEmpty) { + Logger.debug("SDCardWalSync: Stop sync requested but no active device id"); + return; + } + + try { + final connection = await ServiceManager.instance().device.ensureConnection(deviceId); + if (connection == null) { + Logger.debug("SDCardWalSync: Stop sync skipped - connection unavailable"); + return; + } + final stopped = await connection.stopStorageSync(); + Logger.debug("SDCardWalSync: STOP command sent to firmware (ok=$stopped)"); + } catch (e) { + Logger.debug("SDCardWalSync: Failed to send STOP command: $e"); + } + } + void _resetSyncState() { _isCancelled = false; _isSyncing = false; + _activeSyncDeviceId = null; + _firmwareStopRequested = false; _totalBytesDownloaded = 0; _downloadStartTime = null; _currentSpeedKBps = 0.0; @@ -102,8 +136,9 @@ class SDCardWalSyncImpl implements SDCardWalSync { DateTime? _wifiSpeedWindowStart; int _wifiSpeedWindowBytes = 0; DateTime? _lastProgressUpdate; - static const Duration _speedUpdateInterval = Duration(seconds: 3); - static const Duration _progressUpdateInterval = Duration(seconds: 1); + static const Duration _speedUpdateInterval = Duration(seconds: 1); + static const Duration _progressUpdateInterval = Duration(milliseconds: 200); + static const double _speedEmaAlpha = 0.25; // EMA smoothing factor (0=sticky,1=instant) /// Returns (shouldUpdateProgress, shouldUpdateSpeed) (bool, bool) _updateWifiSpeed(int bytesDownloaded) { @@ -122,7 +157,11 @@ class SDCardWalSyncImpl implements SDCardWalSync { if (windowDuration >= _speedUpdateInterval) { final windowSeconds = windowDuration.inMilliseconds / 1000.0; if (windowSeconds > 0) { - _currentSpeedKBps = (_wifiSpeedWindowBytes / 1024) / windowSeconds; + final instantSpeed = (_wifiSpeedWindowBytes / 1024) / windowSeconds; + // EMA smoothing: blend instant speed with previous speed + _currentSpeedKBps = _currentSpeedKBps > 0 + ? _speedEmaAlpha * instantSpeed + (1 - _speedEmaAlpha) * _currentSpeedKBps + : instantSpeed; } _wifiSpeedWindowStart = now; _wifiSpeedWindowBytes = 0; @@ -168,7 +207,10 @@ class SDCardWalSyncImpl implements SDCardWalSync { _wals.removeWhere((w) => w.id == wal.id); if (_device != null) { - await _writeToStorage(_device!.id, wal.fileNum, 1, 0); + var connection = await ServiceManager.instance().device.ensureConnection(_device!.id); + if (connection != null) { + await connection.deleteFile(wal.fileNum); + } } listener.onWalUpdated(); @@ -179,7 +221,10 @@ class SDCardWalSyncImpl implements SDCardWalSync { return []; } String deviceId = _device!.id; - List wals = []; + var connection = await ServiceManager.instance().device.ensureConnection(deviceId); + if (connection == null) { + return []; + } var storageFiles = await _getStorageList(deviceId); if (storageFiles.isEmpty) { return []; @@ -188,52 +233,68 @@ class SDCardWalSyncImpl implements SDCardWalSync { if (totalBytes <= 0) { return []; } - var storageOffset = storageFiles.length < 2 ? 0 : storageFiles[1]; - if (storageOffset > totalBytes) { - Logger.debug("SDCard bad state, offset > total"); - storageOffset = 0; - } + int fileCount = storageFiles.length >= 2 ? storageFiles[1] : 0; BleAudioCodec codec = await _getAudioCodec(deviceId); - if (totalBytes - storageOffset > 10 * codec.getFramesLengthInBytes() * codec.getFramesPerSecond()) { - var seconds = ((totalBytes - storageOffset) / codec.getFramesLengthInBytes()) ~/ codec.getFramesPerSecond(); - // Use device-provided recording start timestamp if available (firmware >= 3.0.16), otherwise estimate - int timerStart; - if (_supportsTimestampMarkers() && storageFiles.length >= 3 && storageFiles[2] > 0) { - timerStart = storageFiles[2]; - } else { - timerStart = DateTime.now().millisecondsSinceEpoch ~/ 1000 - seconds; + + // New multi-file protocol only: CMD_LIST_FILES + if (fileCount > 0) { + // Stop any active auto-sync first to avoid notification conflicts + await connection.stopStorageSync(); + await Future.delayed(const Duration(milliseconds: 500)); + + List files = []; + for (int attempt = 0; attempt < 3 && files.isEmpty; attempt++) { + files = await connection.listFiles(); + if (files.isEmpty && attempt < 2) { + Logger.debug("SDCardWalSync: listFiles attempt ${attempt + 1} empty, retrying..."); + await Future.delayed(const Duration(milliseconds: 700)); + } } - Logger.debug( - 'SDCardWalSync: totalBytes=$totalBytes storageOffset=$storageOffset frameLengthInBytes=${codec.getFramesLengthInBytes()} fps=${codec.getFramesPerSecond()} calculatedSeconds=$seconds timerStart=$timerStart now=${DateTime.now().millisecondsSinceEpoch ~/ 1000}', - ); - var connection = await ServiceManager.instance().device.ensureConnection(deviceId); - if (connection == null) { - Logger.debug("SDCard: Failed to establish connection for device info"); - return wals; + if (files.isNotEmpty) { + return _buildWalsFromFileList(deviceId, codec, files); } - var pd = await _device!.getDeviceInfo(connection); - String deviceModel = pd.modelNumber.isNotEmpty ? pd.modelNumber : "Omi"; - - wals.add( - Wal( - codec: codec, - timerStart: timerStart, - status: WalStatus.miss, - storage: WalStorage.sdcard, - seconds: seconds, - storageOffset: storageOffset, - storageTotalBytes: totalBytes, - fileNum: 1, - device: _device!.id, - deviceModel: deviceModel, - totalFrames: seconds * codec.getFramesPerSecond(), - syncedFrameOffset: 0, - ), - ); + + Logger.debug("SDCardWalSync: listFiles failed while storage reports fileCount=$fileCount"); + } + + return []; + } + + /// Build WAL list from file list response (new multi-file protocol) + Future> _buildWalsFromFileList(String deviceId, BleAudioCodec codec, List files) async { + var connection = await ServiceManager.instance().device.ensureConnection(deviceId); + var pd = await _device!.getDeviceInfo(connection); + String deviceModel = pd.modelNumber.isNotEmpty ? pd.modelNumber : "Omi"; + + List wals = []; + for (var file in files) { + if (file.size <= 0) continue; + + var seconds = (file.size / codec.getFramesLengthInBytes()) ~/ codec.getFramesPerSecond(); + if (seconds < 10) continue; // Skip very small files (<10s) + + wals.add(Wal( + codec: codec, + timerStart: file.timestamp, + status: WalStatus.miss, + storage: WalStorage.sdcard, + seconds: seconds, + storageOffset: 0, + storageTotalBytes: file.size, + fileNum: file.index, + device: deviceId, + deviceModel: deviceModel, + totalFrames: seconds * codec.getFramesPerSecond(), + syncedFrameOffset: 0, + )); } + // Deterministic sync order: oldest -> newest + wals.sort((a, b) => a.timerStart.compareTo(b.timerStart)); + + Logger.debug("SDCardWalSync: Built ${wals.length} WALs from ${files.length} files"); return wals; } @@ -312,23 +373,26 @@ class SDCardWalSyncImpl implements SDCardWalSync { Future _readStorageBytesToFile( Wal wal, - Function(File f, int offset, int timerStart, int chunkFrames) callback, - ) async { + Function(File f, int offset, int timerStart, int chunkFrames) callback, { + void Function(int offset, int packetBytes)? onPacketReceived, + }) async { if (_supportsTimestampMarkers()) { return _readStorageBytesToFileWithMarkers(wal, callback); } - return _readStorageBytesToFileLegacy(wal, callback); + return _readStorageBytesToFileLegacy(wal, callback, onPacketReceived: onPacketReceived); } Future _readStorageBytesToFileLegacy( Wal wal, - Function(File f, int offset, int timerStart, int chunkFrames) callback, - ) async { + Function(File f, int offset, int timerStart, int chunkFrames) callback, { + void Function(int offset, int packetBytes)? onPacketReceived, + }) async { var deviceId = wal.device; + _activeSyncDeviceId = deviceId; int fileNum = wal.fileNum; int offset = wal.storageOffset; - Logger.debug("_readStorageBytesToFileLegacy ${offset}"); + Logger.debug("_readStorageBytesToFileLegacy offset=$offset fileNum=$fileNum"); List> bytesData = []; var chunkSize = sdcardChunkSizeSecs * 100; @@ -341,6 +405,13 @@ class SDCardWalSyncImpl implements SDCardWalSync { _storageStream = await _getBleStorageBytesListener( deviceId, onStorageBytesReceived: (List value) async { + if (_isCancelled) { + await _requestFirmwareStopSync(); + if (!completer.isCompleted) { + completer.completeError(Exception('Sync cancelled by user')); + } + return; + } if (value.isEmpty || hasError) return; if (!firstDataReceived) { @@ -349,6 +420,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { Logger.debug('First data received, timeout cancelled'); } + // Single byte = status/end signal if (value.length == 1) { Logger.debug('returned $value'); if (value[0] == 0) { @@ -366,7 +438,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { completer.complete(true); } } else { - Logger.debug('Error bit returned'); + Logger.debug('Error/status byte: ${value[0]}'); if (!completer.isCompleted) { completer.complete(true); } @@ -374,31 +446,49 @@ class SDCardWalSyncImpl implements SDCardWalSync { return; } - if (value.length == 83) { - var amount = value[3]; - bytesData.add(value.sublist(4, 4 + amount)); - offset += 80; - } else if (value.length == 440) { + int packetAudioBytes = 0; + + // New protocol: [timestamp:4 BE][audio_data:up to 440] (5-444 bytes) + if (value.length > 4) { + // Skip 4-byte timestamp prefix, extract audio data + var audioData = value.sublist(4); + var audioLen = audioData.length; + + // Parse packed opus frames from audio data: [size:1][frame:size]... var packageOffset = 0; - while (packageOffset < value.length - 1) { - var packageSize = value[packageOffset]; + while (packageOffset < audioLen - 1) { + var packageSize = audioData[packageOffset]; if (packageSize == 0) { - packageOffset += packageSize + 1; + packageOffset += 1; continue; } - if (packageOffset + 1 + packageSize >= value.length) { + if (packageOffset + 1 + packageSize > audioLen) { break; } - var frame = value.sublist(packageOffset + 1, packageOffset + 1 + packageSize); + var frame = audioData.sublist(packageOffset + 1, packageOffset + 1 + packageSize); bytesData.add(frame); packageOffset += packageSize + 1; } - offset += value.length; + packetAudioBytes = audioLen; + offset += audioLen; + } + // Fire intermediate per-packet progress (throttled by caller) + if (packetAudioBytes > 0) { + onPacketReceived?.call(offset, packetAudioBytes); + } + + // Check if we've received all expected data + if (offset >= wal.storageTotalBytes) { + Logger.debug('File transfer complete: offset=$offset >= totalBytes=${wal.storageTotalBytes}'); + if (!completer.isCompleted) { + completer.complete(true); + } } }, ); - await _writeToStorage(deviceId, fileNum, 0, offset); + // Send read command (new protocol only): CMD_READ_FILE (0x11) + await _writeToStorage(deviceId, fileNum, 0x11, offset); timeoutTimer = Timer(const Duration(seconds: 5), () { if (!firstDataReceived && !completer.isCompleted) { @@ -415,6 +505,9 @@ class SDCardWalSyncImpl implements SDCardWalSync { } catch (e) { rethrow; } finally { + if (_isCancelled) { + await _requestFirmwareStopSync(); + } await _storageStream?.cancel(); timeoutTimer.cancel(); } @@ -673,9 +766,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { int lastOffset = wal.storageOffset; int totalBytesToDownload = wal.storageTotalBytes - wal.storageOffset; - Logger.debug( - "SDCard Phase 1: Downloading ~${(totalBytesToDownload / 1024).toStringAsFixed(1)} KB to phone storage", - ); + Logger.debug("SDCard Phase 1: Downloading ~${(totalBytesToDownload / 1024).toStringAsFixed(1)} KB (protocol=new)"); DebugLogManager.logEvent('sdcard_ble_download_started', { 'walId': wal.id, 'totalBytes': totalBytesToDownload, @@ -685,27 +776,40 @@ class SDCardWalSyncImpl implements SDCardWalSync { _downloadStartTime = DateTime.now(); _totalBytesDownloaded = 0; + // Throttle intermediate progress updates to every 200 ms. + DateTime _lastBleProgressUpdate = DateTime.now(); + const Duration _bleProgressInterval = Duration(milliseconds: 200); + try { - await _readStorageBytesToFile(wal, (File file, int offset, int timerStart, int chunkFrames) async { - if (_isCancelled) { - throw Exception('Sync cancelled by user'); - } + await _readStorageBytesToFile( + wal, + (File file, int offset, int timerStart, int chunkFrames) async { + if (_isCancelled) { + throw Exception('Sync cancelled by user'); + } - int bytesInChunk = offset - lastOffset; - _updateSpeed(bytesInChunk); - await _registerSingleChunk(wal, file, timerStart, chunkFrames); - chunksDownloaded++; - lastOffset = offset; + // Speed already accumulated per-packet in onPacketReceived; no double-count here. + await _registerSingleChunk(wal, file, timerStart, chunkFrames); + chunksDownloaded++; + lastOffset = offset; - listener.onWalUpdated(); - if (updates != null) { - updates(offset, _currentSpeedKBps); - } + // Fire definitive progress at chunk boundary + if (updates != null) updates(offset, _currentSpeedKBps); + listener.onWalUpdated(); - Logger.debug( - "SDCard: Chunk $chunksDownloaded downloaded (ts: $timerStart, speed: ${_currentSpeedKBps.toStringAsFixed(1)} KB/s)", - ); - }); + Logger.debug( + "SDCard: Chunk $chunksDownloaded downloaded (ts: $timerStart, speed: ${_currentSpeedKBps.toStringAsFixed(1)} KB/s)"); + }, + onPacketReceived: (int offset, int packetBytes) { + // Per-packet intermediate progress (fired every ~440 bytes, throttled) + _updateSpeed(packetBytes); + final now = DateTime.now(); + if (now.difference(_lastBleProgressUpdate) >= _bleProgressInterval) { + _lastBleProgressUpdate = now; + if (updates != null) updates(offset, _currentSpeedKBps); + } + }, + ); } catch (e) { await _storageStream?.cancel(); Logger.debug('SDCard download failed: $e'); @@ -728,8 +832,9 @@ class SDCardWalSyncImpl implements SDCardWalSync { Logger.debug("SDCard Phase 1 complete: $chunksDownloaded chunks downloaded"); DebugLogManager.logInfo('SD card BLE download complete', {'chunks': chunksDownloaded}); - Logger.debug("SDCard Phase 3: Clearing SD card storage"); - await _writeToStorage(wal.device, wal.fileNum, 1, 0); + // Deletion is now handled by the caller (syncAll) after confirming receipt. + // For BLE: caller deletes after each file. + // For WiFi: caller deletes after all files are received. return SyncLocalFilesResponse(newConversationIds: [], updatedConversationIds: []); } @@ -770,6 +875,8 @@ class SDCardWalSyncImpl implements SDCardWalSync { IWifiConnectionListener? connectionListener, }) async { var wals = _wals.where((w) => w.status == WalStatus.miss && w.storage == WalStorage.sdcard).toList(); + // Always process oldest -> newest regardless source ordering. + wals.sort((a, b) => a.timerStart.compareTo(b.timerStart)); if (wals.isEmpty) { Logger.debug("SDCardWalSync: All synced!"); return null; @@ -780,7 +887,8 @@ class SDCardWalSyncImpl implements SDCardWalSync { var resp = SyncLocalFilesResponse(newConversationIds: [], updatedConversationIds: []); - for (var i = wals.length - 1; i >= 0; i--) { + // Iterate oldest -> newest + for (var i = 0; i < wals.length; i++) { if (_isCancelled) { Logger.debug("SDCardWalSync: Sync cancelled before processing WAL ${wals[i].id}"); break; @@ -823,6 +931,28 @@ class SDCardWalSyncImpl implements SDCardWalSync { ); wal.status = WalStatus.synced; + + // BLE sync: delete file from firmware after successful download + if (wal.fileNum >= 0) { + try { + var connection = await ServiceManager.instance().device.ensureConnection(wal.device); + if (connection != null) { + Logger.debug("SDCardWalSync: Deleting file index ${wal.fileNum} after successful BLE sync"); + bool deleted = await connection.deleteFile(wal.fileNum); + Logger.debug("SDCardWalSync: Delete file ${wal.fileNum} result: $deleted"); + + // After deletion, file indices shift for all files that had higher index. + for (var j = 0; j < wals.length; j++) { + if (j == i) continue; + if (wals[j].fileNum > wal.fileNum) { + wals[j].fileNum = wals[j].fileNum - 1; + } + } + } + } catch (e) { + Logger.debug("SDCardWalSync: Failed to delete file ${wal.fileNum}: $e (data is safe on phone)"); + } + } } catch (e) { Logger.debug("SDCardWalSync: Error syncing WAL ${wal.id}: $e"); DebugLogManager.logError(e, null, 'SD card syncAll WAL failed: ${e.toString()}', {'walId': wal.id}); @@ -854,69 +984,10 @@ class SDCardWalSyncImpl implements SDCardWalSync { IWalSyncProgressListener? progress, IWifiConnectionListener? connectionListener, }) async { - var walToSync = _wals.where((w) => w == wal).toList().first; - - _resetSyncState(); - _isSyncing = true; - - var resp = SyncLocalFilesResponse(newConversationIds: [], updatedConversationIds: []); - walToSync.isSyncing = true; - walToSync.syncStartedAt = DateTime.now(); - walToSync.syncMethod = SyncMethod.ble; - listener.onWalUpdated(); - - final storageOffsetStarts = wal.storageOffset; - final totalBytes = wal.storageTotalBytes - storageOffsetStarts; - - try { - var partialRes = await _syncWal(wal, (offset, speedKBps) { - walToSync.storageOffset = offset; - walToSync.syncSpeedKBps = speedKBps; - - final bytesRemaining = walToSync.storageTotalBytes - offset; - if (speedKBps > 0) { - walToSync.syncEtaSeconds = (bytesRemaining / 1024 / speedKBps).round(); - } - - final bytesDownloaded = offset - storageOffsetStarts; - final progressPercent = totalBytes > 0 ? bytesDownloaded / totalBytes : 0.0; - - progress?.onWalSyncedProgress(progressPercent.clamp(0.0, 1.0), speedKBps: speedKBps); - listener.onWalUpdated(); - }); - - resp.newConversationIds.addAll( - partialRes.newConversationIds.where((id) => !resp.newConversationIds.contains(id)), - ); - resp.updatedConversationIds.addAll( - partialRes.updatedConversationIds.where( - (id) => !resp.updatedConversationIds.contains(id) && !resp.newConversationIds.contains(id), - ), - ); - - wal.status = WalStatus.synced; - } catch (e) { - Logger.debug("SDCardWalSync: Error syncing WAL ${wal.id}: $e"); - DebugLogManager.logError(e, null, 'SD card single WAL sync failed: ${e.toString()}', {'walId': wal.id}); - walToSync.isSyncing = false; - walToSync.syncStartedAt = null; - walToSync.syncEtaSeconds = null; - walToSync.syncSpeedKBps = null; - walToSync.syncMethod = SyncMethod.ble; - listener.onWalUpdated(); - _resetSyncState(); - rethrow; - } - - wal.isSyncing = false; - wal.syncStartedAt = null; - wal.syncEtaSeconds = null; - wal.syncSpeedKBps = null; - wal.syncMethod = SyncMethod.ble; - - listener.onWalUpdated(); - _resetSyncState(); - return resp; + // Per-file sync is no longer supported. Redirect to syncAll which + // syncs all files oldest→newest with proper delete-after-download. + Logger.debug("SDCardWalSync: syncWal called - redirecting to syncAll (per-file sync disabled)"); + return syncAll(progress: progress, connectionListener: connectionListener); } @override @@ -961,7 +1032,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { return await connection.isWifiSyncSupported(); } - // Legacy methods - kept for interface compatibility but no longer used in AP mode + // AP-mode compatibility stubs (credentials are not used in AP flow) @override Future setWifiCredentials(String ssid, String password) async { // In AP mode, credentials are not needed - SSID is auto-generated from device ID @@ -1045,6 +1116,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { IWifiConnectionListener? connectionListener, }) async { var wals = _wals.where((w) => w.status == WalStatus.miss && w.storage == WalStorage.sdcard).toList(); + wals.sort((a, b) => a.timerStart.compareTo(b.timerStart)); if (wals.isEmpty) { Logger.debug("SDCardWalSync WiFi: All synced!"); return null; @@ -1210,29 +1282,26 @@ class SDCardWalSyncImpl implements SDCardWalSync { var resp = SyncLocalFilesResponse(newConversationIds: [], updatedConversationIds: []); - List> bytesData = []; - var bytesLeft = 0; - final bool useMarkers = _supportsTimestampMarkers(); - var chunkSize = useMarkers ? sdcardChunkSizeSecs * wal.codec.getFramesPerSecond() : sdcardChunkSizeSecs * 100; - var timerStart = wal.timerStart; - List> timestampMarkers = []; + // Per-file data collection: keyed by TIMESTAMP (not index!). + // Firmware sends files sequentially without deleting them. The 4-byte + // timestamp embedded in every data packet is the stable unique key. + Map>> fileFrames = {}; // ts → frames + Map fileSizes = {}; // ts → expected bytes (from header) + Map fileReceivedBytes = {}; // ts → received bytes + final Map walByTimestamp = {for (final item in wals) item.timerStart: item}; + int? activeFileTs; + int totalExpectedBytes = 0; + int totalReceivedBytes = 0; - final initialOffset = wal.storageOffset; - var offset = wal.storageOffset; - final totalBytes = wal.storageTotalBytes - initialOffset; + var timerStart = wal.timerStart; + // Cursor-based buffer: avoids O(N²) list copies for large transfers. List tcpBuffer = []; + int tcpBufPos = 0; + bool headerParsed = false; - // Step 7: Send command to start SD card read over BLE - debugPrint("SDCardWalSync WiFi: Step 7 - Sending start read command over BLE..."); - - final readStarted = await _writeToStorage(deviceId, wal.fileNum, 0, offset); - if (!readStarted) { - await _cleanupWifiSync(tcpTransport, wifiNetwork, ssid, connection, deviceId: deviceId); - throw WifiSyncException('Failed to start storage read'); - } - - // Step 7b: Disconnect BLE intentionally before data transfer + // Firmware auto-starts WiFi sync after WIFI_START command (no BLE read needed) + // Disconnect BLE before data transfer to free bandwidth debugPrint("SDCardWalSync WiFi: Disconnecting BLE before data transfer (expected)..."); try { ServiceManager.instance().device.setWifiSyncInProgress(true); @@ -1242,8 +1311,8 @@ class SDCardWalSyncImpl implements SDCardWalSync { } connection = null; - // Step 8: Receive and process data over WiFi - debugPrint("SDCardWalSync WiFi: Step 8 - Receiving data ($totalBytes bytes to download)"); + // Step 7: Receive and process data over WiFi + debugPrint("SDCardWalSync WiFi: Step 7 - Receiving data over TCP"); final completer = Completer(); _activeTransferCompleter = completer; @@ -1251,22 +1320,22 @@ class SDCardWalSyncImpl implements SDCardWalSync { final audioStream = tcpTransport.dataStream; - // Track position within 440-byte logical blocks - int globalBytePosition = 0; - + // Inactivity timer: if no data is received for 30 seconds, assume + // the firmware has finished (or stalled) and complete the transfer. + // This guards against byte-count mismatches that would otherwise + // cause a hang until the 5-minute overall timeout. Timer? inactivityTimer; void resetInactivityTimer() { inactivityTimer?.cancel(); - inactivityTimer = Timer(const Duration(minutes: 2), () { + inactivityTimer = Timer(const Duration(seconds: 30), () { if (!completer.isCompleted) { - debugPrint("SDCardWalSync WiFi: No data for 2 min, timing out (offset=$offset/${wal.storageTotalBytes})"); + Logger.debug("SDCardWalSync WiFi: No data for 30s \u2014 completing transfer " + "(received $totalReceivedBytes / $totalExpectedBytes bytes)"); completer.complete(); } }); } - resetInactivityTimer(); - audioSubscription = audioStream.listen( (List value) { // Check for cancellation and complete immediately if cancelled @@ -1279,164 +1348,167 @@ class SDCardWalSyncImpl implements SDCardWalSync { } if (completer.isCompleted) return; + // Reset inactivity timer on every data chunk resetInactivityTimer(); - tcpBuffer.addAll(value); - - final bufferLength = tcpBuffer.length; - // Process the buffer - format: [size1][data1][size2][data2]... - // Data is organized in 440-byte logical blocks - var packageOffset = 0; - var bytesProcessed = 0; - - while (packageOffset < bufferLength) { - var packageSize = tcpBuffer[packageOffset]; - - // Calculate position within current 440-byte block - int posInBlock = (globalBytePosition + packageOffset) % 440; - int bytesRemainingInBlock = 440 - posInBlock; + tcpBuffer.addAll(value); - // Skip zero-size markers - if (packageSize == 0) { - packageOffset += 1; - bytesProcessed = packageOffset; - continue; - } + // Periodically compact the buffer to keep memory usage in check. + if (tcpBufPos > 65536) { + tcpBuffer = tcpBuffer.sublist(tcpBufPos); + tcpBufPos = 0; + } - // Timestamp marker: 0xFF followed by 4-byte little-endian epoch (firmware >= 3.0.16) - if (useMarkers && packageSize == 0xFF && packageOffset + 5 <= bufferLength) { - var epoch = - tcpBuffer[packageOffset + 1] | - (tcpBuffer[packageOffset + 2] << 8) | - (tcpBuffer[packageOffset + 3] << 16) | - (tcpBuffer[packageOffset + 4] << 24); - packageOffset += 5; - bytesProcessed = packageOffset; - if (epoch > 0) { - timestampMarkers.add(MapEntry(bytesData.length, epoch)); - Logger.debug('SDCardWalSync WiFi: Timestamp marker: epoch=$epoch at frame ${bytesData.length}'); + // Parse header first: [0xFF][count:1][ts1:4][sz1:4]... + final int available = tcpBuffer.length - tcpBufPos; + if (!headerParsed && available > 0 && tcpBuffer[tcpBufPos] == 0xFF) { + if (available >= 2) { + int fileCount = tcpBuffer[tcpBufPos + 1]; + int headerLen = 2 + fileCount * 8; + if (available >= headerLen) { + Logger.debug("SDCardWalSync WiFi: Parsing header: $fileCount files"); + for (int i = 0; i < fileCount; i++) { + int base = tcpBufPos + 2 + i * 8; + int ts = (tcpBuffer[base] << 24) | + (tcpBuffer[base + 1] << 16) | + (tcpBuffer[base + 2] << 8) | + tcpBuffer[base + 3]; + int sz = (tcpBuffer[base + 4] << 24) | + (tcpBuffer[base + 5] << 16) | + (tcpBuffer[base + 6] << 8) | + tcpBuffer[base + 7]; + fileSizes[ts] = sz; + totalExpectedBytes += sz; + Logger.debug(" File $i: ts=$ts, size=$sz"); + + final walForTs = walByTimestamp[ts]; + if (walForTs != null && walForTs.storageTotalBytes <= 0) { + walForTs.storageTotalBytes = sz; + } + } + tcpBufPos += headerLen; + headerParsed = true; + } else { + return; // Wait for more data } - continue; + } else { + return; // Wait for more data } + } - // Check if we're in padding area at end of block - if (posInBlock > 0 && bytesRemainingInBlock < 12) { - if (packageOffset + bytesRemainingInBlock > bufferLength) { + // Parse data packets: [idx:1][ts:4BE][len:2BE][data:len] + // Key by TIMESTAMP extracted from bytes 1-4, not by idx byte 0. + // Firmware resets idx to 0 after every file delete + list refresh, so + // idx alone is ambiguous across multiple files. + if (headerParsed) { + while (tcpBuffer.length - tcpBufPos >= 7) { + // Extract timestamp from packet header (bytes 1-4 after pos) + int pktTs = (tcpBuffer[tcpBufPos + 1] << 24) | + (tcpBuffer[tcpBufPos + 2] << 16) | + (tcpBuffer[tcpBufPos + 3] << 8) | + tcpBuffer[tcpBufPos + 4]; + int dataLen = (tcpBuffer[tcpBufPos + 5] << 8) | tcpBuffer[tcpBufPos + 6]; + + // Sanity check: dataLen must fit within remaining buffer + if (dataLen <= 0 || dataLen > 8192) { + Logger.debug("SDCardWalSync WiFi: Invalid dataLen=$dataLen, resetting buffer"); + tcpBufPos = tcpBuffer.length; // discard break; } - packageOffset += bytesRemainingInBlock; - bytesProcessed = packageOffset; - continue; - } - // Check if frame would extend beyond block boundary - if (posInBlock > 0 && packageSize + 1 > bytesRemainingInBlock) { - if (packageOffset + bytesRemainingInBlock > bufferLength) { - break; + if (tcpBuffer.length - tcpBufPos < 7 + dataLen) { + break; // Wait for complete packet } - packageOffset += bytesRemainingInBlock; - bytesProcessed = packageOffset; - continue; - } - if (packageSize > 160 || packageSize < 10) { - if (posInBlock == 0) { - packageOffset += 1; - bytesProcessed = packageOffset; - } else if (bytesRemainingInBlock > 0 && packageOffset + bytesRemainingInBlock <= bufferLength) { - packageOffset += bytesRemainingInBlock; - bytesProcessed = packageOffset; - } else { - break; - } - continue; - } + var audioData = tcpBuffer.sublist(tcpBufPos + 7, tcpBufPos + 7 + dataLen); + tcpBufPos += 7 + dataLen; - // Check if we have the complete frame - if (packageOffset + 1 + packageSize > bufferLength) { - break; - } + // Parse packed opus frames: [size:1][frame:size]... + if (!fileFrames.containsKey(pktTs)) { + fileFrames[pktTs] = []; + } + var frameOffset = 0; + while (frameOffset < audioData.length - 1) { + var frameSize = audioData[frameOffset]; + if (frameSize == 0) { + frameOffset += 1; + continue; + } + if (frameOffset + 1 + frameSize > audioData.length) { + break; + } + var frame = audioData.sublist(frameOffset + 1, frameOffset + 1 + frameSize); + fileFrames[pktTs]!.add(frame); + frameOffset += frameSize + 1; + } - // Extract complete frame - var frame = tcpBuffer.sublist(packageOffset + 1, packageOffset + 1 + packageSize); - - bool validToc = - frame.isNotEmpty && - (frame[0] == 0xb8 || - frame[0] == 0xb0 || - frame[0] == 0xbc || - frame[0] == 0xf8 || - frame[0] == 0xfc || - frame[0] == 0x78 || - frame[0] == 0x7c); - - if (!validToc) { - if (posInBlock > 0 && packageOffset + bytesRemainingInBlock <= bufferLength) { - packageOffset += bytesRemainingInBlock; - bytesProcessed = packageOffset; - } else { - packageOffset += packageSize + 1; - bytesProcessed = packageOffset; + totalReceivedBytes += dataLen; + fileReceivedBytes[pktTs] = (fileReceivedBytes[pktTs] ?? 0) + dataLen; + + final walForTs = walByTimestamp[pktTs]; + if (walForTs != null) { + if (activeFileTs != pktTs) { + if (activeFileTs != null) { + final prevWal = walByTimestamp[activeFileTs!]; + if (prevWal != null) { + prevWal.isSyncing = false; + } + } + activeFileTs = pktTs; + walForTs.syncStartedAt ??= DateTime.now(); + walForTs.syncMethod = SyncMethod.wifi; + } + + walForTs.isSyncing = true; + final expectedForFile = fileSizes[pktTs] ?? walForTs.storageTotalBytes; + if (expectedForFile > 0) { + final receivedForFile = fileReceivedBytes[pktTs] ?? 0; + walForTs.storageOffset = receivedForFile > expectedForFile ? expectedForFile : receivedForFile; + } } - continue; } - - bytesData.add(frame); - - packageOffset += packageSize + 1; - bytesProcessed = packageOffset; } - // Update global position for block tracking - globalBytePosition += bytesProcessed; - - // Remove processed bytes from buffer - if (bytesProcessed > 0) { - tcpBuffer = List.from(tcpBuffer.skip(bytesProcessed)); - } - - offset += value.length; + // Update progress final (shouldUpdateProgress, shouldUpdateSpeed) = _updateWifiSpeed(value.length); - wal.storageOffset = offset; - if (shouldUpdateProgress || shouldUpdateSpeed) { - final bytesDownloaded = offset - initialOffset; - final progressPercent = totalBytes > 0 ? bytesDownloaded / totalBytes : 0.0; + final progressPercent = + totalExpectedBytes > 0 ? (totalReceivedBytes / totalExpectedBytes).clamp(0.0, 1.0) : 0.0; if (shouldUpdateSpeed && _currentSpeedKBps > 0) { wal.syncSpeedKBps = _currentSpeedKBps; - final bytesRemaining = wal.storageTotalBytes - offset; - wal.syncEtaSeconds = (bytesRemaining / 1024 / _currentSpeedKBps).round(); + final bytesRemaining = totalExpectedBytes - totalReceivedBytes; + wal.syncEtaSeconds = bytesRemaining > 0 ? (bytesRemaining / 1024 / _currentSpeedKBps).round() : 0; } - progress?.onWalSyncedProgress(progressPercent.clamp(0.0, 1.0), speedKBps: wal.syncSpeedKBps); + progress?.onWalSyncedProgress(progressPercent, speedKBps: wal.syncSpeedKBps); listener.onWalUpdated(); } - // Check if transfer is complete - if (offset >= wal.storageTotalBytes) { - // Send final progress update + // Check completion + if (totalExpectedBytes > 0 && totalReceivedBytes >= totalExpectedBytes) { _finalizeWifiSpeed(); - final bytesDownloaded = offset - initialOffset; - final progressPercent = totalBytes > 0 ? bytesDownloaded / totalBytes : 0.0; wal.syncSpeedKBps = _currentSpeedKBps; wal.syncEtaSeconds = 0; - progress?.onWalSyncedProgress(progressPercent.clamp(0.0, 1.0), speedKBps: _currentSpeedKBps); + progress?.onWalSyncedProgress(1.0, speedKBps: _currentSpeedKBps); listener.onWalUpdated(); if (!completer.isCompleted) { + inactivityTimer?.cancel(); completer.complete(); } } }, onError: (error) { if (!completer.isCompleted) { + inactivityTimer?.cancel(); completer.completeError(error); } }, onDone: () { if (!completer.isCompleted) { + inactivityTimer?.cancel(); completer.complete(); } }, @@ -1451,81 +1523,44 @@ class SDCardWalSyncImpl implements SDCardWalSync { inactivityTimer?.cancel(); - // Check if cancelled - still save any data received before cancellation final wasCancelled = _isCancelled; - final transferComplete = offset >= wal.storageTotalBytes; - - if (useMarkers) { - // Build segment boundaries from timestamp markers - List> segments = []; - int segStart = bytesLeft; - int segEpoch = timerStart; - for (var marker in timestampMarkers) { - int frameIdx = marker.key; - int epoch = marker.value; - if (frameIdx > segStart) { - segments.add([segStart, frameIdx, segEpoch]); - } - segStart = frameIdx; - segEpoch = epoch; - } - if (segStart < bytesData.length) { - segments.add([segStart, bytesData.length, segEpoch]); - } + final allFilesComplete = fileSizes.isNotEmpty && + fileSizes.entries.every((entry) => (fileReceivedBytes[entry.key] ?? 0) >= entry.value); + final fullyReceived = totalExpectedBytes > 0 && totalReceivedBytes >= totalExpectedBytes && allFilesComplete; + + if (!wasCancelled && !fullyReceived) { + throw WifiSyncException( + 'WiFi transfer incomplete: received $totalReceivedBytes / $totalExpectedBytes bytes, ' + 'filesComplete=$allFilesComplete', + ); + } - // Flush each segment, chunking within it - for (var seg in segments) { - int sStart = seg[0]; - int sEnd = seg[1]; - int sEpoch = seg[2]; - timerStart = sEpoch; - int pos = sStart; - while (sEnd - pos >= chunkSize) { - var chunk = bytesData.sublist(pos, pos + chunkSize); - var chunkFrames = chunk.length; - var chunkSecs = chunkFrames ~/ wal.codec.getFramesPerSecond(); - pos += chunkSize; - try { - var file = await _flushToDisk(wal, chunk, timerStart); - await _registerSingleChunk(wal, file, timerStart, chunkFrames); - } catch (e) { - Logger.debug('SDCardWalSync WiFi: Error flushing chunk: $e'); - } - timerStart += chunkSecs; - } - if (pos < sEnd) { - var chunk = bytesData.sublist(pos, sEnd); - var chunkFrames = chunk.length; - try { - var file = await _flushToDisk(wal, chunk, timerStart); - await _registerSingleChunk(wal, file, timerStart, chunkFrames); - } catch (e) { - Logger.debug('SDCardWalSync WiFi: Error flushing final chunk: $e'); - } - } - } - } else { - // Legacy flush: compute accurate duration from actual frame count - int totalFrames = bytesData.length; - int accurateDuration = totalFrames ~/ wal.codec.getFramesPerSecond(); - timerStart = DateTime.now().millisecondsSinceEpoch ~/ 1000 - accurateDuration; + // Flush all collected data per file + // entry.key IS the timestamp since we changed fileFrames to be ts-keyed. + var chunkSize = sdcardChunkSizeSecs * wal.codec.getFramesPerSecond(); + for (var entry in fileFrames.entries) { + int fileTimerStart = entry.key; // key is the unix timestamp + var frames = entry.value; + int bytesLeft = 0; - while (bytesData.length - bytesLeft >= chunkSize) { - var chunk = bytesData.sublist(bytesLeft, bytesLeft + chunkSize); + while (frames.length - bytesLeft >= chunkSize) { + var chunk = frames.sublist(bytesLeft, bytesLeft + chunkSize); bytesLeft += chunkSize; + fileTimerStart += sdcardChunkSizeSecs; try { - var file = await _flushToDisk(wal, chunk, timerStart); - await _registerSingleChunk(wal, file, timerStart, chunkSize); + var file = await _flushToDisk(wal, chunk, fileTimerStart); + await _registerSingleChunk(wal, file, fileTimerStart); } catch (e) { Logger.debug('SDCardWalSync WiFi: Error flushing chunk: $e'); } - timerStart += chunk.length ~/ wal.codec.getFramesPerSecond(); } - if (bytesLeft < bytesData.length) { - var chunk = bytesData.sublist(bytesLeft); + + if (bytesLeft < frames.length) { + var chunk = frames.sublist(bytesLeft); + fileTimerStart += sdcardChunkSizeSecs; try { - var file = await _flushToDisk(wal, chunk, timerStart); - await _registerSingleChunk(wal, file, timerStart, chunk.length); + var file = await _flushToDisk(wal, chunk, fileTimerStart); + await _registerSingleChunk(wal, file, fileTimerStart); } catch (e) { Logger.debug('SDCardWalSync WiFi: Error flushing final chunk: $e'); } @@ -1533,6 +1568,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { } // Step 9: Cleanup + inactivityTimer?.cancel(); debugPrint("SDCardWalSync WiFi: Step 9 - Cleanup"); _activeTcpTransport = null; _activeTransferCompleter = null; @@ -1552,11 +1588,13 @@ class SDCardWalSyncImpl implements SDCardWalSync { } ServiceManager.instance().device.setWifiSyncInProgress(false); - wal.isSyncing = false; - wal.syncStartedAt = null; - wal.syncEtaSeconds = null; - wal.syncSpeedKBps = null; - wal.syncMethod = SyncMethod.ble; + for (final item in wals) { + item.isSyncing = false; + item.syncStartedAt = null; + item.syncEtaSeconds = null; + item.syncSpeedKBps = null; + item.syncMethod = SyncMethod.ble; + } listener.onWalUpdated(); _resetSyncState(); @@ -1583,32 +1621,37 @@ class SDCardWalSyncImpl implements SDCardWalSync { debugPrint("SDCardWalSync WiFi: Could not reconnect BLE for cleanup: $e"); } - // Only clear SD card if all bytes were transferred - if (transferComplete) { + // Only mark as synced if transfer completed fully (not cancelled) + // Firmware no longer auto-deletes: app deletes after confirming receipt + if (!wasCancelled) { + for (final ts in fileFrames.keys) { + final syncedWal = walByTimestamp[ts]; + if (syncedWal != null) { + syncedWal.status = WalStatus.synced; + syncedWal.isSyncing = false; + syncedWal.syncEtaSeconds = null; + syncedWal.syncSpeedKBps = null; + } + } + + // Delete all synced files from firmware after successful WiFi transfer if (bleConnection != null) { try { - await _writeToStorage(deviceId, wal.fileNum, 1, 0); + Logger.debug("SDCardWalSync WiFi: Deleting synced files from firmware"); + // Re-list files and delete them all (they were all transferred) + var files = await bleConnection.listFiles(); + // Delete in reverse order so indices don't shift + for (var i = files.length - 1; i >= 0; i--) { + bool deleted = await bleConnection.deleteFile(i); + Logger.debug("SDCardWalSync WiFi: Delete file[$i] result: $deleted"); + } } catch (e) { - debugPrint("SDCardWalSync WiFi: Could not clear SD card storage: $e"); + Logger.debug("SDCardWalSync WiFi: Error deleting files after sync: $e (data is safe on phone)"); } - } else { - debugPrint("SDCardWalSync WiFi: Skipping SD card clear - no BLE connection"); } - wal.status = WalStatus.synced; - debugPrint("SDCardWalSync WiFi: Sync completed successfully"); - DebugLogManager.logEvent('sdcard_wifi_sync_completed', { - 'bytesTransferred': offset - initialOffset, - 'framesReceived': bytesData.length, - 'speedKBps': _currentSpeedKBps, - }); } else { - debugPrint("SDCardWalSync WiFi: Transfer incomplete ($offset/${wal.storageTotalBytes}), SD card NOT cleared"); - DebugLogManager.logWarning('SD card WiFi transfer incomplete', { - 'bytesTransferred': offset - initialOffset, - 'totalBytes': totalBytes, - 'offsetReached': offset, - 'totalRequired': wal.storageTotalBytes, - }); + // Cancelled - partial data was saved as local WAL files + debugPrint("SDCardWalSync WiFi: Cancelled - partial data saved, user can retry for remaining"); } if (bleConnection != null) { @@ -1629,11 +1672,13 @@ class SDCardWalSyncImpl implements SDCardWalSync { ServiceManager.instance().device.setWifiSyncInProgress(false); // Reset WAL sync state on error - wal.isSyncing = false; - wal.syncStartedAt = null; - wal.syncEtaSeconds = null; - wal.syncSpeedKBps = null; - wal.syncMethod = SyncMethod.ble; + for (final item in wals) { + item.isSyncing = false; + item.syncStartedAt = null; + item.syncEtaSeconds = null; + item.syncSpeedKBps = null; + item.syncMethod = SyncMethod.ble; + } listener.onWalUpdated(); await _cleanupWifiSync(tcpTransport, wifiNetwork, ssid, connection, deviceId: deviceId); diff --git a/app/lib/services/wals/wal_syncs.dart b/app/lib/services/wals/wal_syncs.dart index fd2c0d3cae..cb33e4df65 100644 --- a/app/lib/services/wals/wal_syncs.dart +++ b/app/lib/services/wals/wal_syncs.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/models/sync_state.dart'; @@ -34,8 +33,6 @@ class WalSyncs implements IWalSync { _sdcardSync.setLocalSync(_phoneSync); _flashPageSync.setLocalSync(_phoneSync); - - _sdcardSync.loadWifiCredentials(); } @override @@ -180,19 +177,8 @@ class WalSyncs implements IWalSync { progress?.onWalSyncedProgress(0.0, phase: SyncPhase.downloadingFromDevice); final missingSDCardWals = (await _sdcardSync.getMissingWals()).where((w) => w.status == WalStatus.miss).toList(); - bool usedWifi = false; if (missingSDCardWals.isNotEmpty) { - final preferredMethod = SharedPreferencesUtil().preferredSyncMethod; - final wifiSupported = await _sdcardSync.isWifiSyncSupported(); - - if (preferredMethod == 'wifi' && wifiSupported) { - usedWifi = true; - DebugLogManager.logInfo('SD card sync using WiFi', {'walCount': missingSDCardWals.length}); - await _sdcardSync.syncWithWifi(progress: progress, connectionListener: connectionListener); - } else { - DebugLogManager.logInfo('SD card sync using BLE', {'walCount': missingSDCardWals.length}); - await _sdcardSync.syncAll(progress: progress); - } + await _sdcardSync.syncAll(progress: progress); } if (_isCancelled) { @@ -212,19 +198,6 @@ class WalSyncs implements IWalSync { return resp; } - if (usedWifi) { - Logger.debug("WalSyncs: Waiting for internet after WiFi transfer..."); - DebugLogManager.logInfo('Waiting for internet after WiFi transfer'); - progress?.onWalSyncedProgress(0.0, phase: SyncPhase.waitingForInternet); - await _waitForInternet(); - } - - if (_isCancelled) { - Logger.debug("WalSyncs: Cancelled after waiting for internet"); - DebugLogManager.logWarning('Sync cancelled while waiting for internet'); - return resp; - } - // Phase 2: Upload all phone files to cloud (includes SD card and flash page downloads) Logger.debug("WalSyncs: Phase 2 - Uploading phone files to cloud"); DebugLogManager.logInfo('Sync Phase 2: Uploading phone files to cloud'); @@ -257,14 +230,7 @@ class WalSyncs implements IWalSync { }) async { if (wal.storage == WalStorage.sdcard) { progress?.onWalSyncedProgress(0.0, phase: SyncPhase.downloadingFromDevice); - final preferredMethod = SharedPreferencesUtil().preferredSyncMethod; - final wifiSupported = await _sdcardSync.isWifiSyncSupported(); - - if (preferredMethod == 'wifi' && wifiSupported) { - return await _sdcardSync.syncWithWifi(progress: progress, connectionListener: connectionListener); - } else { - return _sdcardSync.syncWal(wal: wal, progress: progress); - } + return _sdcardSync.syncWal(wal: wal, progress: progress); } else if (wal.storage == WalStorage.flashPage) { progress?.onWalSyncedProgress(0.0, phase: SyncPhase.downloadingFromDevice); return _flashPageSync.syncWal(wal: wal, progress: progress); From b4ca794a31520bbbdd4f1d8d58bd41dbbd109c47 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Sun, 22 Mar 2026 16:35:08 +0700 Subject: [PATCH 2/2] fix compile errors and format issues --- app/lib/providers/auth_provider.dart | 2 -- app/lib/services/auth_service.dart | 23 +++++++++------------- app/lib/services/wals/sdcard_wal_sync.dart | 13 +++++------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index a6122f3ebe..0310062b86 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:firebase_auth/firebase_auth.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/app/lib/services/auth_service.dart b/app/lib/services/auth_service.dart index 7b1f78116d..d2db2e7ebe 100644 --- a/app/lib/services/auth_service.dart +++ b/app/lib/services/auth_service.dart @@ -17,7 +17,6 @@ import 'package:omi/backend/http/api/users.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/env/env.dart'; import 'package:omi/utils/logger.dart'; -import 'package:omi/utils/logger.dart'; import 'package:omi/utils/platform/platform_service.dart'; class AuthService { @@ -220,8 +219,7 @@ class AuthService { Logger.debug('Starting OAuth flow for provider: $provider'); - final authUrl = - '${Env.apiBaseUrl}v1/auth/authorize' + final authUrl = '${Env.apiBaseUrl}v1/auth/authorize' '?provider=$provider' '&redirect_uri=${Uri.encodeComponent(redirectUri)}' '&state=$state'; @@ -514,15 +512,13 @@ class AuthService { Logger.debug('Desktop/Web platform detected - attempting updateProfile with caution'); // Try with a timeout to prevent hanging - await user - .updateProfile(displayName: fullName) - .timeout( - const Duration(seconds: 5), - onTimeout: () { - Logger.debug('updateProfile timed out on desktop platform'); - throw TimeoutException('updateProfile timed out', const Duration(seconds: 5)); - }, - ); + await user.updateProfile(displayName: fullName).timeout( + const Duration(seconds: 5), + onTimeout: () { + Logger.debug('updateProfile timed out on desktop platform'); + throw TimeoutException('updateProfile timed out', const Duration(seconds: 5)); + }, + ); } else { await user.updateProfile(displayName: fullName); } @@ -569,8 +565,7 @@ class AuthService { Logger.debug('Starting OAuth linking flow for provider: $provider'); - final authUrl = - '${Env.apiBaseUrl}v1/auth/authorize' + final authUrl = '${Env.apiBaseUrl}v1/auth/authorize' '?provider=$provider' '&redirect_uri=${Uri.encodeComponent(redirectUri)}' '&state=$state'; diff --git a/app/lib/services/wals/sdcard_wal_sync.dart b/app/lib/services/wals/sdcard_wal_sync.dart index a9f2be343b..c462aad9e8 100644 --- a/app/lib/services/wals/sdcard_wal_sync.dart +++ b/app/lib/services/wals/sdcard_wal_sync.dart @@ -616,8 +616,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { } // Timestamp marker: 0xFF followed by 4-byte little-endian epoch if (packageSize == 0xFF && packageOffset + 5 <= value.length) { - var epoch = - value[packageOffset + 1] | + var epoch = value[packageOffset + 1] | (value[packageOffset + 2] << 8) | (value[packageOffset + 3] << 16) | (value[packageOffset + 4] << 24); @@ -1293,8 +1292,6 @@ class SDCardWalSyncImpl implements SDCardWalSync { int totalExpectedBytes = 0; int totalReceivedBytes = 0; - var timerStart = wal.timerStart; - // Cursor-based buffer: avoids O(N²) list copies for large transfers. List tcpBuffer = []; int tcpBufPos = 0; @@ -1549,7 +1546,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { fileTimerStart += sdcardChunkSizeSecs; try { var file = await _flushToDisk(wal, chunk, fileTimerStart); - await _registerSingleChunk(wal, file, fileTimerStart); + await _registerSingleChunk(wal, file, fileTimerStart, chunk.length); } catch (e) { Logger.debug('SDCardWalSync WiFi: Error flushing chunk: $e'); } @@ -1560,7 +1557,7 @@ class SDCardWalSyncImpl implements SDCardWalSync { fileTimerStart += sdcardChunkSizeSecs; try { var file = await _flushToDisk(wal, chunk, fileTimerStart); - await _registerSingleChunk(wal, file, fileTimerStart); + await _registerSingleChunk(wal, file, fileTimerStart, chunk.length); } catch (e) { Logger.debug('SDCardWalSync WiFi: Error flushing final chunk: $e'); } @@ -1601,8 +1598,8 @@ class SDCardWalSyncImpl implements SDCardWalSync { if (wasCancelled) { debugPrint("SDCardWalSync WiFi: Cancelled - partial data saved, reconnecting BLE in background"); DebugLogManager.logWarning('SD card WiFi sync cancelled', { - 'bytesTransferred': offset - initialOffset, - 'totalBytes': totalBytes, + 'bytesTransferred': totalReceivedBytes, + 'totalBytes': totalExpectedBytes, }); // Reconnect BLE and stop WiFi sync in background _reconnectBleAfterCancel(deviceId);