mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix tracing path and display issues
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 '../../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, {
|
||||||
|
|||||||
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/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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
'received public byte path is reversed before adding to history',
|
||||||
|
() async {
|
||||||
final service = PathHistoryService();
|
final service = PathHistoryService();
|
||||||
await service.initialize();
|
await service.initialize();
|
||||||
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3);
|
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);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}) {
|
||||||
|
|||||||
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