fix tracepath

This commit is contained in:
PacoX
2026-05-26 20:12:27 +02:00
parent e88281b7b6
commit 5d03f99040
11 changed files with 566 additions and 185 deletions
+9 -5
View File
@@ -2821,7 +2821,7 @@ class MeshCoreConnector extends ChangeNotifier {
); );
if (idx != -1) { if (idx != -1) {
_contacts[idx] = _contacts[idx].copyWith( _contacts[idx] = _contacts[idx].copyWith(
pathLength: customPath.length, pathLength: pathLen,
path: customPath, path: customPath,
); );
notifyListeners(); notifyListeners();
@@ -4578,7 +4578,7 @@ class MeshCoreConnector extends ChangeNotifier {
isOutgoing: false, isOutgoing: false,
isCli: isCli, isCli: isCli,
status: MessageStatus.delivered, status: MessageStatus.delivered,
pathLength: pathLength == 0xFF ? 0 : pathLength, pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F),
pathBytes: Uint8List(0), pathBytes: Uint8List(0),
fourByteRoomContactKey: msgText.length >= 4 fourByteRoomContactKey: msgText.length >= 4
? Uint8List.fromList(msgText.substring(0, 4).codeUnits) ? Uint8List.fromList(msgText.substring(0, 4).codeUnits)
@@ -6025,6 +6025,7 @@ class MeshCoreConnector extends ChangeNotifier {
final packet = BufferReader(frame); final packet = BufferReader(frame);
int payloadType = 0; int payloadType = 0;
Uint8List pathBytes = Uint8List(0); Uint8List pathBytes = Uint8List(0);
int pathHashWidth = 1;
try { try {
packet.skipBytes(1); // Skip frame type byte packet.skipBytes(1); // Skip frame type byte
packet.skipBytes(1); // Skip SNR byte packet.skipBytes(1); // Skip SNR byte
@@ -6039,6 +6040,7 @@ class MeshCoreConnector extends ChangeNotifier {
//final payloadVer = (header >> 6) & 0x03; //final payloadVer = (header >> 6) & 0x03;
final pathLenRaw = packet.readByte(); final pathLenRaw = packet.readByte();
final pathByteLen = _decodePathByteLen(pathLenRaw); final pathByteLen = _decodePathByteLen(pathLenRaw);
pathHashWidth = _decodePathHashWidth(pathLenRaw);
pathBytes = packet.readBytes(pathByteLen); pathBytes = packet.readBytes(pathByteLen);
} catch (e) { } catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
@@ -6086,7 +6088,7 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
pathLength: pathBytes.isEmpty ? -1 : pathBytes.length, pathLength: pathBytes.isEmpty ? -1 : (pathBytes.length ~/ pathHashWidth),
path: Uint8List.fromList( path: Uint8List.fromList(
pathBytes.reversed.toList(), pathBytes.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages ), // Store path in reverse for easier use in outgoing messages
@@ -6171,7 +6173,7 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
pathLength: path.length, pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth),
path: Uint8List.fromList( path: Uint8List.fromList(
path.reversed.toList(), path.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages ), // Store path in reverse for easier use in outgoing messages
@@ -6223,7 +6225,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: hasLocation ? longitude : existing.longitude, longitude: hasLocation ? longitude : existing.longitude,
name: hasName ? name : existing.name, name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()), path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length, pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth),
lastMessageAt: mergedLastMessageAt, lastMessageAt: mergedLastMessageAt,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
pathOverride: existing.pathOverride, // Preserve user's path choice 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. /// 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, 1=2bytes, 2=3bytes).
int _decodePathByteLen(int pathLenRaw) { int _decodePathByteLen(int pathLenRaw) {
if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0;
final hashCount = pathLenRaw & 63; final hashCount = pathLenRaw & 63;
final hashSize = _decodePathHashWidth(pathLenRaw); final hashSize = _decodePathHashWidth(pathLenRaw);
return hashCount * hashSize; return hashCount * hashSize;
} }
int _decodePathHashWidth(int pathLenRaw) { int _decodePathHashWidth(int pathLenRaw) {
if (pathLenRaw == 0xFF) return 1;
return ((pathLenRaw >> 6) & 0x03) + 1; return ((pathLenRaw >> 6) & 0x03) + 1;
} }
+4 -5
View File
@@ -963,12 +963,10 @@ class _ChatScreenState extends State<ChatScreen> {
final pathBytes = Uint8List.fromList( final pathBytes = Uint8List.fromList(
path.pathBytes, path.pathBytes,
); );
final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice // Set the path override to persist user's choice
await connector.setPathOverride( await connector.setPathOverride(
_resolveContact(connector), _resolveContact(connector),
pathLen: pathLength, pathLen: path.hopCount,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
@@ -1538,9 +1536,10 @@ class _ChatScreenState extends State<ChatScreen> {
'Calling setPathOverride for ${widget.contact.name}', 'Calling setPathOverride for ${widget.contact.name}',
tag: 'ChatScreen', tag: 'ChatScreen',
); );
final hopsCount = result.length ~/ connector.pathHashByteWidth;
await connector.setPathOverride( await connector.setPathOverride(
_resolveContact(connector), _resolveContact(connector),
pathLen: result.length, pathLen: hopsCount,
pathBytes: result, pathBytes: result,
); );
appLogger.info('setPathOverride completed', tag: 'ChatScreen'); appLogger.info('setPathOverride completed', tag: 'ChatScreen');
@@ -1550,7 +1549,7 @@ class _ChatScreenState extends State<ChatScreen> {
connector, connector,
_resolveContact(connector), _resolveContact(connector),
result, result,
result.length, hopsCount,
); );
} }
+5 -2
View File
@@ -1266,7 +1266,10 @@ class _ContactsScreenState extends State<ContactsScreen>
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPing, title: context.l10n.contacts_repeaterPing,
path: _contactPathPrefix(contact, hw), path: contact.pathBytesForDisplay.isNotEmpty
? contact.pathBytesForDisplay
: _contactPathPrefix(contact, hw),
flipPathAround: true,
targetContact: contact, targetContact: contact,
pathHashByteWidth: hw, pathHashByteWidth: hw,
), ),
@@ -1300,7 +1303,7 @@ class _ContactsScreenState extends State<ContactsScreen>
path: contact.pathBytesForDisplay.isNotEmpty path: contact.pathBytesForDisplay.isNotEmpty
? contact.pathBytesForDisplay ? contact.pathBytesForDisplay
: _contactPathPrefix(contact, hw), : _contactPathPrefix(contact, hw),
flipPathAround: contact.pathBytesForDisplay.isNotEmpty, flipPathAround: true,
targetContact: contact, targetContact: contact,
pathHashByteWidth: hw, pathHashByteWidth: hw,
), ),
+8
View File
@@ -2390,6 +2390,10 @@ class _MapScreenState extends State<MapScreen> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: l10n.contacts_pathTrace, title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace), path: Uint8List.fromList(_pathTrace),
flipPathAround: true,
targetContact: _pathTraceContacts.isNotEmpty
? _pathTraceContacts.last
: null,
pathHashByteWidth: hashW, pathHashByteWidth: hashW,
pathContacts: _pathTraceContacts, pathContacts: _pathTraceContacts,
), ),
@@ -2412,9 +2416,13 @@ class _MapScreenState extends State<MapScreen> {
title: l10n.contacts_pathTrace, title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace), path: Uint8List.fromList(_pathTrace),
flipPathAround: true, flipPathAround: true,
targetContact: _pathTraceContacts.isNotEmpty
? _pathTraceContacts.last
: null,
pathHashByteWidth: context pathHashByteWidth: context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth, .pathHashByteWidth,
pathContacts: _pathTraceContacts,
), ),
), ),
); );
+63 -40
View File
@@ -112,6 +112,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
double _pathDistanceMeters = 0.0; double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true; bool _showNodeLabels = true;
Contact? _targetContact; Contact? _targetContact;
Uint8List? _sentTagBytes;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth)
@@ -203,41 +204,41 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
); );
final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize);
if (pathHops.isEmpty) { // Compute targetPrefix if targetContact is provided
final pk = widget.targetContact?.publicKey; Uint8List? targetPrefix;
if (pk != null && pk.length >= hopWidth) { if (widget.targetContact != null) {
return Uint8List.fromList(pk.sublist(0, hopWidth)); 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 = <Uint8List>[...pathHops]; final outboundHops = <Uint8List>[...pathHops];
final isRepeaterOrRoom = widget.targetContact?.type == advTypeRepeater || if (targetPrefix != null) {
widget.targetContact?.type == advTypeRoom; // Check if targetPrefix is already the last hop in pathHops to avoid duplication
if (isRepeaterOrRoom) { bool alreadyEndedWithTarget = false;
final pk = widget.targetContact?.publicKey; if (pathHops.isNotEmpty) {
if (pk != null && pk.length >= hopWidth) { if (listEquals(pathHops.last, targetPrefix)) {
mirroredHops.add(Uint8List.fromList(pk.sublist(0, hopWidth))); alreadyEndedWithTarget = true;
} else { }
mirroredHops.add(
Uint8List.fromList(
pk != null && pk.isNotEmpty ? [pk[0]] : const [0],
),
);
} }
// For repeaters/rooms, include full reversed path if (!alreadyEndedWithTarget) {
mirroredHops.addAll(pathHops.reversed); outboundHops.add(targetPrefix);
} 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 (outboundHops.isEmpty) {
return Uint8List(0);
}
final mirroredHops = <Uint8List>[...outboundHops];
if (outboundHops.length > 1) {
mirroredHops.addAll(
outboundHops.sublist(0, outboundHops.length - 1).reversed,
);
}
final traceBytes = <int>[]; final traceBytes = <int>[];
for (final hop in mirroredHops) { for (final hop in mirroredHops) {
traceBytes.addAll(hop); traceBytes.addAll(hop);
@@ -266,11 +267,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
); );
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(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( final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000, sentTag,
0, //flags 0, // auth
0, //auth flags, // flag
payload: path, payload: tracePayload,
); );
connector.sendFrame(frame); connector.sendFrame(frame);
} }
@@ -314,15 +325,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
// Check if it's a binary response // Check if it's a binary response
if (frame.length > 8 && if (frame.length >= 12 &&
code == pushCodeTraceData && code == pushCodeTraceData &&
listEquals(frame.sublist(4, 8), tagData)) { (listEquals(frame.sublist(4, 8), _sentTagBytes) || listEquals(frame.sublist(4, 8), tagData))) {
_timeoutTimer?.cancel(); _timeoutTimer?.cancel();
if (!mounted) return; if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag _handleTraceResponse(frame);
if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame);
}
} }
} catch (e) { } catch (e) {
_timeoutTimer?.cancel(); _timeoutTimer?.cancel();
@@ -343,13 +351,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
try { try {
buffer.skipBytes(2); // Skip push code and reserved byte 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(5); // Skip Flag byte and tag data
buffer.skipBytes(4); // Skip auth code buffer.skipBytes(4); // Skip auth code
final pathBytes = buffer.readBytes(pathLength); final pathBytes = buffer.readBytes(pathLength);
final pathData = PathHelper.splitPathBytes( final pathData = PathHelper.splitPathBytes(
pathBytes, pathBytes,
widget.pathHashByteWidth, width,
); );
List<double> snrData = buffer List<double> snrData = buffer
.readRemainingBytes() .readRemainingBytes()
+35 -5
View File
@@ -127,6 +127,38 @@ class ChannelMessageStore {
ChannelMessage _messageFromJson(Map<String, dynamic> json) { ChannelMessage _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String; final rawText = json['text'] as String;
final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText; 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( return ChannelMessage(
senderKey: json['senderKey'] != null senderKey: json['senderKey'] != null
? Uint8List.fromList(base64Decode(json['senderKey'])) ? Uint8List.fromList(base64Decode(json['senderKey']))
@@ -144,11 +176,9 @@ class ChannelMessageStore {
isOutgoing: json['isOutgoing'] as bool, isOutgoing: json['isOutgoing'] as bool,
status: ChannelMessageStatus.values[json['status'] as int], status: ChannelMessageStatus.values[json['status'] as int],
repeatCount: (json['repeatCount'] as int?) ?? 0, repeatCount: (json['repeatCount'] as int?) ?? 0,
pathLength: json['pathLength'] as int?, pathLength: decodedPathLength,
pathHashWidth: json['pathHashWidth'] as int?, pathHashWidth: decodedPathHashWidth,
pathBytes: json['pathBytes'] != null pathBytes: decodedPathBytes,
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
pathVariants: (json['pathVariants'] as List<dynamic>?) pathVariants: (json['pathVariants'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String))) ?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(), .toList(),
+29 -4
View File
@@ -55,15 +55,40 @@ class ContactDiscoveryStore {
final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastSeenMs = json['lastSeen'] as int? ?? 0;
final lastMessageMs = json['lastMessageAt'] as int?; final lastMessageMs = json['lastMessageAt'] as int?;
final lastModifiedMs = json['lastModified'] 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( return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown', name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0, type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0, flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1, pathLength: decodedPathLength,
path: json['path'] != null path: decodedPath,
? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0),
pathOverride: json['pathOverride'] as int?, pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList( ? Uint8List.fromList(
+4 -4
View File
@@ -158,16 +158,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
); );
if (result != null && context.mounted) { if (result != null && context.mounted) {
final hopsCount = result.length ~/ connector.pathHashByteWidth;
await connector.setPathOverride( await connector.setPathOverride(
currentContact, currentContact,
pathLen: result.length, pathLen: hopsCount,
pathBytes: result, pathBytes: result,
); );
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(l10n.chat_hopsCount(result.length)), content: Text(l10n.chat_hopsCount(hopsCount)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
); );
} }
@@ -361,11 +362,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
final pathBytes = Uint8List.fromList( final pathBytes = Uint8List.fromList(
path.pathBytes, path.pathBytes,
); );
final pathLength = path.pathBytes.length;
await connector.setPathOverride( await connector.setPathOverride(
currentContact, currentContact,
pathLen: pathLength, pathLen: path.hopCount,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
+56
View File
@@ -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/prefs_manager.dart';
import 'package:meshcore_open/storage/contact_store.dart'; import 'package:meshcore_open/storage/contact_store.dart';
import 'package:meshcore_open/storage/message_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. // 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)] // 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.pathLength, equals(0));
expect(messages.first.pathBytes, isEmpty); 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])));
});
}); });
} }
+353
View File
@@ -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<Uint8List> _receivedFramesController =
StreamController<Uint8List>.broadcast();
@override
Stream<Uint8List> 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<Contact> get allContactsUnfiltered => [];
@override
List<Contact> get contacts => [];
void emitFrame(Uint8List frame) {
_receivedFramesController.add(frame);
}
final List<Uint8List> sentFrames = [];
@override
Future<void> sendFrame(
Uint8List frame, {
String? channelSendQueueId,
bool expectsGenericAck = false,
}) async {
sentFrames.add(frame);
}
}
Widget _buildTestApp({
required MeshCoreConnector connector,
required Widget child,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
ChangeNotifierProvider<AppSettingsService>(
create: (_) => AppSettingsService(),
),
Provider<MapTileCacheService>(
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);
});
}
-120
View File
@@ -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"
]
}