From 5d03f990407cfbd4e9280ab72a245ae03d86bb1d Mon Sep 17 00:00:00 2001 From: PacoX Date: Tue, 26 May 2026 20:12:27 +0200 Subject: [PATCH] fix tracepath --- lib/connector/meshcore_connector.dart | 14 +- lib/screens/chat_screen.dart | 9 +- lib/screens/contacts_screen.dart | 7 +- lib/screens/map_screen.dart | 8 + lib/screens/path_trace_map.dart | 103 ++++--- lib/storage/channel_message_store.dart | 40 ++- lib/storage/contact_discovery_store.dart | 33 ++- lib/widgets/path_management_dialog.dart | 8 +- test/models/model_changes_test.dart | 56 ++++ test/screens/path_trace_test.dart | 353 +++++++++++++++++++++++ untranslated.json | 120 -------- 11 files changed, 566 insertions(+), 185 deletions(-) create mode 100644 test/screens/path_trace_test.dart delete mode 100644 untranslated.json diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 43456424..8b1f21df 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2821,7 +2821,7 @@ class MeshCoreConnector extends ChangeNotifier { ); if (idx != -1) { _contacts[idx] = _contacts[idx].copyWith( - pathLength: customPath.length, + pathLength: pathLen, path: customPath, ); notifyListeners(); @@ -4578,7 +4578,7 @@ class MeshCoreConnector extends ChangeNotifier { isOutgoing: false, isCli: isCli, status: MessageStatus.delivered, - pathLength: pathLength == 0xFF ? 0 : pathLength, + pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F), pathBytes: Uint8List(0), fourByteRoomContactKey: msgText.length >= 4 ? Uint8List.fromList(msgText.substring(0, 4).codeUnits) @@ -6025,6 +6025,7 @@ class MeshCoreConnector extends ChangeNotifier { final packet = BufferReader(frame); int payloadType = 0; Uint8List pathBytes = Uint8List(0); + int pathHashWidth = 1; try { packet.skipBytes(1); // Skip frame type byte packet.skipBytes(1); // Skip SNR byte @@ -6039,6 +6040,7 @@ class MeshCoreConnector extends ChangeNotifier { //final payloadVer = (header >> 6) & 0x03; final pathLenRaw = packet.readByte(); final pathByteLen = _decodePathByteLen(pathLenRaw); + pathHashWidth = _decodePathHashWidth(pathLenRaw); pathBytes = packet.readBytes(pathByteLen); } catch (e) { appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); @@ -6086,7 +6088,7 @@ class MeshCoreConnector extends ChangeNotifier { publicKey: publicKey, name: name, type: type, - pathLength: pathBytes.isEmpty ? -1 : pathBytes.length, + pathLength: pathBytes.isEmpty ? -1 : (pathBytes.length ~/ pathHashWidth), path: Uint8List.fromList( pathBytes.reversed.toList(), ), // Store path in reverse for easier use in outgoing messages @@ -6171,7 +6173,7 @@ class MeshCoreConnector extends ChangeNotifier { publicKey: publicKey, name: name, type: type, - pathLength: path.length, + pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), path: Uint8List.fromList( path.reversed.toList(), ), // Store path in reverse for easier use in outgoing messages @@ -6223,7 +6225,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude: hasLocation ? longitude : existing.longitude, name: hasName ? name : existing.name, path: Uint8List.fromList(path.reversed.toList()), - pathLength: path.length, + pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), lastMessageAt: mergedLastMessageAt, lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), pathOverride: existing.pathOverride, // Preserve user's path choice @@ -6517,12 +6519,14 @@ 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). int _decodePathByteLen(int pathLenRaw) { + if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0; final hashCount = pathLenRaw & 63; final hashSize = _decodePathHashWidth(pathLenRaw); return hashCount * hashSize; } int _decodePathHashWidth(int pathLenRaw) { + if (pathLenRaw == 0xFF) return 1; return ((pathLenRaw >> 6) & 0x03) + 1; } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 00e0c7d2..faf2ef4e 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -963,12 +963,10 @@ class _ChatScreenState extends State { final pathBytes = Uint8List.fromList( path.pathBytes, ); - final pathLength = path.pathBytes.length; - // Set the path override to persist user's choice await connector.setPathOverride( _resolveContact(connector), - pathLen: pathLength, + pathLen: path.hopCount, pathBytes: pathBytes, ); @@ -1538,9 +1536,10 @@ class _ChatScreenState extends State { 'Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen', ); + final hopsCount = result.length ~/ connector.pathHashByteWidth; await connector.setPathOverride( _resolveContact(connector), - pathLen: result.length, + pathLen: hopsCount, pathBytes: result, ); appLogger.info('setPathOverride completed', tag: 'ChatScreen'); @@ -1550,7 +1549,7 @@ class _ChatScreenState extends State { connector, _resolveContact(connector), result, - result.length, + hopsCount, ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index d320df11..bedb24e2 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1266,7 +1266,10 @@ class _ContactsScreenState extends State MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPing, - path: _contactPathPrefix(contact, hw), + path: contact.pathBytesForDisplay.isNotEmpty + ? contact.pathBytesForDisplay + : _contactPathPrefix(contact, hw), + flipPathAround: true, targetContact: contact, pathHashByteWidth: hw, ), @@ -1300,7 +1303,7 @@ class _ContactsScreenState extends State path: contact.pathBytesForDisplay.isNotEmpty ? contact.pathBytesForDisplay : _contactPathPrefix(contact, hw), - flipPathAround: contact.pathBytesForDisplay.isNotEmpty, + flipPathAround: true, targetContact: contact, pathHashByteWidth: hw, ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f9cd382d..7f828d9d 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2390,6 +2390,10 @@ class _MapScreenState extends State { builder: (context) => PathTraceMapScreen( title: l10n.contacts_pathTrace, path: Uint8List.fromList(_pathTrace), + flipPathAround: true, + targetContact: _pathTraceContacts.isNotEmpty + ? _pathTraceContacts.last + : null, pathHashByteWidth: hashW, pathContacts: _pathTraceContacts, ), @@ -2412,9 +2416,13 @@ class _MapScreenState extends State { title: l10n.contacts_pathTrace, path: Uint8List.fromList(_pathTrace), flipPathAround: true, + targetContact: _pathTraceContacts.isNotEmpty + ? _pathTraceContacts.last + : null, pathHashByteWidth: context .read() .pathHashByteWidth, + pathContacts: _pathTraceContacts, ), ), ); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index f53d1bef..7f18aa8a 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -112,6 +112,7 @@ class _PathTraceMapScreenState extends State { double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; Contact? _targetContact; + Uint8List? _sentTagBytes; String _formatPathPrefixes(Uint8List pathBytes) { return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) @@ -203,41 +204,41 @@ class _PathTraceMapScreenState extends State { ); final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); - if (pathHops.isEmpty) { - final pk = widget.targetContact?.publicKey; - if (pk != null && pk.length >= hopWidth) { - return Uint8List.fromList(pk.sublist(0, hopWidth)); + // Compute targetPrefix if targetContact is provided + Uint8List? targetPrefix; + if (widget.targetContact != null) { + final pk = widget.targetContact!.publicKey; + if (pk.isNotEmpty) { + final len = pk.length >= hopWidth ? hopWidth : pk.length; + targetPrefix = Uint8List.fromList(pk.sublist(0, len)); } - return Uint8List.fromList( - pk != null && pk.isNotEmpty ? [pk[0]] : const [0], - ); } - final mirroredHops = [...pathHops]; - final isRepeaterOrRoom = widget.targetContact?.type == advTypeRepeater || - widget.targetContact?.type == advTypeRoom; - if (isRepeaterOrRoom) { - final pk = widget.targetContact?.publicKey; - if (pk != null && pk.length >= hopWidth) { - mirroredHops.add(Uint8List.fromList(pk.sublist(0, hopWidth))); - } else { - mirroredHops.add( - Uint8List.fromList( - pk != null && pk.isNotEmpty ? [pk[0]] : const [0], - ), - ); + final outboundHops = [...pathHops]; + 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)) { + alreadyEndedWithTarget = true; + } } - // For repeaters/rooms, include full reversed path - mirroredHops.addAll(pathHops.reversed); - } else { - // For non-repeater/room targets, reverse without duplicating the pivot hop - if (pathHops.length > 1) { - mirroredHops.addAll( - pathHops.sublist(0, pathHops.length - 1).reversed, - ); + if (!alreadyEndedWithTarget) { + outboundHops.add(targetPrefix); } } + if (outboundHops.isEmpty) { + return Uint8List(0); + } + + final mirroredHops = [...outboundHops]; + if (outboundHops.length > 1) { + mirroredHops.addAll( + outboundHops.sublist(0, outboundHops.length - 1).reversed, + ); + } + final traceBytes = []; for (final hop in mirroredHops) { traceBytes.addAll(hop); @@ -266,11 +267,21 @@ class _PathTraceMapScreenState extends State { ); final connector = Provider.of(context, listen: false); + final sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000; + _sentTagBytes = Uint8List(4) + ..[0] = sentTag & 0xFF + ..[1] = (sentTag >> 8) & 0xFF + ..[2] = (sentTag >> 16) & 0xFF + ..[3] = (sentTag >> 24) & 0xFF; + + final flags = (widget.pathHashByteWidth - 1).clamp(0, 3); + final tracePayload = path.isEmpty ? Uint8List.fromList([0x00]) : path; + final frame = buildTraceReq( - DateTime.now().millisecondsSinceEpoch ~/ 1000, - 0, //flags - 0, //auth - payload: path, + sentTag, + 0, // auth + flags, // flag + payload: tracePayload, ); connector.sendFrame(frame); } @@ -314,15 +325,12 @@ class _PathTraceMapScreenState extends State { } // Check if it's a binary response - if (frame.length > 8 && + if (frame.length >= 12 && code == pushCodeTraceData && - listEquals(frame.sublist(4, 8), tagData)) { + (listEquals(frame.sublist(4, 8), _sentTagBytes) || listEquals(frame.sublist(4, 8), tagData))) { _timeoutTimer?.cancel(); if (!mounted) return; - frameBuffer.skipBytes(3); //reserved + path length + flag - if (listEquals(frameBuffer.readBytes(4), tagData)) { - _handleTraceResponse(frame); - } + _handleTraceResponse(frame); } } catch (e) { _timeoutTimer?.cancel(); @@ -343,13 +351,28 @@ class _PathTraceMapScreenState extends State { final buffer = BufferReader(frame); try { buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); + 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; + } + } 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, - widget.pathHashByteWidth, + width, ); List snrData = buffer .readRemainingBytes() diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 59c65ebc..f16081a6 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -127,6 +127,38 @@ class ChannelMessageStore { ChannelMessage _messageFromJson(Map json) { final rawText = json['text'] as String; final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText; + + final rawPathLength = json['pathLength'] as int?; + final rawPathBytes = json['pathBytes'] != null + ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) + : Uint8List(0); + final rawPathHashWidth = json['pathHashWidth'] as int?; + + int? decodedPathLength = rawPathLength; + Uint8List decodedPathBytes = rawPathBytes; + int? decodedPathHashWidth = rawPathHashWidth; + + if (rawPathLength != null) { + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPathBytes = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + decodedPathHashWidth = width; + if (byteLen <= rawPathBytes.length) { + decodedPathBytes = rawPathBytes.sublist(0, byteLen); + } else { + decodedPathBytes = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPathBytes = Uint8List(0); + } + } + return ChannelMessage( senderKey: json['senderKey'] != null ? Uint8List.fromList(base64Decode(json['senderKey'])) @@ -144,11 +176,9 @@ class ChannelMessageStore { isOutgoing: json['isOutgoing'] as bool, status: ChannelMessageStatus.values[json['status'] as int], repeatCount: (json['repeatCount'] as int?) ?? 0, - pathLength: json['pathLength'] as int?, - pathHashWidth: json['pathHashWidth'] as int?, - pathBytes: json['pathBytes'] != null - ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + pathHashWidth: decodedPathHashWidth, + pathBytes: decodedPathBytes, pathVariants: (json['pathVariants'] as List?) ?.map((entry) => Uint8List.fromList(base64Decode(entry as String))) .toList(), diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index 3f6f1718..98bf6e8d 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -55,15 +55,40 @@ class ContactDiscoveryStore { final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastMessageMs = json['lastMessageAt'] as int?; final lastModifiedMs = json['lastModified'] as int?; + + final rawPathLength = json['pathLength'] as int? ?? -1; + final rawPath = json['path'] != null + ? Uint8List.fromList(base64Decode(json['path'] as String)) + : Uint8List(0); + + int decodedPathLength = rawPathLength; + Uint8List decodedPath = rawPath; + + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPath = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + if (byteLen <= rawPath.length) { + decodedPath = rawPath.sublist(0, byteLen); + } else { + decodedPath = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPath = Uint8List(0); + } + return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, flags: json['flags'] as int? ?? 0, - pathLength: json['pathLength'] as int? ?? -1, - path: json['path'] != null - ? Uint8List.fromList(base64Decode(json['path'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + path: decodedPath, pathOverride: json['pathOverride'] as int?, pathOverrideBytes: json['pathOverrideBytes'] != null ? Uint8List.fromList( diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index a110b176..c3976e1e 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -158,16 +158,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { ); if (result != null && context.mounted) { + final hopsCount = result.length ~/ connector.pathHashByteWidth; await connector.setPathOverride( currentContact, - pathLen: result.length, + pathLen: hopsCount, pathBytes: result, ); if (!context.mounted) return; showDismissibleSnackBar( context, - content: Text(l10n.chat_hopsCount(result.length)), + content: Text(l10n.chat_hopsCount(hopsCount)), duration: const Duration(seconds: 2), ); } @@ -361,11 +362,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { final pathBytes = Uint8List.fromList( path.pathBytes, ); - final pathLength = path.pathBytes.length; await connector.setPathOverride( currentContact, - pathLen: pathLength, + pathLen: path.hopCount, pathBytes: pathBytes, ); diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart index cc14187a..dd077e64 100644 --- a/test/models/model_changes_test.dart +++ b/test/models/model_changes_test.dart @@ -12,6 +12,8 @@ import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/storage/prefs_manager.dart'; import 'package:meshcore_open/storage/contact_store.dart'; import 'package:meshcore_open/storage/message_store.dart'; +import 'package:meshcore_open/storage/channel_message_store.dart'; +import 'package:meshcore_open/storage/contact_discovery_store.dart'; // Builds a valid contact frame with the given pathLen and optional overrides. // Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)] @@ -553,5 +555,59 @@ void main() { 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 new file mode 100644 index 00000000..6d2b6826 --- /dev/null +++ b/test/screens/path_trace_test.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'dart:typed_data'; + +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; +import 'package:meshcore_open/services/map_tile_cache_service.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; + +class _FakeMeshCoreConnector extends MeshCoreConnector { + final StreamController _receivedFramesController = + StreamController.broadcast(); + + @override + Stream get receivedFrames => _receivedFramesController.stream; + + @override + Uint8List? get selfPublicKey => Uint8List.fromList(List.generate(32, (i) => i)); + + @override + double? get selfLatitude => 48.8566; + + @override + double? get selfLongitude => 2.3522; + + @override + List get allContactsUnfiltered => []; + + @override + List get contacts => []; + + void emitFrame(Uint8List frame) { + _receivedFramesController.add(frame); + } + + final List sentFrames = []; + + @override + Future sendFrame( + Uint8List frame, { + String? channelSendQueueId, + bool expectsGenericAck = false, + }) async { + sentFrames.add(frame); + } +} + +Widget _buildTestApp({ + required MeshCoreConnector connector, + required Widget child, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connector), + ChangeNotifierProvider( + create: (_) => AppSettingsService(), + ), + Provider( + create: (_) => MapTileCacheService(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); +} + +void main() { + testWidgets('PathTraceMapScreen parses response frames correctly', (tester) async { + final connector = _FakeMeshCoreConnector(); + + final screen = PathTraceMapScreen( + title: 'Test Trace', + path: Uint8List.fromList([0x12, 0x34]), + pathHashByteWidth: 1, + ); + + 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); + + // Emit respCodeSent (6) + final respCodeSentFrame = Uint8List(10); + respCodeSentFrame[0] = respCodeSent; + respCodeSentFrame[1] = 0; // reserved / is_flood + respCodeSentFrame.setRange(2, 6, tag); + // timeout milliseconds = 5000 (0x1388) + respCodeSentFrame[6] = 0x88; + respCodeSentFrame[7] = 0x13; + respCodeSentFrame[8] = 0x00; + respCodeSentFrame[9] = 0x00; + + connector.emitFrame(respCodeSentFrame); + await tester.pump(); + + // Now emit PUSH_CODE_TRACE_DATA (0x89) + // Structure: + // Offset 0: pushCodeTraceData (0x89) + // Offset 1: reserved (0) + // Offset 2: pathLength (2) + // 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] -> to be mapped to signed 8 bit Snr/4 + final pushTraceFrame = Uint8List(16); + pushTraceFrame[0] = pushCodeTraceData; + pushTraceFrame[1] = 0; // reserved + pushTraceFrame[2] = 2; // pathLength + pushTraceFrame[3] = 0; // flag + pushTraceFrame.setRange(4, 8, tag); + // 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(); + + // Verify it doesn't show "Path trace not available" or similar + expect(find.text('Path trace not available.'), findsNothing); + }); + + 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, + ); + + 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); + + // 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; + + connector.emitFrame(respCodeSentFrame); + await tester.pump(); + + // 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(); + + // 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]), + name: 'Repeater Node', + type: advTypeRepeater, + path: Uint8List(0), + pathLength: 0, + lastSeen: DateTime.now(), + ); + + final screen = PathTraceMapScreen( + title: 'Direct Repeater Ping', + path: Uint8List.fromList([0xAA]), + targetContact: target, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + 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([0xAA])); + }); + + testWidgets('PathTraceMapScreen buildPath: 1-hop chat contact trace', (tester) async { + final connector = _FakeMeshCoreConnector(); + final target = Contact( + publicKey: Uint8List.fromList([0xCC, 0xDD, 0xEE]), + name: 'Chat User', + type: advTypeChat, + path: Uint8List.fromList([0xBB]), + pathLength: 1, + lastSeen: DateTime.now(), + ); + + final screen = PathTraceMapScreen( + title: '1-Hop Chat Trace', + path: Uint8List.fromList([0xBB]), + targetContact: target, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + 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([0xBB, 0xCC, 0xBB])); + }); + + testWidgets('PathTraceMapScreen buildPath: Multi-hop chat contact trace', (tester) async { + final connector = _FakeMeshCoreConnector(); + final target = Contact( + publicKey: Uint8List.fromList([0x33, 0x44, 0x55]), + name: 'Chat User Multi-hop', + type: advTypeChat, + path: Uint8List.fromList([0x11, 0x22]), + pathLength: 2, + lastSeen: DateTime.now(), + ); + + final screen = PathTraceMapScreen( + title: 'Multi-Hop Chat Trace', + path: Uint8List.fromList([0x11, 0x22]), + targetContact: target, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + 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, 0x33, 0x22, 0x11])); + }); + + 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, + ); + + 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])); + }); + + testWidgets('PathTraceMapScreen parses raw byte length fallback correctly', (tester) async { + final connector = _FakeMeshCoreConnector(); + + final screen = PathTraceMapScreen( + title: 'Test Raw Fallback', + path: Uint8List.fromList([0x12, 0x34]), + pathHashByteWidth: 2, + ); + + await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); + await tester.pump(); + + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + final tag = sentFrame.sublist(1, 5); + + // 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; + + connector.emitFrame(respCodeSentFrame); + await tester.pump(); + + // Now emit PUSH_CODE_TRACE_DATA (0x89) + // Structure: + // Offset 2: pathLength (2) -> raw byte length count, mode is 0! + // Offset 12..13: pathBytes [0x12, 0x34] + final pushTraceFrame = Uint8List(16); + pushTraceFrame[0] = pushCodeTraceData; + pushTraceFrame[1] = 0; // reserved + pushTraceFrame[2] = 2; // pathLength as raw byte length (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(); + + // Verify it parsed correctly (should not show the unavailable message) + expect(find.text('Path trace not available.'), findsNothing); + }); +} diff --git a/untranslated.json b/untranslated.json deleted file mode 100644 index dd0a7e15..00000000 --- a/untranslated.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "bg": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "de": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "es": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "fr": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "hu": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "it": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "ja": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "ko": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "nl": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "pl": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "pt": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "ru": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "sk": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "sl": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "sv": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "uk": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ], - - "zh": [ - "repeater_pathHashModeOption0", - "repeater_pathHashModeOption1", - "repeater_pathHashModeOption2", - "repeater_pathHashModeOption3" - ] -}