mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
feat: Open per-metric sensor history
This commit is contained in:
@@ -41,7 +41,6 @@ import '../../screens/add_contact_screen.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
import 'message_trace_sheet.dart';
|
||||
import 'message_bubble_header.dart';
|
||||
import 'message_bubble_signal.dart';
|
||||
import 'system_message_bubble.dart';
|
||||
@@ -544,17 +543,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_showTechnicalDetails(parentContext);
|
||||
},
|
||||
),
|
||||
if (!isOwnMessage &&
|
||||
widget.message.pathLen > 0 &&
|
||||
widget.message.pathLen < 255)
|
||||
ListTile(
|
||||
leading: Icon(Icons.route),
|
||||
title: Text(l10n.trace),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showTraceSheet(parentContext);
|
||||
},
|
||||
),
|
||||
// Delete message option
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
@@ -573,18 +561,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTraceSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => MessageTraceSheet(message: widget.message),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTechnicalDetails(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
// ignore_for_file: use_null_aware_elements
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:async';
|
||||
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 '../../l10n/app_localizations.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
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;
|
||||
final List<int>? packetPathOverride;
|
||||
final String? descriptionOverride;
|
||||
final String? noRelayMatchTextOverride;
|
||||
|
||||
const MessageTraceSheet({super.key, required this.message})
|
||||
: assert(message != null),
|
||||
packetPathOverride = null,
|
||||
descriptionOverride = null,
|
||||
noRelayMatchTextOverride = null;
|
||||
|
||||
const MessageTraceSheet.packetPath({
|
||||
super.key,
|
||||
required List<int> packetPath,
|
||||
this.descriptionOverride,
|
||||
this.noRelayMatchTextOverride,
|
||||
}) : message = null,
|
||||
packetPathOverride = packetPath;
|
||||
|
||||
@override
|
||||
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
|
||||
}
|
||||
|
||||
class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
late final Future<_TraceResult> _future;
|
||||
_TraceResult? _traceOverride;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _loadTrace();
|
||||
}
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
||||
List<int>? packetPath = widget.packetPathOverride;
|
||||
String? senderPrefix;
|
||||
String? recipientPrefix;
|
||||
|
||||
if (widget.message case final message?) {
|
||||
final storedPath = messagesProvider
|
||||
.getMessageReceptionDetails(message.id)
|
||||
?.pathBytes;
|
||||
packetPath = (storedPath != null && storedPath.isNotEmpty)
|
||||
? storedPath
|
||||
: _extractPathFromPacketLogs(
|
||||
logs: connectionProvider.bleService.packetLogs,
|
||||
message: message,
|
||||
);
|
||||
|
||||
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
|
||||
recipientPrefix = message.recipientPublicKey != null
|
||||
? _toPrefixHex(message.recipientPublicKey)
|
||||
: _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,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
if (_isCompleteTrace(
|
||||
trace,
|
||||
expectedRelayCount: widget.message == null
|
||||
? 0
|
||||
: math.max(0, widget.message!.pathLen),
|
||||
)) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
MeshMapNodesService.syncInBackgroundIfStale(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
),
|
||||
);
|
||||
|
||||
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
localPublicKeys: localPublicKeys,
|
||||
packetPath: packetPath,
|
||||
preferredHashSize: preferredHashSize,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: FutureBuilder<_TraceResult>(
|
||||
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(AppLocalizations.of(context)!.failedToLoadTrace(snapshot.error.toString())),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final trace = _traceOverride ?? snapshot.data!;
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concretePathNodes = routeEntries
|
||||
.where((entry) => entry.resolved.node != null)
|
||||
.map((entry) => entry.resolved.node!)
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
final mapPoints = concretePathNodes
|
||||
.map((n) => LatLng(n.latitude, n.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
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(
|
||||
widget.descriptionOverride ??
|
||||
(trace.mode == TraceMode.packetPath
|
||||
? 'Relay path from packet path bytes'
|
||||
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
|
||||
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: concretePathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => flutter_map.Marker(
|
||||
point: LatLng(
|
||||
entry.value.latitude,
|
||||
entry.value.longitude,
|
||||
),
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
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(
|
||||
'Relay path',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
widget.noRelayMatchTextOverride ??
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
onTap: entry.value.resolved.canCycle
|
||||
? () => setState(() {
|
||||
final baseTrace =
|
||||
_traceOverride ?? snapshot.data!;
|
||||
_traceOverride = baseTrace.cycleEntry(
|
||||
entry.value.target,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: 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(
|
||||
'Path node${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||
),
|
||||
trailing: entry.value.resolved.canCycle
|
||||
? const Icon(Icons.sync_alt)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
return trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
||||
return _RouteDisplayEntry(
|
||||
resolved: entry.value,
|
||||
label: entry.value.node?.name ?? 'Unknown',
|
||||
keyLabel: entry.value.node != null
|
||||
? _prefixKeyLabel(entry.value.node!.publicKey)
|
||||
: hashHex,
|
||||
matchSummary: entry.value.matchSummary,
|
||||
target: _RouteEntryTarget.pathNode(entry.key),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return trace.matchedPathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => entry.value.node != null)
|
||||
.map(
|
||||
(entry) => _RouteDisplayEntry.fromResolved(
|
||||
entry.value,
|
||||
target: _RouteEntryTarget.pathNode(entry.key),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
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) {
|
||||
return 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>()
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
_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 = 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(
|
||||
packetPath,
|
||||
preferredHashSize: preferredHashSize,
|
||||
);
|
||||
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||
packetPath,
|
||||
hashSize: hashSize,
|
||||
);
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
localPublicKeys: localPublicKeys,
|
||||
pathHashes: hopHashes,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
senderLatLng: senderLatLng,
|
||||
recipientLatLng: recipientLatLng,
|
||||
);
|
||||
final alignedMatched = TraceNodeResolver.alignPathSelections(
|
||||
nodes: matched,
|
||||
startNode: senderNode.node,
|
||||
endNode: recipientNode.node,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: hopHashes,
|
||||
matchedPathNodes: alignedMatched,
|
||||
);
|
||||
}
|
||||
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode.node,
|
||||
recipient: recipientNode.node,
|
||||
relayCount: math.max(0, widget.message!.pathLen),
|
||||
);
|
||||
final matchedPathNodes = <ResolvedTraceNode>[
|
||||
if (senderNode.node != null) senderNode,
|
||||
...inferred.map(
|
||||
(node) => ResolvedTraceNode(
|
||||
candidates: [node],
|
||||
matchCount: 1,
|
||||
usedOnlineFallback: false,
|
||||
),
|
||||
),
|
||||
if (recipientNode.node != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
||||
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.node != null);
|
||||
}
|
||||
|
||||
final concreteCount = trace.matchedPathNodes
|
||||
.map((entry) => entry.node)
|
||||
.whereType<MeshMapNode>()
|
||||
.length;
|
||||
return concreteCount >= expectedRelayCount + 2;
|
||||
}
|
||||
|
||||
List<int>? _extractPathFromPacketLogs({
|
||||
required List<BlePacketLog> logs,
|
||||
required Message message,
|
||||
}) {
|
||||
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
|
||||
final expectedPayloadType = message.messageType == MessageType.channel
|
||||
? 0x05
|
||||
: 0x02;
|
||||
BlePacketLog? bestLog;
|
||||
var bestDeltaMs = 999999999;
|
||||
|
||||
for (final log in logs) {
|
||||
if (log.responseCode != 0x88) continue; // pushLogRxData
|
||||
if (log.rawData.length < 6) continue;
|
||||
final decoded = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (decoded == null) continue;
|
||||
if (decoded.payloadType != expectedPayloadType) continue;
|
||||
if (decoded.hopCount != message.pathLen) continue;
|
||||
|
||||
final deltaMs =
|
||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
if (deltaMs < bestDeltaMs) {
|
||||
bestDeltaMs = deltaMs;
|
||||
bestLog = log;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestLog == null || bestDeltaMs > 30000) return null;
|
||||
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
|
||||
if (decoded == null || decoded.pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return decoded.pathBytes;
|
||||
}
|
||||
|
||||
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 = <ResolvedTraceNode>[];
|
||||
for (var i = 0; i < pathHashes.length; i++) {
|
||||
final hashHex = pathHashes[i].toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _inferRelaysFromHopCount({
|
||||
required List<MeshMapNode> nodes,
|
||||
required MeshMapNode? sender,
|
||||
required MeshMapNode? recipient,
|
||||
required int relayCount,
|
||||
}) {
|
||||
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
||||
final candidates = nodes.where((n) {
|
||||
if (sender.publicKey == n.publicKey ||
|
||||
recipient.publicKey == n.publicKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final ranked = candidates
|
||||
..sort((a, b) {
|
||||
final da = _distanceToSegmentMeters(
|
||||
p: LatLng(a.latitude, a.longitude),
|
||||
a: LatLng(sender.latitude, sender.longitude),
|
||||
b: LatLng(recipient.latitude, recipient.longitude),
|
||||
);
|
||||
final db = _distanceToSegmentMeters(
|
||||
p: LatLng(b.latitude, b.longitude),
|
||||
a: LatLng(sender.latitude, sender.longitude),
|
||||
b: LatLng(recipient.latitude, recipient.longitude),
|
||||
);
|
||||
return da.compareTo(db);
|
||||
});
|
||||
|
||||
return ranked.take(relayCount).toList();
|
||||
}
|
||||
|
||||
double _distanceToSegmentMeters({
|
||||
required LatLng p,
|
||||
required LatLng a,
|
||||
required 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 const 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 const Distance().as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
|
||||
enum TraceMode { packetPath, hopCountInference }
|
||||
|
||||
class _TraceResult {
|
||||
final TraceMode mode;
|
||||
final ResolvedTraceNode sender;
|
||||
final ResolvedTraceNode recipient;
|
||||
final List<String> pathHashes;
|
||||
final List<ResolvedTraceNode> matchedPathNodes;
|
||||
|
||||
const _TraceResult({
|
||||
required this.mode,
|
||||
required this.sender,
|
||||
required this.recipient,
|
||||
required this.pathHashes,
|
||||
required this.matchedPathNodes,
|
||||
});
|
||||
|
||||
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
||||
switch (target.kind) {
|
||||
case _RouteEntryKind.pathNode:
|
||||
final updated = matchedPathNodes.toList();
|
||||
updated[target.index] = updated[target.index].cycle();
|
||||
return _TraceResult(
|
||||
mode: mode,
|
||||
sender: sender,
|
||||
recipient: recipient,
|
||||
pathHashes: pathHashes,
|
||||
matchedPathNodes: updated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final ResolvedTraceNode resolved;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
final String? matchSummary;
|
||||
final _RouteEntryTarget target;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.resolved,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
required this.matchSummary,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
MeshMapNode? get node => resolved.node;
|
||||
|
||||
factory _RouteDisplayEntry.fromResolved(
|
||||
ResolvedTraceNode resolved, {
|
||||
required _RouteEntryTarget target,
|
||||
}) {
|
||||
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,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _RouteEntryKind { pathNode }
|
||||
|
||||
class _RouteEntryTarget {
|
||||
final _RouteEntryKind kind;
|
||||
final int index;
|
||||
|
||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||
|
||||
const _RouteEntryTarget.pathNode(int index)
|
||||
: this._(_RouteEntryKind.pathNode, index);
|
||||
}
|
||||
Reference in New Issue
Block a user