mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Add packet trace storage flow
This commit is contained in:
@@ -10,8 +10,10 @@ import 'voice_provider.dart';
|
|||||||
import 'image_provider.dart' as ip;
|
import 'image_provider.dart' as ip;
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
|
import '../services/packet_capture_storage_service.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/ble_packet_log.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
@@ -28,6 +30,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
final TileCacheService tileCacheService;
|
final TileCacheService tileCacheService;
|
||||||
final LocationTrackingService locationTrackingService =
|
final LocationTrackingService locationTrackingService =
|
||||||
LocationTrackingService();
|
LocationTrackingService();
|
||||||
|
final PacketCaptureStorageService packetCaptureStorageService =
|
||||||
|
PacketCaptureStorageService();
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
@@ -52,6 +56,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
|
Timer? _packetCaptureFlushTimer;
|
||||||
|
String? _lastPersistedPacketSignature;
|
||||||
|
bool _isPersistingPacketCapture = false;
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
@@ -72,10 +79,67 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
_loadVoiceCompressorEnabled();
|
_loadVoiceCompressorEnabled();
|
||||||
_loadVoiceLimiterEnabled();
|
_loadVoiceLimiterEnabled();
|
||||||
|
_startPacketCapturePersistence();
|
||||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||||
_isInitialized = true;
|
_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<void> _flushPacketCaptureLogs() async {
|
||||||
|
if (_isPersistingPacketCapture) return;
|
||||||
|
_isPersistingPacketCapture = true;
|
||||||
|
try {
|
||||||
|
final logs = connectionProvider.bleService.packetLogs;
|
||||||
|
if (logs.isEmpty) return;
|
||||||
|
|
||||||
|
List<BlePacketLog> 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)
|
/// Sync drawings from messages on app startup (before BLE connection)
|
||||||
Future<void> _syncDrawingsOnStartup() async {
|
Future<void> _syncDrawingsOnStartup() async {
|
||||||
// Wait for MessagesProvider to finish initializing
|
// Wait for MessagesProvider to finish initializing
|
||||||
@@ -1243,6 +1307,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_packetCaptureFlushTimer?.cancel();
|
||||||
|
unawaited(_flushPacketCaptureLogs());
|
||||||
// Remove connection state listener
|
// Remove connection state listener
|
||||||
connectionProvider.removeListener(_handleConnectionStateChange);
|
connectionProvider.removeListener(_handleConnectionStateChange);
|
||||||
// Clear location service callbacks
|
// Clear location service callbacks
|
||||||
|
|||||||
146
lib/services/packet_capture_storage_service.dart
Normal file
146
lib/services/packet_capture_storage_service.dart
Normal file
@@ -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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'ts': timestamp.millisecondsSinceEpoch,
|
||||||
|
'dir': direction,
|
||||||
|
'code': responseCode,
|
||||||
|
'desc': description,
|
||||||
|
'raw': rawBase64,
|
||||||
|
'size': rawSize,
|
||||||
|
'snr': snrDb,
|
||||||
|
'rssi': rssiDbm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static StoredPacketCapture fromJson(Map<String, dynamic> 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<File> _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<void> appendLogs(List<BlePacketLog> 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<List<StoredPacketCapture>> 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<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [PacketCaptureStorage] Failed to load recent logs: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> 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<void> clear() async {
|
||||||
|
try {
|
||||||
|
final file = await _resolveFile();
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.writeAsString('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [PacketCaptureStorage] Failed to clear logs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -551,16 +551,43 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
ToastLogger.success(context, l10n.textCopiedToClipboard);
|
ToastLogger.success(context, l10n.textCopiedToClipboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
showDialog(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
isScrollControlled: true,
|
||||||
title: Text(l10n.messageTechnicalDetails),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
content: SizedBox(
|
shape: const RoundedRectangleBorder(
|
||||||
width: double.maxFinite,
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
child: Column(
|
builder: (sheetContext) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: SizedBox(
|
||||||
children: [
|
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(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -839,16 +866,13 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(AppLocalizations.of(context)!.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user