mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30: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 '../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<String, String> _voiceSessionSenderKey6 = {};
|
||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||
final Map<String, int> _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<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)
|
||||
Future<void> _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
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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<MessageBubble> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user