Add trace button to message bubble

This commit is contained in:
Janez T
2026-03-05 07:45:16 +01:00
parent b6aa0bcfaa
commit 40d786eb25
3 changed files with 528 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class MeshMapNode {
final int type;
final String name;
final String publicKey;
final double latitude;
final double longitude;
final int updatedAtMs;
const MeshMapNode({
required this.type,
required this.name,
required this.publicKey,
required this.latitude,
required this.longitude,
required this.updatedAtMs,
});
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
return MeshMapNode(
type: (json['type'] as num?)?.toInt() ?? 0,
name: (json['name'] as String?)?.trim() ?? 'Unknown',
publicKey: ((json['public_key'] as String?) ?? '').toLowerCase(),
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
updatedAtMs: (json['updated_at'] as num?)?.toInt() ?? 0,
);
}
}
class MeshMapNodesService {
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2);
static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt;
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async {
final now = DateTime.now();
if (!forceRefresh &&
_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < _cacheTtl) {
return _cachedNodes!;
}
final response = await http
.get(Uri.parse(_nodesEndpoint))
.timeout(const Duration(seconds: 12));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}');
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final nodesRaw = decoded['nodes'] as List<dynamic>? ?? const [];
final nodes = nodesRaw
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0)
.toList();
_cachedNodes = nodes;
_cachedAt = now;
return nodes;
}
}

View File

@@ -25,6 +25,7 @@ import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
import 'message_trace_sheet.dart';
/// Reusable message bubble widget that displays messages with various types:
/// - Regular text messages (channel or direct)
@@ -278,6 +279,17 @@ class _MessageBubbleState extends State<MessageBubble> {
_showTechnicalDetails(context);
},
),
if (!isOwnMessage &&
widget.message.pathLen > 0 &&
widget.message.pathLen < 255)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(context);
_showTraceSheet(context);
},
),
// Delete message option
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
@@ -296,6 +308,18 @@ 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 connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw;

View File

@@ -0,0 +1,436 @@
// ignore_for_file: use_null_aware_elements
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/ble_packet_log.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
const MessageTraceSheet({super.key, required this.message});
@override
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
}
class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final nodes = await MeshMapNodesService.fetchNodes();
final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null
? _toPrefixHex(widget.message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) {
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
matchedPathNodes: matched,
);
}
// Fallback when packet path is unavailable.
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
relayCount: math.max(0, widget.message.pathLen),
);
final matchedPathNodes = <MeshMapNode?>[
if (senderNode != null) senderNode,
...inferred,
if (recipientNode != null) recipientNode,
];
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
);
}
@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('Failed to load trace: ${snapshot.error}'),
),
),
);
}
final trace = snapshot.data!;
final mapPoints = trace.matchedPathNodes
.whereType<MeshMapNode>()
.map((n) => LatLng(n.latitude, n.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = _relayNodes(trace.matchedPathNodes);
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.mode == TraceMode.packetPath
? 'Route from packet path bytes'
: 'Route 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: trace.matchedPathNodes
.whereType<MeshMapNode>()
.toList()
.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 ==
trace
.matchedPathNodes
.whereType<MeshMapNode>()
.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(
'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 message.'),
),
...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<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
final concrete = path.whereType<MeshMapNode>().toList();
if (concrete.length <= 2) return const [];
return concrete.sublist(1, concrete.length - 1);
}
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();
}
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<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 raw = log.rawData;
final header = raw[3];
final payloadType = (header >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + 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 raw = bestLog.rawData;
final pathLen = raw[4];
return raw.sublist(5, 5 + pathLen);
}
List<MeshMapNode?> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes,
required List<int> pathHashes,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
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;
} else 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;
}
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 MeshMapNode? sender;
final MeshMapNode? recipient;
final List<int> pathHashes;
final List<MeshMapNode?> matchedPathNodes;
const _TraceResult({
required this.mode,
required this.sender,
required this.recipient,
required this.pathHashes,
required this.matchedPathNodes,
});
}