diff --git a/documentation/README.md b/documentation/README.md index 13670134..a3700fc6 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -14,7 +14,8 @@ MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking 8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences 9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors 10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more -11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture +11. [Routing Paths](routing-paths.md) - Path encoding, validation, device capability detection, and storage +12. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture ## App Overview diff --git a/documentation/additional-features.md b/documentation/additional-features.md index 997c2c11..6aeb37ed 100644 --- a/documentation/additional-features.md +++ b/documentation/additional-features.md @@ -183,7 +183,7 @@ An ML-based service that predicts expected delivery timeouts: - Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time) - Features with zero variance are automatically excluded from training - Blends per-contact statistics with ML predictions -- Falls back to `3000 + 3000 × pathLength` ms when insufficient data +- Falls back to `3000 + 3000 × pathLength` ms when insufficient data. Note: `pathLength` here refers to the stored hop count in the app's model/storage (number of hops), not the on-air encoded byte length. - Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost) --- diff --git a/documentation/routing-paths.md b/documentation/routing-paths.md new file mode 100644 index 00000000..56b4867f --- /dev/null +++ b/documentation/routing-paths.md @@ -0,0 +1,79 @@ +# Routing Paths + +This page covers how MeshCore Open represents, selects, validates, and stores routing paths in the UI and data layer. + +## Path Routing + +MeshCore supports variable-length multi-byte routing paths so the app can scale from small meshes to very large node sets. + +### Hash Width and Multi-Byte Paths + +The device capability determines the hash width (number of bytes per hop): + +| Width | Max Unique Nodes | Typical Use | +|-------|-----------------|-------------| +| 1 byte | 256 | Single-byte node IDs | +| 2 bytes | 65,536 | Medium meshes | +| 3 bytes | 16.7M | Large networks | +| 4 bytes | 4.3G | Very large meshes / future-proofing | + +### Device Capability Detection + +On device connection, the app reads the firmware capability to set `pathHashByteWidth`: + +```dart +// Read from device info response (offset 81) +final modeRaw = firmwareBytes.length >= 82 + ? (firmwareBytes[81] & 0xFF) + : 0; +final mode = modeRaw.clamp(0, 3); +_pathHashByteWidth = mode + 1; // 1, 2, 3, or 4 bytes per hop +``` + +The connector reads a single-mode byte and clamps to `0..3`, so the supported hop width is `1..4` bytes. UI code also clamps widths when rendering (typically to `1..4`) so 4-byte hops are handled end-to-end in the current codebase. + +### Path Data Structure + +Paths in messages and storage consist of: + + - **`pathLength` (model/storage)**: Hop count (number of hops). Negative values (e.g. `-1`) are used as a flood sentinel. + - **On-air `path_len` byte**: A packed byte that encodes hop count + hash width and is decoded into `pathLength` + `pathBytes` when parsing frames. + - **`pathBytes`**: Raw bytes of the path (concatenated hop prefixes), grouped by `pathHashByteWidth`. + - **`hopCount`**: Derived display value computed from bytes and width: `(byteCount + hashByteWidth - 1) ~/ hashByteWidth`. + - **Example**: With `pathHashByteWidth=2`, a 3-hop path has 6 bytes (`pathBytes.length = 6`) and `pathLength = 3`: + - `pathBytes = [0xA1, 0xA2, 0xB1, 0xB2, 0xC1, 0xC2]` + - Hops: `[0xA1A2]`, `[0xB1B2]`, `[0xC1C2]` + +### Hop Count Calculation + +Convert path byte length to hop count: + +```dart +int hopCount = (byteCount + hashByteWidth - 1) ~/ hashByteWidth; +``` + +Use this consistently when displaying hop counts in UI. Do not treat `pathLength` as a hop count when the path uses multi-byte hop hashes. + +### Path Usage in Different Message Types + +- **Direct messages**: Extract path from decrypted payload to trace sender route. +- **Channel messages**: Decrypt hop-by-hop routing chain from payload; the header carries the encoded byte length for the path blob, not the derived hop count. +- **Contact storage**: Path length in byte 32, raw path bytes in bytes 33-96, grouped by detected `pathHashByteWidth`. + +### UI Hop Count Display + +⚠️ **Important**: In screens like "Channel Message Path", prefer the actual decoded `pathBytes` hop count over `pathLength` metadata: + +```dart +// Preferred: use actual observed path length +final effectiveHopCount = (pathBytes.length + width - 1) ~/ width; + +// Avoid: using encoded byte length as if it were a hop count +// pathLength is bytes; converting it twice causes inflated counts +``` + +Example scenario: +- Radio header reports `pathLength: 32` bytes +- Decoded path bytes: `[0xAB, 0xCD]` (2 bytes = 1 hop with width=2) +- **Display**: "1 hop" (from `pathBytes`), not "32 hops" (which would double-count the encoded length) + diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 989c8da8..4e3ac7d8 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -51,18 +51,53 @@ import 'meshcore_protocol.dart'; class DirectRepeater { static const int maxAgeMinutes = 30; // Max age for direct repeater info - final int pubkeyFirstByte; + List pubkeyPrefix; + int pathHashWidth; + String? contactKeyHex; double snr; DateTime lastUpdated; DirectRepeater({ - required this.pubkeyFirstByte, + required List pubkeyPrefix, + required this.pathHashWidth, + this.contactKeyHex, required this.snr, DateTime? lastUpdated, - }) : lastUpdated = lastUpdated ?? DateTime.now(); + }) : pubkeyPrefix = List.unmodifiable(pubkeyPrefix), + lastUpdated = lastUpdated ?? DateTime.now(); - void update(double newSNR) { + String get pubkeyPrefixHex => pubkeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + + bool matchesPrefix(List prefix) { + return pubkeyPrefix.length == prefix.length && + listEquals(pubkeyPrefix, prefix); + } + + bool matchesPathStart(List pathBytes, int pathHashByteWidth) { + final width = pathHashByteWidth.clamp(1, 4).toInt(); + if (pathBytes.length < width || pubkeyPrefix.length != width) { + return false; + } + return listEquals(pubkeyPrefix, pathBytes.sublist(0, width)); + } + + void update( + double newSNR, { + List? pubkeyPrefix, + int? pathHashWidth, + String? contactKeyHex, + }) { snr = newSNR; + if (pubkeyPrefix != null && + pubkeyPrefix.length >= this.pubkeyPrefix.length) { + this.pubkeyPrefix = List.unmodifiable(pubkeyPrefix); + } + if (pathHashWidth != null && pathHashWidth >= this.pathHashWidth) { + this.pathHashWidth = pathHashWidth; + } + this.contactKeyHex ??= contactKeyHex; lastUpdated = DateTime.now(); } @@ -2889,7 +2924,32 @@ class MeshCoreConnector extends ChangeNotifier { Future setPathHashMode(int mode) async { if (!isConnected) return; - await sendFrame(buildSetPathHashModeFrame(mode.clamp(0, 2))); + final clampedMode = mode.clamp(0, 3).toInt(); + await sendFrame(buildSetPathHashModeFrame(clampedMode)); + final nextWidth = clampedMode + 1; + if (_pathHashByteWidth != nextWidth) { + _pathHashByteWidth = nextWidth; + _directRepeaters.clear(); + notifyListeners(); + } + } + + bool _isPathLenValidForCurrentMode(int pathLen, List pathBytes) { + return _isPathLenValidForMode(pathLen, pathBytes, _pathHashByteWidth); + } + + int? _encodePathLenForCurrentMode(int pathLen, List pathBytes) { + if (pathLen < 0 || pathLen == 0xFF) return pathLen; + if (!_isPathLenValidForCurrentMode(pathLen, pathBytes)) { + appLogger.warn( + 'Invalid path_len for mode: pathLen=$pathLen, ' + 'bytesLen=${pathBytes.length}, width=$_pathHashByteWidth', + tag: 'Connector', + ); + return null; + } + final mode = (_pathHashByteWidth - 1) & 0x03; + return (pathLen & 0x3F) | (mode << 6); } Future refreshDeviceInfo() async { @@ -3106,11 +3166,15 @@ class MeshCoreConnector extends ChangeNotifier { try { if (!isConnected) return; + final encodedPathLen = _encodePathLenForCurrentMode(pathLen, customPath); + if (encodedPathLen == null) { + return; + } await sendFrame( buildUpdateContactPathFrame( contact.publicKey, customPath, - pathLen, + encodedPathLen, type: contact.type, flags: contact.flags, name: contact.name, @@ -3126,7 +3190,7 @@ class MeshCoreConnector extends ChangeNotifier { ); if (idx != -1) { _contacts[idx] = _contacts[idx].copyWith( - pathLength: customPath.length, + pathLength: pathLen, path: customPath, ); notifyListeners(); @@ -3167,11 +3231,16 @@ class MeshCoreConnector extends ChangeNotifier { : (updatedFlags & ~contactFlagTeleEnv)) : updatedFlags; + final encodedPathLen = _encodePathLenForCurrentMode( + latestContact.pathLength, + latestContact.path, + ); + if (encodedPathLen == null) return; await sendFrame( buildUpdateContactPathFrame( latestContact.publicKey, latestContact.path, - latestContact.pathLength, + encodedPathLen, type: latestContact.type, flags: updatedFlags, name: latestContact.name, @@ -3254,6 +3323,18 @@ class MeshCoreConnector extends ChangeNotifier { tag: 'Connector', ); + if (pathLen != null && + pathLen >= 0 && + !_isPathLenValidForCurrentMode(pathLen, pathBytes ?? Uint8List(0))) { + 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, @@ -3601,11 +3682,16 @@ class MeshCoreConnector extends ChangeNotifier { // Manual saves must bypass the firmware's auto-add discovery policy. // CMD_IMPORT_CONTACT replays an advert and may remain discovery-only. + final encodedPathLen = _encodePathLenForCurrentMode( + contact.pathLength, + contact.path, + ); + if (encodedPathLen == null) return false; await sendFrame( buildUpdateContactPathFrame( contact.publicKey, contact.path, - contact.pathLength, + encodedPathLen, type: contact.type, flags: contact.flags, name: contact.name, @@ -4379,12 +4465,16 @@ class MeshCoreConnector extends ChangeNotifier { _clientRepeat = frame[80] != 0; } // Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop + final previousPathHashByteWidth = _pathHashByteWidth; if (frame.length >= 82) { - final mode = (frame[81] & 0xFF).clamp(0, 2); + final mode = (frame[81] & 0xFF).clamp(0, 3); _pathHashByteWidth = mode + 1; } else { _pathHashByteWidth = 1; } + if (_pathHashByteWidth != previousPathHashByteWidth) { + _directRepeaters.clear(); + } // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; @@ -4902,6 +4992,7 @@ class MeshCoreConnector extends ChangeNotifier { String senderName, DateTime timestamp, { Uint8List? pathBytes, + int? pathHashWidth, bool notify = false, }) { final normalized = senderName.trim().toLowerCase(); @@ -4925,7 +5016,11 @@ class MeshCoreConnector extends ChangeNotifier { for (var i = 0; i < _contacts.length; i++) { final contact = _contacts[i]; if (contact.type != advTypeChat) continue; - if (_pathMatchesContact(pathBytes, contact.publicKey)) { + if (_pathMatchesContact( + pathBytes, + contact.publicKey, + pathHashWidth: pathHashWidth, + )) { matches.add(i); } } @@ -4942,8 +5037,12 @@ class MeshCoreConnector extends ChangeNotifier { } } - bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) { - final w = _pathHashByteWidth; + bool _pathMatchesContact( + Uint8List pathBytes, + Uint8List publicKey, { + int? pathHashWidth, + }) { + final w = pathHashWidth ?? _pathHashByteWidth; if (pathBytes.isEmpty || publicKey.length < w) return false; for (int i = 0; i + w <= pathBytes.length; i += w) { final prefix = pathBytes.sublist(i, i + w); @@ -5165,7 +5264,7 @@ class MeshCoreConnector extends ChangeNotifier { isOutgoing: false, isCli: isCli, status: MessageStatus.delivered, - pathLength: pathLength == 0xFF ? 0 : pathLength, + pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F), pathBytes: Uint8List(0), fourByteRoomContactKey: roomAuthorPrefix, ); @@ -5398,6 +5497,7 @@ class MeshCoreConnector extends ChangeNotifier { message.senderName, message.timestamp, pathBytes: message.pathBytes, + pathHashWidth: message.pathHashWidth, ); final isNew = _addChannelMessage(message.channelIndex!, message); _maybeIncrementChannelUnread(message, isNew: isNew); @@ -5475,6 +5575,7 @@ class MeshCoreConnector extends ChangeNotifier { isOutgoing: false, status: ChannelMessageStatus.sent, pathLength: packet.isFlood ? packet.hopCount : 0, + pathHashWidth: packet.pathHashWidth, pathBytes: packet.pathBytes, channelIndex: channel.index, packetHash: pktHash, @@ -5484,6 +5585,7 @@ class MeshCoreConnector extends ChangeNotifier { parsed.senderName, message.timestamp, pathBytes: message.pathBytes, + pathHashWidth: message.pathHashWidth, ); final isNew = _addChannelMessage(channel.index, message); _maybeIncrementChannelUnread(message, isNew: isNew); @@ -6227,6 +6329,7 @@ class MeshCoreConnector extends ChangeNotifier { repeats: message.repeats, repeatCount: message.repeatCount, pathLength: message.pathLength, + pathHashWidth: message.pathHashWidth, pathBytes: message.pathBytes, pathVariants: message.pathVariants, channelIndex: message.channelIndex, @@ -6263,6 +6366,7 @@ class MeshCoreConnector extends ChangeNotifier { messages[existingIndex] = existing.copyWith( repeatCount: newRepeatCount, pathLength: mergedPathLength, + pathHashWidth: existing.pathHashWidth ?? processedMessage.pathHashWidth, pathBytes: mergedPathBytes, pathVariants: mergedPathVariants, packetHash: existing.packetHash ?? processedMessage.packetHash, @@ -6634,6 +6738,7 @@ class MeshCoreConnector extends ChangeNotifier { //final payloadVer = (header >> 6) & 0x03; final pathLenRaw = packet.readByte(); final pathByteLen = _decodePathByteLen(pathLenRaw); + final pathHashWidth = _decodePathHashWidth(pathLenRaw); final pathBytes = packet.readBytes(pathByteLen); final payload = packet.readBytes(packet.remaining); @@ -6644,6 +6749,7 @@ class MeshCoreConnector extends ChangeNotifier { rawPacket, payload, pathBytes, + pathHashWidth, routeType, snr, ); @@ -6656,6 +6762,86 @@ class MeshCoreConnector extends ChangeNotifier { } } + void importContact(Uint8List frame) { + final packet = BufferReader(frame); + int payloadType = 0; + Uint8List pathBytes = Uint8List(0); + int pathHashWidth = 1; + try { + packet.skipBytes(1); // Skip frame type byte + packet.skipBytes(1); // Skip SNR byte + packet.skipBytes(1); // Skip RSSI byte + final header = packet.readByte(); + final routeType = header & 0x03; + payloadType = (header >> 2) & 0x0F; + if (routeType == _routeTransportFlood || + routeType == _routeTransportDirect) { + packet.skipBytes(4); // Skip transport-specific bytes + } + //final payloadVer = (header >> 6) & 0x03; + final pathLenRaw = packet.readByte(); + final pathByteLen = _decodePathByteLen(pathLenRaw); + pathHashWidth = _decodePathHashWidth(pathLenRaw); + pathBytes = packet.readBytes(pathByteLen); + } catch (e) { + appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); + return; + } + double? latitude; + double? longitude; + String name = ''; + Uint8List publicKey = Uint8List(0); + int type = 0; + int timestamp = 0; + bool hasLocation = false; + bool hasName = false; + if (payloadType != payloadTypeADVERT) { + appLogger.warn('Unexpected payload type: $payloadType', tag: 'Connector'); + return; + } + try { + publicKey = packet.readBytes(32); + timestamp = packet.readInt32LE(); + //TODO add signature verification + packet.skipBytes(64); // Skip signature for now + final flags = packet.readByte(); + type = flags & 0x0F; + hasLocation = (flags & 0x10) != 0; + // For future use: + //final hasFeature1 = (flags & 0x20) != 0; + //final hasFeature2 = (flags & 0x40) != 0; + hasName = (flags & 0x80) != 0; + if (hasLocation && packet.remaining >= 8) { + latitude = packet.readInt32LE() / 1e6; + longitude = packet.readInt32LE() / 1e6; + } + if (hasName && packet.remaining > 0) { + name = packet.readCString(); + } + } catch (e) { + appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); + return; + } + + importDiscoveredContact( + Contact( + rawPacket: frame, + publicKey: publicKey, + name: name, + type: type, + 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), + ), + ); + } + bool hasValidLocation(double? latitude, double? longitude) { const double epsilon = 1e-6; final lat = latitude ?? 0.0; @@ -6671,6 +6857,7 @@ class MeshCoreConnector extends ChangeNotifier { Uint8List rawPacket, Uint8List payload, Uint8List path, + int pathHashWidth, int routeType, double snr, ) { @@ -6729,10 +6916,10 @@ class MeshCoreConnector extends ChangeNotifier { publicKey: publicKey, name: name, type: type, - pathLength: path.length, - path: Uint8List.fromList( - path.reversed.toList(), - ), // Store path in reverse for easier use in outgoing messages + pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), + // 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), @@ -6751,7 +6938,12 @@ class MeshCoreConnector extends ChangeNotifier { } else { _handleDiscovery(newContact, rawPacket); } - _updateDirectRepeater(newContact, snr, path); + _updateDirectRepeater( + newContact, + snr, + path, + pathHashWidth: pathHashWidth, + ); return; } @@ -6775,8 +6967,8 @@ 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()), - pathLength: path.length, + path: _reversePathByHop(path, pathHashWidth), + pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth), lastMessageAt: mergedLastMessageAt, lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), pathOverride: existing.pathOverride, // Preserve user's path choice @@ -6789,7 +6981,12 @@ class MeshCoreConnector extends ChangeNotifier { _pathHistoryService!.handlePathUpdated(_contacts[existingIndex]); } - _updateDirectRepeater(_contacts[existingIndex], snr, path); + _updateDirectRepeater( + _contacts[existingIndex], + snr, + path, + pathHashWidth: pathHashWidth, + ); appLogger.info( 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', @@ -6798,10 +6995,42 @@ class MeshCoreConnector extends ChangeNotifier { } } - void _updateDirectRepeater(Contact contact, double snr, Uint8List path) { - final pubkeyFirstByte = path.isNotEmpty - ? path.last - : contact.publicKey.first; + void _updateDirectRepeater( + Contact contact, + double snr, + Uint8List path, { + required int pathHashWidth, + }) { + final hashWidth = pathHashWidth.clamp(1, 4).toInt(); + if (path.isNotEmpty && path.length < hashWidth) { + return; + } + final pathStartIndex = path.isNotEmpty ? path.length - hashWidth : 0; + final publicKeyPrefixEnd = math + .min(hashWidth, contact.publicKey.length) + .toInt(); + final pubkeyPrefix = path.isNotEmpty + ? path.sublist(pathStartIndex) + : contact.publicKey.sublist(0, publicKeyPrefixEnd); + final contactKeyHex = _resolveDirectRepeaterContactKeyHex( + contact, + pubkeyPrefix, + path.isEmpty, + ); + final knownRepeaters = contactKeyHex == null + ? _directRepeaters + .where( + (r) => + r.contactKeyHex != null && + _contactKeyMatchesPrefix(r.contactKeyHex!, pubkeyPrefix), + ) + .toList() + : const []; + final knownRepeater = knownRepeaters.length == 1 + ? knownRepeaters.first + : null; + final effectiveContactKeyHex = + contactKeyHex ?? knownRepeater?.contactKeyHex; _directRepeaters.removeWhere((r) => r.isStale()); @@ -6812,9 +7041,25 @@ class MeshCoreConnector extends ChangeNotifier { return; } - final isTracked = _directRepeaters.where( - (r) => r.pubkeyFirstByte == pubkeyFirstByte, - ); + final isTracked = _directRepeaters.where((r) { + if (knownRepeater != null) { + return identical(r, knownRepeater); + } + if (effectiveContactKeyHex != null) { + return r.contactKeyHex == effectiveContactKeyHex || + (r.contactKeyHex == null && + _contactKeyMatchesPrefix( + effectiveContactKeyHex, + r.pubkeyPrefix, + )); + } + if (r.contactKeyHex == null && + r.pathHashWidth == hashWidth && + r.matchesPrefix(pubkeyPrefix)) { + return true; + } + return false; + }).toList(); final sortedRepeaters = List.from(_directRepeaters) ..sort((a, b) => b.snr.compareTo(a.snr)); @@ -6830,15 +7075,70 @@ class MeshCoreConnector extends ChangeNotifier { if (isTracked.isNotEmpty) { final repeater = isTracked.first; - repeater.update(snr); + repeater.update( + snr, + pubkeyPrefix: pubkeyPrefix, + pathHashWidth: hashWidth, + contactKeyHex: effectiveContactKeyHex, + ); + if (effectiveContactKeyHex != null) { + _directRepeaters.removeWhere( + (r) => + !identical(r, repeater) && + (r.contactKeyHex == effectiveContactKeyHex || + (r.contactKeyHex == null && + _contactKeyMatchesPrefix( + effectiveContactKeyHex, + r.pubkeyPrefix, + ))), + ); + } } else if (_directRepeaters.length < 5) { _directRepeaters.add( - DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr), + DirectRepeater( + pubkeyPrefix: pubkeyPrefix, + pathHashWidth: hashWidth, + contactKeyHex: effectiveContactKeyHex, + snr: snr, + ), ); } notifyListeners(); } + String? _resolveDirectRepeaterContactKeyHex( + Contact contact, + List pubkeyPrefix, + bool pathIsEmpty, + ) { + if (pathIsEmpty && + (contact.type == advTypeRepeater || contact.type == advTypeRoom)) { + return contact.publicKeyHex; + } + + final prefixMatches = allContacts + .where( + (c) => + (c.type == advTypeRepeater || c.type == advTypeRoom) && + _contactKeyMatchesPrefix(c.publicKeyHex, pubkeyPrefix), + ) + .toList(); + if (prefixMatches.length == 1) { + return prefixMatches.first.publicKeyHex; + } + + return null; + } + + bool _contactKeyMatchesPrefix(String contactKeyHex, List pubkeyPrefix) { + // Normalize both sides to upper-case hex to avoid case-sensitive mismatches. + final normalizedPrefixHex = pubkeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + final normalizedContactKeyHex = contactKeyHex.toUpperCase(); + return normalizedContactKeyHex.startsWith(normalizedPrefixHex); + } + void _handleAutoAddConfig(Uint8List frame) { final reader = BufferReader(frame); try { @@ -6978,13 +7278,42 @@ 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; - final hashSize = ((pathLenRaw >> 6) & 0x03) + 1; + final hashSize = _decodePathHashWidth(pathLenRaw); return hashCount * hashSize; } +int _decodePathHashWidth(int pathLenRaw) { + if (pathLenRaw == 0xFF) return 1; + return ((pathLenRaw >> 6) & 0x03) + 1; +} + +bool _isPathLenValidForMode( + int pathLen, + List pathBytes, + int pathHashWidth, +) { + if (pathLen < 0 || pathLen > 0x3F) return false; + final width = pathHashWidth.clamp(1, 4).toInt(); + final maxHopCountByBytes = maxPathSize ~/ width; + if (pathLen > maxHopCountByBytes) return false; + return pathBytes.length <= maxPathSize && pathBytes.length == pathLen * width; +} + +Uint8List _reversePathByHop(Uint8List pathBytes, int pathHashWidth) { + if (pathBytes.isEmpty) return Uint8List(0); + final width = pathHashWidth.clamp(1, 4).toInt(); + final reversed = []; + for (var i = pathBytes.length; i > 0; i -= width) { + final start = (i - width).clamp(0, pathBytes.length).toInt(); + reversed.addAll(pathBytes.sublist(start, i)); + } + return Uint8List.fromList(reversed); +} + class _RawPacket { final int header; final int routeType; @@ -7008,6 +7337,7 @@ class _RawPacket { routeType == _routeFlood || routeType == _routeTransportFlood; int get hopCount => pathLenRaw & 63; + int get pathHashWidth => ((pathLenRaw >> 6) & 0x03) + 1; } class _ParsedText { diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 45e157e5..82c9473d 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -587,9 +587,9 @@ Uint8List buildGetStatsFrame(int statsType) { return Uint8List.fromList([cmdGetStats, statsType & 0xFF]); } -/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash. +/// Path hash width on air: [61][0][mode], mode 0..3 → (mode+1) bytes per hop hash. Uint8List buildSetPathHashModeFrame(int mode) { - final m = mode.clamp(0, 2); + final m = mode.clamp(0, 3).toInt(); return Uint8List.fromList([cmdSetPathHashMode, 0, m]); } diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index bd599c00..198f0530 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -1,5 +1,7 @@ -import '../models/contact.dart'; +import 'package:flutter/foundation.dart'; + import '../connector/meshcore_protocol.dart'; +import '../models/contact.dart'; class PathHelper { static String formatPathHex(List pathBytes) { @@ -12,10 +14,17 @@ class PathHelper { return byte.toRadixString(16).padLeft(2, '0').toUpperCase(); } + static String formatHopHex(List hopBytes) { + return hopBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); + } + static String? hopName(int byte, List allContacts) { final matches = allContacts .where( (c) => + c.publicKey.isNotEmpty && c.publicKey.first == byte && (c.type == advTypeRepeater || c.type == advTypeRoom), ) @@ -25,12 +34,58 @@ class PathHelper { return matches.map((c) => c.name).join(' | '); } + static List splitPathBytes( + List pathBytes, + int hashByteWidth, + ) { + if (pathBytes.isEmpty) return const []; + + final width = hashByteWidth.clamp(1, 4).toInt(); + final hops = []; + for (int i = 0; i < pathBytes.length; i += width) { + final endIdx = (i + width).clamp(0, pathBytes.length).toInt(); + final hopBytes = pathBytes.sublist(i, endIdx); + if (hopBytes.isNotEmpty) { + hops.add(Uint8List.fromList(hopBytes)); + } + } + return hops; + } + + /// Resolves path bytes to contact names, supporting multi-byte hash widths. + /// + /// Groups path bytes according to [hashByteWidth]: + /// - 1: Single byte per hop (256 unique nodes) + /// - 2: Two bytes per hop (65K unique nodes) + /// - 3: Three bytes per hop (16M unique nodes) + /// - 4: Four bytes per hop (4.3G unique nodes) static String resolvePathNames( List pathBytes, List allContacts, + int hashByteWidth, ) { - return pathBytes - .map((b) => hopName(b, allContacts) ?? hopHex(b)) - .join(' \u2192 '); + if (pathBytes.isEmpty) return ''; + + final parts = []; + + for (final hopBytes in splitPathBytes(pathBytes, hashByteWidth)) { + final hex = formatHopHex(hopBytes); + + final matches = allContacts.where((c) { + if (c.publicKey.length < hopBytes.length) return false; + if (c.type != advTypeRepeater && c.type != advTypeRoom) return false; + return listEquals(c.publicKey.sublist(0, hopBytes.length), hopBytes); + }).toList(); + + if (matches.isEmpty) { + parts.add(hex); + } else if (matches.length == 1) { + parts.add(matches.first.name); + } else { + parts.add(matches.map((c) => c.name).join(' | ')); + } + } + + return parts.join(' \u2192 '); } } diff --git a/lib/helpers/path_hop_resolver.dart b/lib/helpers/path_hop_resolver.dart index 19f26001..b17f7b56 100644 --- a/lib/helpers/path_hop_resolver.dart +++ b/lib/helpers/path_hop_resolver.dart @@ -1,6 +1,7 @@ import 'package:latlong2/latlong.dart'; import '../connector/meshcore_protocol.dart'; +import 'path_helper.dart'; import '../models/contact.dart'; class PathHopResolver { @@ -11,30 +12,35 @@ class PathHopResolver { required List contacts, LatLng? endpoint, bool resolveFromEnd = false, + int pathHashByteWidth = 1, }) { - final candidatesByPrefix = >{}; + final width = pathHashByteWidth.clamp(1, 4).toInt(); + final candidatesByPrefix = >{}; for (final contact in contacts) { - if (contact.publicKey.isEmpty) continue; + if (contact.publicKey.length < width) continue; if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { continue; } - candidatesByPrefix - .putIfAbsent(contact.publicKey.first, () => []) - .add(contact); + final prefix = PathHelper.formatHopHex( + contact.publicKey.sublist(0, width), + ); + candidatesByPrefix.putIfAbsent(prefix, () => []).add(contact); } for (final candidates in candidatesByPrefix.values) { candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); } - final resolved = List.filled(pathBytes.length, null); + final hops = PathHelper.splitPathBytes(pathBytes, width); + final resolved = List.filled(hops.length, null); final indexes = resolveFromEnd - ? List.generate(pathBytes.length, (i) => pathBytes.length - 1 - i) - : List.generate(pathBytes.length, (i) => i); + ? List.generate(hops.length, (i) => hops.length - 1 - i) + : List.generate(hops.length, (i) => i); final distance = Distance(); var previousPosition = endpoint; for (final index in indexes) { - final candidates = candidatesByPrefix[pathBytes[index]]; + final candidates = + candidatesByPrefix[PathHelper.formatHopHex(hops[index])]; if (candidates == null || candidates.isEmpty) continue; var bestIndex = 0; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 896d84d3..9c9c8286 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -292,6 +292,10 @@ "appSettings_languageBg": "Български", "appSettings_languageRu": "Русский", "appSettings_languageUk": "Українська", + "repeater_pathHashModeOption0": "0 - 1 byte", + "repeater_pathHashModeOption1": "1 - 2 bytes", + "repeater_pathHashModeOption2": "2 - 3 bytes", + "repeater_pathHashModeOption3": "3 - 4 bytes", "appSettings_enableMessageTracing": "Enable Message Tracing", "appSettings_enableMessageTracingSubtitle": "Show detailed routing and timing metadata for messages", "appSettings_notifications": "Notifications", @@ -1474,7 +1478,7 @@ "repeater_advancedSettings": "Advanced", "repeater_advancedSettingsSubtitle": "Tuning knobs for experienced operators", "repeater_pathHashMode": "Path hash mode", - "repeater_pathHashModeHelper": "Bytes used to encode this repeater's ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths — only raise once your network is on v1.14+.", + "repeater_pathHashModeHelper": "Bytes used to encode this repeater's ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). Firmware before v1.14 always used 1-byte paths; v1.14 and newer can be configured for 2- or 3-byte paths.", "repeater_keySettings": "Change Identity Keys", "repeater_keySettingsSubtitle": "Change the public/private keypair", "repeater_prvKey": "Private key", @@ -1492,9 +1496,6 @@ } } }, - - - "repeater_txDelay": "Flood TX delay", "repeater_txDelayHelper": "Retransmit spacing for flood traffic, as a multiplier of the packet's airtime (0-2, default 0.5). Higher = fewer collisions but slower delivery.", "repeater_directTxDelay": "Direct TX delay", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index db0e118c..00de2511 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1238,7 +1238,7 @@ "repeater_advancedSettings": "Avanzato", "repeater_advancedSettingsSubtitle": "Manopole di regolazione per operatori esperti", "repeater_pathHashMode": "Modalità di hashing del percorso", - "repeater_pathHashModeHelper": "Byte utilizzati per codificare l'ID di questo ripetitore nei tag per il rilevamento del percorso/loop. 0=1 byte (256 ID, fino a 64 salti), 1=2 byte (65.000 ID, fino a 32 salti), 2=3 byte (16 milioni di ID, fino a 21 salti). Le versioni 1.13 e precedenti utilizzano percorsi multi-byte: è necessario attivare la rete prima di utilizzare questa funzionalità (a partire dalla versione 1.14).", + "repeater_pathHashModeHelper": "Byte utilizzati per codificare l'ID di questo ripetitore nei tag di percorso flood/rilevamento loop. 0=1 byte (256 ID, fino a 64 salti), 1=2 byte (65.000 ID, fino a 32 salti), 2=3 byte (16 milioni di ID, fino a 21 salti). Il firmware precedente alla v1.14 usava sempre percorsi a 1 byte; v1.14 e versioni successive possono essere configurate per percorsi a 2 o 3 byte.", "repeater_txDelay": "Ritardo a Flood, TX", "repeater_txDelayHelper": "Riassegnare lo spazio tra i pacchetti per gestire il traffico intenso, come un moltiplicatore del tempo di trasmissione (da 0 a 2, valore predefinito 0,5). Un valore più alto significa meno collisioni, ma una trasmissione più lenta.", "repeater_directTxDelay": "Ritardo diretto TX", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 8db5120e..d619973e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1358,7 +1358,7 @@ "repeater_advancedSettings": "고급", "repeater_advancedSettingsSubtitle": "숙련된 운영자를 위한 조절 노브", "repeater_pathHashMode": "패스 해시 모드", - "repeater_pathHashModeHelper": "이 리피터의 ID를 플러드 경로/루프 감지 태그에 인코딩하는 데 사용되는 바이트 수: 0=1 바이트 (256개의 ID, 최대 64개의 홉), 1=2 바이트 (65,000개의 ID, 최대 32개의 홉), 2=3 바이트 (16백만 개의 ID, 최대 21개의 홉). v1.13 및 이전 버전의 펌웨어는 다중 바이트 경로를 지원하지 않으며, 네트워크가 v1.14 이상으로 업그레이드되면 한 번만 감지합니다.", + "repeater_pathHashModeHelper": "이 리피터의 ID를 플러드 경로/루프 감지 태그에 인코딩하는 데 사용되는 바이트 수입니다. 0=1바이트(256개 ID, 최대 64홉), 1=2바이트(65,000개 ID, 최대 32홉), 2=3바이트(1,600만 개 ID, 최대 21홉). v1.14 이전 펌웨어는 항상 1바이트 경로를 사용했으며, v1.14 이상은 2바이트 또는 3바이트 경로로 설정할 수 있습니다.", "repeater_txDelay": "플러드 TX 지연", "repeater_txDelayHelper": "홍수 시 교통량에 맞춰 재전송 간격을 설정합니다. 이는 패킷의 전송 시간을 곱한 값 (0-2, 기본값 0.5)으로 설정합니다. 값이 클수록 충돌이 줄어들지만 전송 속도가 느려집니다.", "repeater_directTxDelay": "직접적인 TX 지연", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d34afa00..2ad4638e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1462,6 +1462,30 @@ abstract class AppLocalizations { /// **'Українська'** String get appSettings_languageUk; + /// No description provided for @repeater_pathHashModeOption0. + /// + /// In en, this message translates to: + /// **'0 - 1 byte'** + String get repeater_pathHashModeOption0; + + /// No description provided for @repeater_pathHashModeOption1. + /// + /// In en, this message translates to: + /// **'1 - 2 bytes'** + String get repeater_pathHashModeOption1; + + /// No description provided for @repeater_pathHashModeOption2. + /// + /// In en, this message translates to: + /// **'2 - 3 bytes'** + String get repeater_pathHashModeOption2; + + /// No description provided for @repeater_pathHashModeOption3. + /// + /// In en, this message translates to: + /// **'3 - 4 bytes'** + String get repeater_pathHashModeOption3; + /// No description provided for @appSettings_enableMessageTracing. /// /// In en, this message translates to: @@ -4847,7 +4871,7 @@ abstract class AppLocalizations { /// No description provided for @repeater_pathHashModeHelper. /// /// In en, this message translates to: - /// **'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths — only raise once your network is on v1.14+.'** + /// **'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). Firmware before v1.14 always used 1-byte paths; v1.14 and newer can be configured for 2- or 3-byte paths.'** String get repeater_pathHashModeHelper; /// No description provided for @repeater_keySettings. diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 14768373..41c11438 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -741,6 +741,18 @@ class AppLocalizationsBg extends AppLocalizations { @override String get appSettings_languageUk => 'Украински'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Разрешаване на проследяване на съобщения'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cb434eb4..5da89506 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -738,6 +738,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrainisch'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Nachrichtenverfolgung aktivieren'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 65bcc6a8..ccdd9ed0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -726,6 +726,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Enable Message Tracing'; @@ -2656,7 +2668,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). v1.13 and older firmware drops multi-byte paths — only raise once your network is on v1.14+.'; + 'Bytes used to encode this repeater\'s ID in flood path/loop-detect tags. 0=1 byte (256 IDs, up to 64 hops), 1=2 bytes (65K IDs, up to 32 hops), 2=3 bytes (16M IDs, up to 21 hops). Firmware before v1.14 always used 1-byte paths; v1.14 and newer can be configured for 2- or 3-byte paths.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index d075d6c0..da679126 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -737,6 +737,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSettings_languageUk => 'Ucraniano'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Habilitar seguimiento de mensajes'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9566987b..29022552 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -741,6 +741,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrainien'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Activer le traçage des messages'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 0679f558..7fa5c541 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -736,6 +736,18 @@ class AppLocalizationsHu extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Üzenetkövetés engedélyezése'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 4ebbae4c..0e01ff91 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -739,6 +739,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSettings_languageUk => 'Ucraino'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Abilita tracciamento messaggi'; @@ -2703,7 +2715,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Byte utilizzati per codificare l\'ID di questo ripetitore nei tag per il rilevamento del percorso/loop. 0=1 byte (256 ID, fino a 64 salti), 1=2 byte (65.000 ID, fino a 32 salti), 2=3 byte (16 milioni di ID, fino a 21 salti). Le versioni 1.13 e precedenti utilizzano percorsi multi-byte: è necessario attivare la rete prima di utilizzare questa funzionalità (a partire dalla versione 1.14).'; + 'Byte utilizzati per codificare l\'ID di questo ripetitore nei tag di percorso flood/rilevamento loop. 0=1 byte (256 ID, fino a 64 salti), 1=2 byte (65.000 ID, fino a 32 salti), 2=3 byte (16 milioni di ID, fino a 21 salti). Il firmware precedente alla v1.14 usava sempre percorsi a 1 byte; v1.14 e versioni successive possono essere configurate per percorsi a 2 o 3 byte.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index d012a95d..e16d6419 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -709,6 +709,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appSettings_languageUk => 'ウクライナ語'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'メッセージ追跡を有効にする'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 37f2b36b..209867fe 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -710,6 +710,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appSettings_languageUk => '우크라이나어'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => '메시지 추적 기능 활성화'; @@ -2590,7 +2602,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get repeater_pathHashModeHelper => - '이 리피터의 ID를 플러드 경로/루프 감지 태그에 인코딩하는 데 사용되는 바이트 수: 0=1 바이트 (256개의 ID, 최대 64개의 홉), 1=2 바이트 (65,000개의 ID, 최대 32개의 홉), 2=3 바이트 (16백만 개의 ID, 최대 21개의 홉). v1.13 및 이전 버전의 펌웨어는 다중 바이트 경로를 지원하지 않으며, 네트워크가 v1.14 이상으로 업그레이드되면 한 번만 감지합니다.'; + '이 리피터의 ID를 플러드 경로/루프 감지 태그에 인코딩하는 데 사용되는 바이트 수입니다. 0=1바이트(256개 ID, 최대 64홉), 1=2바이트(65,000개 ID, 최대 32홉), 2=3바이트(1,600만 개 ID, 최대 21홉). v1.14 이전 펌웨어는 항상 1바이트 경로를 사용했으며, v1.14 이상은 2바이트 또는 3바이트 경로로 설정할 수 있습니다.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 7c6c7762..bf33733a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -733,6 +733,18 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appSettings_languageUk => 'Oekraïens'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Berichttracking inschakelen'; @@ -2684,7 +2696,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Bytes die gebruikt worden om de ID van deze repeater te coderen in flood-pad/lusdetectietags. 0=1 byte (256 ID\'s, tot 64 hops), 1=2 bytes (65.000 ID\'s, tot 32 hops), 2=3 bytes (16 miljoen ID\'s, tot 21 hops). Versies 1.13 en ouder gebruiken multi-byte paden – alleen na het activeren van het netwerk.'; + 'Bytes die gebruikt worden om de ID van deze repeater te coderen in flood-pad/lusdetectietags. 0=1 byte (256 ID\'s, tot 64 hops), 1=2 bytes (65.000 ID\'s, tot 32 hops), 2=3 bytes (16 miljoen ID\'s, tot 21 hops). Firmware vóór v1.14 gebruikte altijd 1-byte paden; v1.14 en nieuwer kunnen worden ingesteld op 2- of 3-byte paden.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 40965bdd..f0f57506 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -742,6 +742,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get appSettings_languageUk => 'Ukraińska'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Włącz śledzenie wiadomości'; @@ -2717,7 +2729,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Bity wykorzystywane do kodowania identyfikatora tego urządzenia w tagach ścieżek/detekcji pętli. 0=1 bity (256 identyfikatorów, do 64 skoków), 1=2 bity (65 000 identyfikatorów, do 32 skoków), 2=3 bity (1 600 000 identyfikatorów, do 21 skoków). Wersje 1.13 i wcześniejsze nie obsługują ścieżek wielobitowych – wykrywają tylko jedną, gdy sieć jest w wersji 1.14 lub nowszej.'; + 'Bajty używane do kodowania identyfikatora tego repeatera w tagach ścieżki flood/wykrywania pętli. 0=1 bajt (256 identyfikatorów, do 64 skoków), 1=2 bajty (65 000 identyfikatorów, do 32 skoków), 2=3 bajty (16 milionów identyfikatorów, do 21 skoków). Firmware sprzed v1.14 zawsze używał ścieżek 1-bajtowych; v1.14 i nowsze można skonfigurować na ścieżki 2- lub 3-bajtowe.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e41ef822..55c2fa48 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -739,6 +739,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSettings_languageUk => 'Ucraniano'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Ativar rastreamento de mensagens'; @@ -2702,7 +2714,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Bytes utilizados para codificar o ID deste repetidor nas tags de caminho/detecção de loop. 0=1 byte (256 IDs, até 64 saltos), 1=2 bytes (65.000 IDs, até 32 saltos), 2=3 bytes (16 milhões de IDs, até 21 saltos). As versões 1.13 e anteriores do firmware não suportam caminhos multi-byte — apenas funcionam uma vez após a ativação da rede (a partir da versão 1.14+).'; + 'Bytes usados para codificar o ID deste repetidor nas tags de caminho flood/detecção de loop. 0=1 byte (256 IDs, até 64 saltos), 1=2 bytes (65.000 IDs, até 32 saltos), 2=3 bytes (16 milhões de IDs, até 21 saltos). O firmware anterior à v1.14 sempre usava caminhos de 1 byte; v1.14 e versões mais recentes podem ser configuradas para caminhos de 2 ou 3 bytes.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 452308c7..d32b69a4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -739,6 +739,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Включить трассировку сообщений'; @@ -2707,7 +2719,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Байты, используемые для кодирования идентификатора этого ретранслятора в тегах для обнаружения потоков/циклов. 0 = 1 байт (256 идентификаторов, до 64 переходов), 1 = 2 байта (65 000 идентификаторов, до 32 переходов), 2 = 3 байта (1 600 000 идентификаторов, до 21 перехода). Версии прошивки v1.13 и более ранние версии не поддерживают многобайтовые пути — они поднимаются только после того, как ваша сеть будет обновлена до версии v1.14 и выше.'; + 'Байты, используемые для кодирования идентификатора этого ретранслятора в тегах flood-маршрута/обнаружения циклов. 0 = 1 байт (256 идентификаторов, до 64 переходов), 1 = 2 байта (65 000 идентификаторов, до 32 переходов), 2 = 3 байта (16 миллионов идентификаторов, до 21 перехода). Прошивки до v1.14 всегда использовали 1-байтовые маршруты; v1.14 и новее можно настроить на 2- или 3-байтовые маршруты.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index cf0b4a5f..5ee8ec33 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -733,6 +733,18 @@ class AppLocalizationsSk extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrajinská'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Povoliť sledovanie správ'; @@ -2688,7 +2700,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Byty použité na zakódovanie ID tohto opakovača v tagoch pre trasu/detekciu slučky. 0 = 1 bytu (256 ID, až 64 skokov), 1 = 2 byty (65 000 ID, až 32 skokov), 2 = 3 byty (16 miliónov ID, až 21 skokov). Verzie 1.13 a staršie nepodporujú viacbytové trasy – fungujú len, keď je sieť aktivovaná.'; + 'Bajty použité na zakódovanie ID tohto opakovača v tagoch flood trasy/detekcie slučky. 0=1 bajt (256 ID, až 64 skokov), 1=2 bajty (65 000 ID, až 32 skokov), 2=3 bajty (16 miliónov ID, až 21 skokov). Firmvér pred v1.14 vždy používal 1-bajtové trasy; v1.14 a novšie možno nakonfigurovať na 2- alebo 3-bajtové trasy.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index a5e0b3d6..07d1bd4f 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -731,6 +731,18 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrajinsko'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Omogoči sledenje sporočilom'; @@ -2684,7 +2696,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Biti, ki so bila uporabljena za kodiranje ID-ja tega releja v oznakah za zaznavanje pot/kroga, imajo naslednje velikosti: 0=1 bit (256 ID-jev, do 64 skokov), 1=2 biti (65.000 ID-jev, do 32 skokov), 2=3 biti (16 milijonov ID-jev, do 21 skokov). V različicah 1.13 in starejših se ustvarjajo večbitne poti – vendar se to zgodi šele, ko je omrežje vklopljeno v različicah 1.14 in kasnejših.'; + 'Bajti, uporabljeni za kodiranje ID-ja tega repetitorja v oznakah flood poti/zaznavanja zank. 0=1 bajt (256 ID-jev, do 64 skokov), 1=2 bajta (65.000 ID-jev, do 32 skokov), 2=3 bajti (16 milijonov ID-jev, do 21 skokov). Vdelana programska oprema pred v1.14 je vedno uporabljala 1-bajtne poti; v1.14 in novejše je mogoče nastaviti na 2- ali 3-bajtne poti.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index e4768664..8bdd20f9 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -728,6 +728,18 @@ class AppLocalizationsSv extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrainska'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Aktivera meddelandespårning'; @@ -2672,7 +2684,7 @@ class AppLocalizationsSv extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Byte används för att koda denna repeaters ID i taggar för att upptäcka loopar/flödesvägar. 0=1 byte (256 ID:n, upp till 64 hopp), 1=2 byte (65 000 ID:n, upp till 32 hopp), 2=3 byte (16 miljoner ID:n, upp till 21 hopp). Versioner 1.13 och äldre har stöd för multi-byte-vägar – endast en gång när nätverket är aktiverat (från och med version 1.14).'; + 'Byte som används för att koda denna repeaters ID i taggar för flood-väg/loopdetektering. 0=1 byte (256 ID:n, upp till 64 hopp), 1=2 byte (65 000 ID:n, upp till 32 hopp), 2=3 byte (16 miljoner ID:n, upp till 21 hopp). Firmware före v1.14 använde alltid 1-byte-vägar; v1.14 och nyare kan konfigureras för 2- eller 3-byte-vägar.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 7a3158b9..2e87ba8d 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -736,6 +736,18 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => 'Увімкнути відстеження повідомлень'; @@ -2704,7 +2716,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get repeater_pathHashModeHelper => - 'Байти, що використовуються для кодування ідентифікатора цього ретранслятора в тегах для виявлення потоків/петлі. 0=1 байт (256 ідентифікаторів, до 64 перехідів), 1=2 байти (65 000 ідентифікаторів, до 32 перехідів), 2=3 байти (16 мільйонів ідентифікаторів, до 21 переходу). Версії 1.13 та старіші не підтримують багатобайтні шляхи — вони активуються лише після того, як мережа буде оновлена до версії 1.14+.'; + 'Байти, що використовуються для кодування ідентифікатора цього ретранслятора в тегах flood-шляху/виявлення петель. 0=1 байт (256 ідентифікаторів, до 64 переходів), 1=2 байти (65 000 ідентифікаторів, до 32 переходів), 2=3 байти (16 мільйонів ідентифікаторів, до 21 переходу). Прошивки до v1.14 завжди використовували 1-байтові шляхи; v1.14 і новіші можна налаштувати на 2- або 3-байтові шляхи.'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index edcaf12c..fa2813c3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -701,6 +701,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appSettings_languageUk => '乌克兰语'; + @override + String get repeater_pathHashModeOption0 => '0 - 1 byte'; + + @override + String get repeater_pathHashModeOption1 => '1 - 2 bytes'; + + @override + String get repeater_pathHashModeOption2 => '2 - 3 bytes'; + + @override + String get repeater_pathHashModeOption3 => '3 - 4 bytes'; + @override String get appSettings_enableMessageTracing => '启用消息追踪'; @@ -2559,7 +2571,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get repeater_pathHashModeHelper => - '用于编码此复用器的 ID 的字节数,在“洪流路径/环检测”标签中使用。 0=1 字节(256 个 ID,最多 64 个跳跃),1=2 字节(65K 个 ID,最多 32 个跳跃),2=3 字节(16M 个 ID,最多 21 个跳跃)。 v1.13 及更早版本的固件会使用多字节路径——只有在您的网络升级到 v1.14 或更高版本后才会生效。'; + '用于在洪泛路径/环路检测标签中编码此中继器 ID 的字节数。0=1 字节(256 个 ID,最多 64 跳),1=2 字节(65K 个 ID,最多 32 跳),2=3 字节(16M 个 ID,最多 21 跳)。v1.14 之前的固件始终使用 1 字节路径;v1.14 及更新版本可配置为 2 或 3 字节路径。'; @override String get repeater_keySettings => 'Change Identity Keys'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 335af8d7..9671cf8e 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1172,7 +1172,7 @@ "repeater_advancedSettings": "Geavanceerd", "repeater_advancedSettingsSubtitle": "Regelhendels voor ervaren gebruikers", "repeater_pathHashMode": "Hash-modus voor paden", - "repeater_pathHashModeHelper": "Bytes die gebruikt worden om de ID van deze repeater te coderen in flood-pad/lusdetectietags. 0=1 byte (256 ID's, tot 64 hops), 1=2 bytes (65.000 ID's, tot 32 hops), 2=3 bytes (16 miljoen ID's, tot 21 hops). Versies 1.13 en ouder gebruiken multi-byte paden – alleen na het activeren van het netwerk.", + "repeater_pathHashModeHelper": "Bytes die gebruikt worden om de ID van deze repeater te coderen in flood-pad/lusdetectietags. 0=1 byte (256 ID's, tot 64 hops), 1=2 bytes (65.000 ID's, tot 32 hops), 2=3 bytes (16 miljoen ID's, tot 21 hops). Firmware vóór v1.14 gebruikte altijd 1-byte paden; v1.14 en nieuwer kunnen worden ingesteld op 2- of 3-byte paden.", "repeater_txDelay": "Vertraging bij Flood TX", "repeater_txDelayHelper": "Herzendinterval voor verkeer tijdens overstromingen, als een veelvoud van de tijd die het pakket nodig heeft (0-2, standaard 0.5). Een hoger getal betekent minder botsingen, maar ook een langere leveringstijd.", "repeater_directTxDelay": "Directe vertraging", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 8802e1fa..17285d0f 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1182,7 +1182,7 @@ "repeater_advancedSettings": "Zaawansowany", "repeater_advancedSettingsSubtitle": "Regulowane pokrętła dla doświadczonych operatorów", "repeater_pathHashMode": "Tryb haszujący ścieżkę", - "repeater_pathHashModeHelper": "Bity wykorzystywane do kodowania identyfikatora tego urządzenia w tagach ścieżek/detekcji pętli. 0=1 bity (256 identyfikatorów, do 64 skoków), 1=2 bity (65 000 identyfikatorów, do 32 skoków), 2=3 bity (1 600 000 identyfikatorów, do 21 skoków). Wersje 1.13 i wcześniejsze nie obsługują ścieżek wielobitowych – wykrywają tylko jedną, gdy sieć jest w wersji 1.14 lub nowszej.", + "repeater_pathHashModeHelper": "Bajty używane do kodowania identyfikatora tego repeatera w tagach ścieżki flood/wykrywania pętli. 0=1 bajt (256 identyfikatorów, do 64 skoków), 1=2 bajty (65 000 identyfikatorów, do 32 skoków), 2=3 bajty (16 milionów identyfikatorów, do 21 skoków). Firmware sprzed v1.14 zawsze używał ścieżek 1-bajtowych; v1.14 i nowsze można skonfigurować na ścieżki 2- lub 3-bajtowe.", "repeater_txDelay": "Opóźnienie w Flood, TX", "repeater_txDelayHelper": "Ustawienie odstępu dla ruchu związanego z powodzią, jako mnożnik czasu przesyłania pakietu (0-2, domyślnie 0,5). Wyższe wartości oznaczają mniejszą liczbę kolizji, ale wolniejszą prędkość przesyłania.", "repeater_directTxDelay": "Bezpośrednie opóźnienie sygnału TX", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d1716ae2..90b8071a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1172,7 +1172,7 @@ "repeater_advancedSettings": "Avançado", "repeater_advancedSettingsSubtitle": "Controles de ajuste para operadores experientes", "repeater_pathHashMode": "Modo de hash de caminho", - "repeater_pathHashModeHelper": "Bytes utilizados para codificar o ID deste repetidor nas tags de caminho/detecção de loop. 0=1 byte (256 IDs, até 64 saltos), 1=2 bytes (65.000 IDs, até 32 saltos), 2=3 bytes (16 milhões de IDs, até 21 saltos). As versões 1.13 e anteriores do firmware não suportam caminhos multi-byte — apenas funcionam uma vez após a ativação da rede (a partir da versão 1.14+).", + "repeater_pathHashModeHelper": "Bytes usados para codificar o ID deste repetidor nas tags de caminho flood/detecção de loop. 0=1 byte (256 IDs, até 64 saltos), 1=2 bytes (65.000 IDs, até 32 saltos), 2=3 bytes (16 milhões de IDs, até 21 saltos). O firmware anterior à v1.14 sempre usava caminhos de 1 byte; v1.14 e versões mais recentes podem ser configuradas para caminhos de 2 ou 3 bytes.", "repeater_txDelay": "Atraso na entrega em Flood, TX", "repeater_txDelayHelper": "Ajuste de espaçamento para tráfego de inundações, como um multiplicador do tempo de transmissão (0-2, padrão 0,5). Quanto maior, menos colisões, mas uma entrega mais lenta.", "repeater_directTxDelay": "Atraso direto no sinal TX", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 23afe703..485cc641 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1543,7 +1543,7 @@ "repeater_advancedSettings": "Продвинутый", "repeater_advancedSettingsSubtitle": "Регуляторы для опытных операторов", "repeater_pathHashMode": "Режим хеширования пути", - "repeater_pathHashModeHelper": "Байты, используемые для кодирования идентификатора этого ретранслятора в тегах для обнаружения потоков/циклов. 0 = 1 байт (256 идентификаторов, до 64 переходов), 1 = 2 байта (65 000 идентификаторов, до 32 переходов), 2 = 3 байта (1 600 000 идентификаторов, до 21 перехода). Версии прошивки v1.13 и более ранние версии не поддерживают многобайтовые пути — они поднимаются только после того, как ваша сеть будет обновлена до версии v1.14 и выше.", + "repeater_pathHashModeHelper": "Байты, используемые для кодирования идентификатора этого ретранслятора в тегах flood-маршрута/обнаружения циклов. 0 = 1 байт (256 идентификаторов, до 64 переходов), 1 = 2 байта (65 000 идентификаторов, до 32 переходов), 2 = 3 байта (16 миллионов идентификаторов, до 21 перехода). Прошивки до v1.14 всегда использовали 1-байтовые маршруты; v1.14 и новее можно настроить на 2- или 3-байтовые маршруты.", "repeater_txDelay": "Задержка в работе системы Flood TX", "repeater_txDelayHelper": "Передача с увеличенным интервалом для трафика во время наводнения, в качестве коэффициента, умножающего время передачи пакета (от 0 до 2, по умолчанию 0,5). Более высокое значение означает меньшее количество столкновений, но более медленную передачу.", "repeater_directTxDelay": "Прямая задержка сигнала TX", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index a50c4c7f..90cc0413 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1172,7 +1172,7 @@ "repeater_advancedSettings": "Pokročilé", "repeater_advancedSettingsSubtitle": "Ovládacie knopy pre skúsených operátorov", "repeater_pathHashMode": "Režim hashovania cesty", - "repeater_pathHashModeHelper": "Byty použité na zakódovanie ID tohto opakovača v tagoch pre trasu/detekciu slučky. 0 = 1 bytu (256 ID, až 64 skokov), 1 = 2 byty (65 000 ID, až 32 skokov), 2 = 3 byty (16 miliónov ID, až 21 skokov). Verzie 1.13 a staršie nepodporujú viacbytové trasy – fungujú len, keď je sieť aktivovaná.", + "repeater_pathHashModeHelper": "Bajty použité na zakódovanie ID tohto opakovača v tagoch flood trasy/detekcie slučky. 0=1 bajt (256 ID, až 64 skokov), 1=2 bajty (65 000 ID, až 32 skokov), 2=3 bajty (16 miliónov ID, až 21 skokov). Firmvér pred v1.14 vždy používal 1-bajtové trasy; v1.14 a novšie možno nakonfigurovať na 2- alebo 3-bajtové trasy.", "repeater_txDelay": "Zpoždanie v Flood, TX", "repeater_txDelayHelper": "Nastavenie pre opakované vysielanie pre dopravu počas povodní, ako násobok času, ktorý paket využije (0-2, výchoce hodnota 0,5). Vyššia hodnota znamená menej kolízii, ale pomalšie doručovanie.", "repeater_directTxDelay": "Priame oneskorenie TX", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index ab3fd701..35062061 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1172,7 +1172,7 @@ "repeater_advancedSettings": "Napredno", "repeater_advancedSettingsSubtitle": "Gumbi za nastavljanje za izkušene uporabnike", "repeater_pathHashMode": "Način ustvarjanja hash-a poti", - "repeater_pathHashModeHelper": "Biti, ki so bila uporabljena za kodiranje ID-ja tega releja v oznakah za zaznavanje pot/kroga, imajo naslednje velikosti: 0=1 bit (256 ID-jev, do 64 skokov), 1=2 biti (65.000 ID-jev, do 32 skokov), 2=3 biti (16 milijonov ID-jev, do 21 skokov). V različicah 1.13 in starejših se ustvarjajo večbitne poti – vendar se to zgodi šele, ko je omrežje vklopljeno v različicah 1.14 in kasnejših.", + "repeater_pathHashModeHelper": "Bajti, uporabljeni za kodiranje ID-ja tega repetitorja v oznakah flood poti/zaznavanja zank. 0=1 bajt (256 ID-jev, do 64 skokov), 1=2 bajta (65.000 ID-jev, do 32 skokov), 2=3 bajti (16 milijonov ID-jev, do 21 skokov). Vdelana programska oprema pred v1.14 je vedno uporabljala 1-bajtne poti; v1.14 in novejše je mogoče nastaviti na 2- ali 3-bajtne poti.", "repeater_txDelay": "Zatemnitevanje zaradi poplav v Texasu", "repeater_txDelayHelper": "Uporaba intervalov za ponovno pošiljanje v primeru prometa zaradi poplav, kot pomnožnik časovne trajanje paketa (0-2, privzeto 0,5). Veje vrednost = manjše kolizije, vendar počasnejše dostavo.", "repeater_directTxDelay": "Neposredni časovno odlašanje", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 51ac5d24..487e8f96 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1172,7 +1172,7 @@ "repeater_advancedSettings": "Avancerad", "repeater_advancedSettingsSubtitle": "Ställjusteringsknappar för erfarna användare", "repeater_pathHashMode": "Hash-läge för sökväg", - "repeater_pathHashModeHelper": "Byte används för att koda denna repeaters ID i taggar för att upptäcka loopar/flödesvägar. 0=1 byte (256 ID:n, upp till 64 hopp), 1=2 byte (65 000 ID:n, upp till 32 hopp), 2=3 byte (16 miljoner ID:n, upp till 21 hopp). Versioner 1.13 och äldre har stöd för multi-byte-vägar – endast en gång när nätverket är aktiverat (från och med version 1.14).", + "repeater_pathHashModeHelper": "Byte som används för att koda denna repeaters ID i taggar för flood-väg/loopdetektering. 0=1 byte (256 ID:n, upp till 64 hopp), 1=2 byte (65 000 ID:n, upp till 32 hopp), 2=3 byte (16 miljoner ID:n, upp till 21 hopp). Firmware före v1.14 använde alltid 1-byte-vägar; v1.14 och nyare kan konfigureras för 2- eller 3-byte-vägar.", "repeater_txDelay": "Försening i Flood TX", "repeater_txDelayHelper": "Återöverföringsintervall för trafik under perioder med hög belastning, som en multiplikator av paketets överföringstid (0-2, standard 0,5). Högre värde = färre kollisioner, men långsammare leverans.", "repeater_directTxDelay": "Direkt TX-fördröjning", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index df3195a6..870fb06d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1182,7 +1182,7 @@ "repeater_advancedSettings": "Просунутий", "repeater_advancedSettingsSubtitle": "Регулювальні ручки для досвідчених операторів", "repeater_pathHashMode": "Режим хешування шляху", - "repeater_pathHashModeHelper": "Байти, що використовуються для кодування ідентифікатора цього ретранслятора в тегах для виявлення потоків/петлі. 0=1 байт (256 ідентифікаторів, до 64 перехідів), 1=2 байти (65 000 ідентифікаторів, до 32 перехідів), 2=3 байти (16 мільйонів ідентифікаторів, до 21 переходу). Версії 1.13 та старіші не підтримують багатобайтні шляхи — вони активуються лише після того, як мережа буде оновлена до версії 1.14+.", + "repeater_pathHashModeHelper": "Байти, що використовуються для кодування ідентифікатора цього ретранслятора в тегах flood-шляху/виявлення петель. 0=1 байт (256 ідентифікаторів, до 64 переходів), 1=2 байти (65 000 ідентифікаторів, до 32 переходів), 2=3 байти (16 мільйонів ідентифікаторів, до 21 переходу). Прошивки до v1.14 завжди використовували 1-байтові шляхи; v1.14 і новіші можна налаштувати на 2- або 3-байтові шляхи.", "repeater_txDelay": "Затримка у Flood, штат Техас", "repeater_txDelayHelper": "Повторне надсилання з урахуванням навантаження від потоків транспорту, як множник від часу передачі пакета (0-2, за замовчуванням 0,5). Чим вище значення, тим менше конфліктів, але повільніше передавання.", "repeater_directTxDelay": "Пряме затримка TX", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1becea8c..b0dd5b6b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1202,7 +1202,7 @@ "repeater_advancedSettings": "高级", "repeater_advancedSettingsSubtitle": "高级操作员使用的调节旋钮", "repeater_pathHashMode": "路径哈希模式", - "repeater_pathHashModeHelper": "用于编码此复用器的 ID 的字节数,在“洪流路径/环检测”标签中使用。 0=1 字节(256 个 ID,最多 64 个跳跃),1=2 字节(65K 个 ID,最多 32 个跳跃),2=3 字节(16M 个 ID,最多 21 个跳跃)。 v1.13 及更早版本的固件会使用多字节路径——只有在您的网络升级到 v1.14 或更高版本后才会生效。", + "repeater_pathHashModeHelper": "用于在洪泛路径/环路检测标签中编码此中继器 ID 的字节数。0=1 字节(256 个 ID,最多 64 跳),1=2 字节(65K 个 ID,最多 32 跳),2=3 字节(16M 个 ID,最多 21 跳)。v1.14 之前的固件始终使用 1 字节路径;v1.14 及更新版本可配置为 2 或 3 字节路径。", "repeater_txDelay": "洪水(德克萨斯州)延误", "repeater_txDelayHelper": "对于洪水流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.5)的倍数。 较高的值意味着更少的冲突,但传输速度会变慢。", "repeater_directTxDelay": "直接的 TX 延迟", diff --git a/lib/l10n/contact_localization.dart b/lib/l10n/contact_localization.dart index d8344a32..4f21c585 100644 --- a/lib/l10n/contact_localization.dart +++ b/lib/l10n/contact_localization.dart @@ -1,4 +1,5 @@ import '../connector/meshcore_protocol.dart'; +import '../helpers/path_helper.dart'; import '../models/contact.dart'; import 'app_localizations.dart'; @@ -23,14 +24,30 @@ extension ContactLocalization on Contact { } } - String pathLabel(AppLocalizations l10n) { + String pathLabel( + AppLocalizations l10n, { + int pathHashByteWidth = pathHashSize, + }) { if (pathOverride != null) { if (pathOverride! < 0) return l10n.chat_floodForced; if (pathOverride == 0) return l10n.chat_directForced; - return l10n.chat_hopsForced(pathOverride!); + return l10n.chat_hopsForced( + _displayHopCount(pathOverrideBytes, pathOverride!, pathHashByteWidth), + ); } if (pathLength < 0) return l10n.channelPath_floodPath; if (pathLength == 0) return l10n.chat_direct; - return l10n.chat_hopsCount(pathLength); + return l10n.chat_hopsCount( + _displayHopCount(path, pathLength, pathHashByteWidth), + ); + } + + int _displayHopCount( + List? pathBytes, + int storedHopCount, + int hashWidth, + ) { + if (pathBytes == null || pathBytes.isEmpty) return storedHopCount; + return PathHelper.splitPathBytes(pathBytes, hashWidth).length; } } diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 80c9705b..0113c807 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -41,6 +41,7 @@ class ChannelMessage { final List repeats; final int repeatCount; final int? pathLength; + final int? pathHashWidth; final Uint8List pathBytes; final List pathVariants; final int? channelIndex; @@ -66,6 +67,7 @@ class ChannelMessage { this.repeats = const [], this.repeatCount = 0, this.pathLength, + this.pathHashWidth, Uint8List? pathBytes, List? pathVariants, this.channelIndex, @@ -93,6 +95,7 @@ class ChannelMessage { List? repeats, int? repeatCount, int? pathLength, + int? pathHashWidth, Uint8List? pathBytes, List? pathVariants, String? packetHash, @@ -129,6 +132,7 @@ class ChannelMessage { repeats: repeats ?? this.repeats, repeatCount: repeatCount ?? this.repeatCount, pathLength: pathLength ?? this.pathLength, + pathHashWidth: pathHashWidth ?? this.pathHashWidth, pathBytes: pathBytes ?? this.pathBytes, pathVariants: pathVariants ?? this.pathVariants, channelIndex: channelIndex, @@ -155,6 +159,7 @@ class ChannelMessage { int pathLen; int txtType; + int? packetPathHashWidth; Uint8List pathBytes = Uint8List(0); int channelIdx; if (code == respCodeChannelMsgRecvV3) { @@ -163,12 +168,18 @@ class ChannelMessage { final hasPath = (flags & 0x01) != 0; reader.skipBytes(1); // Skip reserved byte channelIdx = reader.readByte(); - pathLen = reader.readInt8(); - txtType = reader.readByte(); - if (hasPath && pathLen > 0) { - reader.rewind(); // Rewind to read path length again for pathBytes - pathBytes = reader.readBytes(pathLen); + final pathByte = reader.readUInt8(); + // pathByte packs: top 2 bits = hash width mode, low 6 bits = hop count + packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1; + final hopCount = pathByte & 0x3F; + pathLen = hopCount; + // If a path is present, read hopCount * width bytes + if (hasPath && hopCount > 0) { + final totalPathBytes = hopCount * packetPathHashWidth; + pathBytes = reader.readBytes(totalPathBytes); } + // After consuming optional path bytes, read the text type byte. + txtType = reader.readByte(); } else { channelIdx = reader.readByte(); pathLen = reader.readInt8(); @@ -209,6 +220,7 @@ class ChannelMessage { isOutgoing: false, status: ChannelMessageStatus.sent, pathLength: pathLen, + pathHashWidth: packetPathHashWidth, pathBytes: pathBytes, channelIndex: channelIdx, ); diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 01d10a9c..d727210c 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -119,7 +119,7 @@ class Contact { String pathFormattedIdList(int hashByteWidth) { final pathBytes = pathBytesForDisplay; if (pathBytes.isEmpty) return ''; - final w = hashByteWidth.clamp(1, 8); + final w = hashByteWidth.clamp(1, 4); final parts = []; for (int i = 0; i < pathBytes.length; i += w) { final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length; @@ -166,8 +166,18 @@ class Contact { final type = reader.readByte(); final flags = reader.readByte(); final pathLen = reader.readByte(); - final safePathLen = pathLen > 0 - ? (pathLen > maxPathSize ? maxPathSize : pathLen) + int hopCount = 0; + int byteLen = 0; + if (pathLen == 0xFF) { + hopCount = -1; + } else { + final mode = (pathLen & 0xC0) >> 6; + hopCount = pathLen & 0x3F; + final width = mode + 1; + byteLen = hopCount * width; + } + final safePathLen = byteLen > 0 + ? (byteLen > maxPathSize ? maxPathSize : byteLen) : 0; final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen); final name = reader.readCStringGreedy(maxNameSize); @@ -213,7 +223,7 @@ class Contact { name: name.isEmpty ? 'Unknown' : name, type: type, flags: flags, - pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen, + pathLength: hopCount, path: pathBytes, latitude: lat, longitude: lon, diff --git a/lib/models/display_path.dart b/lib/models/display_path.dart index 71f8ed53..b82574cc 100644 --- a/lib/models/display_path.dart +++ b/lib/models/display_path.dart @@ -1,3 +1,4 @@ +import 'dart:typed_data'; import 'dart:ui'; import 'package:latlong2/latlong.dart'; @@ -13,8 +14,8 @@ class DisplayPath { final Color color; final bool isPrimary; - /// Outbound hop bytes, including hops that could not be placed on the map. - final List hopBytes; + /// Outbound hop prefixes, including hops that could not be placed on the map. + final List hopBytes; /// Resolved map points: self, each locatable hop, then the target when its /// position is known. Hops with no position are skipped here but still diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index fc8fe2d0..7eb4aa5f 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -10,6 +10,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}); @@ -314,16 +321,17 @@ class _BleDebugLogScreenState extends State { rawHex: _bytesToHex(raw), ); } - final pathLen = raw[index++]; - if (raw.length < index + pathLen) { + final pathLenRaw = raw[index++]; + final pathByteLen = _decodeRawPathByteLen(pathLenRaw); + if (raw.length < index + pathByteLen) { return _RawPacketInfo( title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}', summary: 'Truncated path', rawHex: _bytesToHex(raw), ); } - final pathBytes = raw.sublist(index, index + pathLen); - index += pathLen; + final pathBytes = raw.sublist(index, index + pathByteLen); + index += pathByteLen; if (raw.length <= index) { return _RawPacketInfo( title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}', @@ -336,7 +344,7 @@ class _BleDebugLogScreenState extends State { final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer'; final summary = _decodePayloadSummary(payloadType, payload); - final pathSummary = pathLen > 0 + final pathSummary = pathByteLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none'; final detail = '$summary • $pathSummary • len=${raw.length}'; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 2899d878..ce170aed 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -16,6 +16,7 @@ import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/cyr2lat.dart'; import '../helpers/gif_helper.dart'; +import '../helpers/path_helper.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/snack_bar_builder.dart'; import '../l10n/l10n.dart'; @@ -530,6 +531,14 @@ class _ChannelChatScreenState extends State { : (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0)); + final displayPathHashWidth = + message.pathHashWidth ?? + context.read().pathHashByteWidth; + final displayHopCount = _displayHopCount( + displayPath, + displayPathHashWidth, + message.pathLength, + ); // Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk. final bubbleColor = isOutgoing @@ -684,15 +693,16 @@ class _ChannelChatScreenState extends State { children: [ RouteChip( isDirect: (message.pathLength ?? -1) >= 0, - hops: (message.pathLength ?? -1) >= 0 - ? message.pathLength - : null, + hops: displayHopCount, ), const SizedBox(width: 4), Flexible( child: Text( context.l10n.channels_via( - _formatPathPrefixes(displayPath), + _formatPathPrefixes( + displayPath, + displayPathHashWidth, + ), ), style: MeshTheme.mono( fontSize: 9.5 * textScale, @@ -1578,13 +1588,21 @@ class _ChannelChatScreenState extends State { ); } - String _formatPathPrefixes(Uint8List pathBytes) { - // Join with a comma + zero-width space (not ", ") so paths render as - // "5B,56,65" with no visible gap, while still giving long paths soft-wrap - // break points that keep them confined to the message bubble. - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(',​'); + String _formatPathPrefixes(Uint8List pathBytes, int pathHashByteWidth) { + return PathHelper.splitPathBytes( + pathBytes, + pathHashByteWidth, + ).map(PathHelper.formatHopHex).join(','); + } + + int? _displayHopCount( + Uint8List pathBytes, + int pathHashByteWidth, + int? fallbackPathLength, + ) { + if ((fallbackPathLength ?? -1) < 0) return null; + if (pathBytes.isEmpty) return fallbackPathLength; + return PathHelper.splitPathBytes(pathBytes, pathHashByteWidth).length; } Future openRegionSelectDialog(Channel channel) async { diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 3292e76f..3132bb46 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; @@ -42,19 +43,40 @@ class ChannelMessagePathScreen extends StatelessWidget { message.pathVariants, ); - final primaryPath = !channelMessage && !message.isOutgoing - ? Uint8List.fromList(primaryPathTmp.reversed.toList()) - : primaryPathTmp; + final hashByteWidth = + (message.pathHashWidth ?? connector.pathHashByteWidth) + .clamp(1, 4) + .toInt(); + final primaryPath = _orientPathBytes( + primaryPathTmp, + hashByteWidth, + reverse: !channelMessage && !message.isOutgoing, + ); final hops = _buildPathHops( primaryPath, connector, l10n, + hashByteWidth, resolveFromEnd: !message.isOutgoing, ); final hasHopDetails = primaryPath.isNotEmpty; - final observedLabel = _formatObservedHops( + + // Convert observed path byte length to hop count using the packet width. + // Legacy messages fall back to the current connector width. + // Reported path length (V3+) is already stored as a hop count; preserve + // the negative flood sentinel when present. + final observedHopCount = _hopCountFromBytes( primaryPath.length, - message.pathLength, + hashByteWidth, + ); + final reportedHopCount = message.pathLength; + final effectiveHopCount = observedHopCount > 0 + ? observedHopCount + : reportedHopCount; + + final observedLabel = _formatObservedHops( + observedHopCount, + effectiveHopCount, l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); @@ -72,11 +94,8 @@ class ChannelMessagePathScreen extends StatelessWidget { title: context.l10n.contacts_repeaterPathTrace, path: primaryPath, flipPathAround: true, - reversePathAround: - !(!channelMessage && !message.isOutgoing), - pathHashByteWidth: context - .read() - .pathHashByteWidth, + reversePathAround: false, + pathHashByteWidth: hashByteWidth, ), ), ), @@ -97,13 +116,20 @@ class ChannelMessagePathScreen extends StatelessWidget { child: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ - _buildSummaryCard(context, observedLabel: observedLabel), + _buildSummaryCard( + context, + observedLabel: observedLabel, + effectiveHopCount: effectiveHopCount, + ), + const SizedBox(height: 16), if (extraPaths.isNotEmpty) ...[ SectionHeader( l10n.channelPath_otherObservedPaths, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), ), - _buildPathVariants(context, extraPaths), + const SizedBox(height: 8), + _buildPathVariants(context, extraPaths, hashByteWidth), + const SizedBox(height: 16), ], SectionHeader( l10n.channelPath_repeaterHops, @@ -122,14 +148,18 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { + Widget _buildSummaryCard( + BuildContext context, { + String? observedLabel, + required int? effectiveHopCount, + }) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; - final routeChip = message.pathLength == null + final routeChip = effectiveHopCount == null ? null - : message.pathLength! < 0 + : effectiveHopCount < 0 ? const RouteChip(isDirect: false) - : RouteChip(isDirect: true, hops: message.pathLength); + : RouteChip(isDirect: true, hops: effectiveHopCount); return MeshCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -171,7 +201,7 @@ class ChannelMessagePathScreen extends StatelessWidget { _buildDetailRow( context, l10n.channelPath_pathLabelTitle, - _formatPathLabel(message.pathLength, l10n), + _formatPathLabel(effectiveHopCount, l10n), scheme: scheme, ), if (observedLabel != null) @@ -186,7 +216,11 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - Widget _buildPathVariants(BuildContext context, List variants) { + Widget _buildPathVariants( + BuildContext context, + List variants, + int hashByteWidth, + ) { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -209,7 +243,11 @@ class ChannelMessagePathScreen extends StatelessWidget { Text( l10n.channelPath_observedPathTitle( i + 1, - _formatHopCount(variants[i].length, l10n), + _formatHopCount( + variants[i].length, + hashByteWidth, + l10n, + ), ), style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, @@ -217,7 +255,7 @@ class ChannelMessagePathScreen extends StatelessWidget { ), const SizedBox(height: 2), Text( - _formatPathPrefixes(variants[i]), + _formatPathPrefixes(variants[i], hashByteWidth), style: MeshTheme.mono( fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -401,31 +439,31 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - String _formatPathLabel(int? pathLength, AppLocalizations l10n) { - if (pathLength == null) return l10n.channelPath_unknownPath; - if (pathLength < 0) return l10n.channelPath_floodPath; - if (pathLength == 0) return l10n.channelPath_directPath; - return l10n.chat_hopsCount(pathLength); + String _formatPathLabel(int? hopCount, AppLocalizations l10n) { + if (hopCount == null) return l10n.channelPath_unknownPath; + if (hopCount < 0) return l10n.channelPath_floodPath; + if (hopCount == 0) return l10n.channelPath_directPath; + return l10n.chat_hopsCount(hopCount); } String? _formatObservedHops( int observedCount, - int? pathLength, + int? targetHopCount, AppLocalizations l10n, ) { - if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) { + if (observedCount <= 0 && (targetHopCount == null || targetHopCount <= 0)) { return null; } - if (pathLength == null || pathLength < 0) { + if (targetHopCount == null || targetHopCount < 0) { return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null; } if (observedCount == 0) { - return l10n.channelPath_observedZeroOf(pathLength); + return l10n.channelPath_observedZeroOf(targetHopCount); } - if (observedCount == pathLength) { + if (observedCount == targetHopCount) { return l10n.chat_hopsCount(observedCount); } - return l10n.channelPath_observedSomeOf(observedCount, pathLength); + return l10n.channelPath_observedSomeOf(observedCount, targetHopCount); } Widget _buildDetailRow( @@ -622,7 +660,10 @@ class _ChannelMessagePathMapScreenState ? kPrimaryPathColor : kAlternatePathColors[(index - 1) % kAlternatePathColors.length], isPrimary: isPrimary, - hopBytes: List.from(orientedBytes), + hopBytes: [ + for (final hop in hops) + if (hop.hopBytes != null) Uint8List.fromList(hop.hopBytes!), + ], points: points, pointLabels: labels, pointConfirmed: confirmed, @@ -807,7 +848,11 @@ class _ChannelMessagePathMapScreenState primaryPath, ); - final selectedPath = _orientPath(selectedPathTmp); + final width = + (widget.message.pathHashWidth ?? connector.pathHashByteWidth) + .clamp(1, 4) + .toInt(); + final selectedPath = _orientPath(selectedPathTmp, width); // Match on the unoriented bytes — observedPaths stores them as // recorded, while selectedPath may be reversed for display. @@ -816,19 +861,21 @@ class _ChannelMessagePathMapScreenState selectedPath, connector, context.l10n, + width, resolveFromEnd: !widget.message.isOutgoing, ); // Renderable paths for the animation and combined view. final entries = <_ObservedPathEntry>[]; for (var i = 0; i < observedPaths.length; i++) { - final oriented = _orientPath(observedPaths[i].pathBytes); + final oriented = _orientPath(observedPaths[i].pathBytes, width); final pathHops = i == selectedIndex ? hops : _buildPathHops( oriented, connector, context.l10n, + width, resolveFromEnd: !widget.message.isOutgoing, ); final display = _buildDisplayPath( @@ -907,7 +954,7 @@ class _ChannelMessagePathMapScreenState ? LatLngBounds.fromPoints(points) : null; final mapKey = ValueKey( - '${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}', + '${_formatPathPrefixes(selectedPath, width)},${context.l10n.pathTrace_you}', ); _pathDistance = _getPathDistance(points); @@ -1044,6 +1091,7 @@ class _ChannelMessagePathMapScreenState context, observedPaths, selectedIndex, + width, (index) { setState(() { _selectedPath = observedPaths[index].pathBytes; @@ -1085,10 +1133,12 @@ class _ChannelMessagePathMapScreenState BuildContext context, List<_ObservedPath> paths, int selectedIndex, + int hashByteWidth, ValueChanged onSelected, { double topOffset = 16, }) { final l10n = context.l10n; + final width = hashByteWidth.clamp(1, 4).toInt(); final selectedPath = paths[selectedIndex]; final label = selectedPath.isPrimary ? l10n.channelPath_primaryPath(selectedIndex + 1) @@ -1119,7 +1169,7 @@ class _ChannelMessagePathMapScreenState value: i, child: Text( '${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}' - ' • ${_formatHopCount(paths[i].pathBytes.length, l10n)}', + ' • ${_formatHopCount(paths[i].pathBytes.length, width, l10n)}', ), ), ], @@ -1133,7 +1183,7 @@ class _ChannelMessagePathMapScreenState Text( l10n.channelPath_selectedPathLabel( label, - _formatPathPrefixes(selectedPath.pathBytes), + _formatPathPrefixes(selectedPath.pathBytes, width), ), style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -1377,11 +1427,11 @@ class _ChannelMessagePathMapScreenState } /// Orients recorded path bytes in the direction the packet traveled. - Uint8List _orientPath(Uint8List bytes) { + Uint8List _orientPath(Uint8List bytes, int hashByteWidth) { final reverse = (!widget.message.isOutgoing && !widget.channelMessage) || (widget.message.isOutgoing && widget.channelMessage); - return reverse ? Uint8List.fromList(bytes.reversed.toList()) : bytes; + return _orientPathBytes(bytes, hashByteWidth, reverse: reverse); } Marker _buildNodeLabelMarker({required LatLng point, required String label}) { @@ -1737,6 +1787,7 @@ class _PathHop { final Contact? contact; final LatLng? position; final AppLocalizations l10n; + final Uint8List? hopBytes; const _PathHop({ required this.index, @@ -1744,12 +1795,18 @@ class _PathHop { required this.contact, required this.position, required this.l10n, + this.hopBytes, }); bool get hasLocation => position != null; String get displayLabel { - final prefixLabel = _formatPrefix(prefix); + final prefixLabel = hopBytes != null && hopBytes!.isNotEmpty + ? hopBytes! + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase() + : _formatPrefix(prefix); return '($prefixLabel) ${_resolveName(contact, l10n)}'; } } @@ -1764,10 +1821,12 @@ class _ObservedPath { List<_PathHop> _buildPathHops( Uint8List pathBytes, MeshCoreConnector connector, - AppLocalizations l10n, { + AppLocalizations l10n, + int hashByteWidth, { bool resolveFromEnd = false, }) { if (pathBytes.isEmpty) return const []; + final width = hashByteWidth.clamp(1, 4).toInt(); final endpoint = (connector.selfLatitude != null && connector.selfLongitude != null) ? LatLng(connector.selfLatitude!, connector.selfLongitude!) @@ -1777,19 +1836,23 @@ List<_PathHop> _buildPathHops( contacts: connector.allContacts, endpoint: endpoint, resolveFromEnd: resolveFromEnd, + pathHashByteWidth: width, ); + final hopChunks = PathHelper.splitPathBytes(pathBytes, width); final hops = <_PathHop>[]; - for (var i = 0; i < pathBytes.length; i++) { - final contact = resolvedContacts[i]; + for (var i = 0; i < hopChunks.length; i++) { + final hopBytes = hopChunks[i]; + final contact = i < resolvedContacts.length ? resolvedContacts[i] : null; final resolvedPosition = _resolvePosition(contact); hops.add( _PathHop( index: i + 1, - prefix: pathBytes[i], + prefix: hopBytes.isNotEmpty ? hopBytes[0] : 0, contact: contact, position: resolvedPosition, l10n: l10n, + hopBytes: hopBytes, ), ); } @@ -1809,14 +1872,35 @@ String _formatPrefix(int prefix) { return prefix.toRadixString(16).padLeft(2, '0').toUpperCase(); } -String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); +String _formatPathPrefixes(Uint8List pathBytes, int hashByteWidth) { + return PathHelper.splitPathBytes( + pathBytes, + hashByteWidth, + ).map(PathHelper.formatHopHex).join(','); } -String _formatHopCount(int count, AppLocalizations l10n) { - return l10n.chat_hopsCount(count); +Uint8List _orientPathBytes( + Uint8List pathBytes, + int hashByteWidth, { + required bool reverse, +}) { + if (!reverse || pathBytes.isEmpty) return pathBytes; + final hops = PathHelper.splitPathBytes(pathBytes, hashByteWidth); + return Uint8List.fromList([for (final hop in hops.reversed) ...hop]); +} + +int _hopCountFromBytes(int byteCount, int hashByteWidth) { + if (byteCount <= 0) return 0; + final width = hashByteWidth.clamp(1, 4).toInt(); + return (byteCount + width - 1) ~/ width; +} + +String _formatHopCount( + int byteCount, + int hashByteWidth, + AppLocalizations l10n, +) { + return l10n.chat_hopsCount(_hopCountFromBytes(byteCount, hashByteWidth)); } String _resolveName(Contact? contact, AppLocalizations l10n) { diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2822f18e..bfe5a381 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -12,6 +12,7 @@ import '../utils/platform_info.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/cyr2lat.dart'; +import '../helpers/path_helper.dart'; import '../helpers/reaction_helper.dart'; import '../widgets/message_status_icon.dart'; import '../widgets/empty_state.dart'; @@ -776,17 +777,35 @@ class _ChatScreenState extends State { } String _currentPathLabel(Contact contact) { + final connector = context.read(); + // Check if user has set a path override if (contact.pathOverride != null) { if (contact.pathOverride! < 0) return context.l10n.chat_floodForced; if (contact.pathOverride == 0) return context.l10n.chat_directForced; - return context.l10n.chat_hopsForced(contact.pathOverride!); + final bytes = contact.pathOverrideBytes ?? Uint8List(0); + final hopCount = _displayHopCount( + bytes, + contact.pathOverride!, + connector.pathHashByteWidth, + ); + return context.l10n.chat_hopsForced(hopCount); } // Use device's path if (contact.pathLength < 0) return context.l10n.chat_floodAuto; if (contact.pathLength == 0) return context.l10n.chat_direct; - return context.l10n.chat_hopsCount(contact.pathLength); + final hopCount = _displayHopCount( + contact.path, + contact.pathLength, + connector.pathHashByteWidth, + ); + return context.l10n.chat_hopsCount(hopCount); + } + + int _displayHopCount(List pathBytes, int storedHopCount, int hashWidth) { + if (pathBytes.isEmpty) return storedHopCount; + return PathHelper.splitPathBytes(pathBytes, hashWidth).length; } void _showContactInfo(BuildContext context) { @@ -807,7 +826,10 @@ class _ChatScreenState extends State { ), _buildInfoRow( context.l10n.chat_path, - contact.pathLabel(context.l10n), + contact.pathLabel( + context.l10n, + pathHashByteWidth: connector.pathHashByteWidth, + ), ), _buildInfoRow( context.l10n.contact_lastSeen, diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 44ec78d2..1cfb20a8 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -915,6 +915,7 @@ class _ContactsScreenState extends State return _ContactTileEntrance( index: index, contact: contact, + pathHashByteWidth: connector.pathHashByteWidth, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, isFavorite: contact.isFavorite, @@ -1371,7 +1372,10 @@ class _ContactsScreenState extends State MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPing, - path: Uint8List.fromList([contact.publicKey.first]), + path: contact.pathBytesForDisplay.isNotEmpty + ? contact.pathBytesForDisplay + : _contactPathPrefix(contact, hw), + flipPathAround: true, targetContact: contact, pathHashByteWidth: hw, ), @@ -1405,8 +1409,8 @@ class _ContactsScreenState extends State : context.l10n.contacts_roomPing, path: contact.pathBytesForDisplay.isNotEmpty ? contact.pathBytesForDisplay - : Uint8List.fromList([contact.publicKey.first]), - flipPathAround: contact.pathBytesForDisplay.isNotEmpty, + : _contactPathPrefix(contact, hw), + flipPathAround: true, targetContact: contact, pathHashByteWidth: hw, ), @@ -1516,6 +1520,16 @@ class _ContactsScreenState extends State ); } + Uint8List _contactPathPrefix(Contact contact, int hashByteWidth) { + if (contact.publicKey.isEmpty) return Uint8List(0); + final width = hashByteWidth + .clamp(1, pubKeySize) + .toInt() + .clamp(1, contact.publicKey.length) + .toInt(); + return Uint8List.fromList(contact.publicKey.sublist(0, width)); + } + void _confirmDelete( BuildContext context, MeshCoreConnector connector, @@ -1549,6 +1563,7 @@ class _ContactsScreenState extends State class _ContactTile extends StatelessWidget { final Contact contact; + final int pathHashByteWidth; final DateTime lastSeen; final int unreadCount; final bool isFavorite; @@ -1557,6 +1572,7 @@ class _ContactTile extends StatelessWidget { const _ContactTile({ required this.contact, + required this.pathHashByteWidth, required this.lastSeen, required this.unreadCount, required this.isFavorite, @@ -1676,7 +1692,10 @@ class _ContactTile extends StatelessWidget { children: [ Expanded( child: Text( - contact.pathLabel(context.l10n), + contact.pathLabel( + context.l10n, + pathHashByteWidth: pathHashByteWidth, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -1763,6 +1782,7 @@ class _ContactTile extends StatelessWidget { class _ContactTileEntrance extends StatelessWidget { final int index; final Contact contact; + final int pathHashByteWidth; final DateTime lastSeen; final int unreadCount; final bool isFavorite; @@ -1772,6 +1792,7 @@ class _ContactTileEntrance extends StatelessWidget { const _ContactTileEntrance({ required this.index, required this.contact, + required this.pathHashByteWidth, required this.lastSeen, required this.unreadCount, required this.isFavorite, @@ -1785,6 +1806,7 @@ class _ContactTileEntrance extends StatelessWidget { index: index, child: _ContactTile( contact: contact, + pathHashByteWidth: pathHashByteWidth, lastSeen: lastSeen, unreadCount: unreadCount, isFavorite: isFavorite, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index ec9e96db..b283cd88 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,13 +5,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; -import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; +import '../l10n/l10n.dart'; import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/contact.dart'; @@ -78,6 +79,7 @@ class _MapScreenState extends State { bool _hasInitializedMap = false; bool _removedMarkersLoaded = false; final List _pathTrace = []; + final List _pathTraceHopWidths = []; final List _pathTraceContacts = []; final List _points = []; final List _polylines = []; @@ -426,17 +428,20 @@ class _MapScreenState extends State { // Compute guessed locations with caching final maxRangeKm = _estimateLoRaRangeKm(connector); + final pathHashByteWidth = connector.pathHashByteWidth + .clamp(1, 4) + .toInt(); final filteredKeys = guessCandidates .map((c) => '${c.publicKeyHex}:${c.path.join("-")}') .join(','); final anchorKeys = allContactsWithLocation .map( (c) => - '${c.publicKeyHex}:${c.latitude}:${c.longitude}:${c.path.isNotEmpty ? c.path.last : ""}', + '${c.publicKeyHex}:${c.latitude}:${c.longitude}:${PathHelper.formatHopHex(c.path.isNotEmpty ? c.path.sublist(max(0, c.path.length - pathHashByteWidth)) : const [])}', ) .join(','); final cacheKey = - '$filteredKeys|$anchorKeys|$pathHistoryVersion:${connector.currentFreqHz}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}'; + '$filteredKeys|$anchorKeys|$pathHistoryVersion:$pathHashByteWidth:${connector.currentFreqHz}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}'; if (cacheKey != _guessedLocationsCacheKey) { _guessedLocationsCacheKey = cacheKey; _cachedGuessedLocations = settings.mapShowGuessedLocations @@ -445,6 +450,7 @@ class _MapScreenState extends State { allContactsWithLocation, pathHistory, maxRangeKm, + pathHashByteWidth, ) : []; } @@ -1006,23 +1012,19 @@ class _MapScreenState extends State { List withLocation, PathHistoryService pathHistory, double? maxRangeKm, + int pathHashByteWidth, ) { - // Index known-location repeaters by their 1-byte hash. - // null value = two repeaters share the same hash byte (ambiguous collision). - final repeaterByHash = {}; - - for (final c in withLocation) { - if (c.type == advTypeRepeater) { - if (repeaterByHash.containsKey(c.publicKey[0])) { - repeaterByHash[c.publicKey[0]] = - null; // collision: can't disambiguate - } else { - repeaterByHash[c.publicKey[0]] = c; - } - } - } - final result = <_GuessedLocation>[]; + final hopWidth = pathHashByteWidth.clamp(1, 4).toInt(); + final anchorsByPrefix = >{}; + for (final repeater in withLocation) { + if (repeater.type != advTypeRepeater) continue; + if (repeater.publicKey.length < hopWidth) continue; + final prefix = PathHelper.formatHopHex( + repeater.publicKey.sublist(0, hopWidth), + ); + anchorsByPrefix.putIfAbsent(prefix, () => []).add(repeater); + } for (final contact in allContacts) { if (contact.hasLocation) continue; @@ -1036,21 +1038,23 @@ class _MapScreenState extends State { // Collect the contact-side (last-hop) repeater from every known path. // path = [device-side hop, ..., contact-side hop] - // Only path.last is actually within radio range of the contact — using - // earlier bytes would anchor against our own side of the network. + // Only the last hop chunk is actually within radio range of the contact. final pathSets = >[ contact.path.toList(), ...pathHistory .getRecentPaths(contact.publicKeyHex) .map((r) => r.pathBytes), ]; - final lastHopBytes = {}; for (final pathBytes in pathSets) { if (pathBytes.isEmpty) continue; - final lastHop = pathBytes.last; - lastHopBytes.add(lastHop); - final r = repeaterByHash[lastHop]; - if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!)); + final lastHop = pathBytes.sublist(max(0, pathBytes.length - hopWidth)); + if (lastHop.isEmpty) continue; + + final repeaters = anchorsByPrefix[PathHelper.formatHopHex(lastHop)]; + if (repeaters != null && repeaters.isNotEmpty) { + final repeater = repeaters.first; + anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!)); + } } // Filter anchors that are geometrically inconsistent with radio range. @@ -1325,12 +1329,20 @@ class _MapScreenState extends State { // Key-prefix overlaps are a visual highlight only: flag the repeaters/rooms // whose first key byte collides with another repeater/room on the map. - final overlapPrefixes = {}; + final overlapPrefixes = {}; if (overlapsMode) { - final counts = {}; + final hopWidth = context + .read() + .pathHashByteWidth + .clamp(1, pubKeySize) + .toInt(); + final counts = {}; for (final contact in contacts) { - if (contact.type == advTypeRepeater || contact.type == advTypeRoom) { - final prefix = contact.publicKey.first; + if ((contact.type == advTypeRepeater || contact.type == advTypeRoom) && + contact.publicKey.length >= hopWidth) { + final prefix = PathHelper.formatHopHex( + contact.publicKey.sublist(0, hopWidth), + ); counts[prefix] = (counts[prefix] ?? 0) + 1; } } @@ -1338,10 +1350,20 @@ class _MapScreenState extends State { if (count > 1) overlapPrefixes.add(prefix); }); } + final overlapHopWidth = context + .read() + .pathHashByteWidth + .clamp(1, pubKeySize) + .toInt(); bool isOverlap(Contact contact) => overlapsMode && (contact.type == advTypeRepeater || contact.type == advTypeRoom) && - overlapPrefixes.contains(contact.publicKey.first); + contact.publicKey.length >= overlapHopWidth && + overlapPrefixes.contains( + PathHelper.formatHopHex( + contact.publicKey.sublist(0, overlapHopWidth), + ), + ); void addNode(Contact contact, {bool dot = false}) { final overlap = isOverlap(contact); @@ -2385,7 +2407,10 @@ class _MapScreenState extends State { ), _miniMeta( context.l10n.map_path, - contact.pathLabel(context.l10n), + contact.pathLabel( + context.l10n, + pathHashByteWidth: connector.pathHashByteWidth, + ), ), _miniMeta('ID', contact.publicKeyHex.substring(0, 12)), if (pos != null) @@ -2789,7 +2814,10 @@ class _MapScreenState extends State { children: [ _buildInfoRow( context.l10n.map_path, - contact.pathLabel(context.l10n), + contact.pathLabel( + context.l10n, + pathHashByteWidth: connector.pathHashByteWidth, + ), ), if (contact.hasLocation) _buildInfoRow( @@ -3535,10 +3563,23 @@ class _MapScreenState extends State { } void _addToPath(BuildContext context, Contact contact, {LatLng? position}) { + final connector = context.read(); + 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.add( - contact.publicKey[0], - ); // Add first 16 bytes of public key to path trace + _pathTrace.addAll(hopPrefix); // Add the hop-width pubkey prefix. + _pathTraceHopWidths.add(hopWidth); _pathTraceContacts.add( contact.copyWith( latitude: position?.latitude ?? contact.latitude, @@ -3553,6 +3594,7 @@ class _MapScreenState extends State { setState(() { _isBuildingPathTrace = true; _pathTrace.clear(); + _pathTraceHopWidths.clear(); _pathTraceContacts.clear(); _points.clear(); _polylines.clear(); @@ -3562,8 +3604,19 @@ class _MapScreenState extends State { void _removePath() { setState(() { + final recordedHopWidth = _pathTraceHopWidths.isNotEmpty + ? _pathTraceHopWidths.removeLast() + : context.read().pathHashByteWidth.clamp( + 1, + pubKeySize, + ); + final hopByteCount = min(recordedHopWidth, _pathTrace.length).toInt(); _pathTraceContacts.removeLast(); - _pathTrace.removeLast(); // Remove last node from path trace + // A path trace hop can be wider than one byte; remove the full hash prefix. + _pathTrace.removeRange( + _pathTrace.length - hopByteCount, + _pathTrace.length, + ); _points.removeLast(); // Remove last point from points list _polylines.clear(); // Clear polylines }); @@ -3624,9 +3677,10 @@ class _MapScreenState extends State { ), ), SelectableText( - _pathTrace - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(','), + PathHelper.splitPathBytes( + _pathTrace, + context.read().pathHashByteWidth, + ).map(PathHelper.formatHopHex).join(','), style: MeshTheme.mono( fontSize: 18, fontWeight: FontWeight.w700, @@ -3651,6 +3705,7 @@ class _MapScreenState extends State { builder: (context) => PathTraceMapScreen( title: l10n.contacts_pathTrace, path: Uint8List.fromList(_pathTrace), + flipPathAround: false, pathHashByteWidth: hashW, pathContacts: _pathTraceContacts, ), @@ -3673,6 +3728,10 @@ class _MapScreenState extends State { title: l10n.contacts_pathTrace, path: Uint8List.fromList(_pathTrace), flipPathAround: true, + pathHashByteWidth: context + .read() + .pathHashByteWidth, + pathContacts: _pathTraceContacts, ), ), ); @@ -3695,6 +3754,7 @@ class _MapScreenState extends State { setState(() { _isBuildingPathTrace = false; _pathTrace.clear(); + _pathTraceHopWidths.clear(); _points.clear(); _polylines.clear(); }); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 463c53a0..3d4032c8 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; @@ -26,9 +27,9 @@ export 'package:meshcore_open/widgets/path_map_ui.dart' show formatDistance, getPathDistanceMeters; class PathTraceData { - final Uint8List pathData; + final List pathData; final List snrData; - final Map pathContacts; + final Map pathContacts; PathTraceData({ required this.pathData, @@ -37,6 +38,20 @@ class PathTraceData { }); } +String _hopKey(Uint8List hopBytes) => PathHelper.formatHopHex(hopBytes); + +Uint8List _lastHopChunk(Uint8List path, int hopWidth) { + if (path.isEmpty) return Uint8List(0); + final width = hopWidth.clamp(1, path.length).toInt(); + 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; @@ -79,8 +94,8 @@ class _PathTraceMapScreenState extends State bool _failed2Loaded = false; bool _hasData = false; PathTraceData? _traceData; - // Inferred positions for hops that have no GPS location, keyed by hop byte. - Map _inferredHopPositions = {}; + // Inferred positions for hops that have no GPS location, keyed by hop prefix. + Map _inferredHopPositions = {}; // Endpoint position for the target contact (GPS or guessed). LatLng? _targetContactPosition; bool _targetContactIsGuessed = false; @@ -93,6 +108,7 @@ class _PathTraceMapScreenState extends State double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; Contact? _targetContact; + Uint8List? _sentTagBytes; // Live path resolved at trace time; used by the response handler for // endpoint inference so it matches the path that was actually traced. Uint8List _tracedPath = Uint8List(0); @@ -102,17 +118,82 @@ class _PathTraceMapScreenState extends State PathHistoryService? _pathHistory; PathViewMode _viewMode = PathViewMode.single; List _displayPaths = []; - List _primaryOutboundHops = []; + List _primaryOutboundHops = []; String _selectedPathId = 'primary'; final Set _hiddenPathIds = {}; bool _panelCollapsed = false; bool _animationEnabled = true; bool _followPacket = false; - String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .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 = []; + for (final hop in reversedHops) { + bytes.addAll(hop); + } + return Uint8List.fromList(bytes); + } + + Uint8List? _expandHopForTrace( + Uint8List hop, + int traceHashByteWidth, + MeshCoreConnector connector, + ) { + if (hop.length == traceHashByteWidth) return hop; + + final candidates = [ + ...?widget.pathContacts, + if (widget.targetContact != null) widget.targetContact!, + ...connector.allContactsUnfiltered, + ]; + for (final contact in candidates) { + if (contact.publicKey.length < traceHashByteWidth) continue; + if (!listEquals(contact.publicKey.sublist(0, hop.length), hop)) continue; + return Uint8List.fromList( + contact.publicKey.sublist(0, traceHashByteWidth), + ); + } + return null; + } + + Uint8List? _tracePathFromBytes( + Uint8List pathBytes, + int traceHashByteWidth, + MeshCoreConnector connector, + ) { + final hops = PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth); + final traceBytes = []; + for (final hop in hops) { + final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector); + if (traceHop == null) return null; + traceBytes.addAll(traceHop); + } + return Uint8List.fromList(traceBytes); } @override @@ -232,45 +313,62 @@ class _PathTraceMapScreenState extends State ); } - Uint8List buildPath(Uint8List pathBytes) { - Uint8List traceBytes; + Uint8List? buildPath( + Uint8List pathBytes, + int traceHashByteWidth, + MeshCoreConnector connector, + ) { + final pathHops = PathHelper.splitPathBytes( + pathBytes, + widget.pathHashByteWidth, + ); + final hopWidth = traceHashByteWidth.clamp(1, pubKeySize).toInt(); - if (pathBytes.isEmpty) { - final pk = widget.targetContact?.publicKey; - final n = widget.pathHashByteWidth.clamp(1, pubKeySize); - if (pk != null && pk.length >= n) { - return Uint8List.fromList(pk.sublist(0, n)); + // Compute targetPrefix if targetContact is provided + Uint8List? targetPrefix; + if (widget.targetContact != null) { + final pk = widget.targetContact!.publicKey; + if (pk.isNotEmpty) { + final len = pk.length >= hopWidth ? hopWidth : pk.length; + targetPrefix = Uint8List.fromList(pk.sublist(0, len)); } - traceBytes = Uint8List(1); - traceBytes[0] = pk?[0] ?? 0; - return traceBytes; } - if (widget.targetContact?.type == advTypeRepeater || - widget.targetContact?.type == advTypeRoom) { - final len = (pathBytes.length + pathBytes.length + 1); - traceBytes = Uint8List(len); - traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0; - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length) { - traceBytes[len - 1 - i] = pathBytes[i]; + final outboundHops = []; + for (final hop in pathHops) { + final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector); + if (traceHop == null) return null; + outboundHops.add(traceHop); + } + if (targetPrefix != null) { + // Check if targetPrefix is already the last hop in pathHops to avoid duplication + bool alreadyEndedWithTarget = false; + if (outboundHops.isNotEmpty) { + if (listEquals(outboundHops.last, targetPrefix)) { + alreadyEndedWithTarget = true; } } - } else { - if (pathBytes.length < 2) { - return pathBytes[0] == 0 ? Uint8List(0) : pathBytes; - } - final len = (pathBytes.length + pathBytes.length - 1); - traceBytes = Uint8List(len); - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length - 1) { - traceBytes[len - 1 - i] = pathBytes[i]; - } + if (!alreadyEndedWithTarget) { + outboundHops.add(targetPrefix); } } - return traceBytes; + + if (outboundHops.isEmpty) { + return Uint8List(0); + } + + final mirroredHops = [...outboundHops]; + if (outboundHops.length > 1) { + mirroredHops.addAll( + outboundHops.sublist(0, outboundHops.length - 1).reversed, + ); + } + + final traceBytes = []; + for (final hop in mirroredHops) { + traceBytes.addAll(hop); + } + return Uint8List.fromList(traceBytes); } /// Resolves the path bytes to trace. When tracing a specific contact's @@ -300,26 +398,49 @@ class _PathTraceMapScreenState extends State } final connector = Provider.of(context, listen: false); + final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth); final livePath = _resolveLivePath(connector); _tracedPath = livePath; final pathTmp = widget.reversePathAround - ? Uint8List.fromList(livePath.reversed.toList()) + ? _reversePathByHop(livePath, widget.pathHashByteWidth) : livePath; - 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 sentTag = DateTime.now().millisecondsSinceEpoch ~/ 1000; + _sentTagBytes = Uint8List(4) + ..[0] = sentTag & 0xFF + ..[1] = (sentTag >> 8) & 0xFF + ..[2] = (sentTag >> 16) & 0xFF + ..[3] = (sentTag >> 24) & 0xFF; + + final flags = _traceFlagsForHashWidth(traceHashByteWidth); + final tracePayload = path.isEmpty ? Uint8List.fromList([0x00]) : path; + final frame = buildTraceReq( - DateTime.now().millisecondsSinceEpoch ~/ 1000, - 0, //auth - 0, //flag - payload: path, + sentTag, + 0, // auth + flags, // flag + payload: tracePayload, ); connector.sendFrame(frame); } @@ -363,15 +484,13 @@ class _PathTraceMapScreenState extends State } // Check if it's a binary response - if (frame.length > 8 && + if (frame.length >= 12 && code == pushCodeTraceData && - listEquals(frame.sublist(4, 8), tagData)) { + (listEquals(frame.sublist(4, 8), _sentTagBytes) || + listEquals(frame.sublist(4, 8), tagData))) { _timeoutTimer?.cancel(); if (!mounted) return; - frameBuffer.skipBytes(3); //reserved + path length + flag - if (listEquals(frameBuffer.readBytes(4), tagData)) { - _handleTraceResponse(frame); - } + _handleTraceResponse(frame); } } catch (e) { _timeoutTimer?.cancel(); @@ -392,21 +511,30 @@ class _PathTraceMapScreenState extends State final buffer = BufferReader(frame); try { buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); - final int flags = buffer - .readUInt8(); // path_sz = flags & 0x03 (path-hash mode, fw v1.11+) + final pathLenByte = buffer.readUInt8(); + final flags = buffer.readUInt8(); buffer.skipBytes(4); // Skip tag data buffer.skipBytes(4); // Skip auth code - final int pathSz = flags & 0x03; - Uint8List pathData = buffer.readBytes(pathLength); + 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; + } + } + final pathBytes = buffer.readBytes(pathLength); + final pathData = PathHelper.splitPathBytes(pathBytes, width); // Firmware emits (path_len >> path_sz) hop SNRs plus 1 final SNR (to this node). - final int snrCount = (pathLength >> pathSz) + 1; + final snrCount = (pathLength ~/ width) + 1; List snrData = buffer .readBytes(snrCount) .map((snr) => snr.toSigned(8).toDouble() / 4) .toList(); - Map pathContacts = {}; + Map pathContacts = {}; Contact lastContact = Contact( path: Uint8List(0), pathLength: 0, @@ -418,7 +546,12 @@ class _PathTraceMapScreenState extends State lastSeen: DateTime.now(), ); if (widget.pathContacts != null) { - pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: c}; + final hopWidth = width.clamp(1, pubKeySize).toInt(); + pathContacts = { + for (var c in widget.pathContacts!) + if (c.publicKey.length >= hopWidth) + _hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c, + }; } else { final contacts = connector.allContactsUnfiltered; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { @@ -433,12 +566,14 @@ class _PathTraceMapScreenState extends State _maxRepeaterMatchDistanceMeters) { return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches } - for (var repeaterData in pathData) { + for (final repeaterData in pathData) { + final hopWidth = repeaterData.length; + if (repeater.publicKey.length < hopWidth) continue; if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([repeaterData]), + repeater.publicKey.sublist(0, hopWidth), + repeaterData, )) { - pathContacts[repeaterData] = repeater; + pathContacts[_hopKey(repeaterData)] = repeater; lastContact = repeater; } } @@ -447,13 +582,20 @@ class _PathTraceMapScreenState extends State // For hops with no GPS contact, infer position from other contacts // with known GPS that share the same last-hop byte. - final Map inferredPositions = {}; + final Map inferredPositions = {}; for (final hop in pathData) { - final contact = pathContacts[hop]; + final hopKey = _hopKey(hop); + final contact = pathContacts[hopKey]; if (contact != null && contact.hasLocation) continue; final peers = connector.contacts .where( - (c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop, + (c) => + c.hasLocation && + c.path.isNotEmpty && + _matchesHopPrefix( + _lastHopChunk(c.path, widget.pathHashByteWidth), + hop, + ), ) .toList(); if (peers.isNotEmpty) { @@ -463,7 +605,7 @@ class _PathTraceMapScreenState extends State final lon = peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; - inferredPositions[hop] = LatLng(lat, lon); + inferredPositions[hopKey] = LatLng(lat, lon); } } @@ -485,20 +627,31 @@ class _PathTraceMapScreenState extends State final tc = _targetContact!; if (tc.hasLocation) { targetPos = LatLng(tc.latitude!, tc.longitude!); - } else if (_tracedPath.length > 1) { + } else if (pathData.length > 1 || _tracedPath.isNotEmpty) { // Infer from the last hop: average GPS contacts sharing that hop. // For a round-trip path (flipPathAround/reversePathAround), the target-side hop // sits in the middle of the symmetric sequence; .last is the local side. + final tracedHops = PathHelper.splitPathBytes( + _tracedPath, + widget.pathHashByteWidth, + ); + final hopsForEndpoint = tracedHops.isNotEmpty + ? tracedHops + : pathData; final lastHop = widget.reversePathAround - ? _tracedPath.first - : _tracedPath.last; + ? hopsForEndpoint.first + : hopsForEndpoint.last; + final lastHopKey = _hopKey(lastHop); final peers = connector.allContacts .where( (c) => c.hasLocation && c.path.isNotEmpty && - c.path.last == lastHop, + _matchesHopPrefix( + _lastHopChunk(c.path, widget.pathHashByteWidth), + lastHop, + ), ) .toList(); if (peers.isNotEmpty) { @@ -515,9 +668,9 @@ class _PathTraceMapScreenState extends State lon + offsetDeg * sin(angle), ); targetGuessed = true; - } else if (inferredPositions.containsKey(lastHop)) { - final lat = inferredPositions[lastHop]!.latitude; - final lon = inferredPositions[lastHop]!.longitude; + } else if (inferredPositions.containsKey(lastHopKey)) { + final lat = inferredPositions[lastHopKey]!.latitude; + final lon = inferredPositions[lastHopKey]!.longitude; const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( @@ -527,7 +680,7 @@ class _PathTraceMapScreenState extends State targetGuessed = true; } else { // As a last resort, just place it at the same position as the last hop. - final contact = pathContacts[lastHop]; + final contact = pathContacts[lastHopKey]; if (contact != null && contact.hasLocation) { const offsetDeg = 0.003; final angle = (tc.publicKey[1] / 255.0) * 2 * pi; @@ -545,21 +698,22 @@ class _PathTraceMapScreenState extends State _points = []; _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); - int hopLast = 0; - int hopLastLast = 0; + String hopLast = ''; + String hopLastLast = ''; for (final hop in _traceData!.pathData) { - if (hop == hopLastLast && widget.flipPathAround) { + final hopKey = _hopKey(hop); + if (hopKey == hopLastLast && widget.flipPathAround) { break; //skip duplicate hops in round-trip paths } - final contact = _traceData!.pathContacts[hop]; + final contact = _traceData!.pathContacts[hopKey]; if (contact != null && contact.hasLocation) { _points.add(LatLng(contact.latitude!, contact.longitude!)); } else { - final inferred = inferredPositions[hop]; + final inferred = inferredPositions[hopKey]; if (inferred != null) _points.add(inferred); } hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; } if (targetPos != null) { if (_targetContact != null && _targetContact!.type == advTypeChat) { @@ -582,7 +736,7 @@ class _PathTraceMapScreenState extends State _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; _mapKey = ValueKey( - '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + '${context.l10n.pathTrace_you},${_traceData!.pathData.map(PathHelper.formatHopHex).join(',')}', ); _pathDistanceMeters = getPathDistanceMeters(_points); _primaryOutboundHops = _outboundHops(pathData); @@ -604,37 +758,47 @@ class _PathTraceMapScreenState extends State /// Outbound hop bytes of the traced path, mirroring the round-trip /// dedup logic used when building [_points]. - List _outboundHops(Uint8List pathData) { - final hops = []; - int hopLast = 0; - int hopLastLast = 0; + List _outboundHops(List pathData) { + final hops = []; + var hopLast = ''; + var hopLastLast = ''; for (final hop in pathData) { - if (hop == hopLastLast && widget.flipPathAround) break; + final hopKey = _hopKey(hop); + if (hopKey == hopLastLast && widget.flipPathAround) break; hops.add(hop); hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; } return hops; } - Contact? _contactForHop(int hop, MeshCoreConnector connector) { - final traced = _traceData?.pathContacts[hop]; + Contact? _contactForHop(Uint8List hop, MeshCoreConnector connector) { + final traced = _traceData?.pathContacts[_hopKey(hop)]; if (traced != null) return traced; for (final c in connector.allContactsUnfiltered) { if (c.type != advTypeChat && - c.publicKey.isNotEmpty && - c.publicKey[0] == hop) { + c.publicKey.length >= hop.length && + listEquals(c.publicKey.sublist(0, hop.length), hop)) { return c; } } return null; } - LatLng? _inferredPositionForHop(int hop, MeshCoreConnector connector) { - final cached = _inferredHopPositions[hop]; + LatLng? _inferredPositionForHop(Uint8List hop, MeshCoreConnector connector) { + final hopKey = _hopKey(hop); + final cached = _inferredHopPositions[hopKey]; if (cached != null) return cached; final peers = connector.contacts - .where((c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop) + .where( + (c) => + c.hasLocation && + c.path.isNotEmpty && + _matchesHopPrefix( + _lastHopChunk(c.path, widget.pathHashByteWidth), + hop, + ), + ) .toList(); if (peers.isEmpty) return null; final lat = @@ -642,10 +806,14 @@ class _PathTraceMapScreenState extends State final lon = peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; final pos = LatLng(lat, lon); - _inferredHopPositions[hop] = pos; + _inferredHopPositions[hopKey] = pos; return pos; } + String _pathKeyForHops(List hops) { + return hops.map(PathHelper.formatHopHex).join(','); + } + /// Rebuilds the renderable paths: the traced path as primary plus up to /// four distinct alternates from the target contact's path history. void _rebuildDisplayPaths(MeshCoreConnector connector) { @@ -663,18 +831,22 @@ class _PathTraceMapScreenState extends State final target = widget.targetContact; final history = _pathHistory; if (target != null && history != null) { - final seen = {_primaryOutboundHops.join(',')}; + final seen = {_pathKeyForHops(_primaryOutboundHops)}; var altIndex = 0; for (final record in history.getRecentPaths(target.publicKeyHex)) { if (record.pathBytes.isEmpty) continue; - if (!seen.add(record.pathBytes.join(','))) continue; + final recordHops = PathHelper.splitPathBytes( + record.pathBytes, + widget.pathHashByteWidth, + ); + if (!seen.add(_pathKeyForHops(recordHops))) continue; if (altIndex >= kAlternatePathColors.length) break; final alt = _buildDisplayPath( - id: 'alt-${record.pathBytes.join('-')}', + id: 'alt-${_pathKeyForHops(recordHops)}', label: context.l10n.pathMap_alternate(altIndex + 1), color: kAlternatePathColors[altIndex], isPrimary: false, - hops: record.pathBytes, + hops: recordHops, record: record, connector: connector, ); @@ -699,7 +871,7 @@ class _PathTraceMapScreenState extends State required String label, required Color color, required bool isPrimary, - required List hops, + required List hops, required MeshCoreConnector connector, PathRecord? record, }) { @@ -718,7 +890,7 @@ class _PathTraceMapScreenState extends State for (var i = 0; i < hops.length; i++) { final hop = hops[i]; - final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final hex = PathHelper.formatHopHex(hop); final contact = _contactForHop(hop, connector); LatLng? pos; var isGps = false; @@ -772,7 +944,7 @@ class _PathTraceMapScreenState extends State label: label, color: color, isPrimary: isPrimary, - hopBytes: List.from(hops), + hopBytes: List.from(hops), points: points, pointLabels: labels, pointConfirmed: confirmed, @@ -933,29 +1105,34 @@ class _PathTraceMapScreenState extends State } List _buildHopMarkers( - List pathData, { + List pathData, { required bool showLabels, required Contact? target, }) { final markers = []; - int hopLast = 0; - int hopLastLast = 0; + String hopLast = ''; + String hopLastLast = ''; for (final hop in pathData) { - final contact = _traceData!.pathContacts[hop]; - final inferred = _inferredHopPositions[hop]; + final hopKey = _hopKey(hop); + final contact = _traceData!.pathContacts[hopKey]; + final inferred = _inferredHopPositions[hopKey]; final hasGps = contact != null && contact.hasLocation; - if (hop == hopLastLast && widget.flipPathAround) { + if (hopKey == hopLastLast && widget.flipPathAround) { continue; //skip duplicate hops in round-trip paths } if (!hasGps && inferred == null) { hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; continue; //skip hops with no GPS and no inferred position } final point = hasGps ? LatLng(contact.latitude!, contact.longitude!) : inferred!; - final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + 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( @@ -988,9 +1165,9 @@ class _PathTraceMapScreenState extends State ), alignment: Alignment.center, child: Text( - hasGps ? label : '~$label', + shortLabel, style: MeshTheme.mono( - fontSize: 10, + fontSize: 12, fontWeight: FontWeight.w700, color: hasGps ? MeshPalette.signal : MeshPalette.warn, ), @@ -1000,15 +1177,10 @@ class _PathTraceMapScreenState extends State ), ); if (showLabels) { - markers.add( - _buildNodeLabelMarker( - point: point, - label: contact?.name ?? '~$label', - ), - ); + markers.add(_buildNodeLabelMarker(point: point, label: fullLabel)); } hopLastLast = hopLast; - hopLast = hop; + hopLast = hopKey; } _addEndpointMarkers(markers, showLabels: showLabels, target: target); @@ -1136,17 +1308,22 @@ class _PathTraceMapScreenState extends State final connector = context.read(); final markers = []; - // Hop byte -> paths that use it, in display order. - final hopPaths = >{}; + // Hop prefix -> paths that use it, in display order. + final hopPaths = >{}; + final hopsByKey = {}; for (final path in _visiblePaths) { for (final hop in path.hopBytes) { - final list = hopPaths.putIfAbsent(hop, () => []); + final hopKey = _hopKey(hop); + hopsByKey[hopKey] = hop; + final list = hopPaths.putIfAbsent(hopKey, () => []); if (!list.contains(path)) list.add(path); } } for (final entry in hopPaths.entries) { - final hop = entry.key; + final hopKey = entry.key; + final hop = hopsByKey[hopKey]; + if (hop == null) continue; final paths = entry.value; final contact = _contactForHop(hop, connector); final hasGps = contact != null && contact.hasLocation; @@ -1154,7 +1331,7 @@ class _PathTraceMapScreenState extends State ? LatLng(contact.latitude!, contact.longitude!) : _inferredPositionForHop(hop, connector); if (point == null) continue; - final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final label = PathHelper.formatHopHex(hop); final baseColor = hasGps ? MeshPalette.signal : MeshPalette.warn; final shared = paths.length > 1; @@ -1239,11 +1416,11 @@ class _PathTraceMapScreenState extends State } void _showSharedNodeSheet( - int hop, + Uint8List hop, Contact? contact, List paths, ) { - final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final hex = PathHelper.formatHopHex(hop); showSharedNodeSheet( context, title: @@ -1290,29 +1467,28 @@ class _PathTraceMapScreenState extends State } String formatDirectionText(PathTraceData pathTraceData, int index) { - if (index == 0 || index == pathTraceData.snrData.length - 1) { + if (pathTraceData.pathData.isEmpty) { + return context.l10n.pathTrace_you; + } + if (index < 0 || index > pathTraceData.pathData.length) { + return context.l10n.pathTrace_you; + } + + if (index == 0 || index == pathTraceData.pathData.length) { if (index == 0) { return context.l10n.pathTrace_you; } else { - final contactName = pathTraceData - .pathContacts[pathTraceData.pathData[pathTraceData.pathData.length - - 1]] - ?.name; - final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData.last; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } else { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name; - final hex = pathTraceData.pathData[index - 1] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData[index - 1]; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -1320,14 +1496,18 @@ class _PathTraceMapScreenState extends State } String formatDirectionSubText(PathTraceData pathTraceData, int index) { - if (index == 0 || index == pathTraceData.snrData.length - 1) { + if (pathTraceData.pathData.isEmpty) { + return context.l10n.pathTrace_you; + } + if (index < 0 || index > pathTraceData.pathData.length) { + return context.l10n.pathTrace_you; + } + + if (index == 0 || index == pathTraceData.pathData.length) { if (index == 0) { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name; - final hex = pathTraceData.pathData[0] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData.first; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -1335,12 +1515,9 @@ class _PathTraceMapScreenState extends State return context.l10n.pathTrace_you; } } else { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name; - final hex = pathTraceData.pathData[index] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = pathTraceData.pathData[index]; + final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; + final hex = PathHelper.formatHopHex(hop); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -1669,11 +1846,11 @@ class _PathTraceMapScreenState extends State final connector = context.read(); final l10n = context.l10n; - final hopUseCount = {}; + final hopUseCount = {}; if (_viewMode == PathViewMode.combined) { for (final p in _visiblePaths) { - for (final hop in p.hopBytes.toSet()) { - hopUseCount.update(hop, (v) => v + 1, ifAbsent: () => 1); + for (final hopKey in p.hopBytes.map(PathHelper.formatHopHex).toSet()) { + hopUseCount.update(hopKey, (v) => v + 1, ifAbsent: () => 1); } } } @@ -1689,7 +1866,7 @@ class _PathTraceMapScreenState extends State Widget? trailing; if (index < path.hopBytes.length) { final hop = path.hopBytes[index]; - final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final hex = PathHelper.formatHopHex(hop); final contact = _contactForHop(hop, connector); title = contact != null ? '$hex: ${contact.name}' @@ -1702,7 +1879,7 @@ class _PathTraceMapScreenState extends State : inferred ? l10n.pathTrace_legendInferred : l10n.pathMap_noLocation; - final sharedCount = hopUseCount[hop] ?? 0; + final sharedCount = hopUseCount[_hopKey(hop)] ?? 0; subtitle = sharedCount > 1 ? '$status · ${l10n.pathMap_sharedNodeCount(sharedCount)}' : status; diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index ab15e01e..b23be5dc 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -31,6 +32,7 @@ class RepeaterHubScreen extends StatelessWidget { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; final settingsService = context.watch(); + final connector = context.watch(); final chemistry = settingsService.batteryChemistryForRepeater( repeater.publicKeyHex, ); @@ -85,7 +87,10 @@ class RepeaterHubScreen extends StatelessWidget { ), const SizedBox(height: 4), Text( - repeater.pathLabel(l10n), + repeater.pathLabel( + l10n, + pathHashByteWidth: connector.pathHashByteWidth, + ), style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: scheme.onSurfaceVariant), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4e6ab594..17cca639 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -468,10 +468,30 @@ class _SettingsScreenState extends State { ? () => pushCompanionRadioStatsScreen(context) : null, ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.route_outlined, + title: l10n.repeater_pathHashMode, + subtitle: _pathHashModeSubtitle(context, connector.pathHashByteWidth), + onTap: connector.isConnected + ? () => _editPathHashMode(context, connector) + : null, + ), ], ); } + String _pathHashModeSubtitle(BuildContext context, int pathHashByteWidth) { + final l10n = context.l10n; + return switch (pathHashByteWidth.clamp(1, 4).toInt()) { + 1 => l10n.repeater_pathHashModeOption0, + 2 => l10n.repeater_pathHashModeOption1, + 3 => l10n.repeater_pathHashModeOption2, + _ => l10n.repeater_pathHashModeOption3, + }; + } + Widget _buildLocationCardContent( BuildContext context, MeshCoreConnector connector, @@ -760,6 +780,87 @@ class _SettingsScreenState extends State { ); } + void _editPathHashMode(BuildContext context, MeshCoreConnector connector) { + final l10n = context.l10n; + var selectedMode = (connector.pathHashByteWidth - 1).clamp(0, 3).toInt(); + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(l10n.repeater_pathHashMode), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: selectedMode, + decoration: InputDecoration( + labelText: l10n.repeater_pathHashMode, + border: const OutlineInputBorder(), + ), + 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; + setDialogState(() => selectedMode = value); + }, + ), + const SizedBox(height: 12), + Text( + l10n.repeater_pathHashModeHelper, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.common_cancel), + ), + FilledButton( + onPressed: () async { + Navigator.pop(dialogContext); + try { + await connector.setPathHashMode(selectedMode); + await connector.refreshDeviceInfo(); + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text(l10n.repeater_settingsSaved), + ); + } catch (e) { + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text(l10n.settings_error(e.toString())), + ); + } + }, + child: Text(l10n.common_save), + ), + ], + ), + ), + ); + } + void _editLocation(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; final settingsService = context.read(); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index b148c95d..05ebf6b0 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -27,6 +27,11 @@ class NotificationService { AppLocalizations get _l10n => lookupAppLocalizations(_locale); + String _logSafe(String value) { + final sanitized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' '); + return Uri.encodeComponent(sanitized); + } + // Rate limiting to prevent notification storms // (Added after getting notification-flooded while evaluating RF flood management. The irony.) static const _minNotificationInterval = Duration(seconds: 3); @@ -356,11 +361,11 @@ class NotificationService { String _getNotificationIdentifier(_PendingNotification n) { switch (n.type) { case _NotificationType.advert: - return n.body; + return _logSafe(n.body); case _NotificationType.message: - return 'from: ${n.title}'; + return 'from: ${_logSafe(n.title)}'; case _NotificationType.channelMessage: - return 'in: ${n.title}'; + return 'in: ${_logSafe(n.title)}'; } } @@ -606,7 +611,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) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})' + ? ' (${adverts.take(5).map((n) => _logSafe(n.body)).join(', ')}${adverts.length > 5 ? ', ...' : ''})' : ''; debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo'); diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 5a0fbd8f..f16081a6 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -110,6 +110,7 @@ class ChannelMessageStore { 'channelIndex': msg.channelIndex, 'repeatCount': msg.repeatCount, 'pathLength': msg.pathLength, + 'pathHashWidth': msg.pathHashWidth, 'pathBytes': base64Encode(msg.pathBytes), 'pathVariants': msg.pathVariants.map(base64Encode).toList(), 'repeats': msg.repeats.map(_repeatToJson).toList(), @@ -126,6 +127,38 @@ class ChannelMessageStore { ChannelMessage _messageFromJson(Map json) { final rawText = json['text'] as String; final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText; + + final rawPathLength = json['pathLength'] as int?; + final rawPathBytes = json['pathBytes'] != null + ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) + : Uint8List(0); + final rawPathHashWidth = json['pathHashWidth'] as int?; + + int? decodedPathLength = rawPathLength; + Uint8List decodedPathBytes = rawPathBytes; + int? decodedPathHashWidth = rawPathHashWidth; + + if (rawPathLength != null) { + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPathBytes = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + decodedPathHashWidth = width; + if (byteLen <= rawPathBytes.length) { + decodedPathBytes = rawPathBytes.sublist(0, byteLen); + } else { + decodedPathBytes = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPathBytes = Uint8List(0); + } + } + return ChannelMessage( senderKey: json['senderKey'] != null ? Uint8List.fromList(base64Decode(json['senderKey'])) @@ -143,10 +176,9 @@ class ChannelMessageStore { isOutgoing: json['isOutgoing'] as bool, status: ChannelMessageStatus.values[json['status'] as int], repeatCount: (json['repeatCount'] as int?) ?? 0, - pathLength: json['pathLength'] as int?, - pathBytes: json['pathBytes'] != null - ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + pathHashWidth: decodedPathHashWidth, + pathBytes: decodedPathBytes, pathVariants: (json['pathVariants'] as List?) ?.map((entry) => Uint8List.fromList(base64Decode(entry as String))) .toList(), diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index 3f6f1718..98bf6e8d 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -55,15 +55,40 @@ class ContactDiscoveryStore { final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastMessageMs = json['lastMessageAt'] as int?; final lastModifiedMs = json['lastModified'] as int?; + + final rawPathLength = json['pathLength'] as int? ?? -1; + final rawPath = json['path'] != null + ? Uint8List.fromList(base64Decode(json['path'] as String)) + : Uint8List(0); + + int decodedPathLength = rawPathLength; + Uint8List decodedPath = rawPath; + + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPath = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + if (byteLen <= rawPath.length) { + decodedPath = rawPath.sublist(0, byteLen); + } else { + decodedPath = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPath = Uint8List(0); + } + return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, flags: json['flags'] as int? ?? 0, - pathLength: json['pathLength'] as int? ?? -1, - path: json['path'] != null - ? Uint8List.fromList(base64Decode(json['path'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + path: decodedPath, pathOverride: json['pathOverride'] as int?, pathOverrideBytes: json['pathOverrideBytes'] != null ? Uint8List.fromList( diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 7883d885..fa4152d2 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -88,15 +88,40 @@ class ContactStore { final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastMessageMs = json['lastMessageAt'] as int?; final lastModifiedMs = json['lastModified'] as int?; + + final rawPathLength = json['pathLength'] as int? ?? -1; + final rawPath = json['path'] != null + ? Uint8List.fromList(base64Decode(json['path'] as String)) + : Uint8List(0); + + int decodedPathLength = rawPathLength; + Uint8List decodedPath = rawPath; + + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPath = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + if (byteLen <= rawPath.length) { + decodedPath = rawPath.sublist(0, byteLen); + } else { + decodedPath = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPath = Uint8List(0); + } + return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, flags: json['flags'] as int? ?? 0, - pathLength: json['pathLength'] as int? ?? -1, - path: json['path'] != null - ? Uint8List.fromList(base64Decode(json['path'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + path: decodedPath, pathOverride: json['pathOverride'] as int?, pathOverrideBytes: json['pathOverrideBytes'] != null ? Uint8List.fromList( diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 3d237681..96aaba9c 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -113,6 +113,35 @@ class MessageStore { final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText); + + final rawPathLength = json['pathLength'] as int?; + final rawPathBytes = json['pathBytes'] != null + ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) + : Uint8List(0); + + int? decodedPathLength = rawPathLength; + Uint8List decodedPathBytes = rawPathBytes; + + if (rawPathLength != null) { + if (rawPathLength == 0xFF || rawPathLength < 0) { + decodedPathLength = -1; + decodedPathBytes = Uint8List(0); + } else if (rawPathLength >= 64) { + final mode = (rawPathLength & 0xC0) >> 6; + final hopCount = rawPathLength & 0x3F; + final width = mode + 1; + final byteLen = hopCount * width; + decodedPathLength = hopCount; + if (byteLen <= rawPathBytes.length) { + decodedPathBytes = rawPathBytes.sublist(0, byteLen); + } else { + decodedPathBytes = Uint8List(0); + } + } else if (rawPathLength == 0) { + decodedPathBytes = Uint8List(0); + } + } + return Message( senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)), text: decodedText, @@ -138,10 +167,8 @@ class MessageStore { ? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int) : null, tripTimeMs: json['tripTimeMs'] as int?, - pathLength: json['pathLength'] as int?, - pathBytes: json['pathBytes'] != null - ? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) - : Uint8List(0), + pathLength: decodedPathLength, + pathBytes: decodedPathBytes, reactions: (json['reactions'] as Map?)?.map( (key, value) => MapEntry(key, value as int), diff --git a/lib/widgets/path_editor_sheet.dart b/lib/widgets/path_editor_sheet.dart index ee6ba006..f20aad1c 100644 --- a/lib/widgets/path_editor_sheet.dart +++ b/lib/widgets/path_editor_sheet.dart @@ -11,17 +11,20 @@ import '../models/contact.dart'; class PathEditorSheet extends StatefulWidget { final List availableContacts; final List initialPath; + final int pathHashByteWidth; const PathEditorSheet({ super.key, required this.availableContacts, this.initialPath = const [], + this.pathHashByteWidth = pathHashSize, }); static Future show( BuildContext context, { required List availableContacts, List initialPath = const [], + int pathHashByteWidth = pathHashSize, }) { return showModalBottomSheet( context: context, @@ -36,6 +39,7 @@ class PathEditorSheet extends StatefulWidget { child: PathEditorSheet( availableContacts: availableContacts, initialPath: initialPath, + pathHashByteWidth: pathHashByteWidth, ), ), ), @@ -48,9 +52,9 @@ class PathEditorSheet extends StatefulWidget { class _Hop { final int id; - final int byte; + final Uint8List bytes; - const _Hop(this.id, this.byte); + const _Hop(this.id, this.bytes); } class _PathEditorSheetState extends State { @@ -66,8 +70,11 @@ class _PathEditorSheetState extends State { @override void initState() { super.initState(); - for (final byte in widget.initialPath) { - _hops.add(_Hop(_nextHopId++, byte)); + for (final hop in PathHelper.splitPathBytes( + widget.initialPath, + widget.pathHashByteWidth, + )) { + _hops.add(_Hop(_nextHopId++, hop)); } _syncHexFromHops(); } @@ -90,9 +97,9 @@ class _PathEditorSheetState extends State { void _syncHexFromHops() { _syncingHex = true; - _hexController.text = PathHelper.formatPathHex( - _hops.map((h) => h.byte).toList(), - ); + _hexController.text = _hops + .map((h) => PathHelper.formatHopHex(h.bytes)) + .join(','); _syncingHex = false; _hexError = null; } @@ -104,8 +111,13 @@ class _PathEditorSheetState extends State { .split(RegExp(r'[,\s]+')) .where((t) => t.isNotEmpty) .toList(); + final width = widget.pathHashByteWidth.clamp(1, pubKeySize).toInt(); + final expectedChars = width * 2; final invalid = tokens - .where((t) => t.length != 2 || int.tryParse(t, radix: 16) == null) + .where( + (t) => + t.length != expectedChars || int.tryParse(t, radix: 16) == null, + ) .toList(); setState(() { if (invalid.isNotEmpty) { @@ -120,15 +132,29 @@ class _PathEditorSheetState extends State { _hops ..clear() ..addAll( - tokens.map((t) => _Hop(_nextHopId++, int.parse(t, radix: 16))), + tokens.map((t) { + final bytes = []; + for (var i = 0; i < t.length; i += 2) { + bytes.add(int.parse(t.substring(i, i + 2), radix: 16)); + } + return _Hop(_nextHopId++, Uint8List.fromList(bytes)); + }), ); }); } void _addHop(Contact contact) { if (_hops.length >= _maxHops) return; + final width = widget.pathHashByteWidth + .clamp(1, contact.publicKey.length) + .toInt(); setState(() { - _hops.add(_Hop(_nextHopId++, contact.publicKey.first)); + _hops.add( + _Hop( + _nextHopId++, + Uint8List.fromList(contact.publicKey.sublist(0, width)), + ), + ); _syncHexFromHops(); }); } @@ -149,18 +175,31 @@ class _PathEditorSheetState extends State { } void _save() { - Navigator.pop( - context, - Uint8List.fromList(_hops.map((h) => h.byte).toList()), - ); + final bytes = []; + for (final hop in _hops) { + bytes.addAll(hop.bytes); + } + Navigator.pop(context, Uint8List.fromList(bytes)); } Widget _hopTile(BuildContext context, int index) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; final hop = _hops[index]; - final hex = PathHelper.hopHex(hop.byte); - final name = PathHelper.hopName(hop.byte, widget.availableContacts); + final hex = PathHelper.formatHopHex(hop.bytes); + final matches = widget.availableContacts.where((contact) { + if (contact.publicKey.length < hop.bytes.length) return false; + return contact.publicKey + .sublist(0, hop.bytes.length) + .asMap() + .entries + .every((entry) => entry.value == hop.bytes[entry.key]); + }).toList(); + final name = matches.isEmpty + ? null + : matches.length == 1 + ? matches.first.name + : matches.map((c) => c.name).join(' | '); return ListTile( key: ValueKey(hop.id), @@ -224,7 +263,7 @@ class _PathEditorSheetState extends State { ), title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text( - '${contact.typeLabel(l10n)} • ${PathHelper.hopHex(contact.publicKey.first)}', + '${contact.typeLabel(l10n)} • ${PathHelper.formatHopHex(contact.publicKey.sublist(0, widget.pathHashByteWidth.clamp(1, contact.publicKey.length).toInt()))}', ), trailing: const Icon(Icons.add_circle_outline), onTap: full ? null : () => _addHop(contact), diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 56834455..03f6e345 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -469,7 +469,10 @@ class _RepeaterLoginDialogState extends State { ), const SizedBox(height: 4), Text( - repeater.pathLabel(context.l10n), + repeater.pathLabel( + context.l10n, + pathHashByteWidth: connector.pathHashByteWidth, + ), style: TextStyle( fontSize: 11, color: scheme.onSurfaceVariant, diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index a5e2555b..b88716f8 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -405,7 +405,10 @@ class _RoomLoginDialogState extends State { ), const SizedBox(height: 4), Text( - repeater.pathLabel(context.l10n), + repeater.pathLabel( + context.l10n, + pathHashByteWidth: connector.pathHashByteWidth, + ), style: TextStyle( fontSize: 11, color: scheme.onSurfaceVariant, diff --git a/lib/widgets/routing_sheet.dart b/lib/widgets/routing_sheet.dart index 2cc55b8a..ee11937b 100644 --- a/lib/widgets/routing_sheet.dart +++ b/lib/widgets/routing_sheet.dart @@ -107,11 +107,13 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { context, availableContacts: available, initialPath: initial, + pathHashByteWidth: connector.pathHashByteWidth, ); if (result == null || !mounted) return; + final hopCount = result.length ~/ connector.pathHashByteWidth; await connector.setPathOverride( contact, - pathLen: result.length, + pathLen: hopCount, pathBytes: result, ); await _verifyPath(connector, contact, result); @@ -123,9 +125,10 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { PathRecord record, ) async { final bytes = Uint8List.fromList(record.pathBytes); + final hopCount = bytes.length ~/ connector.pathHashByteWidth; await connector.setPathOverride( contact, - pathLen: bytes.length, + pathLen: hopCount, pathBytes: bytes, ); await _verifyPath(connector, contact, bytes); @@ -157,11 +160,17 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { setState(() => _syncStatus = context.l10n.chat_pathCleared); } - _PathQuality _qualityOf(PathRecord record, List ranked) { + _PathQuality _qualityOf( + MeshCoreConnector connector, + PathRecord record, + List ranked, + ) { if (record.pathBytes.isNotEmpty) { - final first = record.pathBytes.first; for (var i = 0; i < ranked.length && i < 3; i++) { - if (ranked[i].pubkeyFirstByte == first) { + if (ranked[i].matchesPathStart( + record.pathBytes, + connector.pathHashByteWidth, + )) { return switch (i) { 0 => _PathQuality.strong, 1 => _PathQuality.good, @@ -229,14 +238,22 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { case _RoutingMode.manual: final bytes = contact.pathOverrideBytes ?? Uint8List(0); if (bytes.isEmpty) return l10n.routing_directNoHops; - return PathHelper.resolvePathNames(bytes, connector.allContacts); + return PathHelper.resolvePathNames( + bytes, + connector.allContacts, + connector.pathHashByteWidth, + ); case _RoutingMode.auto: if (contact.pathLength < 0) return l10n.routing_noPathYet; if (contact.pathLength == 0) return l10n.routing_directNoHops; if (contact.path.isEmpty) { return l10n.chat_hopsCount(contact.pathLength); } - return PathHelper.resolvePathNames(contact.path, connector.allContacts); + return PathHelper.resolvePathNames( + contact.path, + connector.allContacts, + connector.pathHashByteWidth, + ); } } @@ -275,10 +292,14 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { List pathBytes, ) { final l10n = context.l10n; - final formattedPath = PathHelper.formatPathHex(pathBytes); + final formattedPath = PathHelper.splitPathBytes( + pathBytes, + connector.pathHashByteWidth, + ).map(PathHelper.formatHopHex).join(','); final resolvedNames = PathHelper.resolvePathNames( pathBytes, connector.allContacts, + connector.pathHashByteWidth, ); showDialog( @@ -521,11 +542,21 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { listEquals(record.pathBytes, contact.path))); final title = hasBytes - ? PathHelper.resolvePathNames(record.pathBytes, connector.allContacts) + ? PathHelper.resolvePathNames( + record.pathBytes, + connector.allContacts, + connector.pathHashByteWidth, + ) : l10n.chat_hopsCount(record.hopCount); + final displayHopCount = hasBytes + ? PathHelper.splitPathBytes( + record.pathBytes, + connector.pathHashByteWidth, + ).length + : record.hopCount; final line1 = - '${l10n.chat_hopsCount(record.hopCount)} • ${_qualityLabel(context, quality)}'; + '${l10n.chat_hopsCount(displayHopCount)} • ${_qualityLabel(context, quality)}'; final line2Parts = [ record.timestamp != null ? l10n.routing_lastWorked(_relativeTime(context, record.timestamp!)) @@ -621,7 +652,10 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> { pathService .getRecentPaths(contact.publicKeyHex) .map( - (r) => (quality: _qualityOf(r, rankedRepeaters), record: r), + (r) => ( + quality: _qualityOf(connector, r, rankedRepeaters), + record: r, + ), ) .toList() ..sort((a, b) { diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index f57776de..29512023 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/path_helper.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../theme/mesh_theme.dart'; @@ -11,15 +13,27 @@ import 'signal_ui.dart'; Contact? _getRepeaterPrefixMatchNearLocation( List contacts, - int pubkeyFirstByte, { + List pubkeyPrefix, { + String? contactKeyHex, LatLng? searchPoint, bool preferFavorites = false, }) { + if (contactKeyHex != null) { + for (final c in contacts) { + if (c.publicKeyHex == contactKeyHex) { + return c; + } + } + } + final candidates = contacts .where( (c) => - c.publicKey.isNotEmpty && - c.publicKey.first == pubkeyFirstByte && + c.publicKey.length >= pubkeyPrefix.length && + listEquals( + c.publicKey.sublist(0, pubkeyPrefix.length), + pubkeyPrefix, + ) && (c.type == advTypeRepeater || c.type == advTypeRoom), ) .toList(); @@ -164,7 +178,7 @@ class _SNRIndicatorState extends State { ), if (directRepeater != null) Text( - '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', + '${directRepeaters.length}: ${directRepeater.pubkeyPrefixHex}: ${_formatLastUpdated(directRepeater.lastUpdated)}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -234,15 +248,16 @@ class _SNRIndicatorState extends State { final contact = _getRepeaterPrefixMatchNearLocation( allContacts, - repeater.pubkeyFirstByte, + repeater.pubkeyPrefix, + contactKeyHex: repeater.contactKeyHex, searchPoint: selfPoint, preferFavorites: true, ); final name = contact?.name; - final hex = repeater.pubkeyFirstByte - .toRadixString(16) - .padLeft(2, '0'); + final prefixLabel = PathHelper.formatHopHex( + repeater.pubkeyPrefix, + ); final snrColor = MeshTheme.snrColor( repeater.snr, blocked: false, @@ -256,7 +271,7 @@ class _SNRIndicatorState extends State { child: Row( children: [ AvatarCircle( - name: name ?? hex, + name: name ?? prefixLabel, size: 36, color: snrColor, ), @@ -266,7 +281,7 @@ class _SNRIndicatorState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - name ?? hex, + name ?? prefixLabel, style: Theme.of(context).textTheme.bodyMedium, ), Text( diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index 38abf2cd..67391c94 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -22,15 +22,64 @@ Contact _contact({ } void main() { - test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () { + test('splitPathBytes groups bytes by hash width', () { + final hops = PathHelper.splitPathBytes([0xA1, 0xA2, 0xC1, 0xC2], 2); + + expect(hops, hasLength(2)); + expect(hops.first, equals(Uint8List.fromList([0xA1, 0xA2]))); + expect(hops.last, equals(Uint8List.fromList([0xC1, 0xC2]))); + 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), + ]; + + final resolved = PathHelper.resolvePathNames( + [0xF2, 0x7E, 0xBA], + contacts, + 1, // 1-byte hash width + ); + + expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); + }, + ); + + test('resolvePathNames supports multi-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), + _contact(firstByte: 0xA1, name: 'Repeater1', type: advTypeRepeater), + _contact(firstByte: 0xC1, name: 'Room42', type: advTypeRoom), ]; - final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts); + // Contacts with 2-byte public keys + contacts[0] = Contact( + publicKey: Uint8List.fromList([0xA1, 0xA2, ...List.filled(30, 0)]), + name: 'Repeater1', + type: advTypeRepeater, + pathLength: 0, + path: Uint8List(0), + lastSeen: DateTime.now(), + ); + contacts[1] = Contact( + publicKey: Uint8List.fromList([0xC1, 0xC2, ...List.filled(30, 0)]), + name: 'Room42', + type: advTypeRoom, + pathLength: 0, + path: Uint8List(0), + lastSeen: DateTime.now(), + ); - expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); + final resolved = PathHelper.resolvePathNames( + [0xA1, 0xA2, 0xC1, 0xC2], + contacts, + 2, // 2-byte hash width + ); + + expect(resolved, equals('Repeater1 → Room42')); }); } diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart index edf069ff..cc218c95 100644 --- a/test/models/model_changes_test.dart +++ b/test/models/model_changes_test.dart @@ -1,12 +1,18 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.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/path_history.dart'; import 'package:meshcore_open/models/app_settings.dart'; -import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/storage/contact_store.dart'; +import 'package:meshcore_open/storage/channel_message_store.dart'; +import 'package:meshcore_open/storage/contact_discovery_store.dart'; import 'package:meshcore_open/services/app_settings_service.dart'; import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:meshcore_open/storage/message_store.dart'; import 'package:shared_preferences/shared_preferences.dart'; // Builds a valid contact frame with the given pathLen and optional overrides. @@ -40,6 +46,38 @@ Uint8List _buildContactFrame({ return Uint8List.fromList(writer.toBytes()); } +Uint8List _buildChannelMessageFrameV3({ + required int pathHashWidth, + required int hopCount, + required bool hasPath, + int channelIndex = 7, + String senderName = 'Alice', + + String text = 'Hello world', + int txtType = txtTypePlain, +}) { + final writer = BytesBuilder(); + writer.addByte(respCodeChannelMsgRecvV3); + writer.addByte(0x10); + writer.addByte(hasPath ? 0x01 : 0x00); + writer.addByte(0x00); + writer.addByte(channelIndex); + writer.addByte(((pathHashWidth - 1) << 6) | hopCount); + + if (hasPath && hopCount > 0) { + writer.add( + Uint8List.fromList(List.generate(hopCount * pathHashWidth, (i) => i + 1)), + ); + } + + writer.addByte(txtType); + writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); + writer.add(utf8.encode('$senderName: $text')); + writer.addByte(0x00); + + return Uint8List.fromList(writer.toBytes()); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -49,6 +87,67 @@ void main() { await PrefsManager.initialize(); }); + 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, + ); + + 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')); + }, + ); + + test('no path still reads width, hop count, and txtType correctly', () { + final frame = _buildChannelMessageFrameV3( + pathHashWidth: 4, + hopCount: 5, + hasPath: false, + ); + + final message = ChannelMessage.fromFrame(frame); + + expect(message, isNotNull); + expect(message!.pathHashWidth, equals(4)); + expect(message.pathLength, equals(5)); + expect(message.pathBytes, isEmpty); + expect(message.senderName, equals('Alice')); + expect(message.text, equals('Hello world')); + }); + + test('non-plain txtType with path -> returns null', () { + final frame = _buildChannelMessageFrameV3( + pathHashWidth: 2, + hopCount: 2, + hasPath: true, + txtType: txtTypeCliData, + ); + final message = ChannelMessage.fromFrame(frame); + expect(message, isNull); + }); + + test('non-plain txtType without path -> returns null', () { + final frame = _buildChannelMessageFrameV3( + pathHashWidth: 2, + hopCount: 2, + hasPath: false, + txtType: txtTypeCliData, + ); + final message = ChannelMessage.fromFrame(frame); + expect(message, isNull); + }); + }); + group('Contact.fromFrame — pathLen mapping', () { test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () { final frame = _buildContactFrame(pathLen: 0); @@ -64,11 +163,11 @@ void main() { expect(contact!.pathLength, equals(1)); }); - test('pathLen == 64 (maxPathSize) → pathLength == 64', () { - final frame = _buildContactFrame(pathLen: maxPathSize); + test('pathLen == 64 (mode 1, 0 hops) → pathLength == 0', () { + final frame = _buildContactFrame(pathLen: 64); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); - expect(contact!.pathLength, equals(maxPathSize)); + expect(contact!.pathLength, equals(0)); }); test('pathLen == 0xFF → pathLength == -1 (flood)', () { @@ -78,11 +177,18 @@ void main() { expect(contact!.pathLength, equals(-1)); }); - test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () { + test('pathLen == 65 (mode 1, 1 hop) → pathLength == 1', () { final frame = _buildContactFrame(pathLen: 65); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); - expect(contact!.pathLength, equals(-1)); + expect(contact!.pathLength, equals(1)); + }); + + test('pathLen == 129 (mode 2, 1 hop) → pathLength == 1', () { + final frame = _buildContactFrame(pathLen: 129); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(1)); }); }); @@ -370,6 +476,176 @@ void main() { }); }); + group('Storage migration — multi-byte paths compatibility', () { + 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-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]))); + }, + ); + + }); + group('AppSettingsService — gps interval fallback', () { test('resolvedGpsIntervalSeconds prefers device custom var', () { final service = AppSettingsService(); diff --git a/test/screens/path_trace_test.dart b/test/screens/path_trace_test.dart new file mode 100644 index 00000000..55c10b14 --- /dev/null +++ b/test/screens/path_trace_test.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'dart:typed_data'; + +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; +import 'package:meshcore_open/services/map_tile_cache_service.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/services/path_history_service.dart'; +import 'package:meshcore_open/services/storage_service.dart'; +import 'package:meshcore_open/models/path_history.dart'; + +class _FakeStorageService extends StorageService { + @override + Future savePathHistory( + String contactPubKeyHex, + ContactPathHistory history, + ) async {} + @override + Future loadPathHistory(String contactPubKeyHex) async => null; + @override + Future clearPathHistory(String contactPubKeyHex) async {} +} + +class _FakeMeshCoreConnector extends MeshCoreConnector { + final StreamController _receivedFramesController = + StreamController.broadcast(); + + @override + Stream get receivedFrames => _receivedFramesController.stream; + + @override + Uint8List? get selfPublicKey => + Uint8List.fromList(List.generate(32, (i) => i)); + + @override + double? get selfLatitude => 48.8566; + + @override + double? get selfLongitude => 2.3522; + + @override + List get allContactsUnfiltered => []; + + @override + List get contacts => []; + + void emitFrame(Uint8List frame) { + _receivedFramesController.add(frame); + } + + final List sentFrames = []; + + @override + Future sendFrame( + Uint8List frame, { + String? channelSendQueueId, + bool expectsGenericAck = false, + bool waitForGenericAck = false, + }) async { + sentFrames.add(frame); + } +} + +Widget _buildTestApp({ + required MeshCoreConnector connector, + required Widget child, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connector), + ChangeNotifierProvider( + create: (_) => AppSettingsService(), + ), + Provider(create: (_) => MapTileCacheService()), + ChangeNotifierProvider( + create: (_) => PathHistoryService(_FakeStorageService()), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); +} + +void main() { + testWidgets('PathTraceMapScreen parses response frames correctly', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + + final screen = PathTraceMapScreen( + title: 'Test Trace', + path: Uint8List.fromList([0x12, 0x34]), + pathHashByteWidth: 1, + ); + + await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); + await tester.pump(); + + // Verify a send request was made + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + + // Extract the tag sent in the request (bytes 2-5) + final tag = sentFrame.sublist(2, 6); + + // Emit respCodeSent (6) + final respCodeSentFrame = Uint8List(10); + respCodeSentFrame[0] = respCodeSent; + respCodeSentFrame[1] = 0; // reserved / is_flood + respCodeSentFrame.setRange(2, 6, tag); + // timeout milliseconds = 5000 (0x1388) + respCodeSentFrame[6] = 0x88; + respCodeSentFrame[7] = 0x13; + respCodeSentFrame[8] = 0x00; + respCodeSentFrame[9] = 0x00; + + connector.emitFrame(respCodeSentFrame); + await tester.pump(); + + // Now emit PUSH_CODE_TRACE_DATA (0x89) + // Structure: + // Offset 0: pushCodeTraceData (0x89) + // Offset 1: reserved (0) + // Offset 2: pathLength (2) + // Offset 3: flag (0) + // Offset 4..7: tag + // Offset 8..11: auth (0) + // Offset 12..13: pathBytes [0x12, 0x34] + // Offset 14..16: SNR bytes [12, 16, 20] -> to be mapped to signed 8 bit Snr/4 + final pushTraceFrame = Uint8List(17); + pushTraceFrame[0] = pushCodeTraceData; + pushTraceFrame[1] = 0; // reserved + pushTraceFrame[2] = 2; // pathLength + pushTraceFrame[3] = 0; // flag + pushTraceFrame.setRange(4, 8, tag); + // auth bytes (8..11) = 0 + pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes + pushTraceFrame.setRange(14, 17, [12, 16, 20]); // SNR bytes + + connector.emitFrame(pushTraceFrame); + for (int i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + // Verify it doesn't show "Path trace not available" or similar + expect(find.text('Path trace not available.'), findsNothing); + }); + + testWidgets( + 'PathTraceMapScreen parses multi-byte encoded path trace response correctly', + (tester) async { + final connector = _FakeMeshCoreConnector(); + + final screen = PathTraceMapScreen( + title: 'Test Trace Multi-byte', + path: Uint8List.fromList([0x12, 0x34]), + pathHashByteWidth: 2, + ); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: screen), + ); + await tester.pump(); + + // Verify a send request was made + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + + // Extract the tag sent in the request (bytes 2-5) + final tag = sentFrame.sublist(2, 6); + + // Emit respCodeSent (6) + final respCodeSentFrame = Uint8List(10); + respCodeSentFrame[0] = respCodeSent; + respCodeSentFrame[1] = 0; + respCodeSentFrame.setRange(2, 6, tag); + respCodeSentFrame[6] = 0x88; + respCodeSentFrame[7] = 0x13; + respCodeSentFrame[8] = 0x00; + respCodeSentFrame[9] = 0x00; + + connector.emitFrame(respCodeSentFrame); + await tester.pump(); + + // Now emit PUSH_CODE_TRACE_DATA (0x89) + // Structure: + // Offset 0: pushCodeTraceData (0x89) + // Offset 1: reserved (0) + // Offset 2: pathLength (65) -> mode 1 (width 2), 1 hop + // Offset 3: flag (0) + // Offset 4..7: tag + // Offset 8..11: auth (0) + // Offset 12..13: pathBytes [0x12, 0x34] + // Offset 14..15: SNR bytes [12, 16] + final pushTraceFrame = Uint8List(16); + pushTraceFrame[0] = pushCodeTraceData; + pushTraceFrame[1] = 0; // reserved + pushTraceFrame[2] = 65; // pathLength encoded (mode 1, 1 hop -> 2 bytes) + pushTraceFrame[3] = 0; // flag + pushTraceFrame.setRange(4, 8, tag); + pushTraceFrame.setRange(12, 14, [0x12, 0x34]); // pathBytes + pushTraceFrame.setRange(14, 16, [12, 16]); // SNR bytes + + connector.emitFrame(pushTraceFrame); + for (int i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + // Verify it parsed correctly (should not show the unavailable message) + expect(find.text('Path trace not available.'), findsNothing); + }, + ); + + testWidgets('PathTraceMapScreen buildPath: Direct repeater ping (0-hop)', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + final target = Contact( + publicKey: Uint8List.fromList([0xAA, 0xBB, 0xCC]), + name: 'Repeater Node', + type: advTypeRepeater, + path: Uint8List(0), + pathLength: 0, + lastSeen: DateTime.now(), + ); + + final screen = PathTraceMapScreen( + title: 'Direct Repeater Ping', + path: Uint8List.fromList([0xAA]), + targetContact: target, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); + await tester.pump(); + + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + final payload = sentFrame.sublist(10); + expect(payload, Uint8List.fromList([0xAA])); + }); + + testWidgets('PathTraceMapScreen buildPath: 1-hop chat contact trace', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + final target = Contact( + publicKey: Uint8List.fromList([0xCC, 0xDD, 0xEE]), + name: 'Chat User', + type: advTypeChat, + path: Uint8List.fromList([0xBB]), + pathLength: 1, + lastSeen: DateTime.now(), + ); + + final screen = PathTraceMapScreen( + title: '1-Hop Chat Trace', + path: Uint8List.fromList([0xBB]), + targetContact: target, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); + await tester.pump(); + + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + final payload = sentFrame.sublist(10); + expect(payload, Uint8List.fromList([0xBB, 0xCC, 0xBB])); + }); + + testWidgets('PathTraceMapScreen buildPath: Multi-hop chat contact trace', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + final target = Contact( + publicKey: Uint8List.fromList([0x33, 0x44, 0x55]), + name: 'Chat User Multi-hop', + type: advTypeChat, + path: Uint8List.fromList([0x11, 0x22]), + pathLength: 2, + lastSeen: DateTime.now(), + ); + + final screen = PathTraceMapScreen( + title: 'Multi-Hop Chat Trace', + path: Uint8List.fromList([0x11, 0x22]), + targetContact: target, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); + await tester.pump(); + + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + final payload = sentFrame.sublist(10); + expect(payload, Uint8List.fromList([0x11, 0x22, 0x33, 0x22, 0x11])); + }); + + testWidgets( + 'PathTraceMapScreen buildPath: Map trace with null target contact', + (tester) async { + final connector = _FakeMeshCoreConnector(); + + final screen = PathTraceMapScreen( + title: 'Map Trace No Target', + path: Uint8List.fromList([0x11, 0x22]), + targetContact: null, + flipPathAround: true, + pathHashByteWidth: 1, + ); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: screen), + ); + await tester.pump(); + + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + expect(sentFrame[0], cmdSendTracePath); + final payload = sentFrame.sublist(10); + expect(payload, Uint8List.fromList([0x11, 0x22, 0x11])); + }, + ); + + testWidgets('PathTraceMapScreen parses raw byte length fallback correctly', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + + final screen = PathTraceMapScreen( + title: 'Test Raw Fallback', + path: Uint8List.fromList([0x12, 0x34]), + pathHashByteWidth: 2, + ); + + await tester.pumpWidget(_buildTestApp(connector: connector, child: screen)); + await tester.pump(); + + expect(connector.sentFrames.length, 1); + final sentFrame = connector.sentFrames.first; + // Extract the tag sent in the request (bytes 2-5) + final tag = sentFrame.sublist(2, 6); + + // Emit respCodeSent (6) + final respCodeSentFrame = Uint8List(10); + respCodeSentFrame[0] = respCodeSent; + respCodeSentFrame[1] = 0; + respCodeSentFrame.setRange(2, 6, tag); + respCodeSentFrame[6] = 0x88; + respCodeSentFrame[7] = 0x13; + respCodeSentFrame[8] = 0x00; + respCodeSentFrame[9] = 0x00; + + connector.emitFrame(respCodeSentFrame); + await tester.pump(); + + // Now emit PUSH_CODE_TRACE_DATA (0x89) + // Structure: + // Offset 2: pathLength (1) -> mode 0, hop count 1. + // Offset 12..13: pathBytes [0x12, 0x34] (1 hop * 2 bytes/hop) + final pushTraceFrame = Uint8List(16); + pushTraceFrame[0] = pushCodeTraceData; + pushTraceFrame[1] = 0; // reserved + pushTraceFrame[2] = 1; // pathLength encoded (mode 0, 1 hop) + 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); + for (int i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + // Verify it parsed correctly (should not show the unavailable message) + expect(find.text('Path trace not available.'), findsNothing); + }); +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 87ae729d..3ea6acf1 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -549,12 +549,15 @@ void main() { final pubKey = _hex('w004'); await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3); - svc.recordPathResult( - pubKey, - const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), - success: false, - failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove - ); + // The service requires failureCount >= 3 to remove a path + for (int i = 0; i < 3; i++) { + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: false, + failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove + ); + } await _flush(); final paths = svc.getRecentPaths(pubKey); diff --git a/untranslated.json b/untranslated.json index 8115c352..14f7f7cc 100644 --- a/untranslated.json +++ b/untranslated.json @@ -12,6 +12,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -33,6 +37,10 @@ "settings_regionFetchRegions", "settings_regionFetchRegionsFail", "settings_regionFetchRegionsAlreadyExists", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "chat_receivedGif", "repeater_keySettings", "repeater_keySettingsSubtitle", @@ -59,6 +67,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -89,6 +101,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -119,6 +135,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -149,6 +169,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -179,6 +203,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -209,6 +237,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -239,6 +271,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -269,6 +305,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -299,6 +339,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -329,6 +373,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -359,6 +407,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -389,6 +441,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -419,6 +475,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -449,6 +509,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title", @@ -479,6 +543,10 @@ "settings_regionDeleted", "settings_deleteRegion", "settings_deleteRegionConfirm", + "repeater_pathHashModeOption0", + "repeater_pathHashModeOption1", + "repeater_pathHashModeOption2", + "repeater_pathHashModeOption3", "channels_regionSetTo", "channels_regionNotSet", "channels_regionSelect_Title",