From 5931df754a79ac5bbd7e7699e0e862778bb25524 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 29 Jun 2026 03:57:22 -0400 Subject: [PATCH] fix(#156): width-aware node-prefix matching (repeater highlight, map anchors/overlaps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three routing-match sites keyed/compared nodes by only the first public-key byte, which collides when two nodes share a first byte on a 2-/3-byte path-hash mesh: - DirectRepeater.matchesPathStart() replaces the 1-byte `pubkeyFirstByte == pathBytes.first` direct-repeater check in chat_screen and path_management_dialog (x3 each). - map _computeGuessedLocations packs the full configured-width prefix as the repeaterByHash key and looks up the contact-side hop at the same width — was keying publicKey[0] but reading pathBytes.last, a silent miss at width>1 that stopped location-guessing on multi-byte meshes. - map _filterContactsBySettings overlap detection compares the configured-width prefix via _samePubkeyPrefix (was publicKey.first). Adds DirectRepeater.matchesPathStart unit tests. Bumps build to +46 for the combined path-hash sweep test binary (b46: #151+#154+#155+#156). Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 10 +++ lib/screens/chat_screen.dart | 9 +-- lib/screens/map_screen.dart | 62 ++++++++++++++----- lib/widgets/path_management_dialog.dart | 9 +-- pubspec.yaml | 2 +- .../direct_repeater_ranking_test.dart | 26 ++++++++ 6 files changed, 88 insertions(+), 30 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 2a28cc1..8f2ebdb 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -68,6 +68,16 @@ class DirectRepeater { String get prefixHex => pubkeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + /// True if [pathBytes] begins with this repeater's full configured-width + /// prefix — the width-aware replacement for a 1-byte first-hop match. (#156) + bool matchesPathStart(List pathBytes) { + if (pathBytes.length < pubkeyPrefix.length) return false; + for (var i = 0; i < pubkeyPrefix.length; i++) { + if (pathBytes[i] != pubkeyPrefix[i]) return false; + } + return true; + } + void update(double newSNR) { snr = newSNR; lastUpdated = DateTime.now(); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2bf23ad..9b9a477 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -813,16 +813,13 @@ class _ChatScreenState extends State { pathsWithRepeaters = paths.map((path) { final isDirectRepeater = directRepeater != null && - path.pathBytes.isNotEmpty && - directRepeater.pubkeyFirstByte == path.pathBytes.first; + directRepeater.matchesPathStart(path.pathBytes); final isSecondDirectRepeater = secondDirectRepeater != null && - path.pathBytes.isNotEmpty && - secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + secondDirectRepeater.matchesPathStart(path.pathBytes); final isThirdDirectRepeater = thirdDirectRepeater != null && - path.pathBytes.isNotEmpty && - thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + thirdDirectRepeater.matchesPathStart(path.pathBytes); int ranking = -1; Color color = Colors.grey; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 766b185..75c9d4a 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -269,7 +269,7 @@ class _MapScreenState extends State { ) .join(','); final cacheKey = - '$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}'; + '$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${connector.pathHashByteWidth}:${settings.mapShowGuessedLocations}'; if (cacheKey != _guessedLocationsCacheKey) { _guessedLocationsCacheKey = cacheKey; _cachedGuessedLocations = settings.mapShowGuessedLocations @@ -278,6 +278,7 @@ class _MapScreenState extends State { allContactsWithLocation, pathHistory, maxRangeKm, + connector.pathHashByteWidth, ) : []; } @@ -698,19 +699,28 @@ class _MapScreenState extends State { List withLocation, PathHistoryService pathHistory, double? maxRangeKm, + int pathHashByteWidth, ) { - // Index known-location repeaters by their 1-byte hash. - // null value = two repeaters share the same hash byte (ambiguous collision). - final repeaterByHash = {}; + // Index known-location repeaters by their configured-width hash prefix, + // packed into an int key. null value = two repeaters share that prefix + // (ambiguous collision). Width-aware so 2-/3-byte meshes disambiguate + // instead of colliding on the first byte alone. (#156) + final w = pathHashByteWidth < 1 ? 1 : pathHashByteWidth; + int packPrefix(List bytes, int start) { + var v = 0; + for (var i = 0; i < w; i++) { + v = (v << 8) | bytes[start + i]; + } + return v; + } + final repeaterByHash = {}; for (final c in withLocation) { - if (c.type == advTypeRepeater) { - if (repeaterByHash.containsKey(c.publicKey[0])) { - repeaterByHash[c.publicKey[0]] = - null; // collision: can't disambiguate - } else { - repeaterByHash[c.publicKey[0]] = c; - } + if (c.type == advTypeRepeater && c.publicKey.length >= w) { + final key = packPrefix(c.publicKey, 0); + repeaterByHash[key] = repeaterByHash.containsKey(key) + ? null // collision: can't disambiguate + : c; } } @@ -736,12 +746,13 @@ class _MapScreenState extends State { .getRecentPaths(contact.publicKeyHex) .map((r) => r.pathBytes), ]; - final lastHopBytes = {}; for (final pathBytes in pathSets) { - if (pathBytes.isEmpty) continue; - final lastHop = pathBytes.last; - lastHopBytes.add(lastHop); - final r = repeaterByHash[lastHop]; + if (pathBytes.length < w) continue; + // Anchor on the contact-side hop. Paths are stored device-side-first + // (Contact.path is the reversed wire path), so the contact-side repeater + // is the trailing w bytes — the same hop the 1-byte code read via + // pathBytes.last, just widened to the configured width. + final r = repeaterByHash[packPrefix(pathBytes, pathBytes.length - w)]; if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!)); } @@ -965,6 +976,13 @@ class _MapScreenState extends State { }) { List filtered = []; bool addContact = false; + // Overlap detection compares the configured-width hash prefix, so 2-/3-byte + // meshes only flag true routing collisions, not incidental 1-byte ones. (#156) + int overlapWidth = 1; + if (settings.mapShowOverlaps) { + final hw = context.read().pathHashByteWidth; + overlapWidth = hw < 1 ? 1 : hw; + } for (final contact in contacts) { addContact = false; if (!contact.hasLocation && !noLocations) { @@ -999,7 +1017,7 @@ class _MapScreenState extends State { .where( (c) => c.publicKeyHex != contact.publicKeyHex && - c.publicKey.first == contact.publicKey.first && + _samePubkeyPrefix(c, contact, overlapWidth) && (c.type == advTypeRepeater || c.type == advTypeRoom) && (contact.type == advTypeRepeater || contact.type == advTypeRoom), @@ -1020,6 +1038,16 @@ class _MapScreenState extends State { return filtered; } + /// True if two contacts share their first [width] public-key bytes — the + /// width-aware replacement for a 1-byte first-byte comparison. (#156) + bool _samePubkeyPrefix(Contact a, Contact b, int width) { + if (a.publicKey.length < width || b.publicKey.length < width) return false; + for (var i = 0; i < width; i++) { + if (a.publicKey[i] != b.publicKey[i]) return false; + } + return true; + } + List _buildMarkers( List contacts, settings, { diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 6c9117a..afaf7ed 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -203,16 +203,13 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { paths.map((path) { final isDirectRepeater = directRepeater != null && - path.pathBytes.isNotEmpty && - directRepeater.pubkeyFirstByte == path.pathBytes.first; + directRepeater.matchesPathStart(path.pathBytes); final isSecondDirectRepeater = secondDirectRepeater != null && - path.pathBytes.isNotEmpty && - secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + secondDirectRepeater.matchesPathStart(path.pathBytes); final isThirdDirectRepeater = thirdDirectRepeater != null && - path.pathBytes.isNotEmpty && - thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + thirdDirectRepeater.matchesPathStart(path.pathBytes); int ranking = -1; Color color = Colors.grey; diff --git a/pubspec.yaml b/pubspec.yaml index d5f1b4a..05393a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.2-rc.1+45 +version: 1.1.2-rc.1+46 environment: sdk: ^3.9.2 diff --git a/test/connector/direct_repeater_ranking_test.dart b/test/connector/direct_repeater_ranking_test.dart index f66718c..eea67cc 100644 --- a/test/connector/direct_repeater_ranking_test.dart +++ b/test/connector/direct_repeater_ranking_test.dart @@ -86,4 +86,30 @@ void main() { expect(r.prefixHex, '84'); }); }); + + group('DirectRepeater.matchesPathStart (#156)', () { + DirectRepeater repeater(List prefix) => DirectRepeater( + pubkeyFirstByte: prefix.first, + pubkeyPrefix: Uint8List.fromList(prefix), + snr: 12.0, + ); + + test('width 1 — matches on the single prefix byte', () { + expect(repeater([0x84]).matchesPathStart([0x84, 0xab]), isTrue); + expect(repeater([0x84]).matchesPathStart([0x99]), isFalse); + }); + + test('width 2 — both bytes must match, so a 1-byte collision misses', () { + final r = repeater([0x84, 0xab]); + expect(r.matchesPathStart([0x84, 0xab, 0xc1]), isTrue); + // Shares only the first byte — the #156 collision that a 1-byte match + // would have falsely accepted. + expect(r.matchesPathStart([0x84, 0x99]), isFalse); + }); + + test('a path shorter than the prefix never matches', () { + expect(repeater([0x84, 0xab]).matchesPathStart([0x84]), isFalse); + expect(repeater([0x84]).matchesPathStart([]), isFalse); + }); + }); }