Add packet trace storage flow

This commit is contained in:
Janez T
2026-03-05 07:50:42 +01:00
parent 40d786eb25
commit 13b91e3cc9
3 changed files with 253 additions and 17 deletions

View File

@@ -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

View 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');
}
}

View File

@@ -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),
),
],
),
);
}