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