diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d341feeb..f2d8040c 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -49,15 +49,33 @@ import 'meshcore_protocol.dart'; class DirectRepeater { static const int maxAgeMinutes = 30; // Max age for direct repeater info - final int pubkeyFirstByte; + final List pubkeyPrefix; double snr; DateTime lastUpdated; DirectRepeater({ - required this.pubkeyFirstByte, + required List pubkeyPrefix, required this.snr, DateTime? lastUpdated, - }) : lastUpdated = lastUpdated ?? DateTime.now(); + }) : pubkeyPrefix = List.unmodifiable(pubkeyPrefix), + lastUpdated = lastUpdated ?? DateTime.now(); + + String get pubkeyPrefixHex => pubkeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + + bool matchesPrefix(List prefix) { + return pubkeyPrefix.length == prefix.length && + listEquals(pubkeyPrefix, prefix); + } + + bool matchesPathStart(List pathBytes, int hashByteWidth) { + final width = hashByteWidth.clamp(1, 4).toInt(); + if (pathBytes.length < width || pubkeyPrefix.length != width) { + return false; + } + return listEquals(pubkeyPrefix, pathBytes.sublist(0, width)); + } void update(double newSNR) { snr = newSNR; @@ -3853,12 +3871,16 @@ class MeshCoreConnector extends ChangeNotifier { _clientRepeat = frame[80] != 0; } // Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop + final previousPathHashByteWidth = _pathHashByteWidth; if (frame.length >= 82) { final mode = (frame[81] & 0xFF).clamp(0, 3); _pathHashByteWidth = mode + 1; } else { _pathHashByteWidth = 1; } + if (_pathHashByteWidth != previousPathHashByteWidth) { + _directRepeaters.clear(); + } // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; @@ -4298,7 +4320,11 @@ class MeshCoreConnector extends ChangeNotifier { for (var i = 0; i < _contacts.length; i++) { final contact = _contacts[i]; if (contact.type != advTypeChat) continue; - if (_pathMatchesContact(pathBytes, contact.publicKey, pathHashWidth: pathHashWidth)) { + if (_pathMatchesContact( + pathBytes, + contact.publicKey, + pathHashWidth: pathHashWidth, + )) { matches.add(i); } } @@ -4315,7 +4341,11 @@ class MeshCoreConnector extends ChangeNotifier { } } - bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey, {int? pathHashWidth}) { + bool _pathMatchesContact( + Uint8List pathBytes, + Uint8List publicKey, { + int? pathHashWidth, + }) { final w = pathHashWidth ?? _pathHashByteWidth; if (pathBytes.isEmpty || publicKey.length < w) return false; for (int i = 0; i + w <= pathBytes.length; i += w) { @@ -6170,9 +6200,13 @@ class MeshCoreConnector extends ChangeNotifier { } void _updateDirectRepeater(Contact contact, double snr, Uint8List path) { - final pubkeyFirstByte = path.isNotEmpty - ? path.last - : contact.publicKey.first; + final hashWidth = _pathHashByteWidth.clamp(1, 4).toInt(); + final pubkeyPrefix = path.isNotEmpty + ? path.sublist(math.max(0, path.length - hashWidth)) + : contact.publicKey.sublist( + 0, + math.min(hashWidth, contact.publicKey.length), + ); _directRepeaters.removeWhere((r) => r.isStale()); @@ -6184,7 +6218,7 @@ class MeshCoreConnector extends ChangeNotifier { } final isTracked = _directRepeaters.where( - (r) => r.pubkeyFirstByte == pubkeyFirstByte, + (r) => r.matchesPrefix(pubkeyPrefix), ); final sortedRepeaters = List.from(_directRepeaters) @@ -6204,7 +6238,7 @@ class MeshCoreConnector extends ChangeNotifier { repeater.update(snr); } else if (_directRepeaters.length < 5) { _directRepeaters.add( - DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr), + DirectRepeater(pubkeyPrefix: pubkeyPrefix, snr: snr), ); } notifyListeners(); diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index 3109de90..1971060c 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/foundation.dart'; import '../models/contact.dart'; import '../connector/meshcore_protocol.dart'; @@ -17,7 +15,10 @@ class PathHelper { .join(); } - static List splitPathBytes(List pathBytes, int hashByteWidth) { + static List splitPathBytes( + List pathBytes, + int hashByteWidth, + ) { if (pathBytes.isEmpty) return const []; final width = hashByteWidth.clamp(1, 4); @@ -51,18 +52,13 @@ class PathHelper { final hex = formatHopHex(hopBytes); // Find matching contacts by comparing public key prefix - final matches = allContacts - .where((c) { - if (c.publicKey.length < hopBytes.length) return false; - if (c.type != advTypeRepeater && c.type != advTypeRoom) return false; - // Compare bytes using listEquals for multi-byte support - return listEquals( - c.publicKey.sublist(0, hopBytes.length), - hopBytes, - ); - }) - .toList(); - + final matches = allContacts.where((c) { + if (c.publicKey.length < hopBytes.length) return false; + if (c.type != advTypeRepeater && c.type != advTypeRoom) return false; + // Compare bytes using listEquals for multi-byte support + return listEquals(c.publicKey.sublist(0, hopBytes.length), hopBytes); + }).toList(); + if (matches.isEmpty) { parts.add(hex); } else if (matches.length == 1) { @@ -71,7 +67,7 @@ class PathHelper { parts.add(matches.map((c) => c.name).join(' | ')); } } - + return parts.join(' \u2192 '); } } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index f6a0c1c5..00e0c7d2 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -805,16 +805,22 @@ class _ChatScreenState extends State { pathsWithRepeaters = paths.map((path) { final isDirectRepeater = directRepeater != null && - path.pathBytes.isNotEmpty && - directRepeater.pubkeyFirstByte == path.pathBytes.first; + directRepeater.matchesPathStart( + path.pathBytes, + connector.pathHashByteWidth, + ); final isSecondDirectRepeater = secondDirectRepeater != null && - path.pathBytes.isNotEmpty && - secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + secondDirectRepeater.matchesPathStart( + path.pathBytes, + connector.pathHashByteWidth, + ); final isThirdDirectRepeater = thirdDirectRepeater != null && - path.pathBytes.isNotEmpty && - thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + thirdDirectRepeater.matchesPathStart( + path.pathBytes, + connector.pathHashByteWidth, + ); int ranking = -1; Color color = Colors.grey; diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a4cc35ca..d320df11 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1266,7 +1266,7 @@ class _ContactsScreenState extends State MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPing, - path: Uint8List.fromList([contact.publicKey.first]), + path: _contactPathPrefix(contact, hw), targetContact: contact, pathHashByteWidth: hw, ), @@ -1299,7 +1299,7 @@ class _ContactsScreenState extends State : context.l10n.contacts_roomPing, path: contact.pathBytesForDisplay.isNotEmpty ? contact.pathBytesForDisplay - : Uint8List.fromList([contact.publicKey.first]), + : _contactPathPrefix(contact, hw), flipPathAround: contact.pathBytesForDisplay.isNotEmpty, targetContact: contact, pathHashByteWidth: hw, @@ -1416,6 +1416,16 @@ class _ContactsScreenState extends State ); } + Uint8List _contactPathPrefix(Contact contact, int hashByteWidth) { + if (contact.publicKey.isEmpty) return Uint8List(0); + final width = hashByteWidth + .clamp(1, pubKeySize) + .toInt() + .clamp(1, contact.publicKey.length) + .toInt(); + return Uint8List.fromList(contact.publicKey.sublist(0, width)); + } + void _confirmDelete( BuildContext context, MeshCoreConnector connector, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f9581f91..d11b8ab8 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -728,7 +728,10 @@ class _MapScreenState extends State { for (final repeater in withLocation) { if (repeater.type != advTypeRepeater) continue; if (repeater.publicKey.length < lastHop.length) continue; - if (!listEquals(repeater.publicKey.sublist(0, lastHop.length), lastHop)) { + if (!listEquals( + repeater.publicKey.sublist(0, lastHop.length), + lastHop, + )) { continue; } anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!)); @@ -986,11 +989,21 @@ class _MapScreenState extends State { } if (settings.mapShowOverlaps) { + final hopWidth = context + .read() + .pathHashByteWidth + .clamp(1, pubKeySize) + .toInt(); final hasOverlap = contacts .where( (c) => c.publicKeyHex != contact.publicKeyHex && - c.publicKey.first == contact.publicKey.first && + c.publicKey.length >= hopWidth && + contact.publicKey.length >= hopWidth && + listEquals( + c.publicKey.sublist(0, hopWidth), + contact.publicKey.sublist(0, hopWidth), + ) && (c.type == advTypeRepeater || c.type == advTypeRoom) && (contact.type == advTypeRepeater || contact.type == advTypeRoom), diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index aa877369..a110b176 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -202,16 +202,22 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { paths.map((path) { final isDirectRepeater = directRepeater != null && - path.pathBytes.isNotEmpty && - directRepeater.pubkeyFirstByte == path.pathBytes.first; + directRepeater.matchesPathStart( + path.pathBytes, + connector.pathHashByteWidth, + ); final isSecondDirectRepeater = secondDirectRepeater != null && - path.pathBytes.isNotEmpty && - secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + secondDirectRepeater.matchesPathStart( + path.pathBytes, + connector.pathHashByteWidth, + ); final isThirdDirectRepeater = thirdDirectRepeater != null && - path.pathBytes.isNotEmpty && - thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + thirdDirectRepeater.matchesPathStart( + path.pathBytes, + connector.pathHashByteWidth, + ); int ranking = -1; Color color = Colors.grey; diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index 99f20539..4850393f 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -1,23 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/path_helper.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import 'signal_ui.dart'; Contact? _getRepeaterPrefixMatchNearLocation( List contacts, - int pubkeyFirstByte, { + List pubkeyPrefix, { LatLng? searchPoint, bool preferFavorites = false, }) { final candidates = contacts .where( (c) => - c.publicKey.isNotEmpty && - c.publicKey.first == pubkeyFirstByte && + c.publicKey.length >= pubkeyPrefix.length && + listEquals( + c.publicKey.sublist(0, pubkeyPrefix.length), + pubkeyPrefix, + ) && (c.type == advTypeRepeater || c.type == advTypeRoom), ) .toList(); @@ -162,7 +167,7 @@ class _SNRIndicatorState extends State { ), if (directRepeater != null) Text( - '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', + '${directRepeaters.length}: ${directRepeater.pubkeyPrefixHex}: ${_formatLastUpdated(directRepeater.lastUpdated)}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -236,23 +241,21 @@ class _SNRIndicatorState extends State { final contact = _getRepeaterPrefixMatchNearLocation( allContacts, - repeater.pubkeyFirstByte, + repeater.pubkeyPrefix, searchPoint: selfPoint, preferFavorites: true, ); final name = contact?.name; + final prefixLabel = PathHelper.formatHopHex( + repeater.pubkeyPrefix, + ); return Column( children: [ ListTile( leading: Icon(snrUi.icon, color: snrUi.color), - title: Text( - name ?? - repeater.pubkeyFirstByte - .toRadixString(16) - .padLeft(2, '0'), - ), + title: Text(name ?? prefixLabel), subtitle: Text( 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', ),