Fix tracing path and display issues

This commit is contained in:
Janez T
2026-03-12 14:08:00 +01:00
parent b38a8c55d6
commit 2d1e6c0ca4
11 changed files with 1021 additions and 89 deletions

View File

@@ -1589,8 +1589,17 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
decoded.pathBytes,
hashSize: decoded.hashSize,
);
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
reversedPathBytes,
hashSize: decoded.hashSize,
);
final parsedRoute = ContactRouteCodec.parse( final parsedRoute = ContactRouteCodec.parse(
decoded.hopHashes.join(','), reversedHopHashes.join(','),
expectedHashSize: decoded.hashSize, expectedHashSize: decoded.hashSize,
); );
contactsProvider.retainReceivedRoute( contactsProvider.retainReceivedRoute(

View File

@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1'; static const String _storageKey = 'contact_path_history_v1';
@@ -83,14 +84,19 @@ class PathHistoryService {
return; return;
} }
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex); final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0')) .map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final existing = _findDirectPath(history.directPaths, signature); final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord( final updated = PathRecord(
pathBytes: List<int>.from(pathBytes), pathBytes: normalizedPathBytes,
hopCount: pathBytes.length ~/ hashSize, hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize, hashSize: hashSize,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,

View File

@@ -154,6 +154,26 @@ class LogRxRouteDecoder {
return hops; return hops;
} }
static List<int> reverseHopBytes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return List<int>.from(pathBytes.reversed);
}
final reversed = <int>[];
for (
var index = pathBytes.length - hashSize;
index >= 0;
index -= hashSize
) {
reversed.addAll(pathBytes.sublist(index, index + hashSize));
}
return reversed;
}
static ResolvedNodeHash resolveHash( static ResolvedNodeHash resolveHash(
String hashHex, { String hashHex, {
required Iterable<Contact> contacts, required Iterable<Contact> contacts,

View File

@@ -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<MeshMapNode> nodes,
required Set<String> 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);
}
}

View File

@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
@@ -363,6 +364,15 @@ class ContactTile extends StatelessWidget {
_showSetRouteDialog(context, contact); _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) if (!contact.isPublicChannel)
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), 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( void _showDeleteConfirmation(
BuildContext context, BuildContext context,
Contact contact, { Contact contact, {

View File

@@ -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<ContactTraceSheet> createState() => _ContactTraceSheetState();
}
class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_ContactTraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
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<MeshMapNode>();
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<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? 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 <String>[];
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<int>? 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<MeshMapNode> _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<MeshMapNode>()
.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<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
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<String> routeHashes;
final List<ResolvedTraceNode> 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,
);
}
}

View File

