From 40d786eb2593ac43e9a3f6b1c716791a378cb35e Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 07:45:16 +0100 Subject: [PATCH] Add trace button to message bubble --- lib/services/mesh_map_nodes_service.dart | 68 +++ lib/widgets/messages/message_bubble.dart | 24 + lib/widgets/messages/message_trace_sheet.dart | 436 ++++++++++++++++++ 3 files changed, 528 insertions(+) create mode 100644 lib/services/mesh_map_nodes_service.dart create mode 100644 lib/widgets/messages/message_trace_sheet.dart diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart new file mode 100644 index 0000000..ed4f81b --- /dev/null +++ b/lib/services/mesh_map_nodes_service.dart @@ -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 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? _cachedNodes; + static DateTime? _cachedAt; + + static Future> 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; + final nodesRaw = decoded['nodes'] as List? ?? const []; + + final nodes = nodesRaw + .whereType>() + .map(MeshMapNode.fromJson) + .where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0) + .toList(); + + _cachedNodes = nodes; + _cachedAt = now; + return nodes; + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 3276f74..8a627b8 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -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 { _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 { ); } + 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(); final radioBw = connectionProvider.deviceInfo.radioBw; diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart new file mode 100644 index 0000000..606719a --- /dev/null +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -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 createState() => _MessageTraceSheetState(); +} + +class _MessageTraceSheetState extends State { + late final Future<_TraceResult> _future; + + @override + void initState() { + super.initState(); + _future = _loadTrace(); + } + + Future<_TraceResult> _loadTrace() async { + final connectionProvider = context.read(); + 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 = [ + 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() + .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() + .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() + .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 _relayNodes(List path) { + final concrete = path.whereType().toList(); + if (concrete.length <= 2) return const []; + return concrete.sublist(1, concrete.length - 1); + } + + String? _toPrefixHex(List? 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 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? _extractPathFromPacketLogs({ + required List 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 _matchNodesFromPathHashes({ + required List nodes, + required List pathHashes, + required String? senderPrefix, + required String? recipientPrefix, + }) { + final result = []; + 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 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 _inferRelaysFromHopCount({ + required List 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 pathHashes; + final List matchedPathNodes; + + const _TraceResult({ + required this.mode, + required this.sender, + required this.recipient, + required this.pathHashes, + required this.matchedPathNodes, + }); +}