mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Fix tracing path and display issues
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<int>.from(pathBytes),
|
||||
hopCount: pathBytes.length ~/ hashSize,
|
||||
pathBytes: normalizedPathBytes,
|
||||
hopCount: normalizedPathBytes.length ~/ hashSize,
|
||||
hashSize: hashSize,
|
||||
successCount: existing?.successCount ?? 0,
|
||||
failureCount: existing?.failureCount ?? 0,
|
||||
|
||||
@@ -154,6 +154,26 @@ class LogRxRouteDecoder {
|
||||
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(
|
||||
String hashHex, {
|
||||
required Iterable<Contact> contacts,
|
||||
|
||||
132
lib/utils/trace_node_resolver.dart
Normal file
132
lib/utils/trace_node_resolver.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
525
lib/widgets/contacts/contact_trace_sheet.dart
Normal file
525
lib/widgets/contacts/contact_trace_sheet.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MessageTraceSheet> {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
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<MessageTraceSheet> {
|
||||
: _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<MessageTraceSheet> {
|
||||
);
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
localPublicKeys: localPublicKeys,
|
||||
packetPath: packetPath,
|
||||
preferredHashSize: preferredHashSize,
|
||||
senderPrefix: senderPrefix,
|
||||
@@ -114,8 +118,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
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<MessageTraceSheet> {
|
||||
),
|
||||
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<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
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 (trace.mode == TraceMode.packetPath) {
|
||||
@@ -346,13 +353,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
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) {
|
||||
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<MessageTraceSheet> {
|
||||
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<MessageTraceSheet> {
|
||||
.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) {
|
||||
return contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
@@ -447,13 +455,28 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
_TraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required List<int>? 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<MessageTraceSheet> {
|
||||
).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<MessageTraceSheet> {
|
||||
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
sender: senderNode.node,
|
||||
recipient: recipientNode.node,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
final matchedPathNodes = <ResolvedTraceNode>[
|
||||
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<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
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<MeshMapNode>()
|
||||
.length;
|
||||
return concreteCount >= expectedRelayCount + 2;
|
||||
@@ -551,39 +584,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
List<MeshMapNode?> _matchNodesFromPathHashes({
|
||||
List<ResolvedTraceNode> _matchNodesFromPathHashes({
|
||||
required List<MeshMapNode> nodes,
|
||||
required Set<String> localPublicKeys,
|
||||
required List<String> pathHashes,
|
||||
required String? senderPrefix,
|
||||
required String? recipientPrefix,
|
||||
required LatLng? senderLatLng,
|
||||
required LatLng? recipientLatLng,
|
||||
}) {
|
||||
final result = <MeshMapNode?>[];
|
||||
final result = <ResolvedTraceNode>[];
|
||||
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<MeshMapNode> 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<String> pathHashes;
|
||||
final List<MeshMapNode?> matchedPathNodes;
|
||||
final List<ResolvedTraceNode> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<int> keyPrefix}) {
|
||||
|
||||
80
test/utils/trace_node_resolver_test.dart
Normal file
80
test/utils/trace_node_resolver_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
85
test/widgets/contact_tile_test.dart
Normal file
85
test/widgets/contact_tile_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user