From 2e4f26d7cdff746454670b810cff86975b298e55 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Thu, 28 May 2026 02:20:56 +0300 Subject: [PATCH 1/3] Multibyte: fix map --- lib/connector/meshcore_connector.dart | 10 +- lib/screens/channels_screen.dart | 3 +- lib/screens/map_screen.dart | 21 +- lib/screens/path_trace_map.dart | 24 +- lib/services/notification_service.dart | 6 +- test/helpers/path_helper_test.dart | 31 +-- test/models/model_changes_test.dart | 331 ++++++++++++++----------- test/screens/path_trace_test.dart | 179 +++++++------ 8 files changed, 338 insertions(+), 267 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b21bd3ed..cc627ecf 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2864,7 +2864,8 @@ class MeshCoreConnector extends ChangeNotifier { : updatedFlags; final mode = _pathHashByteWidth - 1; - final encodedPathLen = (latestContact.pathLength >= 0 && latestContact.pathLength != 0xFF) + final encodedPathLen = + (latestContact.pathLength >= 0 && latestContact.pathLength != 0xFF) ? (latestContact.pathLength | (mode << 6)) : latestContact.pathLength; await sendFrame( @@ -3208,7 +3209,8 @@ class MeshCoreConnector extends ChangeNotifier { if (!isConnected) return; final mode = _pathHashByteWidth - 1; - final encodedPathLen = (contact.pathLength >= 0 && contact.pathLength != 0xFF) + final encodedPathLen = + (contact.pathLength >= 0 && contact.pathLength != 0xFF) ? (contact.pathLength | (mode << 6)) : contact.pathLength; await sendFrame( @@ -6089,7 +6091,9 @@ class MeshCoreConnector extends ChangeNotifier { publicKey: publicKey, name: name, type: type, - pathLength: pathBytes.isEmpty ? -1 : (pathBytes.length ~/ pathHashWidth), + pathLength: pathBytes.isEmpty + ? -1 + : (pathBytes.length ~/ pathHashWidth), path: Uint8List.fromList( pathBytes.reversed.toList(), ), // Store path in reverse for easier use in outgoing messages diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 4613a8ee..6dbe1aab 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -301,8 +301,7 @@ class _ChannelsScreenState extends State ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; + onReorderItem: (oldIndex, newIndex) { final reordered = List.from( filteredChannels, ); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 7f828d9d..b6634813 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -70,6 +70,7 @@ class _MapScreenState extends State { bool _hasInitializedMap = false; bool _removedMarkersLoaded = false; final List _pathTrace = []; + final List _pathTraceHopWidths = []; final List _pathTraceContacts = []; final List _points = []; final List _polylines = []; @@ -2302,11 +2303,12 @@ class _MapScreenState extends State { final hopWidth = min( connector.pathHashByteWidth.clamp(1, pubKeySize), contact.publicKey.length, - ); + ).toInt(); setState(() { _pathTrace.addAll( contact.publicKey.sublist(0, hopWidth), ); // Add the hop-width prefix of the public key to the trace + _pathTraceHopWidths.add(hopWidth); _pathTraceContacts.add( contact.copyWith( latitude: position?.latitude ?? contact.latitude, @@ -2321,6 +2323,7 @@ class _MapScreenState extends State { setState(() { _isBuildingPathTrace = true; _pathTrace.clear(); + _pathTraceHopWidths.clear(); _pathTraceContacts.clear(); _points.clear(); _polylines.clear(); @@ -2330,8 +2333,19 @@ class _MapScreenState extends State { void _removePath() { setState(() { + final recordedHopWidth = _pathTraceHopWidths.isNotEmpty + ? _pathTraceHopWidths.removeLast() + : context.read().pathHashByteWidth.clamp( + 1, + pubKeySize, + ); + final hopByteCount = min(recordedHopWidth, _pathTrace.length).toInt(); _pathTraceContacts.removeLast(); - _pathTrace.removeLast(); // Remove last node from path trace + // A path trace hop can be wider than one byte; remove the full hash prefix. + _pathTrace.removeRange( + _pathTrace.length - hopByteCount, + _pathTrace.length, + ); _points.removeLast(); // Remove last point from points list _polylines.clear(); // Clear polylines }); @@ -2369,7 +2383,7 @@ class _MapScreenState extends State { PathHelper.splitPathBytes( _pathTrace, context.read().pathHashByteWidth, - ).map(PathHelper.formatHopHex).join(',') , + ).map(PathHelper.formatHopHex).join(','), style: TextStyle(fontSize: 18), ), // const SizedBox(height: 6), @@ -2445,6 +2459,7 @@ class _MapScreenState extends State { setState(() { _isBuildingPathTrace = false; _pathTrace.clear(); + _pathTraceHopWidths.clear(); _points.clear(); _polylines.clear(); }); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 7f18aa8a..356e8f78 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -115,9 +115,10 @@ class _PathTraceMapScreenState extends State { Uint8List? _sentTagBytes; String _formatPathPrefixes(Uint8List pathBytes) { - return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) - .map(PathHelper.formatHopHex) - .join(','); + return PathHelper.splitPathBytes( + pathBytes, + widget.pathHashByteWidth, + ).map(PathHelper.formatHopHex).join(','); } @override @@ -327,7 +328,8 @@ class _PathTraceMapScreenState extends State { // Check if it's a binary response if (frame.length >= 12 && code == pushCodeTraceData && - (listEquals(frame.sublist(4, 8), _sentTagBytes) || listEquals(frame.sublist(4, 8), tagData))) { + (listEquals(frame.sublist(4, 8), _sentTagBytes) || + listEquals(frame.sublist(4, 8), tagData))) { _timeoutTimer?.cancel(); if (!mounted) return; _handleTraceResponse(frame); @@ -370,10 +372,7 @@ class _PathTraceMapScreenState extends State { buffer.skipBytes(5); // Skip Flag byte and tag data buffer.skipBytes(4); // Skip auth code final pathBytes = buffer.readBytes(pathLength); - final pathData = PathHelper.splitPathBytes( - pathBytes, - width, - ); + final pathData = PathHelper.splitPathBytes(pathBytes, width); List snrData = buffer .readRemainingBytes() .map((snr) => snr.toSigned(8).toDouble() / 4) @@ -414,7 +413,10 @@ class _PathTraceMapScreenState extends State { for (final repeaterData in pathData) { final hopWidth = repeaterData.length; if (repeater.publicKey.length < hopWidth) continue; - if (listEquals(repeater.publicKey.sublist(0, hopWidth), repeaterData)) { + if (listEquals( + repeater.publicKey.sublist(0, hopWidth), + repeaterData, + )) { pathContacts[_hopKey(repeaterData)] = repeater; lastContact = repeater; } @@ -434,8 +436,8 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) == - hopKey, + _hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) == + hopKey, ) .toList(); if (peers.isNotEmpty) { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index f685535b..c0f30153 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -28,8 +28,8 @@ class NotificationService { AppLocalizations get _l10n => lookupAppLocalizations(_locale); String _logSafe(String value) { - final sanitized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' '); - return Uri.encodeComponent(sanitized); + final sanitized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' '); + return Uri.encodeComponent(sanitized); } // Rate limiting to prevent notification storms @@ -577,7 +577,7 @@ class NotificationService { // Show first few device names in batch summary for debugging (only if adverts exist) final deviceInfo = adverts.isNotEmpty - ? ' (${adverts.take(5).map((n) => _logSafe(n.body)).join(', ')}${adverts.length > 5 ? ', ...' : ''})' + ? ' (${adverts.take(5).map((n) => _logSafe(n.body)).join(', ')}${adverts.length > 5 ? ', ...' : ''})' : ''; debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo'); diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index f873b72f..67391c94 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -31,28 +31,31 @@ void main() { expect(PathHelper.formatHopHex(hops.first), equals('A1A2')); }); - test('resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths', () { - 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), - ]; + test( + 'resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths', + () { + 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), + ]; - final resolved = PathHelper.resolvePathNames( - [0xF2, 0x7E, 0xBA], - contacts, - 1, // 1-byte hash width - ); + final resolved = PathHelper.resolvePathNames( + [0xF2, 0x7E, 0xBA], + contacts, + 1, // 1-byte hash width + ); - expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); - }); + expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); + }, + ); test('resolvePathNames supports multi-byte paths', () { final contacts = [ _contact(firstByte: 0xA1, name: 'Repeater1', type: advTypeRepeater), _contact(firstByte: 0xC1, name: 'Room42', type: advTypeRoom), ]; - + // Contacts with 2-byte public keys contacts[0] = Contact( publicKey: Uint8List.fromList([0xA1, 0xA2, ...List.filled(30, 0)]), diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart index dd077e64..1bb6bf2d 100644 --- a/test/models/model_changes_test.dart +++ b/test/models/model_changes_test.dart @@ -6,7 +6,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/models/channel_message.dart'; import 'package:meshcore_open/models/contact.dart'; -import 'package:meshcore_open/models/message.dart'; import 'package:meshcore_open/models/path_history.dart'; import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/storage/prefs_manager.dart'; @@ -65,9 +64,7 @@ Uint8List _buildChannelMessageFrameV3({ if (hasPath && hopCount > 0) { writer.add( - Uint8List.fromList( - List.generate(hopCount * pathHashWidth, (i) => i + 1), - ), + Uint8List.fromList(List.generate(hopCount * pathHashWidth, (i) => i + 1)), ); } @@ -81,23 +78,25 @@ Uint8List _buildChannelMessageFrameV3({ void main() { group('ChannelMessage.fromFrame — V3 packed path decoding', () { - test('hasPath reads width, hop count, path bytes, and txtType correctly', - () { - final frame = _buildChannelMessageFrameV3( - pathHashWidth: 2, - hopCount: 3, - hasPath: true, - ); + test( + 'hasPath reads width, hop count, path bytes, and txtType correctly', + () { + final frame = _buildChannelMessageFrameV3( + pathHashWidth: 2, + hopCount: 3, + hasPath: true, + ); - final message = ChannelMessage.fromFrame(frame); + final message = ChannelMessage.fromFrame(frame); - expect(message, isNotNull); - expect(message!.pathHashWidth, equals(2)); - expect(message.pathLength, equals(3)); - expect(message.pathBytes.length, equals(6)); - expect(message.senderName, equals('Alice')); - expect(message.text, equals('Hello world')); - }); + expect(message, isNotNull); + expect(message!.pathHashWidth, equals(2)); + expect(message.pathLength, equals(3)); + expect(message.pathBytes.length, equals(6)); + expect(message.senderName, equals('Alice')); + expect(message.text, equals('Hello world')); + }, + ); test('no path still reads width, hop count, and txtType correctly', () { final frame = _buildChannelMessageFrameV3( @@ -473,141 +472,171 @@ void main() { await PrefsManager.initialize(); }); - test('ContactStore decodes and migrates legacy mode-encoded paths', () async { - final store = ContactStore()..publicKeyHex = '1234567890'; - - // Let's create a contact with legacy 64 path length (mode 1, 0 hops) - final rawPath = Uint8List(64); // 64 bytes of zeroes - final contactJson = [ - { - 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), - 'name': 'LegacyNode', - 'type': 2, - 'flags': 0, - 'pathLength': 64, // encoded pathLength (mode 1, 0 hops) - 'path': base64Encode(rawPath), - 'lastSeen': DateTime.now().millisecondsSinceEpoch, - 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, - 'isActive': true, - } - ]; - - final prefs = PrefsManager.instance; - await prefs.setString(store.keyFor, jsonEncode(contactJson)); - - final contacts = await store.loadContacts(); - expect(contacts, hasLength(1)); - expect(contacts.first.pathLength, equals(0)); - expect(contacts.first.path, isEmpty); - }); + test( + 'ContactStore decodes and migrates legacy mode-encoded paths', + () async { + final store = ContactStore()..publicKeyHex = '1234567890'; - test('ContactStore decodes and migrates legacy mode-1 paths with hops', () async { - final store = ContactStore()..publicKeyHex = '1234567890'; - - // Contact with legacy 65 path length (mode 1, 1 hop) - final rawPath = Uint8List(64)..[0] = 0xBB..[1] = 0xCC; - final contactJson = [ - { - 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), - 'name': 'LegacyNode2', - 'type': 2, - 'flags': 0, - 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) - 'path': base64Encode(rawPath), - 'lastSeen': DateTime.now().millisecondsSinceEpoch, - 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, - 'isActive': true, - } - ]; - - final prefs = PrefsManager.instance; - await prefs.setString(store.keyFor, jsonEncode(contactJson)); - - final contacts = await store.loadContacts(); - expect(contacts, hasLength(1)); - expect(contacts.first.pathLength, equals(1)); - expect(contacts.first.path, equals(Uint8List.fromList([0xBB, 0xCC]))); - }); + // Let's create a contact with legacy 64 path length (mode 1, 0 hops) + final rawPath = Uint8List(64); // 64 bytes of zeroes + final contactJson = [ + { + 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), + 'name': 'LegacyNode', + 'type': 2, + 'flags': 0, + 'pathLength': 64, // encoded pathLength (mode 1, 0 hops) + 'path': base64Encode(rawPath), + 'lastSeen': DateTime.now().millisecondsSinceEpoch, + 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, + 'isActive': true, + }, + ]; - test('MessageStore decodes and migrates legacy mode-encoded message paths', () async { - final store = MessageStore()..publicKeyHex = '1234567890'; - final contactKeyHex = pubKeyToHex(Uint8List(32)..[0] = 0xAA); - - final rawPath = Uint8List(64); - final messageJson = [ - { - 'senderKey': base64Encode(Uint8List(32)..[0] = 0xAA), - 'text': 'Hello', - 'timestamp': DateTime.now().millisecondsSinceEpoch, - 'isOutgoing': false, - 'status': 2, // delivered - 'messageId': 'msg_1', - 'pathLength': 64, // encoded pathLength (mode 1, 0 hops) - 'pathBytes': base64Encode(rawPath), - } - ]; - - final prefs = PrefsManager.instance; - await prefs.setString('${store.keyFor}$contactKeyHex', jsonEncode(messageJson)); - - final messages = await store.loadMessages(contactKeyHex); - expect(messages, hasLength(1)); - expect(messages.first.pathLength, equals(0)); - expect(messages.first.pathBytes, isEmpty); - }); + final prefs = PrefsManager.instance; + await prefs.setString(store.keyFor, jsonEncode(contactJson)); - test('ChannelMessageStore decodes and migrates legacy mode-encoded paths', () async { - final store = ChannelMessageStore()..publicKeyHex = '1234567890'; - final channelIndex = 1; - - final rawPath = Uint8List(64)..[0] = 0xBB..[1] = 0xCC; - final messageJson = [ - { - 'senderName': 'Alice', - 'text': 'Hello', - 'timestamp': DateTime.now().millisecondsSinceEpoch, - 'isOutgoing': false, - 'status': 2, - 'channelIndex': channelIndex, - 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) - 'pathBytes': base64Encode(rawPath), - } - ]; - - final prefs = PrefsManager.instance; - await prefs.setString('${store.keyFor}$channelIndex', jsonEncode(messageJson)); - - final messages = await store.loadChannelMessages(channelIndex); - expect(messages, hasLength(1)); - expect(messages.first.pathLength, equals(1)); - expect(messages.first.pathBytes, equals(Uint8List.fromList([0xBB, 0xCC]))); - expect(messages.first.pathHashWidth, equals(2)); - }); + final contacts = await store.loadContacts(); + expect(contacts, hasLength(1)); + expect(contacts.first.pathLength, equals(0)); + expect(contacts.first.path, isEmpty); + }, + ); - test('ContactDiscoveryStore decodes and migrates legacy mode-encoded paths', () async { - final store = ContactDiscoveryStore(); - - final rawPath = Uint8List(64)..[0] = 0x11..[1] = 0x22; - final contactJson = [ - { - 'publicKey': base64Encode(Uint8List(32)..[0] = 0xBB), - 'name': 'DiscoveredNode', - 'type': 1, - 'flags': 0, - 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) - 'path': base64Encode(rawPath), - 'lastSeen': DateTime.now().millisecondsSinceEpoch, - 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, - } - ]; - - final prefs = PrefsManager.instance; - await prefs.setString('discovered_contacts', jsonEncode(contactJson)); - - final contacts = await store.loadContacts(); - expect(contacts, hasLength(1)); - expect(contacts.first.pathLength, equals(1)); - expect(contacts.first.path, equals(Uint8List.fromList([0x11, 0x22]))); - }); + test( + 'ContactStore decodes and migrates legacy mode-1 paths with hops', + () async { + final store = ContactStore()..publicKeyHex = '1234567890'; + + // Contact with legacy 65 path length (mode 1, 1 hop) + final rawPath = Uint8List(64) + ..[0] = 0xBB + ..[1] = 0xCC; + final contactJson = [ + { + 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), + 'name': 'LegacyNode2', + 'type': 2, + 'flags': 0, + 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) + 'path': base64Encode(rawPath), + 'lastSeen': DateTime.now().millisecondsSinceEpoch, + 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, + 'isActive': true, + }, + ]; + + final prefs = PrefsManager.instance; + await prefs.setString(store.keyFor, jsonEncode(contactJson)); + + final contacts = await store.loadContacts(); + expect(contacts, hasLength(1)); + expect(contacts.first.pathLength, equals(1)); + expect(contacts.first.path, equals(Uint8List.fromList([0xBB, 0xCC]))); + }, + ); + + test( + 'MessageStore decodes and migrates legacy mode-encoded message paths', + () async { + final store = MessageStore()..publicKeyHex = '1234567890'; + final contactKeyHex = pubKeyToHex(Uint8List(32)..[0] = 0xAA); + + final rawPath = Uint8List(64); + final messageJson = [ + { + 'senderKey': base64Encode(Uint8List(32)..[0] = 0xAA), + 'text': 'Hello', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'isOutgoing': false, + 'status': 2, // delivered + 'messageId': 'msg_1', + 'pathLength': 64, // encoded pathLength (mode 1, 0 hops) + 'pathBytes': base64Encode(rawPath), + }, + ]; + + final prefs = PrefsManager.instance; + await prefs.setString( + '${store.keyFor}$contactKeyHex', + jsonEncode(messageJson), + ); + + final messages = await store.loadMessages(contactKeyHex); + expect(messages, hasLength(1)); + expect(messages.first.pathLength, equals(0)); + expect(messages.first.pathBytes, isEmpty); + }, + ); + + test( + 'ChannelMessageStore decodes and migrates legacy mode-encoded paths', + () async { + final store = ChannelMessageStore()..publicKeyHex = '1234567890'; + final channelIndex = 1; + + final rawPath = Uint8List(64) + ..[0] = 0xBB + ..[1] = 0xCC; + final messageJson = [ + { + 'senderName': 'Alice', + 'text': 'Hello', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'isOutgoing': false, + 'status': 2, + 'channelIndex': channelIndex, + 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) + 'pathBytes': base64Encode(rawPath), + }, + ]; + + final prefs = PrefsManager.instance; + await prefs.setString( + '${store.keyFor}$channelIndex', + jsonEncode(messageJson), + ); + + final messages = await store.loadChannelMessages(channelIndex); + expect(messages, hasLength(1)); + expect(messages.first.pathLength, equals(1)); + expect( + messages.first.pathBytes, + equals(Uint8List.fromList([0xBB, 0xCC])), + ); + expect(messages.first.pathHashWidth, equals(2)); + }, + ); + + test( + 'ContactDiscoveryStore decodes and migrates legacy mode-encoded paths', + () async { + final store = ContactDiscoveryStore(); + + final rawPath = Uint8List(64) + ..[0] = 0x11 + ..[1] = 0x22; + final contactJson = [ + { + 'publicKey': base64Encode(Uint8List(32)..[0] = 0xBB), + 'name': 'DiscoveredNode', + 'type': 1, + 'flags': 0, + 'pathLength': 65, // encoded pathLength (mode 1, 1 hop) + 'path': base64Encode(rawPath), + 'lastSeen': DateTime.now().millisecondsSinceEpoch, + 'lastMessageAt': DateTime.now().millisecondsSinceEpoch, + }, + ]; + + final prefs = PrefsManager.instance; + await prefs.setString('discovered_contacts', jsonEncode(contactJson)); + + final contacts = await store.loadContacts(); + expect(contacts, hasLength(1)); + expect(contacts.first.pathLength, equals(1)); + expect(contacts.first.path, equals(Uint8List.fromList([0x11, 0x22]))); + }, + ); }); } diff --git a/test/screens/path_trace_test.dart b/test/screens/path_trace_test.dart index 6d2b6826..fe333536 100644 --- a/test/screens/path_trace_test.dart +++ b/test/screens/path_trace_test.dart @@ -20,7 +20,8 @@ class _FakeMeshCoreConnector extends MeshCoreConnector { Stream get receivedFrames => _receivedFramesController.stream; @override - Uint8List? get selfPublicKey => Uint8List.fromList(List.generate(32, (i) => i)); + Uint8List? get selfPublicKey => + Uint8List.fromList(List.generate(32, (i) => i)); @override double? get selfLatitude => 48.8566; @@ -60,9 +61,7 @@ Widget _buildTestApp({ ChangeNotifierProvider( create: (_) => AppSettingsService(), ), - Provider( - create: (_) => MapTileCacheService(), - ), + Provider(create: (_) => MapTileCacheService()), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -73,7 +72,9 @@ Widget _buildTestApp({ } void main() { - testWidgets('PathTraceMapScreen parses response frames correctly', (tester) async { + testWidgets('PathTraceMapScreen parses response frames correctly', ( + tester, + ) async { final connector = _FakeMeshCoreConnector(); final screen = PathTraceMapScreen( @@ -89,7 +90,7 @@ void main() { expect(connector.sentFrames.length, 1); final sentFrame = connector.sentFrames.first; expect(sentFrame[0], cmdSendTracePath); - + // Extract the tag sent in the request (bytes 1-4) final tag = sentFrame.sublist(1, 5); @@ -126,7 +127,7 @@ void main() { // auth bytes (8..11) = 0 pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes - + connector.emitFrame(pushTraceFrame); // pump multiple times to handle async tasks await tester.pumpAndSettle(); @@ -135,66 +136,73 @@ void main() { expect(find.text('Path trace not available.'), findsNothing); }); - testWidgets('PathTraceMapScreen parses multi-byte encoded path trace response correctly', (tester) async { - final connector = _FakeMeshCoreConnector(); + testWidgets( + 'PathTraceMapScreen parses multi-byte encoded path trace response correctly', + (tester) async { + final connector = _FakeMeshCoreConnector(); - final screen = PathTraceMapScreen( - title: 'Test Trace Multi-byte', - path: Uint8List.fromList([0x12, 0x34]), - pathHashByteWidth: 2, - ); + final screen = PathTraceMapScreen( + title: 'Test Trace Multi-byte', + path: Uint8List.fromList([0x12, 0x34]), + pathHashByteWidth: 2, + ); - await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); - await tester.pump(); + await tester.pumpWidget( + _buildTestApp(connector: connector, child: screen), + ); + await tester.pump(); - // Verify a send request was made - expect(connector.sentFrames.length, 1); - final sentFrame = connector.sentFrames.first; - expect(sentFrame[0], cmdSendTracePath); - - // Extract the tag sent in the request (bytes 1-4) - final tag = sentFrame.sublist(1, 5); + // Verify a send request was made + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); - // Emit respCodeSent (6) - final respCodeSentFrame = Uint8List(10); - respCodeSentFrame[0] = respCodeSent; - respCodeSentFrame[1] = 0; - respCodeSentFrame.setRange(2, 6, tag); - respCodeSentFrame[6] = 0x88; - respCodeSentFrame[7] = 0x13; - respCodeSentFrame[8] = 0x00; - respCodeSentFrame[9] = 0x00; + // Extract the tag sent in the request (bytes 1-4) + final tag = sentFrame.sublist(1, 5); - connector.emitFrame(respCodeSentFrame); - await tester.pump(); + // Emit respCodeSent (6) + final respCodeSentFrame = Uint8List(10); + respCodeSentFrame[0] = respCodeSent; + respCodeSentFrame[1] = 0; + respCodeSentFrame.setRange(2, 6, tag); + respCodeSentFrame[6] = 0x88; + respCodeSentFrame[7] = 0x13; + respCodeSentFrame[8] = 0x00; + respCodeSentFrame[9] = 0x00; - // Now emit PUSH_CODE_TRACE_DATA (0x89) - // Structure: - // Offset 0: pushCodeTraceData (0x89) - // Offset 1: reserved (0) - // Offset 2: pathLength (65) -> mode 1 (width 2), 1 hop - // Offset 3: flag (0) - // Offset 4..7: tag - // Offset 8..11: auth (0) - // Offset 12..13: pathBytes [0x12, 0x34] - // Offset 14..15: SNR bytes [12, 16] - final pushTraceFrame = Uint8List(16); - pushTraceFrame[0] = pushCodeTraceData; - pushTraceFrame[1] = 0; // reserved - pushTraceFrame[2] = 65; // pathLength encoded (mode 1, 1 hop -> 2 bytes) - pushTraceFrame[3] = 0; // flag - pushTraceFrame.setRange(4, 8, tag); - pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes - pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes - - connector.emitFrame(pushTraceFrame); - await tester.pumpAndSettle(); + connector.emitFrame(respCodeSentFrame); + await tester.pump(); - // Verify it parsed correctly (should not show the unavailable message) - expect(find.text('Path trace not available.'), findsNothing); - }); + // Now emit PUSH_CODE_TRACE_DATA (0x89) + // Structure: + // Offset 0: pushCodeTraceData (0x89) + // Offset 1: reserved (0) + // Offset 2: pathLength (65) -> mode 1 (width 2), 1 hop + // Offset 3: flag (0) + // Offset 4..7: tag + // Offset 8..11: auth (0) + // Offset 12..13: pathBytes [0x12, 0x34] + // Offset 14..15: SNR bytes [12, 16] + final pushTraceFrame = Uint8List(16); + pushTraceFrame[0] = pushCodeTraceData; + pushTraceFrame[1] = 0; // reserved + pushTraceFrame[2] = 65; // pathLength encoded (mode 1, 1 hop -> 2 bytes) + pushTraceFrame[3] = 0; // flag + pushTraceFrame.setRange(4, 8, tag); + pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes + pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes - testWidgets('PathTraceMapScreen buildPath: Direct repeater ping (0-hop)', (tester) async { + connector.emitFrame(pushTraceFrame); + await tester.pumpAndSettle(); + + // Verify it parsed correctly (should not show the unavailable message) + expect(find.text('Path trace not available.'), findsNothing); + }, + ); + + testWidgets('PathTraceMapScreen buildPath: Direct repeater ping (0-hop)', ( + tester, + ) async { final connector = _FakeMeshCoreConnector(); final target = Contact( publicKey: Uint8List.fromList([0xAA, 0xBB, 0xCC]), @@ -223,7 +231,9 @@ void main() { expect(payload, Uint8List.fromList([0xAA])); }); - testWidgets('PathTraceMapScreen buildPath: 1-hop chat contact trace', (tester) async { + testWidgets('PathTraceMapScreen buildPath: 1-hop chat contact trace', ( + tester, + ) async { final connector = _FakeMeshCoreConnector(); final target = Contact( publicKey: Uint8List.fromList([0xCC, 0xDD, 0xEE]), @@ -252,7 +262,9 @@ void main() { expect(payload, Uint8List.fromList([0xBB, 0xCC, 0xBB])); }); - testWidgets('PathTraceMapScreen buildPath: Multi-hop chat contact trace', (tester) async { + testWidgets('PathTraceMapScreen buildPath: Multi-hop chat contact trace', ( + tester, + ) async { final connector = _FakeMeshCoreConnector(); final target = Contact( publicKey: Uint8List.fromList([0x33, 0x44, 0x55]), @@ -281,28 +293,35 @@ void main() { expect(payload, Uint8List.fromList([0x11, 0x22, 0x33, 0x22, 0x11])); }); - testWidgets('PathTraceMapScreen buildPath: Map trace with null target contact', (tester) async { - final connector = _FakeMeshCoreConnector(); + testWidgets( + 'PathTraceMapScreen buildPath: Map trace with null target contact', + (tester) async { + final connector = _FakeMeshCoreConnector(); - final screen = PathTraceMapScreen( - title: 'Map Trace No Target', - path: Uint8List.fromList([0x11, 0x22]), - targetContact: null, - flipPathAround: true, - pathHashByteWidth: 1, - ); + final screen = PathTraceMapScreen( + title: 'Map Trace No Target', + path: Uint8List.fromList([0x11, 0x22]), + targetContact: null, + flipPathAround: true, + pathHashByteWidth: 1, + ); - await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); - await tester.pump(); + await tester.pumpWidget( + _buildTestApp(connector: connector, child: screen), + ); + await tester.pump(); - expect(connector.sentFrames.length, 1); - final sentFrame = connector.sentFrames.first; - expect(sentFrame[0], cmdSendTracePath); - final payload = sentFrame.sublist(10); - expect(payload, Uint8List.fromList([0x11, 0x22, 0x11])); - }); + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + final payload = sentFrame.sublist(10); + expect(payload, Uint8List.fromList([0x11, 0x22, 0x11])); + }, + ); - testWidgets('PathTraceMapScreen parses raw byte length fallback correctly', (tester) async { + testWidgets('PathTraceMapScreen parses raw byte length fallback correctly', ( + tester, + ) async { final connector = _FakeMeshCoreConnector(); final screen = PathTraceMapScreen( @@ -343,7 +362,7 @@ void main() { pushTraceFrame.setRange(4, 8, tag); pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes - + connector.emitFrame(pushTraceFrame); await tester.pumpAndSettle(); From 9e18b99e9d2bd77b2e67b6752ed59a9573e9f608 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Thu, 28 May 2026 02:52:04 +0300 Subject: [PATCH 2/3] Multibyte: fixed path trace repeater icon and potential bugs --- lib/connector/meshcore_connector.dart | 27 ++-- lib/screens/ble_debug_log_screen.dart | 18 ++- lib/screens/path_trace_map.dart | 171 ++++++++++++++++++++------ 3 files changed, 162 insertions(+), 54 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index cc627ecf..1f4ba0e5 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -6094,9 +6094,9 @@ class MeshCoreConnector extends ChangeNotifier { pathLength: pathBytes.isEmpty ? -1 : (pathBytes.length ~/ pathHashWidth), - path: Uint8List.fromList( - pathBytes.reversed.toList(), - ), // Store path in reverse for easier use in outgoing messages + // Store hop order reversed for easier outgoing messages; keep bytes + // inside each multi-byte hop in their original order. + path: _reversePathByHop(pathBytes, pathHashWidth), latitude: latitude, longitude: longitude, lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), @@ -6179,9 +6179,9 @@ class MeshCoreConnector extends ChangeNotifier { name: name, type: type, pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), - path: Uint8List.fromList( - path.reversed.toList(), - ), // Store path in reverse for easier use in outgoing messages + // Store hop order reversed for easier outgoing messages; keep bytes + // inside each multi-byte hop in their original order. + path: _reversePathByHop(path, pathHashWidth), latitude: latitude, longitude: longitude, lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), @@ -6229,7 +6229,7 @@ class MeshCoreConnector extends ChangeNotifier { latitude: hasLocation ? latitude : existing.latitude, longitude: hasLocation ? longitude : existing.longitude, name: hasName ? name : existing.name, - path: Uint8List.fromList(path.reversed.toList()), + path: _reversePathByHop(path, pathHashWidth), pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), lastMessageAt: mergedLastMessageAt, lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), @@ -6522,7 +6522,7 @@ const int _payloadTypeGroupText = 0x05; const int _cipherMacSize = 2; /// Decodes the firmware's encoded path_len byte into actual byte length. -/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte, 1=2bytes, 2=3bytes). +/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte ... 3=4bytes). int _decodePathByteLen(int pathLenRaw) { if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0; final hashCount = pathLenRaw & 63; @@ -6535,6 +6535,17 @@ int _decodePathHashWidth(int pathLenRaw) { return ((pathLenRaw >> 6) & 0x03) + 1; } +Uint8List _reversePathByHop(Uint8List pathBytes, int pathHashWidth) { + if (pathBytes.isEmpty) return Uint8List(0); + final width = pathHashWidth.clamp(1, 4).toInt(); + final reversed = []; + for (var i = pathBytes.length; i > 0; i -= width) { + final start = (i - width).clamp(0, pathBytes.length).toInt(); + reversed.addAll(pathBytes.sublist(start, i)); + } + return Uint8List.fromList(reversed); +} + class _RawPacket { final int header; final int routeType; diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 6d186970..c5e46f60 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -9,6 +9,13 @@ import '../helpers/snack_bar_builder.dart'; enum _BleLogView { frames, rawLogRx } +int _decodeRawPathByteLen(int pathLenRaw) { + if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0; + final hashCount = pathLenRaw & 0x3F; + final hashWidth = ((pathLenRaw >> 6) & 0x03) + 1; + return hashCount * hashWidth; +} + class BleDebugLogScreen extends StatefulWidget { const BleDebugLogScreen({super.key}); @@ -208,16 +215,17 @@ class _BleDebugLogScreenState extends State { rawHex: _bytesToHex(raw), ); } - final pathLen = raw[index++]; - if (raw.length < index + pathLen) { + final pathLenRaw = raw[index++]; + final pathByteLen = _decodeRawPathByteLen(pathLenRaw); + if (raw.length < index + pathByteLen) { return _RawPacketInfo( title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}', summary: 'Truncated path', rawHex: _bytesToHex(raw), ); } - final pathBytes = raw.sublist(index, index + pathLen); - index += pathLen; + final pathBytes = raw.sublist(index, index + pathByteLen); + index += pathByteLen; if (raw.length <= index) { return _RawPacketInfo( title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}', @@ -230,7 +238,7 @@ class _BleDebugLogScreenState extends State { final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer'; final summary = _decodePayloadSummary(payloadType, payload); - final pathSummary = pathLen > 0 + final pathSummary = pathByteLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none'; final detail = '$summary • $pathSummary • len=${raw.length}'; diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 356e8f78..7eba4b0c 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -57,6 +57,12 @@ Uint8List _lastHopChunk(Uint8List path, int hopWidth) { return Uint8List.fromList(path.sublist(path.length - width)); } +bool _matchesHopPrefix(Uint8List a, Uint8List b) { + if (a.isEmpty || b.isEmpty) return false; + final width = min(a.length, b.length); + return listEquals(a.sublist(0, width), b.sublist(0, width)); +} + class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; @@ -114,13 +120,77 @@ class _PathTraceMapScreenState extends State { Contact? _targetContact; Uint8List? _sentTagBytes; - String _formatPathPrefixes(Uint8List pathBytes) { + String _formatPathPrefixes(Uint8List pathBytes, [int? hashByteWidth]) { return PathHelper.splitPathBytes( pathBytes, - widget.pathHashByteWidth, + hashByteWidth ?? widget.pathHashByteWidth, ).map(PathHelper.formatHopHex).join(','); } + int _traceHashByteWidth(int pathHashByteWidth) { + final width = pathHashByteWidth.clamp(1, pubKeySize).toInt(); + if (width <= 1) return 1; + if (width == 2) return 2; + // Trace packets encode hash width as 1 << flags, so 3-byte path hashes + // must be traced with a 4-byte public-key prefix. + return 4; + } + + int _traceFlagsForHashWidth(int traceHashByteWidth) { + if (traceHashByteWidth <= 1) return 0; + if (traceHashByteWidth == 2) return 1; + return 2; + } + + Uint8List _reversePathByHop(Uint8List pathBytes, int hopWidth) { + final reversedHops = PathHelper.splitPathBytes( + pathBytes, + hopWidth, + ).reversed; + final bytes = []; + for (final hop in reversedHops) { + bytes.addAll(hop); + } + return Uint8List.fromList(bytes); + } + + Uint8List? _expandHopForTrace( + Uint8List hop, + int traceHashByteWidth, + MeshCoreConnector connector, + ) { + if (hop.length == traceHashByteWidth) return hop; + + final candidates = [ + ...?widget.pathContacts, + if (widget.targetContact != null) widget.targetContact!, + ...connector.allContactsUnfiltered, + ]; + for (final contact in candidates) { + if (contact.publicKey.length < traceHashByteWidth) continue; + if (!listEquals(contact.publicKey.sublist(0, hop.length), hop)) continue; + return Uint8List.fromList( + contact.publicKey.sublist(0, traceHashByteWidth), + ); + } + return null; + } + + Uint8List? _tracePathFromBytes( + Uint8List pathBytes, + int traceHashByteWidth, + MeshCoreConnector connector, + ) { + final hops = PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth); + final traceBytes = []; + for (final hop in hops) { + final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector); + if (traceHop == null) return null; + traceBytes.addAll(traceHop); + } + return Uint8List.fromList(traceBytes); + } + @override void initState() { super.initState(); @@ -198,12 +268,16 @@ class _PathTraceMapScreenState extends State { ); } - Uint8List buildPath(Uint8List pathBytes) { + Uint8List? buildPath( + Uint8List pathBytes, + int traceHashByteWidth, + MeshCoreConnector connector, + ) { final pathHops = PathHelper.splitPathBytes( pathBytes, widget.pathHashByteWidth, ); - final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); + final hopWidth = traceHashByteWidth.clamp(1, pubKeySize).toInt(); // Compute targetPrefix if targetContact is provided Uint8List? targetPrefix; @@ -215,12 +289,17 @@ class _PathTraceMapScreenState extends State { } } - final outboundHops = [...pathHops]; + final outboundHops = []; + for (final hop in pathHops) { + final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector); + if (traceHop == null) return null; + outboundHops.add(traceHop); + } if (targetPrefix != null) { // Check if targetPrefix is already the last hop in pathHops to avoid duplication bool alreadyEndedWithTarget = false; - if (pathHops.isNotEmpty) { - if (listEquals(pathHops.last, targetPrefix)) { + if (outboundHops.isNotEmpty) { + if (listEquals(outboundHops.last, targetPrefix)) { alreadyEndedWithTarget = true; } } @@ -255,19 +334,32 @@ class _PathTraceMapScreenState extends State { }); } + final connector = Provider.of(context, listen: false); + final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth); final pathTmp = widget.reversePathAround - ? Uint8List.fromList(widget.path.reversed.toList()) + ? _reversePathByHop(widget.path, widget.pathHashByteWidth) : widget.path; - final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp; + final path = widget.flipPathAround + ? buildPath(pathTmp, traceHashByteWidth, connector) + : _tracePathFromBytes(pathTmp, traceHashByteWidth, connector); + + if (path == null) { + if (!mounted) return; + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + return; + } appLogger.info( - 'Initiating path trace with path: ${_formatPathPrefixes(path)}', + 'Initiating path trace with path: ' + '${_formatPathPrefixes(path, traceHashByteWidth)}', tag: 'PathTraceMapScreen', noNotify: !mounted, ); - final connector = Provider.of(context, listen: false); final sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000; _sentTagBytes = Uint8List(4) ..[0] = sentTag & 0xFF @@ -275,7 +367,7 @@ class _PathTraceMapScreenState extends State { ..[2] = (sentTag >> 16) & 0xFF ..[3] = (sentTag >> 24) & 0xFF; - final flags = (widget.pathHashByteWidth - 1).clamp(0, 3); + final flags = _traceFlagsForHashWidth(traceHashByteWidth); final tracePayload = path.isEmpty ? Uint8List.fromList([0x00]) : path; final frame = buildTraceReq( @@ -354,23 +446,19 @@ class _PathTraceMapScreenState extends State { try { buffer.skipBytes(2); // Skip push code and reserved byte final pathLenByte = buffer.readUInt8(); - int hopCount = 0; - int width = widget.pathHashByteWidth; // fallback - int pathLength = 0; - if (pathLenByte != 0xFF) { - final mode = (pathLenByte & 0xC0) >> 6; - if (mode > 0) { - hopCount = pathLenByte & 0x3F; - width = mode + 1; - pathLength = hopCount * width; - } else { - width = widget.pathHashByteWidth; - pathLength = pathLenByte; - hopCount = pathLength ~/ width; + final flags = buffer.readUInt8(); + buffer.skipBytes(4); // Skip tag data + buffer.skipBytes(4); // Skip auth code + var width = _traceHashByteWidth(1 << (flags & 0x03)); + var pathLength = pathLenByte == 0xFF ? 0 : pathLenByte; + if (pathLength > buffer.remaining && (pathLenByte & 0xC0) != 0) { + final packedWidth = ((pathLenByte & 0xC0) >> 6) + 1; + final packedLength = (pathLenByte & 0x3F) * packedWidth; + if (packedLength <= buffer.remaining) { + width = packedWidth; + pathLength = packedLength; } } - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code final pathBytes = buffer.readBytes(pathLength); final pathData = PathHelper.splitPathBytes(pathBytes, width); List snrData = buffer @@ -390,7 +478,7 @@ class _PathTraceMapScreenState extends State { lastSeen: DateTime.now(), ); if (widget.pathContacts != null) { - final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); + final hopWidth = width.clamp(1, pubKeySize).toInt(); pathContacts = { for (var c in widget.pathContacts!) if (c.publicKey.length >= hopWidth) @@ -436,8 +524,10 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) == - hopKey, + _matchesHopPrefix( + _lastHopChunk(c.path, widget.pathHashByteWidth), + hop, + ), ) .toList(); if (peers.isNotEmpty) { @@ -483,10 +573,10 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey( - _lastHopChunk(c.path, widget.pathHashByteWidth), - ) == - lastHopKey, + _matchesHopPrefix( + _lastHopChunk(c.path, widget.pathHashByteWidth), + lastHop, + ), ) .toList(); if (peers.isNotEmpty) { @@ -697,6 +787,10 @@ class _PathTraceMapScreenState extends State { ? LatLng(contact.latitude!, contact.longitude!) : inferred!; final label = PathHelper.formatHopHex(hop); + final shortLabel = label.length > 2 ? label.substring(0, 2) : label; + final fullLabel = label.length > 2 + ? (contact?.name != null ? '$label: ${contact!.name}' : label) + : (contact?.name ?? label); markers.add( Marker( @@ -721,7 +815,7 @@ class _PathTraceMapScreenState extends State { ), alignment: Alignment.center, child: Text( - hasGps ? label : '~$label', + hasGps ? shortLabel : '~$shortLabel', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, @@ -732,12 +826,7 @@ class _PathTraceMapScreenState extends State { ), ); if (showLabels) { - markers.add( - _buildNodeLabelMarker( - point: point, - label: contact?.name ?? '~$label', - ), - ); + markers.add(_buildNodeLabelMarker(point: point, label: fullLabel)); } hopLastLast = hopLast; hopLast = hopKey; From 61fb39b9f86874093bf2e39f463733b677e04e86 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Thu, 28 May 2026 03:17:08 +0300 Subject: [PATCH 3/3] Multibyte: some path fixes - Validate encoded path hop counts before saving or sending custom paths - Fix multi-byte path reversal by reversing hops instead of raw bytes - Decode raw log path lengths using packed path_len format - Localize path hash mode options and optimize map anchor lookup --- lib/connector/meshcore_connector.dart | 68 +++++++++++++++++++++----- lib/screens/map_screen.dart | 37 ++++++++------ lib/screens/settings_screen.dart | 22 +++++++-- lib/widgets/path_selection_dialog.dart | 13 +++-- 4 files changed, 104 insertions(+), 36 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 1f4ba0e5..51fe11bc 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2598,6 +2598,24 @@ class MeshCoreConnector extends ChangeNotifier { } } + bool _isPathLenValidForCurrentMode(int pathLen, List pathBytes) { + return _isPathLenValidForMode(pathLen, pathBytes, _pathHashByteWidth); + } + + int? _encodePathLenForCurrentMode(int pathLen, List pathBytes) { + if (pathLen < 0 || pathLen == 0xFF) return pathLen; + if (!_isPathLenValidForCurrentMode(pathLen, pathBytes)) { + appLogger.warn( + 'Invalid path_len for mode: pathLen=$pathLen, ' + 'bytesLen=${pathBytes.length}, width=$_pathHashByteWidth', + tag: 'Connector', + ); + return null; + } + final mode = (_pathHashByteWidth - 1) & 0x03; + return (pathLen & 0x3F) | (mode << 6); + } + Future refreshDeviceInfo() async { if (!isConnected) return; if (PlatformInfo.isWeb && @@ -2797,11 +2815,10 @@ class MeshCoreConnector extends ChangeNotifier { try { if (!isConnected) return; - final mode = _pathHashByteWidth - 1; - if (pathLen < 0 || pathLen > 0x3F) { + final encodedPathLen = _encodePathLenForCurrentMode(pathLen, customPath); + if (encodedPathLen == null) { return; } - final encodedPathLen = pathLen | (mode << 6); await sendFrame( buildUpdateContactPathFrame( contact.publicKey, @@ -2863,11 +2880,11 @@ class MeshCoreConnector extends ChangeNotifier { : (updatedFlags & ~contactFlagTeleEnv)) : updatedFlags; - final mode = _pathHashByteWidth - 1; - final encodedPathLen = - (latestContact.pathLength >= 0 && latestContact.pathLength != 0xFF) - ? (latestContact.pathLength | (mode << 6)) - : latestContact.pathLength; + final encodedPathLen = _encodePathLenForCurrentMode( + latestContact.pathLength, + latestContact.path, + ); + if (encodedPathLen == null) return; await sendFrame( buildUpdateContactPathFrame( latestContact.publicKey, @@ -2955,6 +2972,19 @@ class MeshCoreConnector extends ChangeNotifier { tag: 'Connector', ); + if (pathLen != null && + pathLen >= 0 && + (pathBytes == null || + !_isPathLenValidForCurrentMode(pathLen, pathBytes))) { + appLogger.warn( + 'setPathOverride: invalid path for ${contact.name}: ' + 'pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}, ' + 'width=$_pathHashByteWidth', + tag: 'Connector', + ); + return; + } + // Update contact with new path override _contacts[index] = _contacts[index].copyWith( pathOverride: pathLen, @@ -3208,11 +3238,11 @@ class MeshCoreConnector extends ChangeNotifier { Future importDiscoveredContact(Contact contact) async { if (!isConnected) return; - final mode = _pathHashByteWidth - 1; - final encodedPathLen = - (contact.pathLength >= 0 && contact.pathLength != 0xFF) - ? (contact.pathLength | (mode << 6)) - : contact.pathLength; + final encodedPathLen = _encodePathLenForCurrentMode( + contact.pathLength, + contact.path, + ); + if (encodedPathLen == null) return; await sendFrame( buildUpdateContactPathFrame( contact.publicKey, @@ -6535,6 +6565,18 @@ int _decodePathHashWidth(int pathLenRaw) { return ((pathLenRaw >> 6) & 0x03) + 1; } +bool _isPathLenValidForMode( + int pathLen, + List pathBytes, + int pathHashWidth, +) { + if (pathLen < 0 || pathLen > 0x3F) return false; + final width = pathHashWidth.clamp(1, 4).toInt(); + final maxHopCountByBytes = maxPathSize ~/ width; + if (pathLen > maxHopCountByBytes) return false; + return pathBytes.length <= maxPathSize && pathBytes.length == pathLen * width; +} + Uint8List _reversePathByHop(Uint8List pathBytes, int pathHashWidth) { if (pathBytes.isEmpty) return Uint8List(0); final width = pathHashWidth.clamp(1, 4).toInt(); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b6634813..efd00718 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -700,6 +700,16 @@ class _MapScreenState extends State { int pathHashByteWidth, ) { final result = <_GuessedLocation>[]; + final hopWidth = pathHashByteWidth.clamp(1, 4).toInt(); + final anchorsByPrefix = >{}; + for (final repeater in withLocation) { + if (repeater.type != advTypeRepeater) continue; + if (repeater.publicKey.length < hopWidth) continue; + final prefix = PathHelper.formatHopHex( + repeater.publicKey.sublist(0, hopWidth), + ); + anchorsByPrefix.putIfAbsent(prefix, () => []).add(repeater); + } for (final contact in allContacts) { if (contact.hasLocation) continue; @@ -722,21 +732,13 @@ class _MapScreenState extends State { ]; for (final pathBytes in pathSets) { if (pathBytes.isEmpty) continue; - final hopWidth = pathHashByteWidth.clamp(1, 4); final lastHop = pathBytes.sublist(max(0, pathBytes.length - hopWidth)); if (lastHop.isEmpty) continue; - for (final repeater in withLocation) { - if (repeater.type != advTypeRepeater) continue; - if (repeater.publicKey.length < lastHop.length) continue; - if (!listEquals( - repeater.publicKey.sublist(0, lastHop.length), - lastHop, - )) { - continue; - } + final repeaters = anchorsByPrefix[PathHelper.formatHopHex(lastHop)]; + if (repeaters != null && repeaters.isNotEmpty) { + final repeater = repeaters.first; anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!)); - break; } } @@ -2304,10 +2306,17 @@ class _MapScreenState extends State { connector.pathHashByteWidth.clamp(1, pubKeySize), contact.publicKey.length, ).toInt(); + final hopPrefix = contact.publicKey.sublist(0, hopWidth); + for (final existingHop in PathHelper.splitPathBytes( + _pathTrace, + connector.pathHashByteWidth, + )) { + if (listEquals(existingHop, hopPrefix)) { + return; + } + } setState(() { - _pathTrace.addAll( - contact.publicKey.sublist(0, hopWidth), - ); // Add the hop-width prefix of the public key to the trace + _pathTrace.addAll(hopPrefix); // Add the hop-width pubkey prefix. _pathTraceHopWidths.add(hopWidth); _pathTraceContacts.add( contact.copyWith( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3294546f..49181fcc 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -580,11 +580,23 @@ class _SettingsScreenState extends State { labelText: l10n.repeater_pathHashMode, border: const OutlineInputBorder(), ), - items: const [ - DropdownMenuItem(value: 0, child: Text('0 - 1 byte')), - DropdownMenuItem(value: 1, child: Text('1 - 2 bytes')), - DropdownMenuItem(value: 2, child: Text('2 - 3 bytes')), - DropdownMenuItem(value: 3, child: Text('3 - 4 bytes')), + items: [ + DropdownMenuItem( + value: 0, + child: Text(l10n.repeater_pathHashModeOption0), + ), + DropdownMenuItem( + value: 1, + child: Text(l10n.repeater_pathHashModeOption1), + ), + DropdownMenuItem( + value: 2, + child: Text(l10n.repeater_pathHashModeOption2), + ), + DropdownMenuItem( + value: 3, + child: Text(l10n.repeater_pathHashModeOption3), + ), ], onChanged: (value) { if (value == null) return; diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 949ebbe8..c87b5a9b 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -126,7 +126,8 @@ class _PathSelectionDialogState extends State { .toList(); final pathBytesList = []; final invalidPrefixes = []; - final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 4) * 2; + final pathHashByteWidth = widget.pathHashByteWidth.clamp(1, 4).toInt(); + final hexCharsPerHop = pathHashByteWidth * 2; for (final id in pathIds) { if (id.length < hexCharsPerHop) { @@ -157,8 +158,12 @@ class _PathSelectionDialogState extends State { return; } - // Check max path size in bytes, as defined by the protocol. - if (pathBytesList.length > maxPathSize) { + final hopCount = pathBytesList.length ~/ pathHashByteWidth; + final maxHopCountByBytes = maxPathSize ~/ pathHashByteWidth; + final maxHopCount = maxHopCountByBytes < 0x3F ? maxHopCountByBytes : 0x3F; + + // path_len stores hop count in 6 bits and the path buffer is byte-limited. + if (hopCount > maxHopCount || pathBytesList.length > maxPathSize) { showDismissibleSnackBar( context, content: Text(l10n.path_tooLong), @@ -235,7 +240,7 @@ class _PathSelectionDialogState extends State { helperText: l10n.path_helperMaxHops, ), textCapitalization: TextCapitalization.characters, - maxLength: 191, // 64 hops * 2 chars + 63 commas + maxLength: 188, // 63 one-byte hops * 2 chars + 62 commas ), const SizedBox(height: 16), const Divider(),