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 <noreply@anthropic.com>
pull/201/head
Strycher 3 weeks ago
parent f25848d16c
commit 09e29f6782

@ -2,30 +2,58 @@ import '../models/contact.dart';
import '../connector/meshcore_protocol.dart';
class PathHelper {
static String formatPathHex(List<int> 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<int> 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<int> pathBytes,
List<Contact> 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<List<int>> _hopGroups(List<int> pathBytes, int hashWidth) {
final w = hashWidth < 1 ? 1 : hashWidth;
final hops = <List<int>>[];
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<int> hop) =>
hop.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
static bool _startsWith(List<int> pubkey, List<int> prefix) {
for (var i = 0; i < prefix.length; i++) {
if (pubkey[i] != prefix[i]) return false;
}
return true;
}
}

@ -1118,8 +1118,13 @@ class _ChatScreenState extends State<ChatScreen> {
final connector = context.read<MeshCoreConnector>();
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,

@ -78,8 +78,13 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
final connector = context.read<MeshCoreConnector>();
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,

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

Loading…
Cancel
Save

Powered by TurnKey Linux.