@@ -14,6 +14,7 @@ import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart'; import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget { class MessageTraceSheet extends StatefulWidget {
final Message message; final Message message;
@@ -38,10 +39,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize(); final preferredHashSize = await RouteHashPreferences.getHashSize();
final storedPath = final storedPath = messagesProvider
messagesProvider.getMessageReceptionDetails(widget.message.id)?.pathBytes; .getMessageReceptionDetails(widget.message.id)
final packetPath = ?.pathBytes;
(storedPath != null && storedPath.isNotEmpty) final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath ? storedPath
: _extractPathFromPacketLogs( : _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
@@ -54,8 +55,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: _toPrefixHex(connectionProvider.deviceInfo.publicKey); : _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final localNodes = _localNodesFromContacts(contactsProvider); final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult( var trace = _buildTraceResult(
nodes: localNodes, nodes: localNodes,
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -79,6 +82,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
trace = _buildTraceResult( trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes), nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -114,8 +118,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final trace = snapshot.data!; final trace = snapshot.data!;
final routeEntries = _displayRouteEntries(trace); final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries final concretePathNodes = routeEntries
.where((entry) => entry.node != null) .where((entry) => entry.resolved.node != null)
.map((entry) => entry.node!) .map((entry) => entry.resolved.node!)
.toList(); .toList();
final mapPoints = concretePathNodes final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude)) .map((n) => LatLng(n.latitude, n.longitude))
@@ -289,7 +293,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( 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<MessageTraceSheet> {
} }
List<MeshMapNode> _relayNodes(_TraceResult trace) { List<MeshMapNode> _relayNodes(_TraceResult trace) {
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList(); final concrete = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (concrete.isEmpty) return const []; if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
@@ -346,13 +353,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList(); final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) { if (pathNodes.isEmpty) {
return [ return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!), if (trace.sender.node != null)
if (trace.recipient != null && _RouteDisplayEntry.fromResolved(trace.sender),
trace.recipient!.publicKey != trace.sender?.publicKey) if (trace.recipient.node != null &&
_RouteDisplayEntry.fromNode(trace.recipient!), trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
@@ -360,29 +371,34 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) { final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase(); final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: entry.value, resolved: entry.value,
label: entry.value?.name ?? 'Unknown', label: entry.value.node?.name ?? 'Unknown',
keyLabel: entry.value != null keyLabel: entry.value.node != null
? _prefixKeyLabel(entry.value!.publicKey) ? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex, : hashHex,
matchSummary: entry.value.matchSummary,
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
...entries, ...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) if (trace.recipient.node != null &&
_RouteDisplayEntry.fromNode(trace.recipient!), trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
final firstKey = pathNodes.first.publicKey; final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
if (trace.sender != null && trace.sender!.publicKey != firstKey) if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromNode(trace.sender!), _RouteDisplayEntry.fromResolved(trace.sender),
...pathNodes.map(_RouteDisplayEntry.fromNode), ...trace.matchedPathNodes
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) .where((entry) => entry.node != null)
_RouteDisplayEntry.fromNode(trace.recipient!), .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<MessageTraceSheet> {
.toLowerCase(); .toLowerCase();
} }
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> 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<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) { List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation return contactsProvider.contactsWithLocation
.map((contact) { .map((contact) {
@@ -447,13 +455,28 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({ _TraceResult _buildTraceResult({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? packetPath, required List<int>? packetPath,
required int preferredHashSize, required int preferredHashSize,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
}) { }) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix); final senderNode = TraceNodeResolver.resolveBest(
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); 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) { if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize( final hashSize = LogRxRouteDecoder.inferHashSize(
@@ -466,9 +489,12 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
).reversed.toList(); ).reversed.toList();
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
localPublicKeys: localPublicKeys,
pathHashes: hopHashes, pathHashes: hopHashes,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
); );
return _TraceResult( return _TraceResult(
mode: TraceMode.packetPath, mode: TraceMode.packetPath,
@@ -481,14 +507,20 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final inferred = _inferRelaysFromHopCount( final inferred = _inferRelaysFromHopCount(
nodes: nodes, nodes: nodes,
sender: senderNode, sender: senderNode.node,
recipient: recipientNode, recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen), relayCount: math.max(0, widget.message.pathLen),
); );
final matchedPathNodes = <MeshMapNode?>[ final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode != null) senderNode, if (senderNode.node != null) senderNode,
...inferred, ...inferred.map(
if (recipientNode != null) recipientNode, (node) => ResolvedTraceNode(
node: node,
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
]; ];
return _TraceResult( return _TraceResult(
@@ -501,16 +533,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) { 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; return false;
} }
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length && return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null); trace.matchedPathNodes.every((node) => node.node != null);
} }
final concreteCount = trace.matchedPathNodes final concreteCount = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>() .whereType<MeshMapNode>()
.length; .length;
return concreteCount >= expectedRelayCount + 2; return concreteCount >= expectedRelayCount + 2;
@@ -551,39 +584,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return decoded.pathBytes; return decoded.pathBytes;
} }
List<MeshMapNode?> _matchNodesFromPathHashes({ List<ResolvedTraceNode> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<String> pathHashes, required List<String> pathHashes,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
required LatLng? senderLatLng,
required LatLng? recipientLatLng,
}) { }) {
final result = <MeshMapNode?>[]; final result = <ResolvedTraceNode>[];
for (var i = 0; i < pathHashes.length; i++) { for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toLowerCase(); final hashHex = pathHashes[i].toLowerCase();
final candidates = nodes result.add(
.where((n) => n.publicKey.startsWith(hashHex)) TraceNodeResolver.resolveBest(
.toList(); nodes: nodes,
if (candidates.isEmpty) { localPublicKeys: localPublicKeys,
result.add(null); prefixHex: hashHex,
continue; preferredPrefix: i == 0
} ? senderPrefix
: (i == pathHashes.length - 1 ? recipientPrefix : null),
List<MeshMapNode> filtered = candidates; referenceA: senderLatLng,
if (i == 0 && senderPrefix != null) { referenceB: recipientLatLng,
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);
} }
return result; return result;
} }
@@ -652,10 +676,10 @@ enum TraceMode { packetPath, hopCountInference }
class _TraceResult { class _TraceResult {
final TraceMode mode; final TraceMode mode;
final MeshMapNode? sender; final ResolvedTraceNode sender;
final MeshMapNode? recipient; final ResolvedTraceNode recipient;
final List<String> pathHashes; final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes; final List<ResolvedTraceNode> matchedPathNodes;
const _TraceResult({ const _TraceResult({
required this.mode, required this.mode,
@@ -667,24 +691,30 @@ class _TraceResult {
} }
class _RouteDisplayEntry { class _RouteDisplayEntry {
final MeshMapNode? node; final ResolvedTraceNode resolved;
final String label; final String label;
final String? keyLabel; final String? keyLabel;
final String? matchSummary;
const _RouteDisplayEntry({ const _RouteDisplayEntry({
required this.node, required this.resolved,
required this.label, required this.label,
required this.keyLabel, 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( return _RouteDisplayEntry(
node: node, resolved: resolved,
label: node.name, label: node.name,
keyLabel: node.publicKey.substring( keyLabel: node.publicKey.substring(
0, 0,
math.min(12, node.publicKey.length), math.min(12, node.publicKey.length),
), ),
matchSummary: resolved.matchSummary,
); );
} }
} }

View File

@@ -161,15 +161,23 @@ void main() {
expect(selection.mode, PathSelectionMode.flood); expect(selection.mode, PathSelectionMode.flood);
}); });
test('received public byte path is added to history', () async { test(
final service = PathHistoryService(); 'received public byte path is reversed before adding to history',
await service.initialize(); () async {
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3); final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [
0x01,
0x02,
0x03,
0x04,
], 2);
final history = service.historyFor('abc123'); final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1)); expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]); expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
expect(history.directPaths.single.hashSize, 3); expect(history.directPaths.single.hashSize, 2);
expect(history.directPaths.single.hopCount, 1); expect(history.directPaths.single.hopCount, 2);
}); },
);
} }

View File

@@ -116,6 +116,21 @@ void main() {
expect(resolved.matchCount, 2); 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<int> keyPrefix}) { Contact _contact({required String name, required List<int> keyPrefix}) {

View File

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

View File

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