diff --git a/Capture d’écran du 2026-05-12 19-43-48.png b/Capture d’écran du 2026-05-12 19-43-48.png new file mode 100644 index 00000000..a498227c Binary files /dev/null and b/Capture d’écran du 2026-05-12 19-43-48.png differ diff --git a/Capture d’écran du 2026-05-12 19-44-15.png b/Capture d’écran du 2026-05-12 19-44-15.png new file mode 100644 index 00000000..182514dd Binary files /dev/null and b/Capture d’écran du 2026-05-12 19-44-15.png differ diff --git a/Capture d’écran du 2026-05-13 08-27-59.png b/Capture d’écran du 2026-05-13 08-27-59.png new file mode 100644 index 00000000..30b7f93b Binary files /dev/null and b/Capture d’écran du 2026-05-13 08-27-59.png differ diff --git a/Capture d’écran du 2026-05-13 08-28-26.png b/Capture d’écran du 2026-05-13 08-28-26.png new file mode 100644 index 00000000..1a19b802 Binary files /dev/null and b/Capture d’écran du 2026-05-13 08-28-26.png differ diff --git a/Capture d’écran du 2026-05-13 08-39-35.png b/Capture d’écran du 2026-05-13 08-39-35.png new file mode 100644 index 00000000..5230bda7 Binary files /dev/null and b/Capture d’écran du 2026-05-13 08-39-35.png differ diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index 5a6d15b5..ff6448c7 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/foundation.dart'; import '../models/contact.dart'; import '../connector/meshcore_protocol.dart'; @@ -9,8 +11,29 @@ class PathHelper { .join(','); } + static String formatHopHex(List hopBytes) { + return hopBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + } + + static List splitPathBytes(List pathBytes, int hashByteWidth) { + if (pathBytes.isEmpty) return const []; + + final width = hashByteWidth.clamp(1, 8); + final hops = []; + for (int i = 0; i < pathBytes.length; i += width) { + final endIdx = (i + width).clamp(0, pathBytes.length); + final hopBytes = pathBytes.sublist(i, endIdx); + if (hopBytes.isNotEmpty) { + hops.add(Uint8List.fromList(hopBytes)); + } + } + return hops; + } + /// Resolves path bytes to contact names, supporting multi-byte hash widths. - /// + /// Groups path bytes according to [hashByteWidth]: /// - 1: Single byte per hop (256 unique nodes) /// - 2: Two bytes per hop (65K unique nodes) @@ -21,22 +44,12 @@ class PathHelper { int hashByteWidth, ) { if (pathBytes.isEmpty) return ''; - - final width = hashByteWidth.clamp(1, 8); + final parts = []; - - // Group bytes according to width - for (int i = 0; i < pathBytes.length; i += width) { - final endIdx = (i + width).clamp(0, pathBytes.length); - final hopBytes = pathBytes.sublist(i, endIdx); - - if (hopBytes.isEmpty) continue; - - // Format hex for display - final hex = hopBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(''); - + + for (final hopBytes in splitPathBytes(pathBytes, hashByteWidth)) { + final hex = formatHopHex(hopBytes); + // Find matching contacts by comparing public key prefix final matches = allContacts .where((c) { diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 47c1181d..d30b0c2f 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; @@ -42,14 +43,22 @@ class ChannelMessagePathScreen extends StatelessWidget { : primaryPathTmp; final hops = _buildPathHops(primaryPath, connector, l10n); final hasHopDetails = primaryPath.isNotEmpty; - + // Convert path byte length to hop count based on device width final width = connector.pathHashByteWidth.clamp(1, 8); - final observedHopCount = primaryPath.length ~/ width; - + final observedHopCount = _hopCountFromBytes(primaryPath.length, width); + final reportedHopCount = message.pathLength == null + ? null + : (message.pathLength! < 0 + ? message.pathLength + : _hopCountFromBytes(message.pathLength!, width)); + final effectiveHopCount = observedHopCount > 0 + ? observedHopCount + : reportedHopCount; + final observedLabel = _formatObservedHops( observedHopCount, - message.pathLength != null ? message.pathLength! ~/ width : null, + effectiveHopCount, l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); @@ -92,7 +101,11 @@ class ChannelMessagePathScreen extends StatelessWidget { child: ListView( padding: const EdgeInsets.all(16), children: [ - _buildSummaryCard(context, observedLabel: observedLabel), + _buildSummaryCard( + context, + observedLabel: observedLabel, + effectiveHopCount: effectiveHopCount, + ), const SizedBox(height: 16), if (extraPaths.isNotEmpty) ...[ Text( @@ -100,7 +113,7 @@ class ChannelMessagePathScreen extends StatelessWidget { style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), - _buildPathVariants(context, extraPaths), + _buildPathVariants(context, extraPaths, width), const SizedBox(height: 16), ], Text( @@ -123,7 +136,11 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { + Widget _buildSummaryCard( + BuildContext context, { + String? observedLabel, + required int? effectiveHopCount, + }) { final l10n = context.l10n; return Card( child: Padding( @@ -148,7 +165,7 @@ class ChannelMessagePathScreen extends StatelessWidget { ), _buildDetailRow( l10n.channelPath_pathLabelTitle, - _formatPathLabel(message.pathLength, l10n), + _formatPathLabel(effectiveHopCount, l10n), ), if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), @@ -158,7 +175,11 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - Widget _buildPathVariants(BuildContext context, List variants) { + Widget _buildPathVariants( + BuildContext context, + List variants, + int hashByteWidth, + ) { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -171,10 +192,10 @@ class ChannelMessagePathScreen extends StatelessWidget { title: Text( l10n.channelPath_observedPathTitle( i + 1, - _formatHopCount(variants[i].length, l10n), + _formatHopCount(variants[i].length, hashByteWidth, l10n), ), ), - subtitle: Text(_formatPathPrefixes(variants[i])), + subtitle: Text(_formatPathPrefixes(variants[i], hashByteWidth)), trailing: const Icon(Icons.map_outlined, size: 20), onTap: () => _openPathMap( context, @@ -228,31 +249,31 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - String _formatPathLabel(int? pathLength, AppLocalizations l10n) { - if (pathLength == null) return l10n.channelPath_unknownPath; - if (pathLength < 0) return l10n.channelPath_floodPath; - if (pathLength == 0) return l10n.channelPath_directPath; - return l10n.chat_hopsCount(pathLength); + String _formatPathLabel(int? hopCount, AppLocalizations l10n) { + if (hopCount == null) return l10n.channelPath_unknownPath; + if (hopCount < 0) return l10n.channelPath_floodPath; + if (hopCount == 0) return l10n.channelPath_directPath; + return l10n.chat_hopsCount(hopCount); } String? _formatObservedHops( int observedCount, - int? pathLength, + int? targetHopCount, AppLocalizations l10n, ) { - if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) { + if (observedCount <= 0 && (targetHopCount == null || targetHopCount <= 0)) { return null; } - if (pathLength == null || pathLength < 0) { + if (targetHopCount == null || targetHopCount < 0) { return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null; } if (observedCount == 0) { - return l10n.channelPath_observedZeroOf(pathLength); + return l10n.channelPath_observedZeroOf(targetHopCount); } - if (observedCount == pathLength) { + if (observedCount == targetHopCount) { return l10n.chat_hopsCount(observedCount); } - return l10n.channelPath_observedSomeOf(observedCount, pathLength); + return l10n.channelPath_observedSomeOf(observedCount, targetHopCount); } Widget _buildDetailRow(String label, String value) { @@ -470,6 +491,7 @@ class _ChannelMessagePathMapScreenState final selectedIndex = _indexForPath(selectedPath, observedPaths); final hops = _buildPathHops(selectedPath, connector, context.l10n); + final width = connector.pathHashByteWidth.clamp(1, 8); final points = []; @@ -510,7 +532,7 @@ class _ChannelMessagePathMapScreenState ? LatLngBounds.fromPoints(points) : null; final mapKey = ValueKey( - '${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}', + '${_formatPathPrefixes(selectedPath, width)},${context.l10n.pathTrace_you}', ); _pathDistance = _getPathDistance(points); @@ -623,6 +645,10 @@ class _ChannelMessagePathMapScreenState ValueChanged onSelected, ) { final l10n = context.l10n; + final width = context.read().pathHashByteWidth.clamp( + 1, + 8, + ); final selectedPath = paths[selectedIndex]; final label = selectedPath.isPrimary ? l10n.channelPath_primaryPath(selectedIndex + 1) @@ -653,7 +679,7 @@ class _ChannelMessagePathMapScreenState value: i, child: Text( '${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}' - ' • ${_formatHopCount(paths[i].pathBytes.length, l10n)}', + ' • ${_formatHopCount(paths[i].pathBytes.length, width, l10n)}', ), ), ], @@ -667,7 +693,7 @@ class _ChannelMessagePathMapScreenState Text( l10n.channelPath_selectedPathLabel( label, - _formatPathPrefixes(selectedPath.pathBytes), + _formatPathPrefixes(selectedPath.pathBytes, width), ), style: TextStyle(color: Colors.grey[700], fontSize: 12), ), @@ -925,11 +951,11 @@ List<_PathHop> _buildPathHops( AppLocalizations l10n, ) { if (pathBytes.isEmpty) return const []; - + final width = connector.pathHashByteWidth.clamp(1, 8); - final candidatesByHashBytes = , List>{}; + final candidatesByHashBytes = >{}; final allContacts = connector.allContacts; - + // Build lookup map using hash byte sequences for (final contact in allContacts) { if (contact.publicKey.isEmpty) continue; @@ -941,14 +967,15 @@ List<_PathHop> _buildPathHops( 0, min(width, contact.publicKey.length), ); - candidatesByHashBytes.putIfAbsent(keyBytes, () => []).add(contact); + final keyHex = PathHelper.formatHopHex(keyBytes); + candidatesByHashBytes.putIfAbsent(keyHex, () => []).add(contact); } - + // Sort candidates by last seen for (final candidates in candidatesByHashBytes.values) { candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); } - + final startPoint = (connector.selfLatitude != null && connector.selfLongitude != null) ? LatLng(connector.selfLatitude!, connector.selfLongitude!) @@ -958,17 +985,18 @@ List<_PathHop> _buildPathHops( var lastDistance = 0.0; var bestDistance = 0.0; final hops = <_PathHop>[]; - + // Process path in hop-sized chunks for (var hopIdx = 0; hopIdx * width < pathBytes.length; hopIdx++) { final startByte = hopIdx * width; final endByte = min(startByte + width, pathBytes.length); final hopBytes = pathBytes.sublist(startByte, endByte); - + final hopKey = PathHelper.formatHopHex(hopBytes); + final searchPoint = hopIdx == 0 ? startPoint : previousPosition; - final candidates = candidatesByHashBytes[hopBytes.toList()]; + final candidates = candidatesByHashBytes[hopKey]; Contact? contact; - + if (candidates != null && candidates.isNotEmpty) { var bestIndex = 0; if (searchPoint != null) { @@ -992,7 +1020,7 @@ List<_PathHop> _buildPathHops( } contact = candidates.removeAt(bestIndex); if (candidates.isEmpty) { - candidatesByHashBytes.remove(hopBytes.toList()); + candidatesByHashBytes.remove(hopKey); } } @@ -1000,7 +1028,7 @@ List<_PathHop> _buildPathHops( if (resolvedPosition != null) { previousPosition = resolvedPosition; } - + // If the best candidate is much farther than the previous hop, it's likely not the correct match. if (lastDistance + bestDistance > 50000 && candidates != null && @@ -1038,14 +1066,20 @@ String _formatPrefix(int prefix) { return prefix.toRadixString(16).padLeft(2, '0').toUpperCase(); } -String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) +String _formatPathPrefixes(Uint8List pathBytes, int hashByteWidth) { + return PathHelper.splitPathBytes(pathBytes, hashByteWidth) + .map(PathHelper.formatHopHex) .join(','); } -String _formatHopCount(int count, AppLocalizations l10n) { - return l10n.chat_hopsCount(count); +int _hopCountFromBytes(int byteCount, int hashByteWidth) { + if (byteCount <= 0) return 0; + final width = hashByteWidth.clamp(1, 8); + return (byteCount + width - 1) ~/ width; +} + +String _formatHopCount(int byteCount, int hashByteWidth, AppLocalizations l10n) { + return l10n.chat_hopsCount(_hopCountFromBytes(byteCount, hashByteWidth)); } String _resolveName(Contact? contact, AppLocalizations l10n) { diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index c810570d..3bf1e7e3 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; @@ -37,9 +38,9 @@ String formatDistance(double distanceMeters, {required bool isImperial}) { } class PathTraceData { - final Uint8List pathData; + final List pathData; final List snrData; - final Map pathContacts; + final Map pathContacts; PathTraceData({ required this.pathData, @@ -48,6 +49,8 @@ class PathTraceData { }); } +String _hopKey(Uint8List hopBytes) => PathHelper.formatHopHex(hopBytes); + class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; @@ -89,8 +92,8 @@ class _PathTraceMapScreenState extends State { bool _failed2Loaded = false; bool _hasData = false; PathTraceData? _traceData; - // Inferred positions for hops that have no GPS location, keyed by hop byte. - Map _inferredHopPositions = {}; + // Inferred positions for hops that have no GPS location, keyed by hop prefix. + Map _inferredHopPositions = {}; // Endpoint position for the target contact (GPS or guessed). LatLng? _targetContactPosition; bool _targetContactIsGuessed = false; @@ -105,8 +108,8 @@ class _PathTraceMapScreenState extends State { Contact? _targetContact; String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) + .map(PathHelper.formatHopHex) .join(','); } @@ -188,44 +191,43 @@ class _PathTraceMapScreenState extends State { } Uint8List buildPath(Uint8List pathBytes) { - Uint8List traceBytes; + final pathHops = PathHelper.splitPathBytes( + pathBytes, + widget.pathHashByteWidth, + ); + final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); - if (pathBytes.isEmpty) { + if (pathHops.isEmpty) { final pk = widget.targetContact?.publicKey; - final n = widget.pathHashByteWidth.clamp(1, pubKeySize); - if (pk != null && pk.length >= n) { - return Uint8List.fromList(pk.sublist(0, n)); + if (pk != null && pk.length >= hopWidth) { + return Uint8List.fromList(pk.sublist(0, hopWidth)); } - traceBytes = Uint8List(1); - traceBytes[0] = pk?[0] ?? 0; - return traceBytes; + return Uint8List.fromList( + pk != null && pk.isNotEmpty ? [pk[0]] : const [0], + ); } + final mirroredHops = [...pathHops]; if (widget.targetContact?.type == advTypeRepeater || widget.targetContact?.type == advTypeRoom) { - final len = (pathBytes.length + pathBytes.length + 1); - traceBytes = Uint8List(len); - traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0; - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length) { - traceBytes[len - 1 - i] = pathBytes[i]; - } - } - } else { - if (pathBytes.length < 2) { - return pathBytes[0] == 0 ? Uint8List(0) : pathBytes; - } - final len = (pathBytes.length + pathBytes.length - 1); - traceBytes = Uint8List(len); - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length - 1) { - traceBytes[len - 1 - i] = pathBytes[i]; - } + final pk = widget.targetContact?.publicKey; + if (pk != null && pk.length >= hopWidth) { + mirroredHops.add(Uint8List.fromList(pk.sublist(0, hopWidth))); + } else { + mirroredHops.add( + Uint8List.fromList( + pk != null && pk.isNotEmpty ? [pk[0]] : const [0], + ), + ); } } - return traceBytes; + mirroredHops.addAll(pathHops.reversed); + + final traceBytes = []; + for (final hop in mirroredHops) { + traceBytes.addAll(hop); + } + return Uint8List.fromList(traceBytes); } Future _doPathTrace() async { @@ -329,13 +331,17 @@ class _PathTraceMapScreenState extends State { int pathLength = buffer.readUInt8(); buffer.skipBytes(5); // Skip Flag byte and tag data buffer.skipBytes(4); // Skip auth code - Uint8List pathData = buffer.readBytes(pathLength); + final pathBytes = buffer.readBytes(pathLength); + final pathData = PathHelper.splitPathBytes( + pathBytes, + widget.pathHashByteWidth, + ); List snrData = buffer .readRemainingBytes() .map((snr) => snr.toSigned(8).toDouble() / 4) .toList(); - Map pathContacts = {}; + Map pathContacts = {}; Contact lastContact = Contact( path: Uint8List(0), pathLength: 0, @@ -347,7 +353,12 @@ class _PathTraceMapScreenState extends State { lastSeen: DateTime.now(), ); if (widget.pathContacts != null) { - pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: c}; + final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); + pathContacts = { + for (var c in widget.pathContacts!) + if (c.publicKey.length >= hopWidth) + _hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c, + }; } else { final contacts = connector.allContactsUnfiltered; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { @@ -362,12 +373,11 @@ class _PathTraceMapScreenState extends State { _maxRepeaterMatchDistanceMeters) { return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches } - for (var repeaterData in pathData) { - if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([repeaterData]), - )) { - pathContacts[repeaterData] = repeater; + for (final repeaterData in pathData) { + final hopWidth = repeaterData.length; + if (repeater.publicKey.length < hopWidth) continue; + if (listEquals(repeater.publicKey.sublist(0, hopWidth), repeaterData)) { + pathContacts[_hopKey(repeaterData)] = repeater; lastContact = repeater; } } @@ -376,13 +386,17 @@ class _PathTraceMapScreenState extends State { // For hops with no GPS contact, infer position from other contacts // with known GPS that share the same last-hop byte. - final Map inferredPositions = {}; + final Map inferredPositions = {}; for (final hop in pathData) { - final contact = pathContacts[hop]; + final hopKey = _hopKey(hop); + final contact = pathContacts[hopKey]; if (contact != null && contact.hasLocation) continue; final peers = connector.contacts .where( - (c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop, + (c) => + c.hasLocation && + c.path.isNotEmpty && + _hopKey(Uint8List.fromList([c.path.last])) == hopKey, ) .toList(); if (peers.isNotEmpty) { @@ -392,7 +406,7 @@ class _PathTraceMapScreenState extends State { final lon = peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; - inferredPositions[hop] = LatLng(lat, lon); + inferredPositions[hopKey] = LatLng(lat, lon); } } @@ -414,20 +428,21 @@ class _PathTraceMapScreenState extends State { final tc = _targetContact!; if (tc.hasLocation) { targetPos = LatLng(tc.latitude!, tc.longitude!); - } else if (widget.path.length > 1) { + } else if (pathData.length > 1) { // Infer from the last hop: average GPS contacts sharing that hop. // For a round-trip path (flipPathAround/reversePathAround), the target-side hop // sits in the middle of the symmetric sequence; .last is the local side. final lastHop = widget.reversePathAround - ? widget.path.first - : widget.path.last; + ? pathData.first + : pathData.last; + final lastHopKey = _hopKey(lastHop); final peers = connector.allContacts .where( (c) => c.hasLocation && c.path.isNotEmpty && - c.path.last == lastHop, + _hopKey(Uint8List.fromList([c.path.last])) == lastHopKey, ) .toList(); if (peers.isNotEmpty) { @@ -444,9 +459,9 @@ class _PathTraceMapScreenState extends State { lon + offsetDeg * sin(angle), ); targetGuessed = true; - } else if (inferredPositions.containsKey(lastHop)) { - final lat = inferredPositions[lastHop]!.latitude; - final lon = inferredPositions[lastHop]!.longitude; + } else if (inferredPositions.containsKey(lastHopKey)) { + final lat = inferredPositions[lastHopKey]!.latitude; + final lon = inferredPositions[lastHopKey]!.longitude; const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( @@ -456,7 +471,7 @@ class _PathTraceMapScreenState extends State { targetGuessed = true; } else { // As a last resort, just place it at the same position as the last hop. - final contact = pathContacts[lastHop]; + final contact = pathContacts[lastHopKey]; if (contact != null && contact.hasLocation) { const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; @@ -474,21 +489,22 @@ class _PathTraceMapScreenState extends State { _points = []; _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); - int hopLast = 0; - int hopLastLast = 0; + String hopLast = ''; + String hopLastLast = ''; for (final hop in _traceData!.pathData) { - if (hop == hopLastLast && widget.flipPathAround) { + final hopKey = _hopKey(hop); + if (hopKey == hopLastLast && widget.flipPathAround) { break; //skip duplicate hops in round-trip paths } - final contact = _traceData!.pathContacts[hop]; + final contact = _traceData!.pathContacts[hopKey]; if (contact != null && contact.hasLocation) { _points.add(LatLng(contact.latitude!, contact.longitude!)); } else { - final inferred = inferredPositions[hop]; + final inferred = inferredPositions[hopKey]; if (inferred != null) _points.add(inferred); } hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; } if (targetPos != null) { if (_targetContact != null && _targetContact!.type == advTypeChat) { @@ -511,7 +527,7 @@ class _PathTraceMapScreenState extends State { _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; _mapKey = ValueKey( - '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + '${context.l10n.pathTrace_you},${_traceData!.pathData.map(PathHelper.formatHopHex).join(',')}', ); _pathDistanceMeters = getPathDistanceMeters(_points); }); @@ -613,29 +629,30 @@ class _PathTraceMapScreenState extends State { } List _buildHopMarkers( - List pathData, { + List pathData, { required bool showLabels, required Contact? target, }) { final markers = []; - int hopLast = 0; - int hopLastLast = 0; + String hopLast = ''; + String hopLastLast = ''; for (final hop in pathData) { - final contact = _traceData!.pathContacts[hop]; - final inferred = _inferredHopPositions[hop]; + final hopKey = _hopKey(hop); + final contact = _traceData!.pathContacts[hopKey]; + final inferred = _inferredHopPositions[hopKey]; final hasGps = contact != null && contact.hasLocation; - if (hop == hopLastLast && widget.flipPathAround) { + if (hopKey == hopLastLast && widget.flipPathAround) { continue; //skip duplicate hops in round-trip paths } if (!hasGps && inferred == null) { hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; continue; //skip hops with no GPS and no inferred position } final point = hasGps ? LatLng(contact.latitude!, contact.longitude!) : inferred!; - final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final label = PathHelper.formatHopHex(hop); markers.add( Marker( @@ -679,7 +696,7 @@ class _PathTraceMapScreenState extends State { ); } hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; } final selfLat = context.read().selfLatitude; @@ -807,29 +824,24 @@ class _PathTraceMapScreenState extends State { } String formatDirectionText(PathTraceData pathTraceData, int index) { - if (index == 0 || index == pathTraceData.snrData.length - 1) { + if (pathTraceData.pathData.isEmpty) { + return context.l10n.pathTrace_you; + } + if (index == 0 || index == pathTraceData.pathData.length - 1) { if (index == 0) { return context.l10n.pathTrace_you; } else { - final contactName = pathTraceData - .pathContacts[pathTraceData.pathData[pathTraceData.pathData.length - - 1]] - ?.name; - final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData.last; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } else { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name; - final hex = pathTraceData.pathData[index - 1] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData[index - 1]; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -837,14 +849,14 @@ class _PathTraceMapScreenState extends State { } String formatDirectionSubText(PathTraceData pathTraceData, int index) { - if (index == 0 || index == pathTraceData.snrData.length - 1) { + if (pathTraceData.pathData.isEmpty) { + return context.l10n.pathTrace_you; + } + if (index == 0 || index == pathTraceData.pathData.length - 1) { if (index == 0) { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name; - final hex = pathTraceData.pathData[0] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData.first; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -852,12 +864,9 @@ class _PathTraceMapScreenState extends State { return context.l10n.pathTrace_you; } } else { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name; - final hex = pathTraceData.pathData[index] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData[index]; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index b367e0e5..708eabff 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -27,6 +27,10 @@ class NotificationService { AppLocalizations get _l10n => lookupAppLocalizations(_locale); + String _logSafe(String value) { + return Uri.encodeComponent(value.replaceAll('\n', ' ')); + } + // Rate limiting to prevent notification storms // (Added after getting notification-flooded while evaluating RF flood management. The irony.) static const _minNotificationInterval = Duration(seconds: 3); @@ -322,11 +326,11 @@ class NotificationService { String _getNotificationIdentifier(_PendingNotification n) { switch (n.type) { case _NotificationType.advert: - return n.body; + return _logSafe(n.body); case _NotificationType.message: - return 'from: ${n.title}'; + return 'from: ${_logSafe(n.title)}'; case _NotificationType.channelMessage: - return 'in: ${n.title}'; + return 'in: ${_logSafe(n.title)}'; } } @@ -572,7 +576,7 @@ class NotificationService { // Show first few device names in batch summary for debugging (only if adverts exist) final deviceInfo = adverts.isNotEmpty - ? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})' + ? ' (${adverts.take(5).map((n) => _logSafe(n.body)).join(', ')}${adverts.length > 5 ? ', ...' : ''})' : ''; debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo'); diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index c1cd3d01..29a5588e 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -126,17 +126,19 @@ class _PathSelectionDialogState extends State { .toList(); final pathBytesList = []; final invalidPrefixes = []; + final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 8) * 2; for (final id in pathIds) { - if (id.length < 2) { + if (id.length < hexCharsPerHop) { invalidPrefixes.add(id); continue; } - final prefix = id.substring(0, 2); + final prefix = id.substring(0, hexCharsPerHop); try { - final byte = int.parse(prefix, radix: 16); - pathBytesList.add(byte); + for (int i = 0; i < prefix.length; i += 2) { + pathBytesList.add(int.parse(prefix.substring(i, i + 2), radix: 16)); + } } catch (e) { invalidPrefixes.add(id); } @@ -318,7 +320,7 @@ class _PathSelectionDialogState extends State { style: const TextStyle(fontSize: 14), ), subtitle: Text( - '${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, 2)}', + '${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, widget.pathHashByteWidth.clamp(1, 8) * 2)}', style: const TextStyle(fontSize: 10), ), trailing: isSelected diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index e2da8776..f873b72f 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -22,6 +22,15 @@ Contact _contact({ } void main() { + test('splitPathBytes groups bytes by hash width', () { + final hops = PathHelper.splitPathBytes([0xA1, 0xA2, 0xC1, 0xC2], 2); + + expect(hops, hasLength(2)); + expect(hops.first, equals(Uint8List.fromList([0xA1, 0xA2]))); + expect(hops.last, equals(Uint8List.fromList([0xC1, 0xC2]))); + expect(PathHelper.formatHopHex(hops.first), equals('A1A2')); + }); + test('resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths', () { final contacts = [ _contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),