fix(#156): width-aware node-prefix matching (repeater highlight, map anchors/overlaps)

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 <noreply@anthropic.com>
pull/201/head
Strycher 3 weeks ago
parent d2d7930858
commit 5931df754a

@ -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<int> 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();

@ -813,16 +813,13 @@ class _ChatScreenState extends State<ChatScreen> {
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;

@ -269,7 +269,7 @@ class _MapScreenState extends State<MapScreen> {
)
.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<MapScreen> {
allContactsWithLocation,
pathHistory,
maxRangeKm,
connector.pathHashByteWidth,
)
: [];
}
@ -698,19 +699,28 @@ class _MapScreenState extends State<MapScreen> {
List<Contact> 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 = <int, Contact?>{};
// 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<int> bytes, int start) {
var v = 0;
for (var i = 0; i < w; i++) {
v = (v << 8) | bytes[start + i];
}
return v;
}
final repeaterByHash = <int, Contact?>{};
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<MapScreen> {
.getRecentPaths(contact.publicKeyHex)
.map((r) => r.pathBytes),
];
final lastHopBytes = <int>{};
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<MapScreen> {
}) {
List<Contact> 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<MeshCoreConnector>().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<MapScreen> {
.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<MapScreen> {
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<Marker> _buildMarkers(
List<Contact> contacts,
settings, {

@ -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;

@ -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

@ -86,4 +86,30 @@ void main() {
expect(r.prefixHex, '84');
});
});
group('DirectRepeater.matchesPathStart (#156)', () {
DirectRepeater repeater(List<int> 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(<int>[]), isFalse);
});
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.