From 09e29f67829391ce206a67642b056941f49e91e0 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 28 Jun 2026 18:08:22 -0400 Subject: [PATCH] fix(#154): make PathHelper width-aware (chat + path-management path displays) PathHelper.formatPathHex / resolvePathNames assumed 1 byte per hop, so the direct-chat "full path" and path-management dialogs rendered routing paths per-byte (84,AB instead of 84AB) and resolved hop names by a single byte on 2-/3-byte path-hash meshes. Group bytes into pathHashByteWidth-sized hops and match hop names by the full-width prefix. - formatPathHex(bytes, width) / resolvePathNames(bytes, contacts, width) group into width-byte hops (trailing partial hop kept); name match uses the full hop prefix. - chat_screen + path_management_dialog pass connector.pathHashByteWidth. analyze clean; 7 PathHelper tests (incl. width-2 collision-avoidance); Gemini: ship. Closes #154 Co-Authored-By: Claude Opus 4.8 --- lib/helpers/path_helper.dart | 48 ++++++++++++++---- lib/screens/chat_screen.dart | 9 +++- lib/widgets/path_management_dialog.dart | 9 +++- test/helpers/path_helper_test.dart | 66 +++++++++++++++++++++---- 4 files changed, 108 insertions(+), 24 deletions(-) diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index fe51d63..2b271e4 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -2,30 +2,58 @@ import '../models/contact.dart'; import '../connector/meshcore_protocol.dart'; class PathHelper { - static String formatPathHex(List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); + /// Path bytes as comma-separated hops, each hop [hashWidth] bytes of hex. (#154) + static String formatPathHex(List pathBytes, int hashWidth) { + return _hopGroups(pathBytes, hashWidth).map(_hopHex).join(','); } + /// Resolves each [hashWidth]-byte hop to a repeater/room name, falling back to + /// the hop's hex prefix when no contact matches. (#154) static String resolvePathNames( List pathBytes, List allContacts, + int hashWidth, ) { - return pathBytes - .map((b) { - final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase(); + return _hopGroups(pathBytes, hashWidth) + .map((hop) { final matches = allContacts .where( (c) => - c.publicKey.first == b && + c.publicKey.length >= hop.length && + _startsWith(c.publicKey, hop) && (c.type == advTypeRepeater || c.type == advTypeRoom), ) .toList(); - if (matches.isEmpty) return hex; + if (matches.isEmpty) return _hopHex(hop); if (matches.length == 1) return matches.first.name; return matches.map((c) => c.name).join(' | '); }) - .join(' \u2192 '); + .join(' → '); + } + + /// Splits [pathBytes] into hops of [hashWidth] bytes (a trailing partial hop, + /// from a malformed path, is kept rather than dropped). + static List> _hopGroups(List pathBytes, int hashWidth) { + final w = hashWidth < 1 ? 1 : hashWidth; + final hops = >[]; + for (var i = 0; i < pathBytes.length; i += w) { + hops.add( + pathBytes.sublist( + i, + i + w > pathBytes.length ? pathBytes.length : i + w, + ), + ); + } + return hops; + } + + static String _hopHex(List hop) => + hop.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(); + + static bool _startsWith(List pubkey, List prefix) { + for (var i = 0; i < prefix.length; i++) { + if (pubkey[i] != prefix[i]) return false; + } + return true; } } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 3ef2364..e0d3550 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1118,8 +1118,13 @@ class _ChatScreenState extends State { final connector = context.read(); final allContacts = connector.allContacts; - final formattedPath = PathHelper.formatPathHex(pathBytes); - final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); + final hashWidth = connector.pathHashByteWidth; + final formattedPath = PathHelper.formatPathHex(pathBytes, hashWidth); + final resolvedNames = PathHelper.resolvePathNames( + pathBytes, + allContacts, + hashWidth, + ); showDialog( context: context, diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index a2122f4..784bcf1 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -78,8 +78,13 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { final connector = context.read(); final allContacts = connector.allContacts; - final formattedPath = PathHelper.formatPathHex(pathBytes); - final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); + final hashWidth = connector.pathHashByteWidth; + final formattedPath = PathHelper.formatPathHex(pathBytes, hashWidth); + final resolvedNames = PathHelper.resolvePathNames( + pathBytes, + allContacts, + hashWidth, + ); showDialog( context: context, diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index 38abf2c..e26368f 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -6,11 +6,14 @@ import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/models/contact.dart'; Contact _contact({ - required int firstByte, + required List prefix, required String name, required int type, }) { - final key = Uint8List(32)..[0] = firstByte; + final key = Uint8List(32); + for (var i = 0; i < prefix.length; i++) { + key[i] = prefix[i]; + } return Contact( publicKey: key, name: name, @@ -22,15 +25,58 @@ Contact _contact({ } void main() { - test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () { - final contacts = [ - _contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat), - _contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater), - _contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom), - ]; + group('PathHelper.formatPathHex (#154)', () { + test('width 1 — one hop per byte, comma-separated', () { + expect(PathHelper.formatPathHex([0x84, 0xab, 0x12], 1), '84,AB,12'); + }); - final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts); + test('width 2 — groups two bytes per hop', () { + expect(PathHelper.formatPathHex([0x84, 0xab, 0x12, 0x34], 2), '84AB,1234'); + }); - expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); + test('width 3 — groups three bytes per hop', () { + expect( + PathHelper.formatPathHex([0x01, 0x02, 0x03, 0x04, 0x05, 0x06], 3), + '010203,040506', + ); + }); + + test('trailing partial hop is kept, not dropped', () { + expect(PathHelper.formatPathHex([0x84, 0xab, 0x12], 2), '84AB,12'); + }); + + test('empty path → empty string', () { + expect(PathHelper.formatPathHex([], 2), ''); + }); + }); + + group('PathHelper.resolvePathNames (#154)', () { + test('width 1 — resolves per-byte, ignoring chat nodes', () { + final contacts = [ + _contact(prefix: [0xF2], name: 'MunTui', type: advTypeChat), + _contact(prefix: [0x7E], name: 'zrepeater', type: advTypeRepeater), + _contact(prefix: [0xBA], name: 'USS Ronald Reagan', type: advTypeRoom), + ]; + final resolved = PathHelper.resolvePathNames( + [0xF2, 0x7E, 0xBA], + contacts, + 1, + ); + expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); + }); + + test('width 2 — resolves by the full 2-byte hop prefix', () { + final contacts = [ + _contact( + prefix: [0x7E, 0xAB], + name: 'zrepeater', + type: advTypeRepeater, + ), + // Shares the first byte but differs in the second — must NOT match. + _contact(prefix: [0x7E, 0x01], name: 'decoy', type: advTypeRepeater), + ]; + final resolved = PathHelper.resolvePathNames([0x7E, 0xAB], contacts, 2); + expect(resolved, equals('zrepeater')); + }); }); }