diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 69c0a73..123e916 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1589,8 +1589,17 @@ class AppProvider with ChangeNotifier { return; } + final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes( + decoded.pathBytes, + hashSize: decoded.hashSize, + ); + final reversedHopHashes = LogRxRouteDecoder.splitHopHashes( + reversedPathBytes, + hashSize: decoded.hashSize, + ); + final parsedRoute = ContactRouteCodec.parse( - decoded.hopHashes.join(','), + reversedHopHashes.join(','), expectedHashSize: decoded.hashSize, ); contactsProvider.retainReceivedRoute( diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index c3fed04..81cfd50 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; import '../models/path_history.dart'; import '../models/path_selection.dart'; +import '../utils/log_rx_route_decoder.dart'; class PathHistoryService { static const String _storageKey = 'contact_path_history_v1'; @@ -83,14 +84,19 @@ class PathHistoryService { return; } + final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes( + pathBytes, + hashSize: hashSize, + ); + final history = _historyFor(contactPublicKeyHex); - final signature = pathBytes + final signature = normalizedPathBytes .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); final existing = _findDirectPath(history.directPaths, signature); final updated = PathRecord( - pathBytes: List.from(pathBytes), - hopCount: pathBytes.length ~/ hashSize, + pathBytes: normalizedPathBytes, + hopCount: normalizedPathBytes.length ~/ hashSize, hashSize: hashSize, successCount: existing?.successCount ?? 0, failureCount: existing?.failureCount ?? 0, diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart index 6742bdf..95564d3 100644 --- a/lib/utils/log_rx_route_decoder.dart +++ b/lib/utils/log_rx_route_decoder.dart @@ -154,6 +154,26 @@ class LogRxRouteDecoder { return hops; } + static List reverseHopBytes( + List pathBytes, { + required int hashSize, + }) { + if (pathBytes.isEmpty) return const []; + if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) { + return List.from(pathBytes.reversed); + } + + final reversed = []; + for ( + var index = pathBytes.length - hashSize; + index >= 0; + index -= hashSize + ) { + reversed.addAll(pathBytes.sublist(index, index + hashSize)); + } + return reversed; + } + static ResolvedNodeHash resolveHash( String hashHex, { required Iterable contacts, diff --git a/lib/utils/trace_node_resolver.dart b/lib/utils/trace_node_resolver.dart new file mode 100644 index 0000000..6a89a1d --- /dev/null +++ b/lib/utils/trace_node_resolver.dart @@ -0,0 +1,132 @@ +import 'package:latlong2/latlong.dart'; + +import '../services/mesh_map_nodes_service.dart'; + +class ResolvedTraceNode { + final MeshMapNode? node; + final int matchCount; + final bool usedOnlineFallback; + + const ResolvedTraceNode({ + required this.node, + required this.matchCount, + required this.usedOnlineFallback, + }); + + bool get hasMatch => node != null; + bool get isAmbiguous => matchCount > 1; + + String? get matchSummary { + if (matchCount <= 1) return null; + final source = usedOnlineFallback ? 'online' : 'local'; + return '$matchCount $source matches'; + } +} + +class TraceNodeResolver { + static const Distance _distance = Distance(); + + const TraceNodeResolver._(); + + static ResolvedTraceNode resolveBest({ + required List nodes, + required Set localPublicKeys, + required String? prefixHex, + LatLng? referenceA, + LatLng? referenceB, + String? preferredPrefix, + }) { + if (prefixHex == null || prefixHex.isEmpty) { + return const ResolvedTraceNode( + node: null, + matchCount: 0, + usedOnlineFallback: false, + ); + } + + final allMatches = nodes + .where((n) => n.publicKey.startsWith(prefixHex)) + .toList(); + if (allMatches.isEmpty) { + return const ResolvedTraceNode( + node: null, + matchCount: 0, + usedOnlineFallback: false, + ); + } + + final localMatches = allMatches + .where((node) => localPublicKeys.contains(node.publicKey)) + .toList(); + var pool = localMatches.isNotEmpty ? localMatches : allMatches; + final usedOnlineFallback = localMatches.isEmpty; + + if (preferredPrefix != null && preferredPrefix.isNotEmpty) { + final preferredMatches = pool + .where((node) => node.publicKey.startsWith(preferredPrefix)) + .toList(); + if (preferredMatches.isNotEmpty) { + pool = preferredMatches; + } + } + + pool.sort((a, b) { + final distanceCompare = + _scoreNode( + a, + referenceA: referenceA, + referenceB: referenceB, + ).compareTo( + _scoreNode(b, referenceA: referenceA, referenceB: referenceB), + ); + if (distanceCompare != 0) return distanceCompare; + return b.updatedAtMs.compareTo(a.updatedAtMs); + }); + + return ResolvedTraceNode( + node: pool.first, + matchCount: pool.length, + usedOnlineFallback: usedOnlineFallback, + ); + } + + static double _scoreNode( + MeshMapNode node, { + LatLng? referenceA, + LatLng? referenceB, + }) { + final point = LatLng(node.latitude, node.longitude); + if (referenceA != null && referenceB != null) { + return _distanceToSegmentMeters(point, referenceA, referenceB); + } + if (referenceA != null) { + return _distance.as(LengthUnit.Meter, point, referenceA); + } + if (referenceB != null) { + return _distance.as(LengthUnit.Meter, point, referenceB); + } + return double.maxFinite; + } + + static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) { + final ax = a.longitude; + final ay = a.latitude; + final bx = b.longitude; + final by = b.latitude; + final px = p.longitude; + final py = p.latitude; + + final abx = bx - ax; + final aby = by - ay; + final apx = px - ax; + final apy = py - ay; + final ab2 = abx * abx + aby * aby; + if (ab2 == 0) { + return _distance.as(LengthUnit.Meter, a, p); + } + var t = (apx * abx + apy * aby) / ab2; + t = t.clamp(0.0, 1.0); + final closest = LatLng(ay + aby * t, ax + abx * t); + return _distance.as(LengthUnit.Meter, closest, p); + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 827a3cf..bc5ebde 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart'; import '../../providers/sensors_provider.dart'; import '../../services/message_destination_preferences.dart'; import 'contact_route_dialog.dart'; +import 'contact_trace_sheet.dart'; import 'room_login_sheet.dart'; import '../common/contact_avatar.dart'; import '../../utils/toast_logger.dart'; @@ -363,6 +364,15 @@ class ContactTile extends StatelessWidget { _showSetRouteDialog(context, contact); }, ), + if (!contact.isChannel) + ListTile( + leading: const Icon(Icons.route), + title: const Text('Trace'), + onTap: () { + Navigator.pop(sheetContext); + _showTraceSheet(context, contact); + }, + ), if (!contact.isPublicChannel) ListTile( leading: const Icon(Icons.delete, color: Colors.red), @@ -453,6 +463,18 @@ class ContactTile extends StatelessWidget { ); } + void _showTraceSheet(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ContactTraceSheet(contact: contact), + ); + } + void _showDeleteConfirmation( BuildContext context, Contact contact, { diff --git a/lib/widgets/contacts/contact_trace_sheet.dart b/lib/widgets/contacts/contact_trace_sheet.dart new file mode 100644 index 0000000..e815288 --- /dev/null +++ b/lib/widgets/contacts/contact_trace_sheet.dart @@ -0,0 +1,525 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../services/mesh_map_nodes_service.dart'; +import '../../utils/trace_node_resolver.dart'; + +class ContactTraceSheet extends StatefulWidget { + final Contact contact; + + const ContactTraceSheet({super.key, required this.contact}); + + @override + State createState() => _ContactTraceSheetState(); +} + +class _ContactTraceSheetState extends State { + late final Future<_ContactTraceResult> _future; + + @override + void initState() { + super.initState(); + _future = _loadTrace(); + } + + Future<_ContactTraceResult> _loadTrace() async { + final connectionProvider = context.read(); + final contactsProvider = context.read(); + final localNodes = _localNodesFromContacts( + contactsProvider, + connectionProvider: connectionProvider, + ); + final localPublicKeys = localNodes.map((node) => node.publicKey).toSet(); + + var trace = _buildTraceResult( + nodes: localNodes, + localPublicKeys: localPublicKeys, + selfPublicKey: connectionProvider.deviceInfo.publicKey, + ); + if (_isCompleteTrace(trace)) { + return trace; + } + + unawaited( + MeshMapNodesService.syncInBackgroundIfStale( + cacheTtl: MeshMapNodesService.traceCacheTtl, + ), + ); + + final remoteNodes = await MeshMapNodesService.loadCachedNodes( + cacheTtl: MeshMapNodesService.traceCacheTtl, + ); + trace = _buildTraceResult( + nodes: _mergeNodes(localNodes, remoteNodes), + localPublicKeys: localPublicKeys, + selfPublicKey: connectionProvider.deviceInfo.publicKey, + ); + return trace; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: FutureBuilder<_ContactTraceResult>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const SizedBox( + height: 360, + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + return SizedBox( + height: 360, + child: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Failed to load trace: ${snapshot.error}'), + ), + ), + ); + } + + final trace = snapshot.data!; + final routeEntries = _displayRouteEntries(trace); + final concreteNodes = routeEntries + .where((entry) => entry.resolved.node != null) + .map((entry) => entry.resolved.node!) + .toList(); + final mapPoints = concreteNodes + .map((node) => LatLng(node.latitude, node.longitude)) + .toList(); + final hasMapPath = mapPoints.length >= 2; + final relayNodes = trace.matchedRelayNodes.whereType(); + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Text( + 'Trace', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + trace.routeHashes.isEmpty + ? 'Direct route to ${widget.contact.displayName}' + : 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 10), + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + height: 240, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + ), + ), + child: hasMapPath + ? flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCameraFit: + flutter_map.CameraFit.bounds( + bounds: + flutter_map + .LatLngBounds.fromPoints( + mapPoints, + ), + padding: const EdgeInsets.all(28), + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar', + ), + flutter_map.PolylineLayer( + polylines: [ + flutter_map.Polyline( + points: mapPoints, + strokeWidth: 4, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ], + ), + flutter_map.MarkerLayer( + markers: concreteNodes + .asMap() + .entries + .map( + (entry) => flutter_map.Marker( + point: LatLng( + entry.value.latitude, + entry.value.longitude, + ), + width: 34, + height: 34, + child: CircleAvatar( + radius: 16, + backgroundColor: + entry.key == 0 + ? Colors.green + : (entry.key == + concreteNodes + .length - + 1 + ? Colors.red + : Colors.blue), + child: Text( + '${entry.key + 1}', + style: const TextStyle( + color: Colors.white, + fontWeight: + FontWeight.bold, + fontSize: 11, + ), + ), + ), + ), + ) + .toList(), + ), + ], + ) + : const Center( + child: Text( + 'Not enough geolocated nodes to draw path', + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Route', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (routeEntries.isEmpty) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'No named nodes could be matched for this trace.', + ), + ), + ...routeEntries.asMap().entries.map( + (entry) => ListTile( + leading: CircleAvatar( + radius: 14, + backgroundColor: entry.key == 0 + ? Colors.green + : (entry.key == routeEntries.length - 1 + ? Colors.red + : Colors.blue), + child: Text( + '${entry.key + 1}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + title: Text(entry.value.label), + subtitle: Text( + '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}', + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Relays (${relayNodes.length})', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (relayNodes.isEmpty) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'No relay nodes could be matched for this contact.', + ), + ), + ...relayNodes.map( + (node) => ListTile( + leading: const Icon(Icons.router), + title: Text(node.name), + subtitle: Text( + '${node.publicKey.substring(0, math.min(12, node.publicKey.length))} • ' + '${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}', + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) { + final entries = <_RouteDisplayEntry>[]; + if (trace.sender.node != null) { + entries.add(_RouteDisplayEntry.fromResolved(trace.sender)); + } + entries.addAll( + trace.matchedRelayNodes.asMap().entries.map((entry) { + final resolved = entry.value; + final node = resolved.node; + final hashHex = trace.routeHashes[entry.key].toUpperCase(); + return _RouteDisplayEntry( + resolved: resolved, + label: node?.name ?? 'Unknown', + keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex, + matchSummary: resolved.matchSummary, + ); + }), + ); + if (trace.recipient.node != null) { + entries.add(_RouteDisplayEntry.fromResolved(trace.recipient)); + } + return entries; + } + + String _routeRoleLabel(int index, int total) { + if (index == 0) return 'Sender'; + if (index == total - 1) return 'Recipient'; + return 'Relay'; + } + + String _prefixKeyLabel(String publicKey) => + publicKey.substring(0, math.min(12, publicKey.length)); + + bool _isCompleteTrace(_ContactTraceResult trace) { + if (trace.sender.node == null || trace.recipient.node == null) { + return false; + } + if (trace.routeHashes.isEmpty) { + return true; + } + return trace.matchedRelayNodes.every((node) => node.node != null); + } + + _ContactTraceResult _buildTraceResult({ + required List nodes, + required Set localPublicKeys, + required List? selfPublicKey, + }) { + final senderNode = TraceNodeResolver.resolveBest( + nodes: nodes, + localPublicKeys: localPublicKeys, + prefixHex: _toPrefixHex(selfPublicKey), + ); + final recipientNode = TraceNodeResolver.resolveBest( + nodes: nodes, + localPublicKeys: localPublicKeys, + prefixHex: _toPrefixHex(widget.contact.publicKey), + ); + final senderLatLng = senderNode.node == null + ? null + : LatLng(senderNode.node!.latitude, senderNode.node!.longitude); + final recipientLatLng = recipientNode.node == null + ? null + : LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude); + final routeHashes = + widget.contact.routeHasPath && widget.contact.routeHopCount > 0 + ? widget.contact.routeCanonicalText + .split(',') + .where((token) => token.isNotEmpty) + .map((token) => token.toLowerCase()) + .toList() + : const []; + + final matchedRelayNodes = routeHashes + .map( + (hash) => TraceNodeResolver.resolveBest( + nodes: nodes, + localPublicKeys: localPublicKeys, + prefixHex: hash, + referenceA: senderLatLng, + referenceB: recipientLatLng, + ), + ) + .toList(); + + return _ContactTraceResult( + sender: senderNode, + recipient: recipientNode, + routeHashes: routeHashes, + matchedRelayNodes: matchedRelayNodes, + ); + } + + String? _toPrefixHex(List? key) { + if (key == null || key.isEmpty) return null; + final take = key.length < 6 ? key.length : 6; + return key + .take(take) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } + + List _localNodesFromContacts( + ContactsProvider contactsProvider, { + required ConnectionProvider connectionProvider, + }) { + final nodes = contactsProvider.contactsWithLocation + .map((contact) { + final location = contact.displayLocation; + if (location == null) return null; + return MeshMapNode( + type: contact.type.index, + name: contact.displayName, + publicKey: contact.publicKeyHex.toLowerCase(), + latitude: location.latitude, + longitude: location.longitude, + updatedAtMs: contact.lastAdvert * 1000, + ); + }) + .whereType() + .toList(); + + final selfNode = _selfNode(connectionProvider); + if (selfNode != null) { + nodes.add(selfNode); + } + return nodes; + } + + MeshMapNode? _selfNode(ConnectionProvider connectionProvider) { + final publicKey = connectionProvider.deviceInfo.publicKey; + final advLat = connectionProvider.deviceInfo.advLat; + final advLon = connectionProvider.deviceInfo.advLon; + if (publicKey == null || advLat == null || advLon == null) { + return null; + } + if (advLat == 0 && advLon == 0) { + return null; + } + + return MeshMapNode( + type: -1, + name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true + ? connectionProvider.deviceInfo.selfName!.trim() + : 'You', + publicKey: publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(), + latitude: advLat / 1e6, + longitude: advLon / 1e6, + updatedAtMs: DateTime.now().millisecondsSinceEpoch, + ); + } + + List _mergeNodes( + List preferred, + List fallback, + ) { + final merged = {}; + for (final node in fallback) { + merged[node.publicKey] = node; + } + for (final node in preferred) { + merged[node.publicKey] = node; + } + return merged.values.toList(); + } +} + +class _ContactTraceResult { + final ResolvedTraceNode sender; + final ResolvedTraceNode recipient; + final List routeHashes; + final List matchedRelayNodes; + + const _ContactTraceResult({ + required this.sender, + required this.recipient, + required this.routeHashes, + required this.matchedRelayNodes, + }); +} + +class _RouteDisplayEntry { + final ResolvedTraceNode resolved; + final String label; + final String? keyLabel; + final String? matchSummary; + + const _RouteDisplayEntry({ + required this.resolved, + required this.label, + required this.keyLabel, + required this.matchSummary, + }); + + MeshMapNode? get node => resolved.node; + + factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) { + final node = resolved.node!; + return _RouteDisplayEntry( + resolved: resolved, + label: node.name, + keyLabel: node.publicKey.substring( + 0, + math.min(12, node.publicKey.length), + ), + matchSummary: resolved.matchSummary, + ); + } +} diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 4b4b858..8e7b4c3 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -14,6 +14,7 @@ import '../../providers/messages_provider.dart'; import '../../services/mesh_map_nodes_service.dart'; import '../../services/route_hash_preferences.dart'; import '../../utils/log_rx_route_decoder.dart'; +import '../../utils/trace_node_resolver.dart'; class MessageTraceSheet extends StatefulWidget { final Message message; @@ -38,10 +39,10 @@ class _MessageTraceSheetState extends State { final contactsProvider = context.read(); final messagesProvider = context.read(); final preferredHashSize = await RouteHashPreferences.getHashSize(); - final storedPath = - messagesProvider.getMessageReceptionDetails(widget.message.id)?.pathBytes; - final packetPath = - (storedPath != null && storedPath.isNotEmpty) + final storedPath = messagesProvider + .getMessageReceptionDetails(widget.message.id) + ?.pathBytes; + final packetPath = (storedPath != null && storedPath.isNotEmpty) ? storedPath : _extractPathFromPacketLogs( logs: connectionProvider.bleService.packetLogs, @@ -54,8 +55,10 @@ class _MessageTraceSheetState extends State { : _toPrefixHex(connectionProvider.deviceInfo.publicKey); final localNodes = _localNodesFromContacts(contactsProvider); + final localPublicKeys = localNodes.map((node) => node.publicKey).toSet(); var trace = _buildTraceResult( nodes: localNodes, + localPublicKeys: localPublicKeys, packetPath: packetPath, preferredHashSize: preferredHashSize, senderPrefix: senderPrefix, @@ -79,6 +82,7 @@ class _MessageTraceSheetState extends State { ); trace = _buildTraceResult( nodes: _mergeNodes(localNodes, remoteNodes), + localPublicKeys: localPublicKeys, packetPath: packetPath, preferredHashSize: preferredHashSize, senderPrefix: senderPrefix, @@ -114,8 +118,8 @@ class _MessageTraceSheetState extends State { final trace = snapshot.data!; final routeEntries = _displayRouteEntries(trace); final concretePathNodes = routeEntries - .where((entry) => entry.node != null) - .map((entry) => entry.node!) + .where((entry) => entry.resolved.node != null) + .map((entry) => entry.resolved.node!) .toList(); final mapPoints = concretePathNodes .map((n) => LatLng(n.latitude, n.longitude)) @@ -289,7 +293,7 @@ class _MessageTraceSheetState extends State { ), title: Text(entry.value.label), subtitle: Text( - '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}', + '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}', ), ), ), @@ -333,7 +337,10 @@ class _MessageTraceSheetState extends State { } List _relayNodes(_TraceResult trace) { - final concrete = trace.matchedPathNodes.whereType().toList(); + final concrete = trace.matchedPathNodes + .map((entry) => entry.node) + .whereType() + .toList(); if (concrete.isEmpty) return const []; if (trace.mode == TraceMode.packetPath) { @@ -346,13 +353,17 @@ class _MessageTraceSheetState extends State { } List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { - final pathNodes = trace.matchedPathNodes.whereType().toList(); + final pathNodes = trace.matchedPathNodes + .map((entry) => entry.node) + .whereType() + .toList(); if (pathNodes.isEmpty) { return [ - if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!), - if (trace.recipient != null && - trace.recipient!.publicKey != trace.sender?.publicKey) - _RouteDisplayEntry.fromNode(trace.recipient!), + if (trace.sender.node != null) + _RouteDisplayEntry.fromResolved(trace.sender), + if (trace.recipient.node != null && + trace.recipient.node!.publicKey != trace.sender.node?.publicKey) + _RouteDisplayEntry.fromResolved(trace.recipient), ]; } @@ -360,29 +371,34 @@ class _MessageTraceSheetState extends State { final entries = trace.matchedPathNodes.asMap().entries.map((entry) { final hashHex = trace.pathHashes[entry.key].toUpperCase(); return _RouteDisplayEntry( - node: entry.value, - label: entry.value?.name ?? 'Unknown', - keyLabel: entry.value != null - ? _prefixKeyLabel(entry.value!.publicKey) + resolved: entry.value, + label: entry.value.node?.name ?? 'Unknown', + keyLabel: entry.value.node != null + ? _prefixKeyLabel(entry.value.node!.publicKey) : hashHex, + matchSummary: entry.value.matchSummary, ); }).toList(); final lastKey = pathNodes.last.publicKey; return [ ...entries, - if (trace.recipient != null && trace.recipient!.publicKey != lastKey) - _RouteDisplayEntry.fromNode(trace.recipient!), + if (trace.recipient.node != null && + trace.recipient.node!.publicKey != lastKey) + _RouteDisplayEntry.fromResolved(trace.recipient), ]; } final firstKey = pathNodes.first.publicKey; final lastKey = pathNodes.last.publicKey; return [ - if (trace.sender != null && trace.sender!.publicKey != firstKey) - _RouteDisplayEntry.fromNode(trace.sender!), - ...pathNodes.map(_RouteDisplayEntry.fromNode), - if (trace.recipient != null && trace.recipient!.publicKey != lastKey) - _RouteDisplayEntry.fromNode(trace.recipient!), + if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey) + _RouteDisplayEntry.fromResolved(trace.sender), + ...trace.matchedPathNodes + .where((entry) => entry.node != null) + .map(_RouteDisplayEntry.fromResolved), + if (trace.recipient.node != null && + trace.recipient.node!.publicKey != lastKey) + _RouteDisplayEntry.fromResolved(trace.recipient), ]; } @@ -405,14 +421,6 @@ class _MessageTraceSheetState extends State { .toLowerCase(); } - MeshMapNode? _bestNodeForPrefix(List nodes, String? prefixHex) { - if (prefixHex == null || prefixHex.isEmpty) return null; - final matches = - nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList() - ..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); - return matches.isEmpty ? null : matches.first; - } - List _localNodesFromContacts(ContactsProvider contactsProvider) { return contactsProvider.contactsWithLocation .map((contact) { @@ -447,13 +455,28 @@ class _MessageTraceSheetState extends State { _TraceResult _buildTraceResult({ required List nodes, + required Set localPublicKeys, required List? packetPath, required int preferredHashSize, required String? senderPrefix, required String? recipientPrefix, }) { - final senderNode = _bestNodeForPrefix(nodes, senderPrefix); - final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); + final senderNode = TraceNodeResolver.resolveBest( + nodes: nodes, + localPublicKeys: localPublicKeys, + prefixHex: senderPrefix, + ); + final recipientNode = TraceNodeResolver.resolveBest( + nodes: nodes, + localPublicKeys: localPublicKeys, + prefixHex: recipientPrefix, + ); + final senderLatLng = senderNode.node == null + ? null + : LatLng(senderNode.node!.latitude, senderNode.node!.longitude); + final recipientLatLng = recipientNode.node == null + ? null + : LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude); if (packetPath != null && packetPath.isNotEmpty) { final hashSize = LogRxRouteDecoder.inferHashSize( @@ -466,9 +489,12 @@ class _MessageTraceSheetState extends State { ).reversed.toList(); final matched = _matchNodesFromPathHashes( nodes: nodes, + localPublicKeys: localPublicKeys, pathHashes: hopHashes, senderPrefix: senderPrefix, recipientPrefix: recipientPrefix, + senderLatLng: senderLatLng, + recipientLatLng: recipientLatLng, ); return _TraceResult( mode: TraceMode.packetPath, @@ -481,14 +507,20 @@ class _MessageTraceSheetState extends State { final inferred = _inferRelaysFromHopCount( nodes: nodes, - sender: senderNode, - recipient: recipientNode, + sender: senderNode.node, + recipient: recipientNode.node, relayCount: math.max(0, widget.message.pathLen), ); - final matchedPathNodes = [ - if (senderNode != null) senderNode, - ...inferred, - if (recipientNode != null) recipientNode, + final matchedPathNodes = [ + if (senderNode.node != null) senderNode, + ...inferred.map( + (node) => ResolvedTraceNode( + node: node, + matchCount: 1, + usedOnlineFallback: false, + ), + ), + if (recipientNode.node != null) recipientNode, ]; return _TraceResult( @@ -501,16 +533,17 @@ class _MessageTraceSheetState extends State { } bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) { - if (trace.sender == null || trace.recipient == null) { + if (trace.sender.node == null || trace.recipient.node == null) { return false; } if (trace.mode == TraceMode.packetPath) { return trace.matchedPathNodes.length == trace.pathHashes.length && - trace.matchedPathNodes.every((node) => node != null); + trace.matchedPathNodes.every((node) => node.node != null); } final concreteCount = trace.matchedPathNodes + .map((entry) => entry.node) .whereType() .length; return concreteCount >= expectedRelayCount + 2; @@ -551,39 +584,30 @@ class _MessageTraceSheetState extends State { return decoded.pathBytes; } - List _matchNodesFromPathHashes({ + List _matchNodesFromPathHashes({ required List nodes, + required Set localPublicKeys, required List pathHashes, required String? senderPrefix, required String? recipientPrefix, + required LatLng? senderLatLng, + required LatLng? recipientLatLng, }) { - final result = []; + final result = []; for (var i = 0; i < pathHashes.length; i++) { final hashHex = pathHashes[i].toLowerCase(); - final candidates = nodes - .where((n) => n.publicKey.startsWith(hashHex)) - .toList(); - if (candidates.isEmpty) { - result.add(null); - continue; - } - - List filtered = candidates; - if (i == 0 && senderPrefix != null) { - final senderMatches = filtered - .where((n) => n.publicKey.startsWith(senderPrefix)) - .toList(); - if (senderMatches.isNotEmpty) filtered = senderMatches; - } - if (i == pathHashes.length - 1 && recipientPrefix != null) { - final recipientMatches = filtered - .where((n) => n.publicKey.startsWith(recipientPrefix)) - .toList(); - if (recipientMatches.isNotEmpty) filtered = recipientMatches; - } - - filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); - result.add(filtered.first); + result.add( + TraceNodeResolver.resolveBest( + nodes: nodes, + localPublicKeys: localPublicKeys, + prefixHex: hashHex, + preferredPrefix: i == 0 + ? senderPrefix + : (i == pathHashes.length - 1 ? recipientPrefix : null), + referenceA: senderLatLng, + referenceB: recipientLatLng, + ), + ); } return result; } @@ -652,10 +676,10 @@ enum TraceMode { packetPath, hopCountInference } class _TraceResult { final TraceMode mode; - final MeshMapNode? sender; - final MeshMapNode? recipient; + final ResolvedTraceNode sender; + final ResolvedTraceNode recipient; final List pathHashes; - final List matchedPathNodes; + final List matchedPathNodes; const _TraceResult({ required this.mode, @@ -667,24 +691,30 @@ class _TraceResult { } class _RouteDisplayEntry { - final MeshMapNode? node; + final ResolvedTraceNode resolved; final String label; final String? keyLabel; + final String? matchSummary; const _RouteDisplayEntry({ - required this.node, + required this.resolved, required this.label, required this.keyLabel, + required this.matchSummary, }); - factory _RouteDisplayEntry.fromNode(MeshMapNode node) { + MeshMapNode? get node => resolved.node; + + factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) { + final node = resolved.node!; return _RouteDisplayEntry( - node: node, + resolved: resolved, label: node.name, keyLabel: node.publicKey.substring( 0, math.min(12, node.publicKey.length), ), + matchSummary: resolved.matchSummary, ); } } diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index d7fc26e..9ae869d 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -161,15 +161,23 @@ void main() { expect(selection.mode, PathSelectionMode.flood); }); - test('received public byte path is added to history', () async { - final service = PathHistoryService(); - await service.initialize(); - await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3); + test( + 'received public byte path is reversed before adding to history', + () async { + final service = PathHistoryService(); + await service.initialize(); + await service.recordReceivedBytePath('abc123', [ + 0x01, + 0x02, + 0x03, + 0x04, + ], 2); - final history = service.historyFor('abc123'); - expect(history.directPaths, hasLength(1)); - expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]); - expect(history.directPaths.single.hashSize, 3); - expect(history.directPaths.single.hopCount, 1); - }); + final history = service.historyFor('abc123'); + expect(history.directPaths, hasLength(1)); + expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]); + expect(history.directPaths.single.hashSize, 2); + expect(history.directPaths.single.hopCount, 2); + }, + ); } diff --git a/test/utils/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart index 6c92ecf..0317789 100644 --- a/test/utils/log_rx_route_decoder_test.dart +++ b/test/utils/log_rx_route_decoder_test.dart @@ -116,6 +116,21 @@ void main() { expect(resolved.matchCount, 2); }); }); + + group('LogRxRouteDecoder.reverseHopBytes', () { + test('reverses path by hop size', () { + final reversed = LogRxRouteDecoder.reverseHopBytes([ + 0xc2, + 0xba, + 0x5f, + 0xde, + 0xaa, + 0xbb, + ], hashSize: 2); + + expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]); + }); + }); } Contact _contact({required String name, required List keyPrefix}) { diff --git a/test/utils/trace_node_resolver_test.dart b/test/utils/trace_node_resolver_test.dart new file mode 100644 index 0000000..cec0e21 --- /dev/null +++ b/test/utils/trace_node_resolver_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart'; +import 'package:meshcore_sar_app/utils/trace_node_resolver.dart'; + +void main() { + test( + 'prefers closest local repeater over online fallback for shared prefix', + () { + final localNear = _node( + name: 'Near Local', + publicKey: 'aa1100', + latitude: 46.05, + longitude: 14.50, + ); + final localFar = _node( + name: 'Far Local', + publicKey: 'aa2200', + latitude: 46.40, + longitude: 14.90, + ); + final online = _node( + name: 'Online', + publicKey: 'aa3300', + latitude: 46.06, + longitude: 14.51, + ); + + final resolved = TraceNodeResolver.resolveBest( + nodes: [localNear, localFar, online], + localPublicKeys: {localNear.publicKey, localFar.publicKey}, + prefixHex: 'aa', + referenceA: const LatLng(46.0, 14.5), + referenceB: const LatLng(46.1, 14.5), + ); + + expect(resolved.node?.name, 'Near Local'); + expect(resolved.usedOnlineFallback, isFalse); + expect(resolved.matchCount, 2); + expect(resolved.matchSummary, '2 local matches'); + }, + ); + + test('falls back to online node only when local match is missing', () { + final online = _node( + name: 'Online Only', + publicKey: 'bb1100', + latitude: 46.06, + longitude: 14.51, + ); + + final resolved = TraceNodeResolver.resolveBest( + nodes: [online], + localPublicKeys: const {}, + prefixHex: 'bb', + referenceA: const LatLng(46.0, 14.5), + referenceB: const LatLng(46.1, 14.5), + ); + + expect(resolved.node?.name, 'Online Only'); + expect(resolved.usedOnlineFallback, isTrue); + expect(resolved.matchCount, 1); + }); +} + +MeshMapNode _node({ + required String name, + required String publicKey, + required double latitude, + required double longitude, +}) { + return MeshMapNode( + type: 1, + name: name, + publicKey: publicKey, + latitude: latitude, + longitude: longitude, + updatedAtMs: 1, + ); +} diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart new file mode 100644 index 0000000..4f5d3f3 --- /dev/null +++ b/test/widgets/contact_tile_test.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 1, + }) { + final publicKey = Uint8List(32); + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46562000, + advLon: 14950000, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Future pumpTile(WidgetTester tester, Contact contact) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ConnectionProvider()), + ChangeNotifierProvider(create: (_) => ContactsProvider()), + ChangeNotifierProvider(create: (_) => MessagesProvider()), + ChangeNotifierProvider(create: (_) => SensorsProvider()), + ChangeNotifierProvider(create: (_) => MapProvider()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: ContactTile(contact: contact)), + ), + ), + ); + } + + testWidgets('shows trace action for non-channel contacts', (tester) async { + await pumpTile( + tester, + buildContact(name: 'John Smith', type: ContactType.chat), + ); + + await tester.tap(find.text('John Smith')); + await tester.pumpAndSettle(); + + expect(find.text('Trace'), findsOneWidget); + }); + + testWidgets('does not show trace action for channels', (tester) async { + await pumpTile( + tester, + buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3), + ); + + await tester.tap(find.text('Ops')); + await tester.pumpAndSettle(); + + expect(find.text('Trace'), findsNothing); + }); +}