Merge pull request #3 from HDDen/update-pacox-multibyte

Updated multibyte
This commit is contained in:
PacoX
2026-05-28 08:43:18 +02:00
committed by GitHub
11 changed files with 597 additions and 350 deletions
+77 -20
View File
@@ -2598,6 +2598,24 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
bool _isPathLenValidForCurrentMode(int pathLen, List<int> pathBytes) {
return _isPathLenValidForMode(pathLen, pathBytes, _pathHashByteWidth);
}
int? _encodePathLenForCurrentMode(int pathLen, List<int> 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<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (!isConnected) return; if (!isConnected) return;
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
@@ -2797,11 +2815,10 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
if (!isConnected) return; if (!isConnected) return;
final mode = _pathHashByteWidth - 1; final encodedPathLen = _encodePathLenForCurrentMode(pathLen, customPath);
if (pathLen < 0 || pathLen > 0x3F) { if (encodedPathLen == null) {
return; return;
} }
final encodedPathLen = pathLen | (mode << 6);
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
@@ -2863,10 +2880,11 @@ class MeshCoreConnector extends ChangeNotifier {
: (updatedFlags & ~contactFlagTeleEnv)) : (updatedFlags & ~contactFlagTeleEnv))
: updatedFlags; : updatedFlags;
final mode = _pathHashByteWidth - 1; final encodedPathLen = _encodePathLenForCurrentMode(
final encodedPathLen = (latestContact.pathLength >= 0 && latestContact.pathLength != 0xFF) latestContact.pathLength,
? (latestContact.pathLength | (mode << 6)) latestContact.path,
: latestContact.pathLength; );
if (encodedPathLen == null) return;
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
latestContact.publicKey, latestContact.publicKey,
@@ -2954,6 +2972,19 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector', 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 // Update contact with new path override
_contacts[index] = _contacts[index].copyWith( _contacts[index] = _contacts[index].copyWith(
pathOverride: pathLen, pathOverride: pathLen,
@@ -3207,10 +3238,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> importDiscoveredContact(Contact contact) async { Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
final mode = _pathHashByteWidth - 1; final encodedPathLen = _encodePathLenForCurrentMode(
final encodedPathLen = (contact.pathLength >= 0 && contact.pathLength != 0xFF) contact.pathLength,
? (contact.pathLength | (mode << 6)) contact.path,
: contact.pathLength; );
if (encodedPathLen == null) return;
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
@@ -6089,10 +6121,12 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
pathLength: pathBytes.isEmpty ? -1 : (pathBytes.length ~/ pathHashWidth), pathLength: pathBytes.isEmpty
path: Uint8List.fromList( ? -1
pathBytes.reversed.toList(), : (pathBytes.length ~/ pathHashWidth),
), // 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, latitude: latitude,
longitude: longitude, longitude: longitude,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
@@ -6175,9 +6209,9 @@ class MeshCoreConnector extends ChangeNotifier {
name: name, name: name,
type: type, type: type,
pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth),
path: Uint8List.fromList( // Store hop order reversed for easier outgoing messages; keep bytes
path.reversed.toList(), // inside each multi-byte hop in their original order.
), // Store path in reverse for easier use in outgoing messages path: _reversePathByHop(path, pathHashWidth),
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
@@ -6225,7 +6259,7 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: hasLocation ? latitude : existing.latitude, latitude: hasLocation ? latitude : existing.latitude,
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: _reversePathByHop(path, pathHashWidth),
pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth),
lastMessageAt: mergedLastMessageAt, lastMessageAt: mergedLastMessageAt,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
@@ -6518,7 +6552,7 @@ const int _payloadTypeGroupText = 0x05;
const int _cipherMacSize = 2; 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 ... 3=4bytes).
int _decodePathByteLen(int pathLenRaw) { int _decodePathByteLen(int pathLenRaw) {
if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0; if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0;
final hashCount = pathLenRaw & 63; final hashCount = pathLenRaw & 63;
@@ -6531,6 +6565,29 @@ int _decodePathHashWidth(int pathLenRaw) {
return ((pathLenRaw >> 6) & 0x03) + 1; return ((pathLenRaw >> 6) & 0x03) + 1;
} }
bool _isPathLenValidForMode(
int pathLen,
List<int> 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();
final reversed = <int>[];
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 { class _RawPacket {
final int header; final int header;
final int routeType; final int routeType;
+13 -5
View File
@@ -9,6 +9,13 @@ import '../helpers/snack_bar_builder.dart';
enum _BleLogView { frames, rawLogRx } 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 { class BleDebugLogScreen extends StatefulWidget {
const BleDebugLogScreen({super.key}); const BleDebugLogScreen({super.key});
@@ -208,16 +215,17 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
rawHex: _bytesToHex(raw), rawHex: _bytesToHex(raw),
); );
} }
final pathLen = raw[index++]; final pathLenRaw = raw[index++];
if (raw.length < index + pathLen) { final pathByteLen = _decodeRawPathByteLen(pathLenRaw);
if (raw.length < index + pathByteLen) {
return _RawPacketInfo( return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}', title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Truncated path', summary: 'Truncated path',
rawHex: _bytesToHex(raw), rawHex: _bytesToHex(raw),
); );
} }
final pathBytes = raw.sublist(index, index + pathLen); final pathBytes = raw.sublist(index, index + pathByteLen);
index += pathLen; index += pathByteLen;
if (raw.length <= index) { if (raw.length <= index) {
return _RawPacketInfo( return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}', title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
@@ -230,7 +238,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final title = final title =
'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer'; 'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final summary = _decodePayloadSummary(payloadType, payload); final summary = _decodePayloadSummary(payloadType, payload);
final pathSummary = pathLen > 0 final pathSummary = pathByteLen > 0
? 'Path=${_bytesToHex(pathBytes)}' ? 'Path=${_bytesToHex(pathBytes)}'
: 'Path=none'; : 'Path=none';
final detail = '$summary$pathSummary • len=${raw.length}'; final detail = '$summary$pathSummary • len=${raw.length}';
+1 -2
View File
@@ -301,8 +301,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
itemCount: filteredChannels.length, itemCount: filteredChannels.length,
onReorder: (oldIndex, newIndex) { onReorderItem: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from( final reordered = List<Channel>.from(
filteredChannels, filteredChannels,
); );
+41 -17
View File
@@ -70,6 +70,7 @@ class _MapScreenState extends State<MapScreen> {
bool _hasInitializedMap = false; bool _hasInitializedMap = false;
bool _removedMarkersLoaded = false; bool _removedMarkersLoaded = false;
final List<int> _pathTrace = []; final List<int> _pathTrace = [];
final List<int> _pathTraceHopWidths = [];
final List<Contact> _pathTraceContacts = []; final List<Contact> _pathTraceContacts = [];
final List<LatLng> _points = []; final List<LatLng> _points = [];
final List<Polyline> _polylines = []; final List<Polyline> _polylines = [];
@@ -699,6 +700,16 @@ class _MapScreenState extends State<MapScreen> {
int pathHashByteWidth, int pathHashByteWidth,
) { ) {
final result = <_GuessedLocation>[]; final result = <_GuessedLocation>[];
final hopWidth = pathHashByteWidth.clamp(1, 4).toInt();
final anchorsByPrefix = <String, List<Contact>>{};
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) { for (final contact in allContacts) {
if (contact.hasLocation) continue; if (contact.hasLocation) continue;
@@ -721,21 +732,13 @@ class _MapScreenState extends State<MapScreen> {
]; ];
for (final pathBytes in pathSets) { for (final pathBytes in pathSets) {
if (pathBytes.isEmpty) continue; if (pathBytes.isEmpty) continue;
final hopWidth = pathHashByteWidth.clamp(1, 4);
final lastHop = pathBytes.sublist(max(0, pathBytes.length - hopWidth)); final lastHop = pathBytes.sublist(max(0, pathBytes.length - hopWidth));
if (lastHop.isEmpty) continue; if (lastHop.isEmpty) continue;
for (final repeater in withLocation) { final repeaters = anchorsByPrefix[PathHelper.formatHopHex(lastHop)];
if (repeater.type != advTypeRepeater) continue; if (repeaters != null && repeaters.isNotEmpty) {
if (repeater.publicKey.length < lastHop.length) continue; final repeater = repeaters.first;
if (!listEquals(
repeater.publicKey.sublist(0, lastHop.length),
lastHop,
)) {
continue;
}
anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!)); anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!));
break;
} }
} }
@@ -2302,11 +2305,19 @@ class _MapScreenState extends State<MapScreen> {
final hopWidth = min( final hopWidth = min(
connector.pathHashByteWidth.clamp(1, pubKeySize), connector.pathHashByteWidth.clamp(1, pubKeySize),
contact.publicKey.length, 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(() { setState(() {
_pathTrace.addAll( _pathTrace.addAll(hopPrefix); // Add the hop-width pubkey prefix.
contact.publicKey.sublist(0, hopWidth), _pathTraceHopWidths.add(hopWidth);
); // Add the hop-width prefix of the public key to the trace
_pathTraceContacts.add( _pathTraceContacts.add(
contact.copyWith( contact.copyWith(
latitude: position?.latitude ?? contact.latitude, latitude: position?.latitude ?? contact.latitude,
@@ -2321,6 +2332,7 @@ class _MapScreenState extends State<MapScreen> {
setState(() { setState(() {
_isBuildingPathTrace = true; _isBuildingPathTrace = true;
_pathTrace.clear(); _pathTrace.clear();
_pathTraceHopWidths.clear();
_pathTraceContacts.clear(); _pathTraceContacts.clear();
_points.clear(); _points.clear();
_polylines.clear(); _polylines.clear();
@@ -2330,8 +2342,19 @@ class _MapScreenState extends State<MapScreen> {
void _removePath() { void _removePath() {
setState(() { setState(() {
final recordedHopWidth = _pathTraceHopWidths.isNotEmpty
? _pathTraceHopWidths.removeLast()
: context.read<MeshCoreConnector>().pathHashByteWidth.clamp(
1,
pubKeySize,
);
final hopByteCount = min(recordedHopWidth, _pathTrace.length).toInt();
_pathTraceContacts.removeLast(); _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 _points.removeLast(); // Remove last point from points list
_polylines.clear(); // Clear polylines _polylines.clear(); // Clear polylines
}); });
@@ -2369,7 +2392,7 @@ class _MapScreenState extends State<MapScreen> {
PathHelper.splitPathBytes( PathHelper.splitPathBytes(
_pathTrace, _pathTrace,
context.read<MeshCoreConnector>().pathHashByteWidth, context.read<MeshCoreConnector>().pathHashByteWidth,
).map(PathHelper.formatHopHex).join(',') , ).map(PathHelper.formatHopHex).join(','),
style: TextStyle(fontSize: 18), style: TextStyle(fontSize: 18),
), ),
// const SizedBox(height: 6), // const SizedBox(height: 6),
@@ -2445,6 +2468,7 @@ class _MapScreenState extends State<MapScreen> {
setState(() { setState(() {
_isBuildingPathTrace = false; _isBuildingPathTrace = false;
_pathTrace.clear(); _pathTrace.clear();
_pathTraceHopWidths.clear();
_points.clear(); _points.clear();
_polylines.clear(); _polylines.clear();
}); });
+140 -49
View File
@@ -57,6 +57,12 @@ Uint8List _lastHopChunk(Uint8List path, int hopWidth) {
return Uint8List.fromList(path.sublist(path.length - width)); 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 { class PathTraceMapScreen extends StatefulWidget {
final String title; final String title;
final Uint8List path; final Uint8List path;
@@ -114,10 +120,75 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Contact? _targetContact; Contact? _targetContact;
Uint8List? _sentTagBytes; Uint8List? _sentTagBytes;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes, [int? hashByteWidth]) {
return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) return PathHelper.splitPathBytes(
.map(PathHelper.formatHopHex) pathBytes,
.join(','); 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 = <int>[];
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 = <Contact>[
...?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 = <int>[];
for (final hop in hops) {
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
if (traceHop == null) return null;
traceBytes.addAll(traceHop);
}
return Uint8List.fromList(traceBytes);
} }
@override @override
@@ -197,12 +268,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
); );
} }
Uint8List buildPath(Uint8List pathBytes) { Uint8List? buildPath(
Uint8List pathBytes,
int traceHashByteWidth,
MeshCoreConnector connector,
) {
final pathHops = PathHelper.splitPathBytes( final pathHops = PathHelper.splitPathBytes(
pathBytes, pathBytes,
widget.pathHashByteWidth, widget.pathHashByteWidth,
); );
final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); final hopWidth = traceHashByteWidth.clamp(1, pubKeySize).toInt();
// Compute targetPrefix if targetContact is provided // Compute targetPrefix if targetContact is provided
Uint8List? targetPrefix; Uint8List? targetPrefix;
@@ -214,12 +289,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
} }
final outboundHops = <Uint8List>[...pathHops]; final outboundHops = <Uint8List>[];
for (final hop in pathHops) {
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
if (traceHop == null) return null;
outboundHops.add(traceHop);
}
if (targetPrefix != null) { if (targetPrefix != null) {
// Check if targetPrefix is already the last hop in pathHops to avoid duplication // Check if targetPrefix is already the last hop in pathHops to avoid duplication
bool alreadyEndedWithTarget = false; bool alreadyEndedWithTarget = false;
if (pathHops.isNotEmpty) { if (outboundHops.isNotEmpty) {
if (listEquals(pathHops.last, targetPrefix)) { if (listEquals(outboundHops.last, targetPrefix)) {
alreadyEndedWithTarget = true; alreadyEndedWithTarget = true;
} }
} }
@@ -254,19 +334,32 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
}); });
} }
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth);
final pathTmp = widget.reversePathAround final pathTmp = widget.reversePathAround
? Uint8List.fromList(widget.path.reversed.toList()) ? _reversePathByHop(widget.path, widget.pathHashByteWidth)
: widget.path; : 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( appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}', 'Initiating path trace with path: '
'${_formatPathPrefixes(path, traceHashByteWidth)}',
tag: 'PathTraceMapScreen', tag: 'PathTraceMapScreen',
noNotify: !mounted, noNotify: !mounted,
); );
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000; final sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000;
_sentTagBytes = Uint8List(4) _sentTagBytes = Uint8List(4)
..[0] = sentTag & 0xFF ..[0] = sentTag & 0xFF
@@ -274,7 +367,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
..[2] = (sentTag >> 16) & 0xFF ..[2] = (sentTag >> 16) & 0xFF
..[3] = (sentTag >> 24) & 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 tracePayload = path.isEmpty ? Uint8List.fromList([0x00]) : path;
final frame = buildTraceReq( final frame = buildTraceReq(
@@ -327,7 +420,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Check if it's a binary response // Check if it's a binary response
if (frame.length >= 12 && if (frame.length >= 12 &&
code == pushCodeTraceData && 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(); _timeoutTimer?.cancel();
if (!mounted) return; if (!mounted) return;
_handleTraceResponse(frame); _handleTraceResponse(frame);
@@ -352,28 +446,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
try { try {
buffer.skipBytes(2); // Skip push code and reserved byte buffer.skipBytes(2); // Skip push code and reserved byte
final pathLenByte = buffer.readUInt8(); final pathLenByte = buffer.readUInt8();
int hopCount = 0; final flags = buffer.readUInt8();
int width = widget.pathHashByteWidth; // fallback buffer.skipBytes(4); // Skip tag data
int pathLength = 0; buffer.skipBytes(4); // Skip auth code
if (pathLenByte != 0xFF) { var width = _traceHashByteWidth(1 << (flags & 0x03));
final mode = (pathLenByte & 0xC0) >> 6; var pathLength = pathLenByte == 0xFF ? 0 : pathLenByte;
if (mode > 0) { if (pathLength > buffer.remaining && (pathLenByte & 0xC0) != 0) {
hopCount = pathLenByte & 0x3F; final packedWidth = ((pathLenByte & 0xC0) >> 6) + 1;
width = mode + 1; final packedLength = (pathLenByte & 0x3F) * packedWidth;
pathLength = hopCount * width; if (packedLength <= buffer.remaining) {
} else { width = packedWidth;
width = widget.pathHashByteWidth; pathLength = packedLength;
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 pathBytes = buffer.readBytes(pathLength);
final pathData = PathHelper.splitPathBytes( final pathData = PathHelper.splitPathBytes(pathBytes, width);
pathBytes,
width,
);
List<double> snrData = buffer List<double> snrData = buffer
.readRemainingBytes() .readRemainingBytes()
.map((snr) => snr.toSigned(8).toDouble() / 4) .map((snr) => snr.toSigned(8).toDouble() / 4)
@@ -391,7 +478,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
); );
if (widget.pathContacts != null) { if (widget.pathContacts != null) {
final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); final hopWidth = width.clamp(1, pubKeySize).toInt();
pathContacts = { pathContacts = {
for (var c in widget.pathContacts!) for (var c in widget.pathContacts!)
if (c.publicKey.length >= hopWidth) if (c.publicKey.length >= hopWidth)
@@ -414,7 +501,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
for (final repeaterData in pathData) { for (final repeaterData in pathData) {
final hopWidth = repeaterData.length; final hopWidth = repeaterData.length;
if (repeater.publicKey.length < hopWidth) continue; 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; pathContacts[_hopKey(repeaterData)] = repeater;
lastContact = repeater; lastContact = repeater;
} }
@@ -434,8 +524,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
(c) => (c) =>
c.hasLocation && c.hasLocation &&
c.path.isNotEmpty && c.path.isNotEmpty &&
_hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) == _matchesHopPrefix(
hopKey, _lastHopChunk(c.path, widget.pathHashByteWidth),
hop,
),
) )
.toList(); .toList();
if (peers.isNotEmpty) { if (peers.isNotEmpty) {
@@ -481,10 +573,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
(c) => (c) =>
c.hasLocation && c.hasLocation &&
c.path.isNotEmpty && c.path.isNotEmpty &&
_hopKey( _matchesHopPrefix(
_lastHopChunk(c.path, widget.pathHashByteWidth), _lastHopChunk(c.path, widget.pathHashByteWidth),
) == lastHop,
lastHopKey, ),
) )
.toList(); .toList();
if (peers.isNotEmpty) { if (peers.isNotEmpty) {
@@ -695,6 +787,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
? LatLng(contact.latitude!, contact.longitude!) ? LatLng(contact.latitude!, contact.longitude!)
: inferred!; : inferred!;
final label = PathHelper.formatHopHex(hop); 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( markers.add(
Marker( Marker(
@@ -719,7 +815,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
hasGps ? label : '~$label', hasGps ? shortLabel : '~$shortLabel',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -730,12 +826,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
); );
if (showLabels) { if (showLabels) {
markers.add( markers.add(_buildNodeLabelMarker(point: point, label: fullLabel));
_buildNodeLabelMarker(
point: point,
label: contact?.name ?? '~$label',
),
);
} }
hopLastLast = hopLast; hopLastLast = hopLast;
hopLast = hopKey; hopLast = hopKey;
+17 -5
View File
@@ -580,11 +580,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
labelText: l10n.repeater_pathHashMode, labelText: l10n.repeater_pathHashMode,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: const [ items: [
DropdownMenuItem(value: 0, child: Text('0 - 1 byte')), DropdownMenuItem(
DropdownMenuItem(value: 1, child: Text('1 - 2 bytes')), value: 0,
DropdownMenuItem(value: 2, child: Text('2 - 3 bytes')), child: Text(l10n.repeater_pathHashModeOption0),
DropdownMenuItem(value: 3, child: Text('3 - 4 bytes')), ),
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) { onChanged: (value) {
if (value == null) return; if (value == null) return;
+3 -3
View File
@@ -28,8 +28,8 @@ class NotificationService {
AppLocalizations get _l10n => lookupAppLocalizations(_locale); AppLocalizations get _l10n => lookupAppLocalizations(_locale);
String _logSafe(String value) { String _logSafe(String value) {
final sanitized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' '); final sanitized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' ');
return Uri.encodeComponent(sanitized); return Uri.encodeComponent(sanitized);
} }
// Rate limiting to prevent notification storms // 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) // Show first few device names in batch summary for debugging (only if adverts exist)
final deviceInfo = adverts.isNotEmpty 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'); debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
+9 -4
View File
@@ -126,7 +126,8 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
.toList(); .toList();
final pathBytesList = <int>[]; final pathBytesList = <int>[];
final invalidPrefixes = <String>[]; final invalidPrefixes = <String>[];
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) { for (final id in pathIds) {
if (id.length < hexCharsPerHop) { if (id.length < hexCharsPerHop) {
@@ -157,8 +158,12 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
return; return;
} }
// Check max path size in bytes, as defined by the protocol. final hopCount = pathBytesList.length ~/ pathHashByteWidth;
if (pathBytesList.length > maxPathSize) { 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( showDismissibleSnackBar(
context, context,
content: Text(l10n.path_tooLong), content: Text(l10n.path_tooLong),
@@ -235,7 +240,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
helperText: l10n.path_helperMaxHops, helperText: l10n.path_helperMaxHops,
), ),
textCapitalization: TextCapitalization.characters, 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 SizedBox(height: 16),
const Divider(), const Divider(),
+16 -13
View File
@@ -31,21 +31,24 @@ void main() {
expect(PathHelper.formatHopHex(hops.first), equals('A1A2')); expect(PathHelper.formatHopHex(hops.first), equals('A1A2'));
}); });
test('resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths', () { test(
final contacts = [ 'resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths',
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat), () {
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater), final contacts = [
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom), _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( final resolved = PathHelper.resolvePathNames(
[0xF2, 0x7E, 0xBA], [0xF2, 0x7E, 0xBA],
contacts, contacts,
1, // 1-byte hash width 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', () { test('resolvePathNames supports multi-byte paths', () {
final contacts = [ final contacts = [
+165 -136
View File
@@ -6,7 +6,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/channel_message.dart'; import 'package:meshcore_open/models/channel_message.dart';
import 'package:meshcore_open/models/contact.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/path_history.dart';
import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/app_settings.dart';
import 'package:meshcore_open/storage/prefs_manager.dart'; import 'package:meshcore_open/storage/prefs_manager.dart';
@@ -65,9 +64,7 @@ Uint8List _buildChannelMessageFrameV3({
if (hasPath && hopCount > 0) { if (hasPath && hopCount > 0) {
writer.add( writer.add(
Uint8List.fromList( Uint8List.fromList(List.generate(hopCount * pathHashWidth, (i) => i + 1)),
List.generate(hopCount * pathHashWidth, (i) => i + 1),
),
); );
} }
@@ -81,23 +78,25 @@ Uint8List _buildChannelMessageFrameV3({
void main() { void main() {
group('ChannelMessage.fromFrame — V3 packed path decoding', () { group('ChannelMessage.fromFrame — V3 packed path decoding', () {
test('hasPath reads width, hop count, path bytes, and txtType correctly', test(
() { 'hasPath reads width, hop count, path bytes, and txtType correctly',
final frame = _buildChannelMessageFrameV3( () {
pathHashWidth: 2, final frame = _buildChannelMessageFrameV3(
hopCount: 3, pathHashWidth: 2,
hasPath: true, hopCount: 3,
); hasPath: true,
);
final message = ChannelMessage.fromFrame(frame); final message = ChannelMessage.fromFrame(frame);
expect(message, isNotNull); expect(message, isNotNull);
expect(message!.pathHashWidth, equals(2)); expect(message!.pathHashWidth, equals(2));
expect(message.pathLength, equals(3)); expect(message.pathLength, equals(3));
expect(message.pathBytes.length, equals(6)); expect(message.pathBytes.length, equals(6));
expect(message.senderName, equals('Alice')); expect(message.senderName, equals('Alice'));
expect(message.text, equals('Hello world')); expect(message.text, equals('Hello world'));
}); },
);
test('no path still reads width, hop count, and txtType correctly', () { test('no path still reads width, hop count, and txtType correctly', () {
final frame = _buildChannelMessageFrameV3( final frame = _buildChannelMessageFrameV3(
@@ -473,141 +472,171 @@ void main() {
await PrefsManager.initialize(); await PrefsManager.initialize();
}); });
test('ContactStore decodes and migrates legacy mode-encoded paths', () async { test(
final store = ContactStore()..publicKeyHex = '1234567890'; '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) // Let's create a contact with legacy 64 path length (mode 1, 0 hops)
final rawPath = Uint8List(64); // 64 bytes of zeroes final rawPath = Uint8List(64); // 64 bytes of zeroes
final contactJson = [ final contactJson = [
{ {
'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA),
'name': 'LegacyNode', 'name': 'LegacyNode',
'type': 2, 'type': 2,
'flags': 0, 'flags': 0,
'pathLength': 64, // encoded pathLength (mode 1, 0 hops) 'pathLength': 64, // encoded pathLength (mode 1, 0 hops)
'path': base64Encode(rawPath), 'path': base64Encode(rawPath),
'lastSeen': DateTime.now().millisecondsSinceEpoch, 'lastSeen': DateTime.now().millisecondsSinceEpoch,
'lastMessageAt': DateTime.now().millisecondsSinceEpoch, 'lastMessageAt': DateTime.now().millisecondsSinceEpoch,
'isActive': true, 'isActive': true,
} },
]; ];
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString(store.keyFor, jsonEncode(contactJson)); await prefs.setString(store.keyFor, jsonEncode(contactJson));
final contacts = await store.loadContacts(); final contacts = await store.loadContacts();
expect(contacts, hasLength(1)); expect(contacts, hasLength(1));
expect(contacts.first.pathLength, equals(0)); expect(contacts.first.pathLength, equals(0));
expect(contacts.first.path, isEmpty); expect(contacts.first.path, isEmpty);
}); },
);
test('ContactStore decodes and migrates legacy mode-1 paths with hops', () async { test(
final store = ContactStore()..publicKeyHex = '1234567890'; '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) // Contact with legacy 65 path length (mode 1, 1 hop)
final rawPath = Uint8List(64)..[0] = 0xBB..[1] = 0xCC; final rawPath = Uint8List(64)
final contactJson = [ ..[0] = 0xBB
{ ..[1] = 0xCC;
'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA), final contactJson = [
'name': 'LegacyNode2', {
'type': 2, 'publicKey': base64Encode(Uint8List(32)..[0] = 0xAA),
'flags': 0, 'name': 'LegacyNode2',
'pathLength': 65, // encoded pathLength (mode 1, 1 hop) 'type': 2,
'path': base64Encode(rawPath), 'flags': 0,
'lastSeen': DateTime.now().millisecondsSinceEpoch, 'pathLength': 65, // encoded pathLength (mode 1, 1 hop)
'lastMessageAt': DateTime.now().millisecondsSinceEpoch, 'path': base64Encode(rawPath),
'isActive': true, 'lastSeen': DateTime.now().millisecondsSinceEpoch,
} 'lastMessageAt': DateTime.now().millisecondsSinceEpoch,
]; 'isActive': true,
},
];
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString(store.keyFor, jsonEncode(contactJson)); await prefs.setString(store.keyFor, jsonEncode(contactJson));
final contacts = await store.loadContacts(); final contacts = await store.loadContacts();
expect(contacts, hasLength(1)); expect(contacts, hasLength(1));
expect(contacts.first.pathLength, equals(1)); expect(contacts.first.pathLength, equals(1));
expect(contacts.first.path, equals(Uint8List.fromList([0xBB, 0xCC]))); expect(contacts.first.path, equals(Uint8List.fromList([0xBB, 0xCC])));
}); },
);
test('MessageStore decodes and migrates legacy mode-encoded message paths', () async { test(
final store = MessageStore()..publicKeyHex = '1234567890'; 'MessageStore decodes and migrates legacy mode-encoded message paths',
final contactKeyHex = pubKeyToHex(Uint8List(32)..[0] = 0xAA); () async {
final store = MessageStore()..publicKeyHex = '1234567890';
final contactKeyHex = pubKeyToHex(Uint8List(32)..[0] = 0xAA);
final rawPath = Uint8List(64); final rawPath = Uint8List(64);
final messageJson = [ final messageJson = [
{ {
'senderKey': base64Encode(Uint8List(32)..[0] = 0xAA), 'senderKey': base64Encode(Uint8List(32)..[0] = 0xAA),
'text': 'Hello', 'text': 'Hello',
'timestamp': DateTime.now().millisecondsSinceEpoch, 'timestamp': DateTime.now().millisecondsSinceEpoch,
'isOutgoing': false, 'isOutgoing': false,
'status': 2, // delivered 'status': 2, // delivered
'messageId': 'msg_1', 'messageId': 'msg_1',
'pathLength': 64, // encoded pathLength (mode 1, 0 hops) 'pathLength': 64, // encoded pathLength (mode 1, 0 hops)
'pathBytes': base64Encode(rawPath), 'pathBytes': base64Encode(rawPath),
} },
]; ];
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString('${store.keyFor}$contactKeyHex', jsonEncode(messageJson)); await prefs.setString(
'${store.keyFor}$contactKeyHex',
jsonEncode(messageJson),
);
final messages = await store.loadMessages(contactKeyHex); final messages = await store.loadMessages(contactKeyHex);
expect(messages, hasLength(1)); expect(messages, hasLength(1));
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 { test(
final store = ChannelMessageStore()..publicKeyHex = '1234567890'; 'ChannelMessageStore decodes and migrates legacy mode-encoded paths',
final channelIndex = 1; () async {
final store = ChannelMessageStore()..publicKeyHex = '1234567890';
final channelIndex = 1;
final rawPath = Uint8List(64)..[0] = 0xBB..[1] = 0xCC; final rawPath = Uint8List(64)
final messageJson = [ ..[0] = 0xBB
{ ..[1] = 0xCC;
'senderName': 'Alice', final messageJson = [
'text': 'Hello', {
'timestamp': DateTime.now().millisecondsSinceEpoch, 'senderName': 'Alice',
'isOutgoing': false, 'text': 'Hello',
'status': 2, 'timestamp': DateTime.now().millisecondsSinceEpoch,
'channelIndex': channelIndex, 'isOutgoing': false,
'pathLength': 65, // encoded pathLength (mode 1, 1 hop) 'status': 2,
'pathBytes': base64Encode(rawPath), 'channelIndex': channelIndex,
} 'pathLength': 65, // encoded pathLength (mode 1, 1 hop)
]; 'pathBytes': base64Encode(rawPath),
},
];
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString('${store.keyFor}$channelIndex', jsonEncode(messageJson)); await prefs.setString(
'${store.keyFor}$channelIndex',
jsonEncode(messageJson),
);
final messages = await store.loadChannelMessages(channelIndex); final messages = await store.loadChannelMessages(channelIndex);
expect(messages, hasLength(1)); expect(messages, hasLength(1));
expect(messages.first.pathLength, equals(1)); expect(messages.first.pathLength, equals(1));
expect(messages.first.pathBytes, equals(Uint8List.fromList([0xBB, 0xCC]))); expect(
expect(messages.first.pathHashWidth, equals(2)); messages.first.pathBytes,
}); equals(Uint8List.fromList([0xBB, 0xCC])),
);
expect(messages.first.pathHashWidth, equals(2));
},
);
test('ContactDiscoveryStore decodes and migrates legacy mode-encoded paths', () async { test(
final store = ContactDiscoveryStore(); 'ContactDiscoveryStore decodes and migrates legacy mode-encoded paths',
() async {
final store = ContactDiscoveryStore();
final rawPath = Uint8List(64)..[0] = 0x11..[1] = 0x22; final rawPath = Uint8List(64)
final contactJson = [ ..[0] = 0x11
{ ..[1] = 0x22;
'publicKey': base64Encode(Uint8List(32)..[0] = 0xBB), final contactJson = [
'name': 'DiscoveredNode', {
'type': 1, 'publicKey': base64Encode(Uint8List(32)..[0] = 0xBB),
'flags': 0, 'name': 'DiscoveredNode',
'pathLength': 65, // encoded pathLength (mode 1, 1 hop) 'type': 1,
'path': base64Encode(rawPath), 'flags': 0,
'lastSeen': DateTime.now().millisecondsSinceEpoch, 'pathLength': 65, // encoded pathLength (mode 1, 1 hop)
'lastMessageAt': DateTime.now().millisecondsSinceEpoch, 'path': base64Encode(rawPath),
} 'lastSeen': DateTime.now().millisecondsSinceEpoch,
]; 'lastMessageAt': DateTime.now().millisecondsSinceEpoch,
},
];
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString('discovered_contacts', jsonEncode(contactJson)); await prefs.setString('discovered_contacts', jsonEncode(contactJson));
final contacts = await store.loadContacts(); final contacts = await store.loadContacts();
expect(contacts, hasLength(1)); expect(contacts, hasLength(1));
expect(contacts.first.pathLength, equals(1)); expect(contacts.first.pathLength, equals(1));
expect(contacts.first.path, equals(Uint8List.fromList([0x11, 0x22]))); expect(contacts.first.path, equals(Uint8List.fromList([0x11, 0x22])));
}); },
);
}); });
} }
+94 -75
View File
@@ -20,7 +20,8 @@ class _FakeMeshCoreConnector extends MeshCoreConnector {
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
@override @override
Uint8List? get selfPublicKey => Uint8List.fromList(List.generate(32, (i) => i)); Uint8List? get selfPublicKey =>
Uint8List.fromList(List.generate(32, (i) => i));
@override @override
double? get selfLatitude => 48.8566; double? get selfLatitude => 48.8566;
@@ -60,9 +61,7 @@ Widget _buildTestApp({
ChangeNotifierProvider<AppSettingsService>( ChangeNotifierProvider<AppSettingsService>(
create: (_) => AppSettingsService(), create: (_) => AppSettingsService(),
), ),
Provider<MapTileCacheService>( Provider<MapTileCacheService>(create: (_) => MapTileCacheService()),
create: (_) => MapTileCacheService(),
),
], ],
child: MaterialApp( child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -73,7 +72,9 @@ Widget _buildTestApp({
} }
void main() { void main() {
testWidgets('PathTraceMapScreen parses response frames correctly', (tester) async { testWidgets('PathTraceMapScreen parses response frames correctly', (
tester,
) async {
final connector = _FakeMeshCoreConnector(); final connector = _FakeMeshCoreConnector();
final screen = PathTraceMapScreen( final screen = PathTraceMapScreen(
@@ -135,66 +136,73 @@ void main() {
expect(find.text('Path trace not available.'), findsNothing); expect(find.text('Path trace not available.'), findsNothing);
}); });
testWidgets('PathTraceMapScreen parses multi-byte encoded path trace response correctly', (tester) async { testWidgets(
final connector = _FakeMeshCoreConnector(); 'PathTraceMapScreen parses multi-byte encoded path trace response correctly',
(tester) async {
final connector = _FakeMeshCoreConnector();
final screen = PathTraceMapScreen( final screen = PathTraceMapScreen(
title: 'Test Trace Multi-byte', title: 'Test Trace Multi-byte',
path: Uint8List.fromList([0x12, 0x34]), path: Uint8List.fromList([0x12, 0x34]),
pathHashByteWidth: 2, pathHashByteWidth: 2,
); );
await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); await tester.pumpWidget(
await tester.pump(); _buildTestApp(connector: connector, child: screen),
);
await tester.pump();
// Verify a send request was made // Verify a send request was made
expect(connector.sentFrames.length, 1); expect(connector.sentFrames.length, 1);
final sentFrame = connector.sentFrames.first; final sentFrame = connector.sentFrames.first;
expect(sentFrame[0], cmdSendTracePath); expect(sentFrame[0], cmdSendTracePath);
// Extract the tag sent in the request (bytes 1-4) // Extract the tag sent in the request (bytes 1-4)
final tag = sentFrame.sublist(1, 5); final tag = sentFrame.sublist(1, 5);
// Emit respCodeSent (6) // Emit respCodeSent (6)
final respCodeSentFrame = Uint8List(10); final respCodeSentFrame = Uint8List(10);
respCodeSentFrame[0] = respCodeSent; respCodeSentFrame[0] = respCodeSent;
respCodeSentFrame[1] = 0; respCodeSentFrame[1] = 0;
respCodeSentFrame.setRange(2, 6, tag); respCodeSentFrame.setRange(2, 6, tag);
respCodeSentFrame[6] = 0x88; respCodeSentFrame[6] = 0x88;
respCodeSentFrame[7] = 0x13; respCodeSentFrame[7] = 0x13;
respCodeSentFrame[8] = 0x00; respCodeSentFrame[8] = 0x00;
respCodeSentFrame[9] = 0x00; respCodeSentFrame[9] = 0x00;
connector.emitFrame(respCodeSentFrame); connector.emitFrame(respCodeSentFrame);
await tester.pump(); await tester.pump();
// Now emit PUSH_CODE_TRACE_DATA (0x89) // Now emit PUSH_CODE_TRACE_DATA (0x89)
// Structure: // Structure:
// Offset 0: pushCodeTraceData (0x89) // Offset 0: pushCodeTraceData (0x89)
// Offset 1: reserved (0) // Offset 1: reserved (0)
// Offset 2: pathLength (65) -> mode 1 (width 2), 1 hop // Offset 2: pathLength (65) -> mode 1 (width 2), 1 hop
// Offset 3: flag (0) // Offset 3: flag (0)
// Offset 4..7: tag // Offset 4..7: tag
// Offset 8..11: auth (0) // Offset 8..11: auth (0)
// Offset 12..13: pathBytes [0x12, 0x34] // Offset 12..13: pathBytes [0x12, 0x34]
// Offset 14..15: SNR bytes [12, 16] // Offset 14..15: SNR bytes [12, 16]
final pushTraceFrame = Uint8List(16); final pushTraceFrame = Uint8List(16);
pushTraceFrame[0] = pushCodeTraceData; pushTraceFrame[0] = pushCodeTraceData;
pushTraceFrame[1] = 0; // reserved pushTraceFrame[1] = 0; // reserved
pushTraceFrame[2] = 65; // pathLength encoded (mode 1, 1 hop -> 2 bytes) pushTraceFrame[2] = 65; // pathLength encoded (mode 1, 1 hop -> 2 bytes)
pushTraceFrame[3] = 0; // flag pushTraceFrame[3] = 0; // flag
pushTraceFrame.setRange(4, 8, tag); pushTraceFrame.setRange(4, 8, tag);
pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes
pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes
connector.emitFrame(pushTraceFrame); connector.emitFrame(pushTraceFrame);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify it parsed correctly (should not show the unavailable message) // Verify it parsed correctly (should not show the unavailable message)
expect(find.text('Path trace not available.'), findsNothing); expect(find.text('Path trace not available.'), findsNothing);
}); },
);
testWidgets('PathTraceMapScreen buildPath: Direct repeater ping (0-hop)', (tester) async { testWidgets('PathTraceMapScreen buildPath: Direct repeater ping (0-hop)', (
tester,
) async {
final connector = _FakeMeshCoreConnector(); final connector = _FakeMeshCoreConnector();
final target = Contact( final target = Contact(
publicKey: Uint8List.fromList([0xAA, 0xBB, 0xCC]), publicKey: Uint8List.fromList([0xAA, 0xBB, 0xCC]),
@@ -223,7 +231,9 @@ void main() {
expect(payload, Uint8List.fromList([0xAA])); 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 connector = _FakeMeshCoreConnector();
final target = Contact( final target = Contact(
publicKey: Uint8List.fromList([0xCC, 0xDD, 0xEE]), publicKey: Uint8List.fromList([0xCC, 0xDD, 0xEE]),
@@ -252,7 +262,9 @@ void main() {
expect(payload, Uint8List.fromList([0xBB, 0xCC, 0xBB])); 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 connector = _FakeMeshCoreConnector();
final target = Contact( final target = Contact(
publicKey: Uint8List.fromList([0x33, 0x44, 0x55]), publicKey: Uint8List.fromList([0x33, 0x44, 0x55]),
@@ -281,28 +293,35 @@ void main() {
expect(payload, Uint8List.fromList([0x11, 0x22, 0x33, 0x22, 0x11])); expect(payload, Uint8List.fromList([0x11, 0x22, 0x33, 0x22, 0x11]));
}); });
testWidgets('PathTraceMapScreen buildPath: Map trace with null target contact', (tester) async { testWidgets(
final connector = _FakeMeshCoreConnector(); 'PathTraceMapScreen buildPath: Map trace with null target contact',
(tester) async {
final connector = _FakeMeshCoreConnector();
final screen = PathTraceMapScreen( final screen = PathTraceMapScreen(
title: 'Map Trace No Target', title: 'Map Trace No Target',
path: Uint8List.fromList([0x11, 0x22]), path: Uint8List.fromList([0x11, 0x22]),
targetContact: null, targetContact: null,
flipPathAround: true, flipPathAround: true,
pathHashByteWidth: 1, pathHashByteWidth: 1,
); );
await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); await tester.pumpWidget(
await tester.pump(); _buildTestApp(connector: connector, child: screen),
);
await tester.pump();
expect(connector.sentFrames.length, 1); expect(connector.sentFrames.length, 1);
final sentFrame = connector.sentFrames.first; final sentFrame = connector.sentFrames.first;
expect(sentFrame[0], cmdSendTracePath); expect(sentFrame[0], cmdSendTracePath);
final payload = sentFrame.sublist(10); final payload = sentFrame.sublist(10);
expect(payload, Uint8List.fromList([0x11, 0x22, 0x11])); 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 connector = _FakeMeshCoreConnector();
final screen = PathTraceMapScreen( final screen = PathTraceMapScreen(