diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index 25e607c..93721d5 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart'; import '../l10n/app_localizations.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; +import '../services/route_hash_preferences.dart'; import '../utils/log_rx_route_decoder.dart'; class PacketLogScreen extends StatefulWidget { @@ -460,26 +461,6 @@ class _PacketLogCard extends StatelessWidget { final isRx = log.direction == PacketDirection.rx; final directionColor = isRx ? Colors.green : Colors.blue; final rxInfo = log.logRxDataInfo; - final contacts = context.watch().contacts; - final connectionProvider = context.watch(); - final decodedRoute = LogRxRouteDecoder.decode(log.rawData); - final ownPublicKey = connectionProvider.deviceInfo.publicKey; - final ownName = - connectionProvider.deviceInfo.selfName ?? - connectionProvider.deviceInfo.displayName; - final resolvedPath = decodedRoute?.pathHashes - .map( - (hash) => LogRxRouteDecoder.resolveHash( - hash, - contacts: contacts, - ownPublicKey: ownPublicKey, - ownName: ownName, - ), - ) - .toList(); - final originalSender = resolvedPath != null && resolvedPath.isNotEmpty - ? resolvedPath.first - : null; return Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -604,13 +585,9 @@ class _PacketLogCard extends StatelessWidget { ), ), ], - if (isRx && decodedRoute != null) ...[ + if (isRx) ...[ const SizedBox(height: 12), - _RouteSection( - route: decodedRoute, - path: resolvedPath ?? const [], - originalSender: originalSender, - ), + _DecodedRouteSection(log: log), ], const SizedBox(height: 12), Container( @@ -755,6 +732,53 @@ class _PacketLogCard extends StatelessWidget { } } +class _DecodedRouteSection extends StatelessWidget { + final BlePacketLog log; + + const _DecodedRouteSection({required this.log}); + + @override + Widget build(BuildContext context) { + final contacts = context.watch().contacts; + final connectionProvider = context.watch(); + final ownPublicKey = connectionProvider.deviceInfo.publicKey; + final ownName = + connectionProvider.deviceInfo.selfName ?? + connectionProvider.deviceInfo.displayName; + + return FutureBuilder( + future: RouteHashPreferences.getHashSize(), + builder: (context, snapshot) { + final decodedRoute = LogRxRouteDecoder.decode( + log.rawData, + preferredHashSize: snapshot.data, + ); + if (decodedRoute == null) { + return const SizedBox.shrink(); + } + + final resolvedPath = decodedRoute.hopHashes + .map( + (hashHex) => LogRxRouteDecoder.resolveHash( + hashHex, + contacts: contacts, + ownPublicKey: ownPublicKey, + ownName: ownName, + ), + ) + .toList(); + final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first; + + return _RouteSection( + route: decodedRoute, + path: resolvedPath, + originalSender: originalSender, + ); + }, + ); + } +} + class _RouteSection extends StatelessWidget { final DecodedLogRxRoute route; final List path; @@ -802,7 +826,13 @@ class _RouteSection extends StatelessWidget { _FactCard( icon: Icons.hub, label: 'Hops', - value: '${route.pathHashes.length}', + value: '${route.hopCount}', + ), + _FactCard( + icon: Icons.tag, + label: 'Hash size', + value: + '${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}', ), if (originalSender != null) _FactCard( diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart index 006829c..2e35aac 100644 --- a/lib/utils/log_rx_route_decoder.dart +++ b/lib/utils/log_rx_route_decoder.dart @@ -4,38 +4,49 @@ import '../models/contact.dart'; class DecodedLogRxRoute { final int payloadType; - final List pathHashes; + final List pathBytes; + final int hashSize; const DecodedLogRxRoute({ required this.payloadType, - required this.pathHashes, + required this.pathBytes, + required this.hashSize, }); - int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first; + List get hopHashes => + LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize); + + int get hopCount => hopHashes.length; + + String? get originalSenderHashHex => + hopHashes.isEmpty ? null : hopHashes.first; } class ResolvedNodeHash { - final int hash; + final String hashHex; final String label; final bool isOwnNode; final bool isUniqueMatch; final int matchCount; const ResolvedNodeHash({ - required this.hash, + required this.hashHex, required this.label, required this.isOwnNode, required this.isUniqueMatch, required this.matchCount, }); - String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}'; + String get hexLabel => '0x${hashHex.toUpperCase()}'; } class LogRxRouteDecoder { const LogRxRouteDecoder._(); - static DecodedLogRxRoute? decode(Uint8List rawData) { + static DecodedLogRxRoute? decode( + Uint8List rawData, { + int? preferredHashSize, + }) { if (rawData.length < 5 || rawData[0] != 0x88) return null; final rawPacketData = rawData.sublist(3); @@ -54,28 +65,79 @@ class LogRxRouteDecoder { if (rawPacketData.length <= index) return null; final pathLen = rawPacketData[index++]; if (rawPacketData.length < index + pathLen) return null; + final pathBytes = rawPacketData.sublist(index, index + pathLen); + final hashSize = inferHashSize( + pathBytes, + preferredHashSize: preferredHashSize, + ); return DecodedLogRxRoute( payloadType: payloadType, - pathHashes: rawPacketData.sublist(index, index + pathLen), + pathBytes: pathBytes, + hashSize: hashSize, ); } + static int inferHashSize(List pathBytes, {int? preferredHashSize}) { + if (pathBytes.isEmpty) return 1; + + final normalizedPreferred = + preferredHashSize != null && + preferredHashSize >= 1 && + preferredHashSize <= 3 + ? preferredHashSize + : null; + if (normalizedPreferred != null && + pathBytes.length % normalizedPreferred == 0) { + return normalizedPreferred; + } + + for (final candidate in const [3, 2, 1]) { + if (pathBytes.length % candidate == 0) { + return candidate; + } + } + + return 1; + } + + static List splitHopHashes( + List pathBytes, { + required int hashSize, + }) { + if (pathBytes.isEmpty) return const []; + if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) { + return pathBytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .toList(); + } + + final hops = []; + for (var index = 0; index < pathBytes.length; index += hashSize) { + hops.add( + pathBytes + .sublist(index, index + hashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(), + ); + } + return hops; + } + static ResolvedNodeHash resolveHash( - int hash, { + String hashHex, { required Iterable contacts, Uint8List? ownPublicKey, String? ownName, }) { - final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty - ? ownPublicKey.first - : null; - if (ownHash == hash) { + final normalizedHashHex = hashHex.toLowerCase(); + final ownKeyHex = _bytesToHex(ownPublicKey); + if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) { final ownLabel = (ownName != null && ownName.trim().isNotEmpty) ? '$ownName (you)' : 'You'; return ResolvedNodeHash( - hash: hash, + hashHex: normalizedHashHex, label: ownLabel, isOwnNode: true, isUniqueMatch: true, @@ -84,12 +146,12 @@ class LogRxRouteDecoder { } final matches = contacts.where((contact) { - return contact.publicKey.isNotEmpty && contact.publicKey.first == hash; + return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex); }).toList(); if (matches.isEmpty) { return ResolvedNodeHash( - hash: hash, + hashHex: normalizedHashHex, label: 'Unknown', isOwnNode: false, isUniqueMatch: false, @@ -99,7 +161,7 @@ class LogRxRouteDecoder { if (matches.length == 1) { return ResolvedNodeHash( - hash: hash, + hashHex: normalizedHashHex, label: matches.first.displayName, isOwnNode: false, isUniqueMatch: true, @@ -119,11 +181,19 @@ class LogRxRouteDecoder { ? '$candidateNames +$extraCount' : candidateNames; return ResolvedNodeHash( - hash: hash, + hashHex: normalizedHashHex, label: label, isOwnNode: false, isUniqueMatch: false, matchCount: matches.length, ); } + + static String? _bytesToHex(Uint8List? bytes) { + if (bytes == null || bytes.isEmpty) return null; + return bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } } diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index f31cfbd..138740f 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -10,6 +10,8 @@ import '../../models/message.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../services/mesh_map_nodes_service.dart'; +import '../../services/route_hash_preferences.dart'; +import '../../utils/log_rx_route_decoder.dart'; class MessageTraceSheet extends StatefulWidget { final Message message; @@ -32,6 +34,7 @@ class _MessageTraceSheetState extends State { Future<_TraceResult> _loadTrace() async { final connectionProvider = context.read(); final contactsProvider = context.read(); + final preferredHashSize = await RouteHashPreferences.getHashSize(); final packetPath = _extractPathFromPacketLogs( logs: connectionProvider.bleService.packetLogs, message: widget.message, @@ -46,10 +49,14 @@ class _MessageTraceSheetState extends State { var trace = _buildTraceResult( nodes: localNodes, packetPath: packetPath, + preferredHashSize: preferredHashSize, senderPrefix: senderPrefix, recipientPrefix: recipientPrefix, ); - if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) { + if (_isCompleteTrace( + trace, + expectedRelayCount: math.max(0, widget.message.pathLen), + )) { return trace; } @@ -59,6 +66,7 @@ class _MessageTraceSheetState extends State { trace = _buildTraceResult( nodes: _mergeNodes(localNodes, remoteNodes), packetPath: packetPath, + preferredHashSize: preferredHashSize, senderPrefix: senderPrefix, recipientPrefix: recipientPrefix, ); @@ -336,9 +344,7 @@ class _MessageTraceSheetState extends State { if (trace.mode == TraceMode.packetPath) { final entries = trace.matchedPathNodes.asMap().entries.map((entry) { - final hashHex = trace.pathHashes[entry.key] - .toRadixString(16) - .padLeft(2, '0'); + final hashHex = trace.pathHashes[entry.key].toUpperCase(); return _RouteDisplayEntry( node: entry.value, label: entry.value?.name ?? 'Unknown', @@ -428,6 +434,7 @@ class _MessageTraceSheetState extends State { _TraceResult _buildTraceResult({ required List nodes, required List? packetPath, + required int preferredHashSize, required String? senderPrefix, required String? recipientPrefix, }) { @@ -435,9 +442,17 @@ class _MessageTraceSheetState extends State { final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); if (packetPath != null && packetPath.isNotEmpty) { + final hashSize = LogRxRouteDecoder.inferHashSize( + packetPath, + preferredHashSize: preferredHashSize, + ); + final hopHashes = LogRxRouteDecoder.splitHopHashes( + packetPath, + hashSize: hashSize, + ); final matched = _matchNodesFromPathHashes( nodes: nodes, - pathHashes: packetPath, + pathHashes: hopHashes, senderPrefix: senderPrefix, recipientPrefix: recipientPrefix, ); @@ -445,7 +460,7 @@ class _MessageTraceSheetState extends State { mode: TraceMode.packetPath, sender: senderNode, recipient: recipientNode, - pathHashes: packetPath, + pathHashes: hopHashes, matchedPathNodes: matched, ); } @@ -481,7 +496,9 @@ class _MessageTraceSheetState extends State { trace.matchedPathNodes.every((node) => node != null); } - final concreteCount = trace.matchedPathNodes.whereType().length; + final concreteCount = trace.matchedPathNodes + .whereType() + .length; return concreteCount >= expectedRelayCount + 2; } @@ -523,13 +540,13 @@ class _MessageTraceSheetState extends State { List _matchNodesFromPathHashes({ required List nodes, - required List pathHashes, + required List pathHashes, required String? senderPrefix, required String? recipientPrefix, }) { final result = []; for (var i = 0; i < pathHashes.length; i++) { - final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0'); + final hashHex = pathHashes[i].toLowerCase(); final candidates = nodes .where((n) => n.publicKey.startsWith(hashHex)) .toList(); @@ -618,7 +635,7 @@ class _TraceResult { final TraceMode mode; final MeshMapNode? sender; final MeshMapNode? recipient; - final List pathHashes; + final List pathHashes; final List matchedPathNodes; const _TraceResult({ @@ -645,7 +662,10 @@ class _RouteDisplayEntry { return _RouteDisplayEntry( node: node, label: node.name, - keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)), + keyLabel: node.publicKey.substring( + 0, + math.min(12, node.publicKey.length), + ), ); } } diff --git a/test/utils/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart index 6e25321..fb5a775 100644 --- a/test/utils/log_rx_route_decoder_test.dart +++ b/test/utils/log_rx_route_decoder_test.dart @@ -24,15 +24,39 @@ void main() { expect(decoded, isNotNull); expect(decoded!.payloadType, 0x01); - expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]); - expect(decoded.originalSenderHash, 0xc2); + expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); + expect(decoded.hashSize, 2); + expect(decoded.hopHashes, ['c2ba', '5fde']); + expect(decoded.originalSenderHashHex, 'c2ba'); + }); + + test('uses preferred hash size when packet length is ambiguous', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x09, + 0x06, + 0xaa, + 0xbb, + 0xcc, + 0xdd, + 0xee, + 0xff, + ]); + + final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 1); + + expect(decoded, isNotNull); + expect(decoded!.hashSize, 1); + expect(decoded.hopHashes, ['aa', 'bb', 'cc', 'dd', 'ee', 'ff']); }); }); group('LogRxRouteDecoder.resolveHash', () { test('prefers own node when hash matches device key', () { final resolved = LogRxRouteDecoder.resolveHash( - 0xc2, + 'c201', contacts: const [], ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]), ownName: 'Base', @@ -44,8 +68,10 @@ void main() { test('resolves unique contact by first public key byte', () { final resolved = LogRxRouteDecoder.resolveHash( - 0xc2, - contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)], + 'c211', + contacts: [ + _contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]), + ], ); expect(resolved.isUniqueMatch, isTrue); @@ -54,10 +80,10 @@ void main() { test('marks ambiguous matches without pretending certainty', () { final resolved = LogRxRouteDecoder.resolveHash( - 0xc2, + 'c2', contacts: [ - _contact(name: 'Alpha', keyPrefix: 0xc2), - _contact(name: 'Bravo', keyPrefix: 0xc2), + _contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]), + _contact(name: 'Bravo', keyPrefix: [0xc2, 0x22]), ], ); @@ -67,10 +93,10 @@ void main() { }); } -Contact _contact({required String name, required int keyPrefix}) { +Contact _contact({required String name, required List keyPrefix}) { return Contact( publicKey: Uint8List.fromList([ - keyPrefix, + ...keyPrefix, 0x11, 0x22, 0x33,