mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-13 04:12:00 +10:00
Merge pull request #450 from PacoX/dev
Add Support for Multi-Byte Routing Paths
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<int> pubkeyPrefix;
|
||||
int pathHashWidth;
|
||||
String? contactKeyHex;
|
||||
double snr;
|
||||
DateTime lastUpdated;
|
||||
|
||||
DirectRepeater({
|
||||
required this.pubkeyFirstByte,
|
||||
required List<int> 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<int> prefix) {
|
||||
return pubkeyPrefix.length == prefix.length &&
|
||||
listEquals(pubkeyPrefix, prefix);
|
||||
}
|
||||
|
||||
bool matchesPathStart(List<int> 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<int>? 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<void> 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<int> pathBytes) {
|
||||
return _isPathLenValidForMode(pathLen, pathBytes, _pathHashByteWidth);
|
||||
}
|
||||
|
||||
int? _encodePathLenForCurrentMode(int pathLen, List<int> pathBytes) {
|
||||
if (pathLen < 0 || pathLen == 0xFF) return pathLen;
|
||||
if (!_isPathLenValidForCurrentMode(pathLen, pathBytes)) {
|
||||
appLogger.warn(
|
||||
'Invalid path_len for mode: pathLen=$pathLen, '
|
||||
'bytesLen=${pathBytes.length}, width=$_pathHashByteWidth',
|
||||
tag: 'Connector',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final mode = (_pathHashByteWidth - 1) & 0x03;
|
||||
return (pathLen & 0x3F) | (mode << 6);
|
||||
}
|
||||
|
||||
Future<void> refreshDeviceInfo() async {
|
||||
@@ -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 <DirectRepeater>[];
|
||||
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<DirectRepeater>.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<int> 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<int> 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<int> pathBytes,
|
||||
int pathHashWidth,
|
||||
) {
|
||||
if (pathLen < 0 || pathLen > 0x3F) return false;
|
||||
final width = pathHashWidth.clamp(1, 4).toInt();
|
||||
final maxHopCountByBytes = maxPathSize ~/ width;
|
||||
if (pathLen > maxHopCountByBytes) return false;
|
||||
return pathBytes.length <= maxPathSize && pathBytes.length == pathLen * width;
|
||||
}
|
||||
|
||||
Uint8List _reversePathByHop(Uint8List pathBytes, int pathHashWidth) {
|
||||
if (pathBytes.isEmpty) return Uint8List(0);
|
||||
final width = pathHashWidth.clamp(1, 4).toInt();
|
||||
final reversed = <int>[];
|
||||
for (var i = pathBytes.length; i > 0; i -= width) {
|
||||
final start = (i - width).clamp(0, pathBytes.length).toInt();
|
||||
reversed.addAll(pathBytes.sublist(start, i));
|
||||
}
|
||||
return Uint8List.fromList(reversed);
|
||||
}
|
||||
|
||||
class _RawPacket {
|
||||
final int header;
|
||||
final int routeType;
|
||||
@@ -7008,6 +7337,7 @@ class _RawPacket {
|
||||
routeType == _routeFlood || routeType == _routeTransportFlood;
|
||||
|
||||
int get hopCount => pathLenRaw & 63;
|
||||
int get pathHashWidth => ((pathLenRaw >> 6) & 0x03) + 1;
|
||||
}
|
||||
|
||||
class _ParsedText {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<int> pathBytes) {
|
||||
@@ -12,10 +14,17 @@ class PathHelper {
|
||||
return byte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
static String formatHopHex(List<int> hopBytes) {
|
||||
return hopBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join();
|
||||
}
|
||||
|
||||
static String? hopName(int byte, List<Contact> 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<Uint8List> splitPathBytes(
|
||||
List<int> pathBytes,
|
||||
int hashByteWidth,
|
||||
) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
|
||||
final width = hashByteWidth.clamp(1, 4).toInt();
|
||||
final hops = <Uint8List>[];
|
||||
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<int> pathBytes,
|
||||
List<Contact> allContacts,
|
||||
int hashByteWidth,
|
||||
) {
|
||||
return pathBytes
|
||||
.map((b) => hopName(b, allContacts) ?? hopHex(b))
|
||||
.join(' \u2192 ');
|
||||
if (pathBytes.isEmpty) return '';
|
||||
|
||||
final parts = <String>[];
|
||||
|
||||
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 ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Contact> contacts,
|
||||
LatLng? endpoint,
|
||||
bool resolveFromEnd = false,
|
||||
int pathHashByteWidth = 1,
|
||||
}) {
|
||||
final candidatesByPrefix = <int, List<Contact>>{};
|
||||
final width = pathHashByteWidth.clamp(1, 4).toInt();
|
||||
final candidatesByPrefix = <String, List<Contact>>{};
|
||||
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, () => <Contact>[])
|
||||
.add(contact);
|
||||
final prefix = PathHelper.formatHopHex(
|
||||
contact.publicKey.sublist(0, width),
|
||||
);
|
||||
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
|
||||
}
|
||||
for (final candidates in candidatesByPrefix.values) {
|
||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
}
|
||||
|
||||
final resolved = List<Contact?>.filled(pathBytes.length, null);
|
||||
final hops = PathHelper.splitPathBytes(pathBytes, width);
|
||||
final resolved = List<Contact?>.filled(hops.length, null);
|
||||
final indexes = resolveFromEnd
|
||||
? List<int>.generate(pathBytes.length, (i) => pathBytes.length - 1 - i)
|
||||
: List<int>.generate(pathBytes.length, (i) => i);
|
||||
? List<int>.generate(hops.length, (i) => hops.length - 1 - i)
|
||||
: List<int>.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;
|
||||
|
||||
+5
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 지연",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =>
|
||||
'Разрешаване на проследяване на съобщения';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => 'メッセージ追跡を有効にする';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 延迟",
|
||||
|
||||
@@ -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<int>? pathBytes,
|
||||
int storedHopCount,
|
||||
int hashWidth,
|
||||
) {
|
||||
if (pathBytes == null || pathBytes.isEmpty) return storedHopCount;
|
||||
return PathHelper.splitPathBytes(pathBytes, hashWidth).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class ChannelMessage {
|
||||
final List<Repeat> repeats;
|
||||
final int repeatCount;
|
||||
final int? pathLength;
|
||||
final int? pathHashWidth;
|
||||
final Uint8List pathBytes;
|
||||
final List<Uint8List> pathVariants;
|
||||
final int? channelIndex;
|
||||
@@ -66,6 +67,7 @@ class ChannelMessage {
|
||||
this.repeats = const [],
|
||||
this.repeatCount = 0,
|
||||
this.pathLength,
|
||||
this.pathHashWidth,
|
||||
Uint8List? pathBytes,
|
||||
List<Uint8List>? pathVariants,
|
||||
this.channelIndex,
|
||||
@@ -93,6 +95,7 @@ class ChannelMessage {
|
||||
List<Repeat>? repeats,
|
||||
int? repeatCount,
|
||||
int? pathLength,
|
||||
int? pathHashWidth,
|
||||
Uint8List? pathBytes,
|
||||
List<Uint8List>? 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,
|
||||
);
|
||||
|
||||
+14
-4
@@ -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 = <String>[];
|
||||
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,
|
||||
|
||||
@@ -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<int> hopBytes;
|
||||
/// Outbound hop prefixes, including hops that could not be placed on the map.
|
||||
final List<Uint8List> 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
|
||||
|
||||
@@ -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<BleDebugLogScreen> {
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
final pathLen = raw[index++];
|
||||
if (raw.length < index + pathLen) {
|
||||
final pathLenRaw = raw[index++];
|
||||
final pathByteLen = _decodeRawPathByteLen(pathLenRaw);
|
||||
if (raw.length < index + pathByteLen) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||
summary: 'Truncated path',
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
final pathBytes = raw.sublist(index, index + pathLen);
|
||||
index += pathLen;
|
||||
final pathBytes = raw.sublist(index, index + pathByteLen);
|
||||
index += pathByteLen;
|
||||
if (raw.length <= index) {
|
||||
return _RawPacketInfo(
|
||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||
@@ -336,7 +344,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
final title =
|
||||
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final summary = _decodePayloadSummary(payloadType, payload);
|
||||
final pathSummary = pathLen > 0
|
||||
final pathSummary = pathByteLen > 0
|
||||
? 'Path=${_bytesToHex(pathBytes)}'
|
||||
: 'Path=none';
|
||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||
|
||||
@@ -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<ChannelChatScreen> {
|
||||
: (message.pathVariants.isNotEmpty
|
||||
? message.pathVariants.first
|
||||
: Uint8List(0));
|
||||
final displayPathHashWidth =
|
||||
message.pathHashWidth ??
|
||||
context.read<MeshCoreConnector>().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<ChannelChatScreen> {
|
||||
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<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<void> openRegionSelectDialog(Channel channel) async {
|
||||
|
||||
@@ -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<MeshCoreConnector>()
|
||||
.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<Uint8List> variants) {
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> 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<int>.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<int> 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) {
|
||||
|
||||
@@ -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<ChatScreen> {
|
||||
}
|
||||
|
||||
String _currentPathLabel(Contact contact) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
|
||||
// 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<int> 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<ChatScreen> {
|
||||
),
|
||||
_buildInfoRow(
|
||||
context.l10n.chat_path,
|
||||
contact.pathLabel(context.l10n),
|
||||
contact.pathLabel(
|
||||
context.l10n,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
_buildInfoRow(
|
||||
context.l10n.contact_lastSeen,
|
||||
|
||||
@@ -915,6 +915,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
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<ContactsScreen>
|
||||
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<ContactsScreen>
|
||||
: 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<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
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<ContactsScreen>
|
||||
|
||||
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,
|
||||
|
||||
+99
-39
@@ -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<MapScreen> {
|
||||
bool _hasInitializedMap = false;
|
||||
bool _removedMarkersLoaded = false;
|
||||
final List<int> _pathTrace = [];
|
||||
final List<int> _pathTraceHopWidths = [];
|
||||
final List<Contact> _pathTraceContacts = [];
|
||||
final List<LatLng> _points = [];
|
||||
final List<Polyline> _polylines = [];
|
||||
@@ -426,17 +428,20 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
// 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<MapScreen> {
|
||||
allContactsWithLocation,
|
||||
pathHistory,
|
||||
maxRangeKm,
|
||||
pathHashByteWidth,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
@@ -1006,23 +1012,19 @@ class _MapScreenState extends State<MapScreen> {
|
||||
List<Contact> 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 = <int, Contact?>{};
|
||||
|
||||
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 = <String, List<Contact>>{};
|
||||
for (final repeater in withLocation) {
|
||||
if (repeater.type != advTypeRepeater) continue;
|
||||
if (repeater.publicKey.length < hopWidth) continue;
|
||||
final prefix = PathHelper.formatHopHex(
|
||||
repeater.publicKey.sublist(0, hopWidth),
|
||||
);
|
||||
anchorsByPrefix.putIfAbsent(prefix, () => []).add(repeater);
|
||||
}
|
||||
|
||||
for (final contact in allContacts) {
|
||||
if (contact.hasLocation) continue;
|
||||
@@ -1036,21 +1038,23 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
// 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 = <List<int>>[
|
||||
contact.path.toList(),
|
||||
...pathHistory
|
||||
.getRecentPaths(contact.publicKeyHex)
|
||||
.map((r) => r.pathBytes),
|
||||
];
|
||||
final lastHopBytes = <int>{};
|
||||
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<MapScreen> {
|
||||
|
||||
// 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 = <int>{};
|
||||
final overlapPrefixes = <String>{};
|
||||
if (overlapsMode) {
|
||||
final counts = <int, int>{};
|
||||
final hopWidth = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth
|
||||
.clamp(1, pubKeySize)
|
||||
.toInt();
|
||||
final counts = <String, int>{};
|
||||
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<MapScreen> {
|
||||
if (count > 1) overlapPrefixes.add(prefix);
|
||||
});
|
||||
}
|
||||
final overlapHopWidth = context
|
||||
.read<MeshCoreConnector>()
|
||||
.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<MapScreen> {
|
||||
),
|
||||
_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<MapScreen> {
|
||||
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<MapScreen> {
|
||||
}
|
||||
|
||||
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
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<MapScreen> {
|
||||
setState(() {
|
||||
_isBuildingPathTrace = true;
|
||||
_pathTrace.clear();
|
||||
_pathTraceHopWidths.clear();
|
||||
_pathTraceContacts.clear();
|
||||
_points.clear();
|
||||
_polylines.clear();
|
||||
@@ -3562,8 +3604,19 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
void _removePath() {
|
||||
setState(() {
|
||||
final recordedHopWidth = _pathTraceHopWidths.isNotEmpty
|
||||
? _pathTraceHopWidths.removeLast()
|
||||
: context.read<MeshCoreConnector>().pathHashByteWidth.clamp(
|
||||
1,
|
||||
pubKeySize,
|
||||
);
|
||||
final hopByteCount = min(recordedHopWidth, _pathTrace.length).toInt();
|
||||
_pathTraceContacts.removeLast();
|
||||
_pathTrace.removeLast(); // Remove last node from path trace
|
||||
// A path trace hop can be wider than one byte; remove the full hash prefix.
|
||||
_pathTrace.removeRange(
|
||||
_pathTrace.length - hopByteCount,
|
||||
_pathTrace.length,
|
||||
);
|
||||
_points.removeLast(); // Remove last point from points list
|
||||
_polylines.clear(); // Clear polylines
|
||||
});
|
||||
@@ -3624,9 +3677,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
_pathTrace
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(','),
|
||||
PathHelper.splitPathBytes(
|
||||
_pathTrace,
|
||||
context.read<MeshCoreConnector>().pathHashByteWidth,
|
||||
).map(PathHelper.formatHopHex).join(','),
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -3651,6 +3705,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
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<MapScreen> {
|
||||
title: l10n.contacts_pathTrace,
|
||||
path: Uint8List.fromList(_pathTrace),
|
||||
flipPathAround: true,
|
||||
pathHashByteWidth: context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth,
|
||||
pathContacts: _pathTraceContacts,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -3695,6 +3754,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
setState(() {
|
||||
_isBuildingPathTrace = false;
|
||||
_pathTrace.clear();
|
||||
_pathTraceHopWidths.clear();
|
||||
_points.clear();
|
||||
_polylines.clear();
|
||||
});
|
||||
|
||||
+339
-162
@@ -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<Uint8List> pathData;
|
||||
final List<double> snrData;
|
||||
final Map<int, Contact> pathContacts;
|
||||
final Map<String, Contact> 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<PathTraceMapScreen>
|
||||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
PathTraceData? _traceData;
|
||||
// Inferred positions for hops that have no GPS location, keyed by hop byte.
|
||||
Map<int, LatLng> _inferredHopPositions = {};
|
||||
// Inferred positions for hops that have no GPS location, keyed by hop prefix.
|
||||
Map<String, LatLng> _inferredHopPositions = {};
|
||||
// Endpoint position for the target contact (GPS or guessed).
|
||||
LatLng? _targetContactPosition;
|
||||
bool _targetContactIsGuessed = false;
|
||||
@@ -93,6 +108,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
PathHistoryService? _pathHistory;
|
||||
PathViewMode _viewMode = PathViewMode.single;
|
||||
List<DisplayPath> _displayPaths = [];
|
||||
List<int> _primaryOutboundHops = [];
|
||||
List<Uint8List> _primaryOutboundHops = [];
|
||||
String _selectedPathId = 'primary';
|
||||
final Set<String> _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 = <int>[];
|
||||
for (final hop in reversedHops) {
|
||||
bytes.addAll(hop);
|
||||
}
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
Uint8List? _expandHopForTrace(
|
||||
Uint8List hop,
|
||||
int traceHashByteWidth,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
if (hop.length == traceHashByteWidth) return hop;
|
||||
|
||||
final candidates = <Contact>[
|
||||
...?widget.pathContacts,
|
||||
if (widget.targetContact != null) widget.targetContact!,
|
||||
...connector.allContactsUnfiltered,
|
||||
];
|
||||
for (final contact in candidates) {
|
||||
if (contact.publicKey.length < traceHashByteWidth) continue;
|
||||
if (!listEquals(contact.publicKey.sublist(0, hop.length), hop)) continue;
|
||||
return Uint8List.fromList(
|
||||
contact.publicKey.sublist(0, traceHashByteWidth),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Uint8List? _tracePathFromBytes(
|
||||
Uint8List pathBytes,
|
||||
int traceHashByteWidth,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
final hops = PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth);
|
||||
final traceBytes = <int>[];
|
||||
for (final hop in hops) {
|
||||
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
|
||||
if (traceHop == null) return null;
|
||||
traceBytes.addAll(traceHop);
|
||||
}
|
||||
return Uint8List.fromList(traceBytes);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -232,45 +313,62 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
||||
);
|
||||
}
|
||||
|
||||
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 = <Uint8List>[];
|
||||
for (final hop in pathHops) {
|
||||
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
|
||||
if (traceHop == null) return null;
|
||||
outboundHops.add(traceHop);
|
||||
}
|
||||
if (targetPrefix != null) {
|
||||
// Check if targetPrefix is already the last hop in pathHops to avoid duplication
|
||||
bool alreadyEndedWithTarget = false;
|
||||
if (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 = <Uint8List>[...outboundHops];
|
||||
if (outboundHops.length > 1) {
|
||||
mirroredHops.addAll(
|
||||
outboundHops.sublist(0, outboundHops.length - 1).reversed,
|
||||
);
|
||||
}
|
||||
|
||||
final traceBytes = <int>[];
|
||||
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<PathTraceMapScreen>
|
||||
}
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(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<PathTraceMapScreen>
|
||||
}
|
||||
|
||||
// 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<PathTraceMapScreen>
|
||||
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<double> snrData = buffer
|
||||
.readBytes(snrCount)
|
||||
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
||||
.toList();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
Map<String, Contact> pathContacts = {};
|
||||
Contact lastContact = Contact(
|
||||
path: Uint8List(0),
|
||||
pathLength: 0,
|
||||
@@ -418,7 +546,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
_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<PathTraceMapScreen>
|
||||
|
||||
// For hops with no GPS contact, infer position from other contacts
|
||||
// with known GPS that share the same last-hop byte.
|
||||
final Map<int, LatLng> inferredPositions = {};
|
||||
final Map<String, LatLng> 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<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
|
||||
_points = <LatLng>[];
|
||||
_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<PathTraceMapScreen>
|
||||
_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<PathTraceMapScreen>
|
||||
|
||||
/// Outbound hop bytes of the traced path, mirroring the round-trip
|
||||
/// dedup logic used when building [_points].
|
||||
List<int> _outboundHops(Uint8List pathData) {
|
||||
final hops = <int>[];
|
||||
int hopLast = 0;
|
||||
int hopLastLast = 0;
|
||||
List<Uint8List> _outboundHops(List<Uint8List> pathData) {
|
||||
final hops = <Uint8List>[];
|
||||
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<PathTraceMapScreen>
|
||||
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<Uint8List> 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<PathTraceMapScreen>
|
||||
final target = widget.targetContact;
|
||||
final history = _pathHistory;
|
||||
if (target != null && history != null) {
|
||||
final seen = <String>{_primaryOutboundHops.join(',')};
|
||||
final seen = <String>{_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<PathTraceMapScreen>
|
||||
required String label,
|
||||
required Color color,
|
||||
required bool isPrimary,
|
||||
required List<int> hops,
|
||||
required List<Uint8List> hops,
|
||||
required MeshCoreConnector connector,
|
||||
PathRecord? record,
|
||||
}) {
|
||||
@@ -718,7 +890,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
||||
|
||||
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<PathTraceMapScreen>
|
||||
label: label,
|
||||
color: color,
|
||||
isPrimary: isPrimary,
|
||||
hopBytes: List<int>.from(hops),
|
||||
hopBytes: List<Uint8List>.from(hops),
|
||||
points: points,
|
||||
pointLabels: labels,
|
||||
pointConfirmed: confirmed,
|
||||
@@ -933,29 +1105,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(
|
||||
List<int> pathData, {
|
||||
List<Uint8List> pathData, {
|
||||
required bool showLabels,
|
||||
required Contact? target,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
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<PathTraceMapScreen>
|
||||
),
|
||||
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<PathTraceMapScreen>
|
||||
),
|
||||
);
|
||||
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<PathTraceMapScreen>
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final markers = <Marker>[];
|
||||
|
||||
// Hop byte -> paths that use it, in display order.
|
||||
final hopPaths = <int, List<DisplayPath>>{};
|
||||
// Hop prefix -> paths that use it, in display order.
|
||||
final hopPaths = <String, List<DisplayPath>>{};
|
||||
final hopsByKey = <String, Uint8List>{};
|
||||
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<PathTraceMapScreen>
|
||||
? 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<PathTraceMapScreen>
|
||||
}
|
||||
|
||||
void _showSharedNodeSheet(
|
||||
int hop,
|
||||
Uint8List hop,
|
||||
Contact? contact,
|
||||
List<DisplayPath> 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<PathTraceMapScreen>
|
||||
}
|
||||
|
||||
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<PathTraceMapScreen>
|
||||
}
|
||||
|
||||
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<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final l10n = context.l10n;
|
||||
|
||||
final hopUseCount = <int, int>{};
|
||||
final hopUseCount = <String, int>{};
|
||||
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<PathTraceMapScreen>
|
||||
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<PathTraceMapScreen>
|
||||
: 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;
|
||||
|
||||
@@ -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<AppSettingsService>();
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
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),
|
||||
),
|
||||
|
||||
@@ -468,10 +468,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? () => 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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<int>(
|
||||
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<AppSettingsService>();
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<String, dynamic> 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<dynamic>?)
|
||||
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
|
||||
.toList(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, value as int),
|
||||
|
||||
@@ -11,17 +11,20 @@ import '../models/contact.dart';
|
||||
class PathEditorSheet extends StatefulWidget {
|
||||
final List<Contact> availableContacts;
|
||||
final List<int> initialPath;
|
||||
final int pathHashByteWidth;
|
||||
|
||||
const PathEditorSheet({
|
||||
super.key,
|
||||
required this.availableContacts,
|
||||
this.initialPath = const [],
|
||||
this.pathHashByteWidth = pathHashSize,
|
||||
});
|
||||
|
||||
static Future<Uint8List?> show(
|
||||
BuildContext context, {
|
||||
required List<Contact> availableContacts,
|
||||
List<int> initialPath = const [],
|
||||
int pathHashByteWidth = pathHashSize,
|
||||
}) {
|
||||
return showModalBottomSheet<Uint8List>(
|
||||
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<PathEditorSheet> {
|
||||
@@ -66,8 +70,11 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
||||
@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<PathEditorSheet> {
|
||||
|
||||
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<PathEditorSheet> {
|
||||
.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<PathEditorSheet> {
|
||||
_hops
|
||||
..clear()
|
||||
..addAll(
|
||||
tokens.map((t) => _Hop(_nextHopId++, int.parse(t, radix: 16))),
|
||||
tokens.map((t) {
|
||||
final bytes = <int>[];
|
||||
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<PathEditorSheet> {
|
||||
}
|
||||
|
||||
void _save() {
|
||||
Navigator.pop(
|
||||
context,
|
||||
Uint8List.fromList(_hops.map((h) => h.byte).toList()),
|
||||
);
|
||||
final bytes = <int>[];
|
||||
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<PathEditorSheet> {
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -469,7 +469,10 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel(context.l10n),
|
||||
repeater.pathLabel(
|
||||
context.l10n,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
|
||||
@@ -405,7 +405,10 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel(context.l10n),
|
||||
repeater.pathLabel(
|
||||
context.l10n,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
|
||||
@@ -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<DirectRepeater> ranked) {
|
||||
_PathQuality _qualityOf(
|
||||
MeshCoreConnector connector,
|
||||
PathRecord record,
|
||||
List<DirectRepeater> 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<int> 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 = <String>[
|
||||
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) {
|
||||
|
||||
@@ -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<Contact> contacts,
|
||||
int pubkeyFirstByte, {
|
||||
List<int> 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<SNRIndicator> {
|
||||
),
|
||||
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<SNRIndicator> {
|
||||
|
||||
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<SNRIndicator> {
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarCircle(
|
||||
name: name ?? hex,
|
||||
name: name ?? prefixLabel,
|
||||
size: 36,
|
||||
color: snrColor,
|
||||
),
|
||||
@@ -266,7 +281,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name ?? hex,
|
||||
name ?? prefixLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> savePathHistory(
|
||||
String contactPubKeyHex,
|
||||
ContactPathHistory history,
|
||||
) async {}
|
||||
@override
|
||||
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async => null;
|
||||
@override
|
||||
Future<void> clearPathHistory(String contactPubKeyHex) async {}
|
||||
}
|
||||
|
||||
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||
final StreamController<Uint8List> _receivedFramesController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
|
||||
|
||||
@override
|
||||
Uint8List? get selfPublicKey =>
|
||||
Uint8List.fromList(List.generate(32, (i) => i));
|
||||
|
||||
@override
|
||||
double? get selfLatitude => 48.8566;
|
||||
|
||||
@override
|
||||
double? get selfLongitude => 2.3522;
|
||||
|
||||
@override
|
||||
List<Contact> get allContactsUnfiltered => [];
|
||||
|
||||
@override
|
||||
List<Contact> get contacts => [];
|
||||
|
||||
void emitFrame(Uint8List frame) {
|
||||
_receivedFramesController.add(frame);
|
||||
}
|
||||
|
||||
final List<Uint8List> sentFrames = [];
|
||||
|
||||
@override
|
||||
Future<void> sendFrame(
|
||||
Uint8List frame, {
|
||||
String? channelSendQueueId,
|
||||
bool expectsGenericAck = false,
|
||||
bool waitForGenericAck = false,
|
||||
}) async {
|
||||
sentFrames.add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTestApp({
|
||||
required MeshCoreConnector connector,
|
||||
required Widget child,
|
||||
}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
|
||||
ChangeNotifierProvider<AppSettingsService>(
|
||||
create: (_) => AppSettingsService(),
|
||||
),
|
||||
Provider<MapTileCacheService>(create: (_) => MapTileCacheService()),
|
||||
ChangeNotifierProvider<PathHistoryService>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user