diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index ff376be..1bbd0f6 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -10,8 +10,10 @@ import 'voice_provider.dart'; import 'image_provider.dart' as ip; import '../services/tile_cache_service.dart'; import '../services/location_tracking_service.dart'; +import '../services/packet_capture_storage_service.dart'; import '../models/contact.dart'; import '../models/message.dart'; +import '../models/ble_packet_log.dart'; import '../utils/drawing_message_parser.dart'; import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; @@ -28,6 +30,8 @@ class AppProvider with ChangeNotifier { final TileCacheService tileCacheService; final LocationTrackingService locationTrackingService = LocationTrackingService(); + final PacketCaptureStorageService packetCaptureStorageService = + PacketCaptureStorageService(); bool _isInitialized = false; bool get isInitialized => _isInitialized; @@ -52,6 +56,9 @@ class AppProvider with ChangeNotifier { final Map _voiceSessionSenderKey6 = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; + Timer? _packetCaptureFlushTimer; + String? _lastPersistedPacketSignature; + bool _isPersistingPacketCapture = false; AppProvider({ required this.connectionProvider, @@ -72,10 +79,67 @@ class AppProvider with ChangeNotifier { _loadVoiceBandPassFilterEnabled(); _loadVoiceCompressorEnabled(); _loadVoiceLimiterEnabled(); + _startPacketCapturePersistence(); _syncDrawingsOnStartup(); // Sync drawings immediately after providers load _isInitialized = true; } + void _startPacketCapturePersistence() { + _packetCaptureFlushTimer?.cancel(); + _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { + unawaited(_flushPacketCaptureLogs()); + }); + unawaited(_flushPacketCaptureLogs()); + } + + String _packetLogSignature(BlePacketLog log) { + final prefix = log.rawData.length <= 12 + ? log.rawData + : log.rawData.sublist(0, 12); + final prefixHex = prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${log.timestamp.microsecondsSinceEpoch}|' + '${log.direction.name}|${log.responseCode ?? -1}|' + '${log.rawData.length}|$prefixHex'; + } + + Future _flushPacketCaptureLogs() async { + if (_isPersistingPacketCapture) return; + _isPersistingPacketCapture = true; + try { + final logs = connectionProvider.bleService.packetLogs; + if (logs.isEmpty) return; + + List toPersist = const []; + if (_lastPersistedPacketSignature == null) { + toPersist = logs; + } else { + final lastSig = _lastPersistedPacketSignature!; + var lastIndex = -1; + for (var i = logs.length - 1; i >= 0; i--) { + if (_packetLogSignature(logs[i]) == lastSig) { + lastIndex = i; + break; + } + } + if (lastIndex == -1) { + // In-memory log rotated or cleared; persist current window to avoid gaps. + toPersist = logs; + } else if (lastIndex < logs.length - 1) { + toPersist = logs.sublist(lastIndex + 1); + } + } + + if (toPersist.isNotEmpty) { + await packetCaptureStorageService.appendLogs(toPersist); + } + _lastPersistedPacketSignature = _packetLogSignature(logs.last); + } catch (e) { + debugPrint('❌ [AppProvider] Packet capture flush failed: $e'); + } finally { + _isPersistingPacketCapture = false; + } + } + /// Sync drawings from messages on app startup (before BLE connection) Future _syncDrawingsOnStartup() async { // Wait for MessagesProvider to finish initializing @@ -1243,6 +1307,8 @@ class AppProvider with ChangeNotifier { @override void dispose() { + _packetCaptureFlushTimer?.cancel(); + unawaited(_flushPacketCaptureLogs()); // Remove connection state listener connectionProvider.removeListener(_handleConnectionStateChange); // Clear location service callbacks diff --git a/lib/services/packet_capture_storage_service.dart b/lib/services/packet_capture_storage_service.dart new file mode 100644 index 0000000..dd7a68a --- /dev/null +++ b/lib/services/packet_capture_storage_service.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import '../models/ble_packet_log.dart'; + +class StoredPacketCapture { + final DateTime timestamp; + final String direction; + final int? responseCode; + final String? description; + final String rawBase64; + final int rawSize; + final double? snrDb; + final int? rssiDbm; + + const StoredPacketCapture({ + required this.timestamp, + required this.direction, + required this.responseCode, + required this.description, + required this.rawBase64, + required this.rawSize, + required this.snrDb, + required this.rssiDbm, + }); + + Map toJson() { + return { + 'ts': timestamp.millisecondsSinceEpoch, + 'dir': direction, + 'code': responseCode, + 'desc': description, + 'raw': rawBase64, + 'size': rawSize, + 'snr': snrDb, + 'rssi': rssiDbm, + }; + } + + static StoredPacketCapture fromJson(Map json) { + return StoredPacketCapture( + timestamp: DateTime.fromMillisecondsSinceEpoch((json['ts'] as num).toInt()), + direction: (json['dir'] as String?) ?? 'rx', + responseCode: (json['code'] as num?)?.toInt(), + description: json['desc'] as String?, + rawBase64: (json['raw'] as String?) ?? '', + rawSize: (json['size'] as num?)?.toInt() ?? 0, + snrDb: (json['snr'] as num?)?.toDouble(), + rssiDbm: (json['rssi'] as num?)?.toInt(), + ); + } +} + +/// Durable storage for raw BLE packets so future features can consume +/// historical packet bytes across app restarts. +class PacketCaptureStorageService { + static const String _fileName = 'packet_captures.jsonl'; + static const int _maxStoredPackets = 20000; + + File? _file; + + Future _resolveFile() async { + if (_file != null) return _file!; + final dir = await getApplicationSupportDirectory(); + final file = File('${dir.path}/$_fileName'); + if (!await file.exists()) { + await file.create(recursive: true); + } + _file = file; + return file; + } + + Future appendLogs(List logs) async { + if (logs.isEmpty) return; + try { + final file = await _resolveFile(); + final sink = file.openWrite(mode: FileMode.append); + for (final log in logs) { + final row = StoredPacketCapture( + timestamp: log.timestamp, + direction: log.direction.name, + responseCode: log.responseCode, + description: log.description, + rawBase64: base64Encode(log.rawData), + rawSize: log.rawData.length, + snrDb: log.logRxDataInfo?.snrDb, + rssiDbm: log.logRxDataInfo?.rssiDbm, + ); + sink.writeln(jsonEncode(row.toJson())); + } + await sink.flush(); + await sink.close(); + await _pruneIfNeeded(file); + } catch (e) { + debugPrint('❌ [PacketCaptureStorage] Failed to append logs: $e'); + } + } + + Future> loadRecent({int limit = 500}) async { + try { + final file = await _resolveFile(); + if (!await file.exists()) return const []; + final lines = await file.readAsLines(); + if (lines.isEmpty) return const []; + final start = lines.length > limit ? lines.length - limit : 0; + return lines + .sublist(start) + .where((l) => l.trim().isNotEmpty) + .map((l) => StoredPacketCapture.fromJson(jsonDecode(l) as Map)) + .toList(); + } catch (e) { + debugPrint('❌ [PacketCaptureStorage] Failed to load recent logs: $e'); + return const []; + } + } + + Future count() async { + try { + final file = await _resolveFile(); + if (!await file.exists()) return 0; + final lines = await file.readAsLines(); + return lines.where((l) => l.trim().isNotEmpty).length; + } catch (_) { + return 0; + } + } + + Future clear() async { + try { + final file = await _resolveFile(); + if (await file.exists()) { + await file.writeAsString(''); + } + } catch (e) { + debugPrint('❌ [PacketCaptureStorage] Failed to clear logs: $e'); + } + } + + Future _pruneIfNeeded(File file) async { + final lines = await file.readAsLines(); + if (lines.length <= _maxStoredPackets) return; + final keep = lines.sublist(lines.length - _maxStoredPackets); + await file.writeAsString('${keep.join('\n')}\n'); + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 8a627b8..0287978 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -551,16 +551,43 @@ class _MessageBubbleState extends State { ToastLogger.success(context, l10n.textCopiedToClipboard); } - showDialog( + showModalBottomSheet( context: context, - builder: (context) => AlertDialog( - title: Text(l10n.messageTechnicalDetails), - content: SizedBox( - width: double.maxFinite, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SafeArea( + child: SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.85, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + l10n.messageTechnicalDetails, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + ), + IconButton( + onPressed: () => Navigator.pop(sheetContext), + icon: const Icon(Icons.close), + tooltip: l10n.close, + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Wrap( spacing: 8, runSpacing: 8, @@ -839,16 +866,13 @@ class _MessageBubbleState extends State { ), ], ), - ], - ), + ], + ), + ), + ), + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(AppLocalizations.of(context)!.close), - ), - ], ), ); }