diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 01d10a9..7c690d3 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -166,9 +166,14 @@ class Contact { final type = reader.readByte(); final flags = reader.readByte(); final pathLen = reader.readByte(); - final safePathLen = pathLen > 0 - ? (pathLen > maxPathSize ? maxPathSize : pathLen) - : 0; + // The firmware path-len byte packs a hash-mode hint in the high 2 bits and + // the path BYTE length in the low 6 (#222). Decode before use: a direct + // node at 2-byte mode sends 0x40, whose raw value would otherwise read as + // 64 hops and pull 64 junk bytes into the path. 0xFF stays the flood + // sentinel; the device-configured pathHashByteWidth converts bytes->hops + // at display time. + final byteLen = pathLen == 0xFF ? -1 : pathHopCount(pathLen); + final safePathLen = byteLen > 0 ? byteLen : 0; final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen); final name = reader.readCStringGreedy(maxNameSize); @@ -213,7 +218,7 @@ class Contact { name: name.isEmpty ? 'Unknown' : name, type: type, flags: flags, - pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen, + pathLength: byteLen, // decoded low-6-bit byte length; -1 = flood (#222) path: pathBytes, latitude: lat, longitude: lon, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index f90933c..677f4b1 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1239,10 +1239,17 @@ class _ChatScreenState extends State { return context.l10n.chat_hopsForced(contact.pathOverride!); } - // Use device's path + // Use device's path. pathLength is a BYTE length; divide by the device's + // configured hash width to get the true hop count (#222). if (contact.pathLength < 0) return context.l10n.chat_floodAuto; - if (contact.pathLength == 0) return context.l10n.chat_direct; - return context.l10n.chat_hopsCount(contact.pathLength); + final hops = + realHopCount( + contact.pathLength, + context.read().pathHashByteWidth, + ) ?? + 0; + if (hops == 0) return context.l10n.chat_direct; + return context.l10n.chat_hopsCount(hops); } Future _notifyPathSet( diff --git a/test/models/contact_pathlen_test.dart b/test/models/contact_pathlen_test.dart new file mode 100644 index 0000000..c89af0c --- /dev/null +++ b/test/models/contact_pathlen_test.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/contact.dart'; + +/// Builds a minimal RESP_CODE_CONTACT frame with a caller-chosen path-len byte. +Uint8List _frame({required int pathLen, List pathBytes = const []}) { + final frame = Uint8List(contactFrameSize); + frame[0] = respCodeContact; + for (var i = contactPubKeyOffset; i < contactPubKeyOffset + pubKeySize; i++) { + frame[i] = 0x11; // non-zero public key passes the zeroed-key guard + } + frame[contactTypeOffset] = advTypeRepeater; + frame[contactFlagsOffset] = 0; + frame[contactPathLenOffset] = pathLen; + for (var i = 0; i < pathBytes.length && i < maxPathSize; i++) { + frame[contactPathOffset + i] = pathBytes[i]; + } + const name = 'TestNode'; + for (var i = 0; i < name.length; i++) { + frame[contactNameOffset + i] = name.codeUnitAt(i); + } + return frame; +} + +void main() { + group('Contact.fromFrame path-len decode (#222)', () { + test('direct at 2-byte mode (0x40) -> 0 hops, empty path', () { + final c = Contact.fromFrame(_frame(pathLen: 0x40))!; + expect(c.pathLength, 0); // was 64 pre-#222 + expect(c.path, isEmpty); // was 64 junk bytes pre-#222 + }); + + test('plain direct (0x00) -> 0 hops, empty path', () { + final c = Contact.fromFrame(_frame(pathLen: 0x00))!; + expect(c.pathLength, 0); + expect(c.path, isEmpty); + }); + + test('1-byte-mode 3-byte path keeps its length and bytes', () { + final c = Contact.fromFrame( + _frame(pathLen: 0x03, pathBytes: [0xAA, 0xBB, 0xCC]), + )!; + expect(c.pathLength, 3); + expect(c.path, [0xAA, 0xBB, 0xCC]); + }); + + test('2-byte-mode multi-hop (0x44) decodes to 4 bytes, not flood', () { + // High bits = mode-1 hint, low bits = 4 bytes = a 2-hop path at 2-byte + // width. Pre-#222 the raw 68 (> maxPathSize) was mis-flagged as flood. + final c = Contact.fromFrame( + _frame(pathLen: 0x44, pathBytes: [1, 2, 3, 4]), + )!; + expect(c.pathLength, 4); + expect(c.path, [1, 2, 3, 4]); + expect( + realHopCount(c.pathLength, 2), + 2, + ); // 4 bytes / 2-byte width = 2 hops + }); + + test('flood sentinel (0xFF) stays -1, empty path', () { + final c = Contact.fromFrame(_frame(pathLen: 0xFF))!; + expect(c.pathLength, -1); + expect(c.path, isEmpty); + }); + }); +} diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart index a80c794..fc307ef 100644 --- a/test/models/model_changes_test.dart +++ b/test/models/model_changes_test.dart @@ -53,12 +53,16 @@ void main() { expect(contact!.pathLength, equals(1)); }); - test('pathLen == 64 (maxPathSize) → pathLength == 64', () { - final frame = _buildContactFrame(pathLen: maxPathSize); - final contact = Contact.fromFrame(frame); - expect(contact, isNotNull); - expect(contact!.pathLength, equals(maxPathSize)); - }); + test( + 'pathLen == 0x40 (2-byte-mode hint + 0 length) → pathLength == 0 (#222)', + () { + // High 2 bits = hash-mode hint (2-byte), low 6 bits = 0 → direct. + final frame = _buildContactFrame(pathLen: 0x40); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(0)); + }, + ); test('pathLen == 0xFF → pathLength == -1 (flood)', () { final frame = _buildContactFrame(pathLen: 0xFF); @@ -67,12 +71,16 @@ void main() { expect(contact!.pathLength, equals(-1)); }); - test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () { - final frame = _buildContactFrame(pathLen: 65); - final contact = Contact.fromFrame(frame); - expect(contact, isNotNull); - expect(contact!.pathLength, equals(-1)); - }); + test( + 'pathLen == 0x41 (2-byte-mode hint + 1 byte) → pathLength == 1 (#222)', + () { + // High 2 bits = hash-mode hint, low 6 bits = 1 → 1-byte path, not flood. + final frame = _buildContactFrame(pathLen: 0x41); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(1)); + }, + ); }); group('Contact.fromFrame — corrupt contact guards', () {