mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-27 14:06:38 +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
|
8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
|
||||||
9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
|
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
|
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
|
## 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)
|
- 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
|
- Features with zero variance are automatically excluded from training
|
||||||
- Blends per-contact statistics with ML predictions
|
- 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)
|
- 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 {
|
class DirectRepeater {
|
||||||
static const int maxAgeMinutes = 30; // Max age for direct repeater info
|
static const int maxAgeMinutes = 30; // Max age for direct repeater info
|
||||||
final int pubkeyFirstByte;
|
List<int> pubkeyPrefix;
|
||||||
|
int pathHashWidth;
|
||||||
|
String? contactKeyHex;
|
||||||
double snr;
|
double snr;
|
||||||
DateTime lastUpdated;
|
DateTime lastUpdated;
|
||||||
|
|
||||||
DirectRepeater({
|
DirectRepeater({
|
||||||
required this.pubkeyFirstByte,
|
required List<int> pubkeyPrefix,
|
||||||
|
required this.pathHashWidth,
|
||||||
|
this.contactKeyHex,
|
||||||
required this.snr,
|
required this.snr,
|
||||||
DateTime? lastUpdated,
|
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;
|
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();
|
lastUpdated = DateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2889,7 +2924,32 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> setPathHashMode(int mode) async {
|
Future<void> setPathHashMode(int mode) async {
|
||||||
if (!isConnected) return;
|
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 {
|
Future<void> refreshDeviceInfo() async {
|
||||||
@@ -3106,11 +3166,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
|
final encodedPathLen = _encodePathLenForCurrentMode(pathLen, customPath);
|
||||||
|
if (encodedPathLen == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
buildUpdateContactPathFrame(
|
buildUpdateContactPathFrame(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
customPath,
|
customPath,
|
||||||
pathLen,
|
encodedPathLen,
|
||||||
type: contact.type,
|
type: contact.type,
|
||||||
flags: contact.flags,
|
flags: contact.flags,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
@@ -3126,7 +3190,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
if (idx != -1) {
|
if (idx != -1) {
|
||||||
_contacts[idx] = _contacts[idx].copyWith(
|
_contacts[idx] = _contacts[idx].copyWith(
|
||||||
pathLength: customPath.length,
|
pathLength: pathLen,
|
||||||
path: customPath,
|
path: customPath,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -3167,11 +3231,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
: (updatedFlags & ~contactFlagTeleEnv))
|
: (updatedFlags & ~contactFlagTeleEnv))
|
||||||
: updatedFlags;
|
: updatedFlags;
|
||||||
|
|
||||||
|
final encodedPathLen = _encodePathLenForCurrentMode(
|
||||||
|
latestContact.pathLength,
|
||||||
|
latestContact.path,
|
||||||
|
);
|
||||||
|
if (encodedPathLen == null) return;
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
buildUpdateContactPathFrame(
|
buildUpdateContactPathFrame(
|
||||||
latestContact.publicKey,
|
latestContact.publicKey,
|
||||||
latestContact.path,
|
latestContact.path,
|
||||||
latestContact.pathLength,
|
encodedPathLen,
|
||||||
type: latestContact.type,
|
type: latestContact.type,
|
||||||
flags: updatedFlags,
|
flags: updatedFlags,
|
||||||
name: latestContact.name,
|
name: latestContact.name,
|
||||||
@@ -3254,6 +3323,18 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
tag: 'Connector',
|
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
|
// Update contact with new path override
|
||||||
_contacts[index] = _contacts[index].copyWith(
|
_contacts[index] = _contacts[index].copyWith(
|
||||||
pathOverride: pathLen,
|
pathOverride: pathLen,
|
||||||
@@ -3601,11 +3682,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
// Manual saves must bypass the firmware's auto-add discovery policy.
|
// Manual saves must bypass the firmware's auto-add discovery policy.
|
||||||
// CMD_IMPORT_CONTACT replays an advert and may remain discovery-only.
|
// 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(
|
await sendFrame(
|
||||||
buildUpdateContactPathFrame(
|
buildUpdateContactPathFrame(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
contact.path,
|
contact.path,
|
||||||
contact.pathLength,
|
encodedPathLen,
|
||||||
type: contact.type,
|
type: contact.type,
|
||||||
flags: contact.flags,
|
flags: contact.flags,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
@@ -4379,12 +4465,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_clientRepeat = frame[80] != 0;
|
_clientRepeat = frame[80] != 0;
|
||||||
}
|
}
|
||||||
// Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
|
// Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
|
||||||
|
final previousPathHashByteWidth = _pathHashByteWidth;
|
||||||
if (frame.length >= 82) {
|
if (frame.length >= 82) {
|
||||||
final mode = (frame[81] & 0xFF).clamp(0, 2);
|
final mode = (frame[81] & 0xFF).clamp(0, 3);
|
||||||
_pathHashByteWidth = mode + 1;
|
_pathHashByteWidth = mode + 1;
|
||||||
} else {
|
} else {
|
||||||
_pathHashByteWidth = 1;
|
_pathHashByteWidth = 1;
|
||||||
}
|
}
|
||||||
|
if (_pathHashByteWidth != previousPathHashByteWidth) {
|
||||||
|
_directRepeaters.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
|
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
|
||||||
final reportedContacts = frame[2];
|
final reportedContacts = frame[2];
|
||||||
@@ -4902,6 +4992,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
String senderName,
|
String senderName,
|
||||||
DateTime timestamp, {
|
DateTime timestamp, {
|
||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
|
int? pathHashWidth,
|
||||||
bool notify = false,
|
bool notify = false,
|
||||||
}) {
|
}) {
|
||||||
final normalized = senderName.trim().toLowerCase();
|
final normalized = senderName.trim().toLowerCase();
|
||||||
@@ -4925,7 +5016,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
for (var i = 0; i < _contacts.length; i++) {
|
for (var i = 0; i < _contacts.length; i++) {
|
||||||
final contact = _contacts[i];
|
final contact = _contacts[i];
|
||||||
if (contact.type != advTypeChat) continue;
|
if (contact.type != advTypeChat) continue;
|
||||||
if (_pathMatchesContact(pathBytes, contact.publicKey)) {
|
if (_pathMatchesContact(
|
||||||
|
pathBytes,
|
||||||
|
contact.publicKey,
|
||||||
|
pathHashWidth: pathHashWidth,
|
||||||
|
)) {
|
||||||
matches.add(i);
|
matches.add(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4942,8 +5037,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) {
|
bool _pathMatchesContact(
|
||||||
final w = _pathHashByteWidth;
|
Uint8List pathBytes,
|
||||||
|
Uint8List publicKey, {
|
||||||
|
int? pathHashWidth,
|
||||||
|
}) {
|
||||||
|
final w = pathHashWidth ?? _pathHashByteWidth;
|
||||||
if (pathBytes.isEmpty || publicKey.length < w) return false;
|
if (pathBytes.isEmpty || publicKey.length < w) return false;
|
||||||
for (int i = 0; i + w <= pathBytes.length; i += w) {
|
for (int i = 0; i + w <= pathBytes.length; i += w) {
|
||||||
final prefix = pathBytes.sublist(i, i + w);
|
final prefix = pathBytes.sublist(i, i + w);
|
||||||
@@ -5165,7 +5264,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
isOutgoing: false,
|
isOutgoing: false,
|
||||||
isCli: isCli,
|
isCli: isCli,
|
||||||
status: MessageStatus.delivered,
|
status: MessageStatus.delivered,
|
||||||
pathLength: pathLength == 0xFF ? 0 : pathLength,
|
pathLength: pathLength == 0xFF ? -1 : (pathLength & 0x3F),
|
||||||
pathBytes: Uint8List(0),
|
pathBytes: Uint8List(0),
|
||||||
fourByteRoomContactKey: roomAuthorPrefix,
|
fourByteRoomContactKey: roomAuthorPrefix,
|
||||||
);
|
);
|
||||||
@@ -5398,6 +5497,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
message.senderName,
|
message.senderName,
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
pathBytes: message.pathBytes,
|
pathBytes: message.pathBytes,
|
||||||
|
pathHashWidth: message.pathHashWidth,
|
||||||
);
|
);
|
||||||
final isNew = _addChannelMessage(message.channelIndex!, message);
|
final isNew = _addChannelMessage(message.channelIndex!, message);
|
||||||
_maybeIncrementChannelUnread(message, isNew: isNew);
|
_maybeIncrementChannelUnread(message, isNew: isNew);
|
||||||
@@ -5475,6 +5575,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
isOutgoing: false,
|
isOutgoing: false,
|
||||||
status: ChannelMessageStatus.sent,
|
status: ChannelMessageStatus.sent,
|
||||||
pathLength: packet.isFlood ? packet.hopCount : 0,
|
pathLength: packet.isFlood ? packet.hopCount : 0,
|
||||||
|
pathHashWidth: packet.pathHashWidth,
|
||||||
pathBytes: packet.pathBytes,
|
pathBytes: packet.pathBytes,
|
||||||
channelIndex: channel.index,
|
channelIndex: channel.index,
|
||||||
packetHash: pktHash,
|
packetHash: pktHash,
|
||||||
@@ -5484,6 +5585,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
parsed.senderName,
|
parsed.senderName,
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
pathBytes: message.pathBytes,
|
pathBytes: message.pathBytes,
|
||||||
|
pathHashWidth: message.pathHashWidth,
|
||||||
);
|
);
|
||||||
final isNew = _addChannelMessage(channel.index, message);
|
final isNew = _addChannelMessage(channel.index, message);
|
||||||
_maybeIncrementChannelUnread(message, isNew: isNew);
|
_maybeIncrementChannelUnread(message, isNew: isNew);
|
||||||
@@ -6227,6 +6329,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
repeats: message.repeats,
|
repeats: message.repeats,
|
||||||
repeatCount: message.repeatCount,
|
repeatCount: message.repeatCount,
|
||||||
pathLength: message.pathLength,
|
pathLength: message.pathLength,
|
||||||
|
pathHashWidth: message.pathHashWidth,
|
||||||
pathBytes: message.pathBytes,
|
pathBytes: message.pathBytes,
|
||||||
pathVariants: message.pathVariants,
|
pathVariants: message.pathVariants,
|
||||||
channelIndex: message.channelIndex,
|
channelIndex: message.channelIndex,
|
||||||
@@ -6263,6 +6366,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
messages[existingIndex] = existing.copyWith(
|
messages[existingIndex] = existing.copyWith(
|
||||||
repeatCount: newRepeatCount,
|
repeatCount: newRepeatCount,
|
||||||
pathLength: mergedPathLength,
|
pathLength: mergedPathLength,
|
||||||
|
pathHashWidth: existing.pathHashWidth ?? processedMessage.pathHashWidth,
|
||||||
pathBytes: mergedPathBytes,
|
pathBytes: mergedPathBytes,
|
||||||
pathVariants: mergedPathVariants,
|
pathVariants: mergedPathVariants,
|
||||||
packetHash: existing.packetHash ?? processedMessage.packetHash,
|
packetHash: existing.packetHash ?? processedMessage.packetHash,
|
||||||
@@ -6634,6 +6738,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
//final payloadVer = (header >> 6) & 0x03;
|
//final payloadVer = (header >> 6) & 0x03;
|
||||||
final pathLenRaw = packet.readByte();
|
final pathLenRaw = packet.readByte();
|
||||||
final pathByteLen = _decodePathByteLen(pathLenRaw);
|
final pathByteLen = _decodePathByteLen(pathLenRaw);
|
||||||
|
final pathHashWidth = _decodePathHashWidth(pathLenRaw);
|
||||||
final pathBytes = packet.readBytes(pathByteLen);
|
final pathBytes = packet.readBytes(pathByteLen);
|
||||||
final payload = packet.readBytes(packet.remaining);
|
final payload = packet.readBytes(packet.remaining);
|
||||||
|
|
||||||
@@ -6644,6 +6749,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
rawPacket,
|
rawPacket,
|
||||||
payload,
|
payload,
|
||||||
pathBytes,
|
pathBytes,
|
||||||
|
pathHashWidth,
|
||||||
routeType,
|
routeType,
|
||||||
snr,
|
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) {
|
bool hasValidLocation(double? latitude, double? longitude) {
|
||||||
const double epsilon = 1e-6;
|
const double epsilon = 1e-6;
|
||||||
final lat = latitude ?? 0.0;
|
final lat = latitude ?? 0.0;
|
||||||
@@ -6671,6 +6857,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
Uint8List rawPacket,
|
Uint8List rawPacket,
|
||||||
Uint8List payload,
|
Uint8List payload,
|
||||||
Uint8List path,
|
Uint8List path,
|
||||||
|
int pathHashWidth,
|
||||||
int routeType,
|
int routeType,
|
||||||
double snr,
|
double snr,
|
||||||
) {
|
) {
|
||||||
@@ -6729,10 +6916,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
pathLength: path.length,
|
pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth),
|
||||||
path: Uint8List.fromList(
|
// Store hop order reversed for easier outgoing messages; keep bytes
|
||||||
path.reversed.toList(),
|
// inside each multi-byte hop in their original order.
|
||||||
), // Store path in reverse for easier use in outgoing messages
|
path: _reversePathByHop(path, pathHashWidth),
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
||||||
@@ -6751,7 +6938,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
_handleDiscovery(newContact, rawPacket);
|
_handleDiscovery(newContact, rawPacket);
|
||||||
}
|
}
|
||||||
_updateDirectRepeater(newContact, snr, path);
|
_updateDirectRepeater(
|
||||||
|
newContact,
|
||||||
|
snr,
|
||||||
|
path,
|
||||||
|
pathHashWidth: pathHashWidth,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6775,8 +6967,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
latitude: hasLocation ? latitude : existing.latitude,
|
latitude: hasLocation ? latitude : existing.latitude,
|
||||||
longitude: hasLocation ? longitude : existing.longitude,
|
longitude: hasLocation ? longitude : existing.longitude,
|
||||||
name: hasName ? name : existing.name,
|
name: hasName ? name : existing.name,
|
||||||
path: Uint8List.fromList(path.reversed.toList()),
|
path: _reversePathByHop(path, pathHashWidth),
|
||||||
pathLength: path.length,
|
pathLength: path.isEmpty ? -1 : (path.length ~/ pathHashWidth),
|
||||||
lastMessageAt: mergedLastMessageAt,
|
lastMessageAt: mergedLastMessageAt,
|
||||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
||||||
pathOverride: existing.pathOverride, // Preserve user's path choice
|
pathOverride: existing.pathOverride, // Preserve user's path choice
|
||||||
@@ -6789,7 +6981,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_pathHistoryService!.handlePathUpdated(_contacts[existingIndex]);
|
_pathHistoryService!.handlePathUpdated(_contacts[existingIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateDirectRepeater(_contacts[existingIndex], snr, path);
|
_updateDirectRepeater(
|
||||||
|
_contacts[existingIndex],
|
||||||
|
snr,
|
||||||
|
path,
|
||||||
|
pathHashWidth: pathHashWidth,
|
||||||
|
);
|
||||||
|
|
||||||
appLogger.info(
|
appLogger.info(
|
||||||
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
|
'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) {
|
void _updateDirectRepeater(
|
||||||
final pubkeyFirstByte = path.isNotEmpty
|
Contact contact,
|
||||||
? path.last
|
double snr,
|
||||||
: contact.publicKey.first;
|
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());
|
_directRepeaters.removeWhere((r) => r.isStale());
|
||||||
|
|
||||||
@@ -6812,9 +7041,25 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isTracked = _directRepeaters.where(
|
final isTracked = _directRepeaters.where((r) {
|
||||||
(r) => r.pubkeyFirstByte == pubkeyFirstByte,
|
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)
|
final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters)
|
||||||
..sort((a, b) => b.snr.compareTo(a.snr));
|
..sort((a, b) => b.snr.compareTo(a.snr));
|
||||||
@@ -6830,15 +7075,70 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
if (isTracked.isNotEmpty) {
|
if (isTracked.isNotEmpty) {
|
||||||
final repeater = isTracked.first;
|
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) {
|
} else if (_directRepeaters.length < 5) {
|
||||||
_directRepeaters.add(
|
_directRepeaters.add(
|
||||||
DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
|
DirectRepeater(
|
||||||
|
pubkeyPrefix: pubkeyPrefix,
|
||||||
|
pathHashWidth: hashWidth,
|
||||||
|
contactKeyHex: effectiveContactKeyHex,
|
||||||
|
snr: snr,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
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) {
|
void _handleAutoAddConfig(Uint8List frame) {
|
||||||
final reader = BufferReader(frame);
|
final reader = BufferReader(frame);
|
||||||
try {
|
try {
|
||||||
@@ -6978,13 +7278,42 @@ const int _payloadTypeGroupText = 0x05;
|
|||||||
const int _cipherMacSize = 2;
|
const int _cipherMacSize = 2;
|
||||||
|
|
||||||
/// Decodes the firmware's encoded path_len byte into actual byte length.
|
/// Decodes the firmware's encoded path_len byte into actual byte length.
|
||||||
/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte, 1=2bytes, 2=3bytes).
|
/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte ... 3=4bytes).
|
||||||
int _decodePathByteLen(int pathLenRaw) {
|
int _decodePathByteLen(int pathLenRaw) {
|
||||||
|
if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0;
|
||||||
final hashCount = pathLenRaw & 63;
|
final hashCount = pathLenRaw & 63;
|
||||||
final hashSize = ((pathLenRaw >> 6) & 0x03) + 1;
|
final hashSize = _decodePathHashWidth(pathLenRaw);
|
||||||
return hashCount * hashSize;
|
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 {
|
class _RawPacket {
|
||||||
final int header;
|
final int header;
|
||||||
final int routeType;
|
final int routeType;
|
||||||
@@ -7008,6 +7337,7 @@ class _RawPacket {
|
|||||||
routeType == _routeFlood || routeType == _routeTransportFlood;
|
routeType == _routeFlood || routeType == _routeTransportFlood;
|
||||||
|
|
||||||
int get hopCount => pathLenRaw & 63;
|
int get hopCount => pathLenRaw & 63;
|
||||||
|
int get pathHashWidth => ((pathLenRaw >> 6) & 0x03) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ParsedText {
|
class _ParsedText {
|
||||||
|
|||||||
@@ -587,9 +587,9 @@ Uint8List buildGetStatsFrame(int statsType) {
|
|||||||
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
|
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) {
|
Uint8List buildSetPathHashModeFrame(int mode) {
|
||||||
final m = mode.clamp(0, 2);
|
final m = mode.clamp(0, 3).toInt();
|
||||||
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
|
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 '../connector/meshcore_protocol.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
class PathHelper {
|
class PathHelper {
|
||||||
static String formatPathHex(List<int> pathBytes) {
|
static String formatPathHex(List<int> pathBytes) {
|
||||||
@@ -12,10 +14,17 @@ class PathHelper {
|
|||||||
return byte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
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) {
|
static String? hopName(int byte, List<Contact> allContacts) {
|
||||||
final matches = allContacts
|
final matches = allContacts
|
||||||
.where(
|
.where(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
c.publicKey.isNotEmpty &&
|
||||||
c.publicKey.first == byte &&
|
c.publicKey.first == byte &&
|
||||||
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||||
)
|
)
|
||||||
@@ -25,12 +34,58 @@ class PathHelper {
|
|||||||
return matches.map((c) => c.name).join(' | ');
|
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(
|
static String resolvePathNames(
|
||||||
List<int> pathBytes,
|
List<int> pathBytes,
|
||||||
List<Contact> allContacts,
|
List<Contact> allContacts,
|
||||||
|
int hashByteWidth,
|
||||||
) {
|
) {
|
||||||
return pathBytes
|
if (pathBytes.isEmpty) return '';
|
||||||
.map((b) => hopName(b, allContacts) ?? hopHex(b))
|
|
||||||
.join(' \u2192 ');
|
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 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import 'path_helper.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
|
||||||
class PathHopResolver {
|
class PathHopResolver {
|
||||||
@@ -11,30 +12,35 @@ class PathHopResolver {
|
|||||||
required List<Contact> contacts,
|
required List<Contact> contacts,
|
||||||
LatLng? endpoint,
|
LatLng? endpoint,
|
||||||
bool resolveFromEnd = false,
|
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) {
|
for (final contact in contacts) {
|
||||||
if (contact.publicKey.isEmpty) continue;
|
if (contact.publicKey.length < width) continue;
|
||||||
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidatesByPrefix
|
final prefix = PathHelper.formatHopHex(
|
||||||
.putIfAbsent(contact.publicKey.first, () => <Contact>[])
|
contact.publicKey.sublist(0, width),
|
||||||
.add(contact);
|
);
|
||||||
|
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
|
||||||
}
|
}
|
||||||
for (final candidates in candidatesByPrefix.values) {
|
for (final candidates in candidatesByPrefix.values) {
|
||||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
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
|
final indexes = resolveFromEnd
|
||||||
? List<int>.generate(pathBytes.length, (i) => pathBytes.length - 1 - i)
|
? List<int>.generate(hops.length, (i) => hops.length - 1 - i)
|
||||||
: List<int>.generate(pathBytes.length, (i) => i);
|
: List<int>.generate(hops.length, (i) => i);
|
||||||
final distance = Distance();
|
final distance = Distance();
|
||||||
var previousPosition = endpoint;
|
var previousPosition = endpoint;
|
||||||
|
|
||||||
for (final index in indexes) {
|
for (final index in indexes) {
|
||||||
final candidates = candidatesByPrefix[pathBytes[index]];
|
final candidates =
|
||||||
|
candidatesByPrefix[PathHelper.formatHopHex(hops[index])];
|
||||||
if (candidates == null || candidates.isEmpty) continue;
|
if (candidates == null || candidates.isEmpty) continue;
|
||||||
|
|
||||||
var bestIndex = 0;
|
var bestIndex = 0;
|
||||||
|
|||||||
+5
-4
@@ -292,6 +292,10 @@
|
|||||||
"appSettings_languageBg": "Български",
|
"appSettings_languageBg": "Български",
|
||||||
"appSettings_languageRu": "Русский",
|
"appSettings_languageRu": "Русский",
|
||||||
"appSettings_languageUk": "Українська",
|
"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_enableMessageTracing": "Enable Message Tracing",
|
||||||
"appSettings_enableMessageTracingSubtitle": "Show detailed routing and timing metadata for messages",
|
"appSettings_enableMessageTracingSubtitle": "Show detailed routing and timing metadata for messages",
|
||||||
"appSettings_notifications": "Notifications",
|
"appSettings_notifications": "Notifications",
|
||||||
@@ -1474,7 +1478,7 @@
|
|||||||
"repeater_advancedSettings": "Advanced",
|
"repeater_advancedSettings": "Advanced",
|
||||||
"repeater_advancedSettingsSubtitle": "Tuning knobs for experienced operators",
|
"repeater_advancedSettingsSubtitle": "Tuning knobs for experienced operators",
|
||||||
"repeater_pathHashMode": "Path hash mode",
|
"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_keySettings": "Change Identity Keys",
|
||||||
"repeater_keySettingsSubtitle": "Change the public/private keypair",
|
"repeater_keySettingsSubtitle": "Change the public/private keypair",
|
||||||
"repeater_prvKey": "Private key",
|
"repeater_prvKey": "Private key",
|
||||||
@@ -1492,9 +1496,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"repeater_txDelay": "Flood TX delay",
|
"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_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",
|
"repeater_directTxDelay": "Direct TX delay",
|
||||||
|
|||||||
+1
-1
@@ -1238,7 +1238,7 @@
|
|||||||
"repeater_advancedSettings": "Avanzato",
|
"repeater_advancedSettings": "Avanzato",
|
||||||
"repeater_advancedSettingsSubtitle": "Manopole di regolazione per operatori esperti",
|
"repeater_advancedSettingsSubtitle": "Manopole di regolazione per operatori esperti",
|
||||||
"repeater_pathHashMode": "Modalità di hashing del percorso",
|
"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_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_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",
|
"repeater_directTxDelay": "Ritardo diretto TX",
|
||||||
|
|||||||
+1
-1
@@ -1358,7 +1358,7 @@
|
|||||||
"repeater_advancedSettings": "고급",
|
"repeater_advancedSettings": "고급",
|
||||||
"repeater_advancedSettingsSubtitle": "숙련된 운영자를 위한 조절 노브",
|
"repeater_advancedSettingsSubtitle": "숙련된 운영자를 위한 조절 노브",
|
||||||
"repeater_pathHashMode": "패스 해시 모드",
|
"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_txDelay": "플러드 TX 지연",
|
||||||
"repeater_txDelayHelper": "홍수 시 교통량에 맞춰 재전송 간격을 설정합니다. 이는 패킷의 전송 시간을 곱한 값 (0-2, 기본값 0.5)으로 설정합니다. 값이 클수록 충돌이 줄어들지만 전송 속도가 느려집니다.",
|
"repeater_txDelayHelper": "홍수 시 교통량에 맞춰 재전송 간격을 설정합니다. 이는 패킷의 전송 시간을 곱한 값 (0-2, 기본값 0.5)으로 설정합니다. 값이 클수록 충돌이 줄어들지만 전송 속도가 느려집니다.",
|
||||||
"repeater_directTxDelay": "직접적인 TX 지연",
|
"repeater_directTxDelay": "직접적인 TX 지연",
|
||||||
|
|||||||
@@ -1462,6 +1462,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Українська'**
|
/// **'Українська'**
|
||||||
String get appSettings_languageUk;
|
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.
|
/// No description provided for @appSettings_enableMessageTracing.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4847,7 +4871,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @repeater_pathHashModeHelper.
|
/// No description provided for @repeater_pathHashModeHelper.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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;
|
String get repeater_pathHashModeHelper;
|
||||||
|
|
||||||
/// No description provided for @repeater_keySettings.
|
/// No description provided for @repeater_keySettings.
|
||||||
|
|||||||
@@ -741,6 +741,18 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Украински';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Разрешаване на проследяване на съобщения';
|
'Разрешаване на проследяване на съобщения';
|
||||||
|
|||||||
@@ -738,6 +738,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ukrainisch';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Nachrichtenverfolgung aktivieren';
|
'Nachrichtenverfolgung aktivieren';
|
||||||
|
|||||||
@@ -726,6 +726,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Українська';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Enable Message Tracing';
|
String get appSettings_enableMessageTracing => 'Enable Message Tracing';
|
||||||
|
|
||||||
@@ -2656,7 +2668,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -737,6 +737,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ucraniano';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Habilitar seguimiento de mensajes';
|
'Habilitar seguimiento de mensajes';
|
||||||
|
|||||||
@@ -741,6 +741,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ukrainien';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Activer le traçage des messages';
|
'Activer le traçage des messages';
|
||||||
|
|||||||
@@ -736,6 +736,18 @@ class AppLocalizationsHu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Українська';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Üzenetkövetés engedélyezése';
|
String get appSettings_enableMessageTracing => 'Üzenetkövetés engedélyezése';
|
||||||
|
|
||||||
|
|||||||
@@ -739,6 +739,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ucraino';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Abilita tracciamento messaggi';
|
'Abilita tracciamento messaggi';
|
||||||
@@ -2703,7 +2715,7 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -709,6 +709,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'ウクライナ語';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'メッセージ追跡を有効にする';
|
String get appSettings_enableMessageTracing => 'メッセージ追跡を有効にする';
|
||||||
|
|
||||||
|
|||||||
@@ -710,6 +710,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => '우크라이나어';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => '메시지 추적 기능 활성화';
|
String get appSettings_enableMessageTracing => '메시지 추적 기능 활성화';
|
||||||
|
|
||||||
@@ -2590,7 +2602,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -733,6 +733,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Oekraïens';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Berichttracking inschakelen';
|
String get appSettings_enableMessageTracing => 'Berichttracking inschakelen';
|
||||||
|
|
||||||
@@ -2684,7 +2696,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -742,6 +742,18 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ukraińska';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Włącz śledzenie wiadomości';
|
String get appSettings_enableMessageTracing => 'Włącz śledzenie wiadomości';
|
||||||
|
|
||||||
@@ -2717,7 +2729,7 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -739,6 +739,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ucraniano';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Ativar rastreamento de mensagens';
|
'Ativar rastreamento de mensagens';
|
||||||
@@ -2702,7 +2714,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -739,6 +739,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Українська';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Включить трассировку сообщений';
|
'Включить трассировку сообщений';
|
||||||
@@ -2707,7 +2719,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -733,6 +733,18 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ukrajinská';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Povoliť sledovanie správ';
|
String get appSettings_enableMessageTracing => 'Povoliť sledovanie správ';
|
||||||
|
|
||||||
@@ -2688,7 +2700,7 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -731,6 +731,18 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ukrajinsko';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Omogoči sledenje sporočilom';
|
String get appSettings_enableMessageTracing => 'Omogoči sledenje sporočilom';
|
||||||
|
|
||||||
@@ -2684,7 +2696,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -728,6 +728,18 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Ukrainska';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => 'Aktivera meddelandespårning';
|
String get appSettings_enableMessageTracing => 'Aktivera meddelandespårning';
|
||||||
|
|
||||||
@@ -2672,7 +2684,7 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -736,6 +736,18 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => 'Українська';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing =>
|
String get appSettings_enableMessageTracing =>
|
||||||
'Увімкнути відстеження повідомлень';
|
'Увімкнути відстеження повідомлень';
|
||||||
@@ -2704,7 +2716,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
@@ -701,6 +701,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get appSettings_languageUk => '乌克兰语';
|
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
|
@override
|
||||||
String get appSettings_enableMessageTracing => '启用消息追踪';
|
String get appSettings_enableMessageTracing => '启用消息追踪';
|
||||||
|
|
||||||
@@ -2559,7 +2571,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater_pathHashModeHelper =>
|
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
|
@override
|
||||||
String get repeater_keySettings => 'Change Identity Keys';
|
String get repeater_keySettings => 'Change Identity Keys';
|
||||||
|
|||||||
+1
-1
@@ -1172,7 +1172,7 @@
|
|||||||
"repeater_advancedSettings": "Geavanceerd",
|
"repeater_advancedSettings": "Geavanceerd",
|
||||||
"repeater_advancedSettingsSubtitle": "Regelhendels voor ervaren gebruikers",
|
"repeater_advancedSettingsSubtitle": "Regelhendels voor ervaren gebruikers",
|
||||||
"repeater_pathHashMode": "Hash-modus voor paden",
|
"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_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_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",
|
"repeater_directTxDelay": "Directe vertraging",
|
||||||
|
|||||||
+1
-1
@@ -1182,7 +1182,7 @@
|
|||||||
"repeater_advancedSettings": "Zaawansowany",
|
"repeater_advancedSettings": "Zaawansowany",
|
||||||
"repeater_advancedSettingsSubtitle": "Regulowane pokrętła dla doświadczonych operatorów",
|
"repeater_advancedSettingsSubtitle": "Regulowane pokrętła dla doświadczonych operatorów",
|
||||||
"repeater_pathHashMode": "Tryb haszujący ścieżkę",
|
"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_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_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",
|
"repeater_directTxDelay": "Bezpośrednie opóźnienie sygnału TX",
|
||||||
|
|||||||
+1
-1
@@ -1172,7 +1172,7 @@
|
|||||||
"repeater_advancedSettings": "Avançado",
|
"repeater_advancedSettings": "Avançado",
|
||||||
"repeater_advancedSettingsSubtitle": "Controles de ajuste para operadores experientes",
|
"repeater_advancedSettingsSubtitle": "Controles de ajuste para operadores experientes",
|
||||||
"repeater_pathHashMode": "Modo de hash de caminho",
|
"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_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_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",
|
"repeater_directTxDelay": "Atraso direto no sinal TX",
|
||||||
|
|||||||
+1
-1
@@ -1543,7 +1543,7 @@
|
|||||||
"repeater_advancedSettings": "Продвинутый",
|
"repeater_advancedSettings": "Продвинутый",
|
||||||
"repeater_advancedSettingsSubtitle": "Регуляторы для опытных операторов",
|
"repeater_advancedSettingsSubtitle": "Регуляторы для опытных операторов",
|
||||||
"repeater_pathHashMode": "Режим хеширования пути",
|
"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_txDelay": "Задержка в работе системы Flood TX",
|
||||||
"repeater_txDelayHelper": "Передача с увеличенным интервалом для трафика во время наводнения, в качестве коэффициента, умножающего время передачи пакета (от 0 до 2, по умолчанию 0,5). Более высокое значение означает меньшее количество столкновений, но более медленную передачу.",
|
"repeater_txDelayHelper": "Передача с увеличенным интервалом для трафика во время наводнения, в качестве коэффициента, умножающего время передачи пакета (от 0 до 2, по умолчанию 0,5). Более высокое значение означает меньшее количество столкновений, но более медленную передачу.",
|
||||||
"repeater_directTxDelay": "Прямая задержка сигнала TX",
|
"repeater_directTxDelay": "Прямая задержка сигнала TX",
|
||||||
|
|||||||
+1
-1
@@ -1172,7 +1172,7 @@
|
|||||||
"repeater_advancedSettings": "Pokročilé",
|
"repeater_advancedSettings": "Pokročilé",
|
||||||
"repeater_advancedSettingsSubtitle": "Ovládacie knopy pre skúsených operátorov",
|
"repeater_advancedSettingsSubtitle": "Ovládacie knopy pre skúsených operátorov",
|
||||||
"repeater_pathHashMode": "Režim hashovania cesty",
|
"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_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_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",
|
"repeater_directTxDelay": "Priame oneskorenie TX",
|
||||||
|
|||||||
+1
-1
@@ -1172,7 +1172,7 @@
|
|||||||
"repeater_advancedSettings": "Napredno",
|
"repeater_advancedSettings": "Napredno",
|
||||||
"repeater_advancedSettingsSubtitle": "Gumbi za nastavljanje za izkušene uporabnike",
|
"repeater_advancedSettingsSubtitle": "Gumbi za nastavljanje za izkušene uporabnike",
|
||||||
"repeater_pathHashMode": "Način ustvarjanja hash-a poti",
|
"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_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_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",
|
"repeater_directTxDelay": "Neposredni časovno odlašanje",
|
||||||
|
|||||||
+1
-1
@@ -1172,7 +1172,7 @@
|
|||||||
"repeater_advancedSettings": "Avancerad",
|
"repeater_advancedSettings": "Avancerad",
|
||||||
"repeater_advancedSettingsSubtitle": "Ställjusteringsknappar för erfarna användare",
|
"repeater_advancedSettingsSubtitle": "Ställjusteringsknappar för erfarna användare",
|
||||||
"repeater_pathHashMode": "Hash-läge för sökväg",
|
"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_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_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",
|
"repeater_directTxDelay": "Direkt TX-fördröjning",
|
||||||
|
|||||||
+1
-1
@@ -1182,7 +1182,7 @@
|
|||||||
"repeater_advancedSettings": "Просунутий",
|
"repeater_advancedSettings": "Просунутий",
|
||||||
"repeater_advancedSettingsSubtitle": "Регулювальні ручки для досвідчених операторів",
|
"repeater_advancedSettingsSubtitle": "Регулювальні ручки для досвідчених операторів",
|
||||||
"repeater_pathHashMode": "Режим хешування шляху",
|
"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_txDelay": "Затримка у Flood, штат Техас",
|
||||||
"repeater_txDelayHelper": "Повторне надсилання з урахуванням навантаження від потоків транспорту, як множник від часу передачі пакета (0-2, за замовчуванням 0,5). Чим вище значення, тим менше конфліктів, але повільніше передавання.",
|
"repeater_txDelayHelper": "Повторне надсилання з урахуванням навантаження від потоків транспорту, як множник від часу передачі пакета (0-2, за замовчуванням 0,5). Чим вище значення, тим менше конфліктів, але повільніше передавання.",
|
||||||
"repeater_directTxDelay": "Пряме затримка TX",
|
"repeater_directTxDelay": "Пряме затримка TX",
|
||||||
|
|||||||
+1
-1
@@ -1202,7 +1202,7 @@
|
|||||||
"repeater_advancedSettings": "高级",
|
"repeater_advancedSettings": "高级",
|
||||||
"repeater_advancedSettingsSubtitle": "高级操作员使用的调节旋钮",
|
"repeater_advancedSettingsSubtitle": "高级操作员使用的调节旋钮",
|
||||||
"repeater_pathHashMode": "路径哈希模式",
|
"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_txDelay": "洪水(德克萨斯州)延误",
|
||||||
"repeater_txDelayHelper": "对于洪水流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.5)的倍数。 较高的值意味着更少的冲突,但传输速度会变慢。",
|
"repeater_txDelayHelper": "对于洪水流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.5)的倍数。 较高的值意味着更少的冲突,但传输速度会变慢。",
|
||||||
"repeater_directTxDelay": "直接的 TX 延迟",
|
"repeater_directTxDelay": "直接的 TX 延迟",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import 'app_localizations.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 != null) {
|
||||||
if (pathOverride! < 0) return l10n.chat_floodForced;
|
if (pathOverride! < 0) return l10n.chat_floodForced;
|
||||||
if (pathOverride == 0) return l10n.chat_directForced;
|
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.channelPath_floodPath;
|
||||||
if (pathLength == 0) return l10n.chat_direct;
|
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 List<Repeat> repeats;
|
||||||
final int repeatCount;
|
final int repeatCount;
|
||||||
final int? pathLength;
|
final int? pathLength;
|
||||||
|
final int? pathHashWidth;
|
||||||
final Uint8List pathBytes;
|
final Uint8List pathBytes;
|
||||||
final List<Uint8List> pathVariants;
|
final List<Uint8List> pathVariants;
|
||||||
final int? channelIndex;
|
final int? channelIndex;
|
||||||
@@ -66,6 +67,7 @@ class ChannelMessage {
|
|||||||
this.repeats = const [],
|
this.repeats = const [],
|
||||||
this.repeatCount = 0,
|
this.repeatCount = 0,
|
||||||
this.pathLength,
|
this.pathLength,
|
||||||
|
this.pathHashWidth,
|
||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
List<Uint8List>? pathVariants,
|
List<Uint8List>? pathVariants,
|
||||||
this.channelIndex,
|
this.channelIndex,
|
||||||
@@ -93,6 +95,7 @@ class ChannelMessage {
|
|||||||
List<Repeat>? repeats,
|
List<Repeat>? repeats,
|
||||||
int? repeatCount,
|
int? repeatCount,
|
||||||
int? pathLength,
|
int? pathLength,
|
||||||
|
int? pathHashWidth,
|
||||||
Uint8List? pathBytes,
|
Uint8List? pathBytes,
|
||||||
List<Uint8List>? pathVariants,
|
List<Uint8List>? pathVariants,
|
||||||
String? packetHash,
|
String? packetHash,
|
||||||
@@ -129,6 +132,7 @@ class ChannelMessage {
|
|||||||
repeats: repeats ?? this.repeats,
|
repeats: repeats ?? this.repeats,
|
||||||
repeatCount: repeatCount ?? this.repeatCount,
|
repeatCount: repeatCount ?? this.repeatCount,
|
||||||
pathLength: pathLength ?? this.pathLength,
|
pathLength: pathLength ?? this.pathLength,
|
||||||
|
pathHashWidth: pathHashWidth ?? this.pathHashWidth,
|
||||||
pathBytes: pathBytes ?? this.pathBytes,
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
pathVariants: pathVariants ?? this.pathVariants,
|
pathVariants: pathVariants ?? this.pathVariants,
|
||||||
channelIndex: channelIndex,
|
channelIndex: channelIndex,
|
||||||
@@ -155,6 +159,7 @@ class ChannelMessage {
|
|||||||
|
|
||||||
int pathLen;
|
int pathLen;
|
||||||
int txtType;
|
int txtType;
|
||||||
|
int? packetPathHashWidth;
|
||||||
Uint8List pathBytes = Uint8List(0);
|
Uint8List pathBytes = Uint8List(0);
|
||||||
int channelIdx;
|
int channelIdx;
|
||||||
if (code == respCodeChannelMsgRecvV3) {
|
if (code == respCodeChannelMsgRecvV3) {
|
||||||
@@ -163,12 +168,18 @@ class ChannelMessage {
|
|||||||
final hasPath = (flags & 0x01) != 0;
|
final hasPath = (flags & 0x01) != 0;
|
||||||
reader.skipBytes(1); // Skip reserved byte
|
reader.skipBytes(1); // Skip reserved byte
|
||||||
channelIdx = reader.readByte();
|
channelIdx = reader.readByte();
|
||||||
pathLen = reader.readInt8();
|
final pathByte = reader.readUInt8();
|
||||||
txtType = reader.readByte();
|
// pathByte packs: top 2 bits = hash width mode, low 6 bits = hop count
|
||||||
if (hasPath && pathLen > 0) {
|
packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1;
|
||||||
reader.rewind(); // Rewind to read path length again for pathBytes
|
final hopCount = pathByte & 0x3F;
|
||||||
pathBytes = reader.readBytes(pathLen);
|
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 {
|
} else {
|
||||||
channelIdx = reader.readByte();
|
channelIdx = reader.readByte();
|
||||||
pathLen = reader.readInt8();
|
pathLen = reader.readInt8();
|
||||||
@@ -209,6 +220,7 @@ class ChannelMessage {
|
|||||||
isOutgoing: false,
|
isOutgoing: false,
|
||||||
status: ChannelMessageStatus.sent,
|
status: ChannelMessageStatus.sent,
|
||||||
pathLength: pathLen,
|
pathLength: pathLen,
|
||||||
|
pathHashWidth: packetPathHashWidth,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
channelIndex: channelIdx,
|
channelIndex: channelIdx,
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-4
@@ -119,7 +119,7 @@ class Contact {
|
|||||||
String pathFormattedIdList(int hashByteWidth) {
|
String pathFormattedIdList(int hashByteWidth) {
|
||||||
final pathBytes = pathBytesForDisplay;
|
final pathBytes = pathBytesForDisplay;
|
||||||
if (pathBytes.isEmpty) return '';
|
if (pathBytes.isEmpty) return '';
|
||||||
final w = hashByteWidth.clamp(1, 8);
|
final w = hashByteWidth.clamp(1, 4);
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
for (int i = 0; i < pathBytes.length; i += w) {
|
for (int i = 0; i < pathBytes.length; i += w) {
|
||||||
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
||||||
@@ -166,8 +166,18 @@ class Contact {
|
|||||||
final type = reader.readByte();
|
final type = reader.readByte();
|
||||||
final flags = reader.readByte();
|
final flags = reader.readByte();
|
||||||
final pathLen = reader.readByte();
|
final pathLen = reader.readByte();
|
||||||
final safePathLen = pathLen > 0
|
int hopCount = 0;
|
||||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
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;
|
: 0;
|
||||||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||||
final name = reader.readCStringGreedy(maxNameSize);
|
final name = reader.readCStringGreedy(maxNameSize);
|
||||||
@@ -213,7 +223,7 @@ class Contact {
|
|||||||
name: name.isEmpty ? 'Unknown' : name,
|
name: name.isEmpty ? 'Unknown' : name,
|
||||||
type: type,
|
type: type,
|
||||||
flags: flags,
|
flags: flags,
|
||||||
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen,
|
pathLength: hopCount,
|
||||||
path: pathBytes,
|
path: pathBytes,
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -13,8 +14,8 @@ class DisplayPath {
|
|||||||
final Color color;
|
final Color color;
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
|
|
||||||
/// Outbound hop bytes, including hops that could not be placed on the map.
|
/// Outbound hop prefixes, including hops that could not be placed on the map.
|
||||||
final List<int> hopBytes;
|
final List<Uint8List> hopBytes;
|
||||||
|
|
||||||
/// Resolved map points: self, each locatable hop, then the target when its
|
/// Resolved map points: self, each locatable hop, then the target when its
|
||||||
/// position is known. Hops with no position are skipped here but still
|
/// 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 }
|
enum _BleLogView { frames, rawLogRx }
|
||||||
|
|
||||||
|
int _decodeRawPathByteLen(int pathLenRaw) {
|
||||||
|
if (pathLenRaw == 0xFF || pathLenRaw == 0) return 0;
|
||||||
|
final hashCount = pathLenRaw & 0x3F;
|
||||||
|
final hashWidth = ((pathLenRaw >> 6) & 0x03) + 1;
|
||||||
|
return hashCount * hashWidth;
|
||||||
|
}
|
||||||
|
|
||||||
class BleDebugLogScreen extends StatefulWidget {
|
class BleDebugLogScreen extends StatefulWidget {
|
||||||
const BleDebugLogScreen({super.key});
|
const BleDebugLogScreen({super.key});
|
||||||
|
|
||||||
@@ -314,16 +321,17 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
rawHex: _bytesToHex(raw),
|
rawHex: _bytesToHex(raw),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pathLen = raw[index++];
|
final pathLenRaw = raw[index++];
|
||||||
if (raw.length < index + pathLen) {
|
final pathByteLen = _decodeRawPathByteLen(pathLenRaw);
|
||||||
|
if (raw.length < index + pathByteLen) {
|
||||||
return _RawPacketInfo(
|
return _RawPacketInfo(
|
||||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||||
summary: 'Truncated path',
|
summary: 'Truncated path',
|
||||||
rawHex: _bytesToHex(raw),
|
rawHex: _bytesToHex(raw),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pathBytes = raw.sublist(index, index + pathLen);
|
final pathBytes = raw.sublist(index, index + pathByteLen);
|
||||||
index += pathLen;
|
index += pathByteLen;
|
||||||
if (raw.length <= index) {
|
if (raw.length <= index) {
|
||||||
return _RawPacketInfo(
|
return _RawPacketInfo(
|
||||||
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
|
||||||
@@ -336,7 +344,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
final title =
|
final title =
|
||||||
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||||
final summary = _decodePayloadSummary(payloadType, payload);
|
final summary = _decodePayloadSummary(payloadType, payload);
|
||||||
final pathSummary = pathLen > 0
|
final pathSummary = pathByteLen > 0
|
||||||
? 'Path=${_bytesToHex(pathBytes)}'
|
? 'Path=${_bytesToHex(pathBytes)}'
|
||||||
: 'Path=none';
|
: 'Path=none';
|
||||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../helpers/chat_scroll_controller.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../helpers/cyr2lat.dart';
|
import '../helpers/cyr2lat.dart';
|
||||||
import '../helpers/gif_helper.dart';
|
import '../helpers/gif_helper.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
import '../helpers/reaction_helper.dart';
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
@@ -530,6 +531,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
: (message.pathVariants.isNotEmpty
|
: (message.pathVariants.isNotEmpty
|
||||||
? message.pathVariants.first
|
? message.pathVariants.first
|
||||||
: Uint8List(0));
|
: 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.
|
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
|
||||||
final bubbleColor = isOutgoing
|
final bubbleColor = isOutgoing
|
||||||
@@ -684,15 +693,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
RouteChip(
|
RouteChip(
|
||||||
isDirect: (message.pathLength ?? -1) >= 0,
|
isDirect: (message.pathLength ?? -1) >= 0,
|
||||||
hops: (message.pathLength ?? -1) >= 0
|
hops: displayHopCount,
|
||||||
? message.pathLength
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.channels_via(
|
context.l10n.channels_via(
|
||||||
_formatPathPrefixes(displayPath),
|
_formatPathPrefixes(
|
||||||
|
displayPath,
|
||||||
|
displayPathHashWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: MeshTheme.mono(
|
style: MeshTheme.mono(
|
||||||
fontSize: 9.5 * textScale,
|
fontSize: 9.5 * textScale,
|
||||||
@@ -1578,13 +1588,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes, int pathHashByteWidth) {
|
||||||
// Join with a comma + zero-width space (not ", ") so paths render as
|
return PathHelper.splitPathBytes(
|
||||||
// "5B,56,65" with no visible gap, while still giving long paths soft-wrap
|
pathBytes,
|
||||||
// break points that keep them confined to the message bubble.
|
pathHashByteWidth,
|
||||||
return pathBytes
|
).map(PathHelper.formatHopHex).join(',');
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
}
|
||||||
.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 {
|
Future<void> openRegionSelectDialog(Channel channel) async {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.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/screens/path_trace_map.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -42,19 +43,40 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
message.pathVariants,
|
message.pathVariants,
|
||||||
);
|
);
|
||||||
|
|
||||||
final primaryPath = !channelMessage && !message.isOutgoing
|
final hashByteWidth =
|
||||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
(message.pathHashWidth ?? connector.pathHashByteWidth)
|
||||||
: primaryPathTmp;
|
.clamp(1, 4)
|
||||||
|
.toInt();
|
||||||
|
final primaryPath = _orientPathBytes(
|
||||||
|
primaryPathTmp,
|
||||||
|
hashByteWidth,
|
||||||
|
reverse: !channelMessage && !message.isOutgoing,
|
||||||
|
);
|
||||||
final hops = _buildPathHops(
|
final hops = _buildPathHops(
|
||||||
primaryPath,
|
primaryPath,
|
||||||
connector,
|
connector,
|
||||||
l10n,
|
l10n,
|
||||||
|
hashByteWidth,
|
||||||
resolveFromEnd: !message.isOutgoing,
|
resolveFromEnd: !message.isOutgoing,
|
||||||
);
|
);
|
||||||
final hasHopDetails = primaryPath.isNotEmpty;
|
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,
|
primaryPath.length,
|
||||||
message.pathLength,
|
hashByteWidth,
|
||||||
|
);
|
||||||
|
final reportedHopCount = message.pathLength;
|
||||||
|
final effectiveHopCount = observedHopCount > 0
|
||||||
|
? observedHopCount
|
||||||
|
: reportedHopCount;
|
||||||
|
|
||||||
|
final observedLabel = _formatObservedHops(
|
||||||
|
observedHopCount,
|
||||||
|
effectiveHopCount,
|
||||||
l10n,
|
l10n,
|
||||||
);
|
);
|
||||||
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
||||||
@@ -72,11 +94,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
title: context.l10n.contacts_repeaterPathTrace,
|
title: context.l10n.contacts_repeaterPathTrace,
|
||||||
path: primaryPath,
|
path: primaryPath,
|
||||||
flipPathAround: true,
|
flipPathAround: true,
|
||||||
reversePathAround:
|
reversePathAround: false,
|
||||||
!(!channelMessage && !message.isOutgoing),
|
pathHashByteWidth: hashByteWidth,
|
||||||
pathHashByteWidth: context
|
|
||||||
.read<MeshCoreConnector>()
|
|
||||||
.pathHashByteWidth,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -97,13 +116,20 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryCard(context, observedLabel: observedLabel),
|
_buildSummaryCard(
|
||||||
|
context,
|
||||||
|
observedLabel: observedLabel,
|
||||||
|
effectiveHopCount: effectiveHopCount,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
if (extraPaths.isNotEmpty) ...[
|
if (extraPaths.isNotEmpty) ...[
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
l10n.channelPath_otherObservedPaths,
|
l10n.channelPath_otherObservedPaths,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
),
|
),
|
||||||
_buildPathVariants(context, extraPaths),
|
const SizedBox(height: 8),
|
||||||
|
_buildPathVariants(context, extraPaths, hashByteWidth),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
l10n.channelPath_repeaterHops,
|
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 l10n = context.l10n;
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final routeChip = message.pathLength == null
|
final routeChip = effectiveHopCount == null
|
||||||
? null
|
? null
|
||||||
: message.pathLength! < 0
|
: effectiveHopCount < 0
|
||||||
? const RouteChip(isDirect: false)
|
? const RouteChip(isDirect: false)
|
||||||
: RouteChip(isDirect: true, hops: message.pathLength);
|
: RouteChip(isDirect: true, hops: effectiveHopCount);
|
||||||
|
|
||||||
return MeshCard(
|
return MeshCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
@@ -171,7 +201,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
context,
|
context,
|
||||||
l10n.channelPath_pathLabelTitle,
|
l10n.channelPath_pathLabelTitle,
|
||||||
_formatPathLabel(message.pathLength, l10n),
|
_formatPathLabel(effectiveHopCount, l10n),
|
||||||
scheme: scheme,
|
scheme: scheme,
|
||||||
),
|
),
|
||||||
if (observedLabel != null)
|
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;
|
final l10n = context.l10n;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -209,7 +243,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
l10n.channelPath_observedPathTitle(
|
l10n.channelPath_observedPathTitle(
|
||||||
i + 1,
|
i + 1,
|
||||||
_formatHopCount(variants[i].length, l10n),
|
_formatHopCount(
|
||||||
|
variants[i].length,
|
||||||
|
hashByteWidth,
|
||||||
|
l10n,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -217,7 +255,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
_formatPathPrefixes(variants[i]),
|
_formatPathPrefixes(variants[i], hashByteWidth),
|
||||||
style: MeshTheme.mono(
|
style: MeshTheme.mono(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
@@ -401,31 +439,31 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
|
String _formatPathLabel(int? hopCount, AppLocalizations l10n) {
|
||||||
if (pathLength == null) return l10n.channelPath_unknownPath;
|
if (hopCount == null) return l10n.channelPath_unknownPath;
|
||||||
if (pathLength < 0) return l10n.channelPath_floodPath;
|
if (hopCount < 0) return l10n.channelPath_floodPath;
|
||||||
if (pathLength == 0) return l10n.channelPath_directPath;
|
if (hopCount == 0) return l10n.channelPath_directPath;
|
||||||
return l10n.chat_hopsCount(pathLength);
|
return l10n.chat_hopsCount(hopCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _formatObservedHops(
|
String? _formatObservedHops(
|
||||||
int observedCount,
|
int observedCount,
|
||||||
int? pathLength,
|
int? targetHopCount,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
) {
|
) {
|
||||||
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
|
if (observedCount <= 0 && (targetHopCount == null || targetHopCount <= 0)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (pathLength == null || pathLength < 0) {
|
if (targetHopCount == null || targetHopCount < 0) {
|
||||||
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
|
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
|
||||||
}
|
}
|
||||||
if (observedCount == 0) {
|
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.chat_hopsCount(observedCount);
|
||||||
}
|
}
|
||||||
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
|
return l10n.channelPath_observedSomeOf(observedCount, targetHopCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDetailRow(
|
Widget _buildDetailRow(
|
||||||
@@ -622,7 +660,10 @@ class _ChannelMessagePathMapScreenState
|
|||||||
? kPrimaryPathColor
|
? kPrimaryPathColor
|
||||||
: kAlternatePathColors[(index - 1) % kAlternatePathColors.length],
|
: kAlternatePathColors[(index - 1) % kAlternatePathColors.length],
|
||||||
isPrimary: isPrimary,
|
isPrimary: isPrimary,
|
||||||
hopBytes: List<int>.from(orientedBytes),
|
hopBytes: [
|
||||||
|
for (final hop in hops)
|
||||||
|
if (hop.hopBytes != null) Uint8List.fromList(hop.hopBytes!),
|
||||||
|
],
|
||||||
points: points,
|
points: points,
|
||||||
pointLabels: labels,
|
pointLabels: labels,
|
||||||
pointConfirmed: confirmed,
|
pointConfirmed: confirmed,
|
||||||
@@ -807,7 +848,11 @@ class _ChannelMessagePathMapScreenState
|
|||||||
primaryPath,
|
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
|
// Match on the unoriented bytes — observedPaths stores them as
|
||||||
// recorded, while selectedPath may be reversed for display.
|
// recorded, while selectedPath may be reversed for display.
|
||||||
@@ -816,19 +861,21 @@ class _ChannelMessagePathMapScreenState
|
|||||||
selectedPath,
|
selectedPath,
|
||||||
connector,
|
connector,
|
||||||
context.l10n,
|
context.l10n,
|
||||||
|
width,
|
||||||
resolveFromEnd: !widget.message.isOutgoing,
|
resolveFromEnd: !widget.message.isOutgoing,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Renderable paths for the animation and combined view.
|
// Renderable paths for the animation and combined view.
|
||||||
final entries = <_ObservedPathEntry>[];
|
final entries = <_ObservedPathEntry>[];
|
||||||
for (var i = 0; i < observedPaths.length; i++) {
|
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
|
final pathHops = i == selectedIndex
|
||||||
? hops
|
? hops
|
||||||
: _buildPathHops(
|
: _buildPathHops(
|
||||||
oriented,
|
oriented,
|
||||||
connector,
|
connector,
|
||||||
context.l10n,
|
context.l10n,
|
||||||
|
width,
|
||||||
resolveFromEnd: !widget.message.isOutgoing,
|
resolveFromEnd: !widget.message.isOutgoing,
|
||||||
);
|
);
|
||||||
final display = _buildDisplayPath(
|
final display = _buildDisplayPath(
|
||||||
@@ -907,7 +954,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
? LatLngBounds.fromPoints(points)
|
? LatLngBounds.fromPoints(points)
|
||||||
: null;
|
: null;
|
||||||
final mapKey = ValueKey(
|
final mapKey = ValueKey(
|
||||||
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
|
'${_formatPathPrefixes(selectedPath, width)},${context.l10n.pathTrace_you}',
|
||||||
);
|
);
|
||||||
_pathDistance = _getPathDistance(points);
|
_pathDistance = _getPathDistance(points);
|
||||||
|
|
||||||
@@ -1044,6 +1091,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
context,
|
context,
|
||||||
observedPaths,
|
observedPaths,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
width,
|
||||||
(index) {
|
(index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedPath = observedPaths[index].pathBytes;
|
_selectedPath = observedPaths[index].pathBytes;
|
||||||
@@ -1085,10 +1133,12 @@ class _ChannelMessagePathMapScreenState
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<_ObservedPath> paths,
|
List<_ObservedPath> paths,
|
||||||
int selectedIndex,
|
int selectedIndex,
|
||||||
|
int hashByteWidth,
|
||||||
ValueChanged<int> onSelected, {
|
ValueChanged<int> onSelected, {
|
||||||
double topOffset = 16,
|
double topOffset = 16,
|
||||||
}) {
|
}) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final width = hashByteWidth.clamp(1, 4).toInt();
|
||||||
final selectedPath = paths[selectedIndex];
|
final selectedPath = paths[selectedIndex];
|
||||||
final label = selectedPath.isPrimary
|
final label = selectedPath.isPrimary
|
||||||
? l10n.channelPath_primaryPath(selectedIndex + 1)
|
? l10n.channelPath_primaryPath(selectedIndex + 1)
|
||||||
@@ -1119,7 +1169,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
value: i,
|
value: i,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
|
'${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(
|
Text(
|
||||||
l10n.channelPath_selectedPathLabel(
|
l10n.channelPath_selectedPathLabel(
|
||||||
label,
|
label,
|
||||||
_formatPathPrefixes(selectedPath.pathBytes),
|
_formatPathPrefixes(selectedPath.pathBytes, width),
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
@@ -1377,11 +1427,11 @@ class _ChannelMessagePathMapScreenState
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Orients recorded path bytes in the direction the packet traveled.
|
/// Orients recorded path bytes in the direction the packet traveled.
|
||||||
Uint8List _orientPath(Uint8List bytes) {
|
Uint8List _orientPath(Uint8List bytes, int hashByteWidth) {
|
||||||
final reverse =
|
final reverse =
|
||||||
(!widget.message.isOutgoing && !widget.channelMessage) ||
|
(!widget.message.isOutgoing && !widget.channelMessage) ||
|
||||||
(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}) {
|
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
|
||||||
@@ -1737,6 +1787,7 @@ class _PathHop {
|
|||||||
final Contact? contact;
|
final Contact? contact;
|
||||||
final LatLng? position;
|
final LatLng? position;
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
|
final Uint8List? hopBytes;
|
||||||
|
|
||||||
const _PathHop({
|
const _PathHop({
|
||||||
required this.index,
|
required this.index,
|
||||||
@@ -1744,12 +1795,18 @@ class _PathHop {
|
|||||||
required this.contact,
|
required this.contact,
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
|
this.hopBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get hasLocation => position != null;
|
bool get hasLocation => position != null;
|
||||||
|
|
||||||
String get displayLabel {
|
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)}';
|
return '($prefixLabel) ${_resolveName(contact, l10n)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1764,10 +1821,12 @@ class _ObservedPath {
|
|||||||
List<_PathHop> _buildPathHops(
|
List<_PathHop> _buildPathHops(
|
||||||
Uint8List pathBytes,
|
Uint8List pathBytes,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
AppLocalizations l10n, {
|
AppLocalizations l10n,
|
||||||
|
int hashByteWidth, {
|
||||||
bool resolveFromEnd = false,
|
bool resolveFromEnd = false,
|
||||||
}) {
|
}) {
|
||||||
if (pathBytes.isEmpty) return const [];
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
final width = hashByteWidth.clamp(1, 4).toInt();
|
||||||
final endpoint =
|
final endpoint =
|
||||||
(connector.selfLatitude != null && connector.selfLongitude != null)
|
(connector.selfLatitude != null && connector.selfLongitude != null)
|
||||||
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
||||||
@@ -1777,19 +1836,23 @@ List<_PathHop> _buildPathHops(
|
|||||||
contacts: connector.allContacts,
|
contacts: connector.allContacts,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
resolveFromEnd: resolveFromEnd,
|
resolveFromEnd: resolveFromEnd,
|
||||||
|
pathHashByteWidth: width,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final hopChunks = PathHelper.splitPathBytes(pathBytes, width);
|
||||||
final hops = <_PathHop>[];
|
final hops = <_PathHop>[];
|
||||||
for (var i = 0; i < pathBytes.length; i++) {
|
for (var i = 0; i < hopChunks.length; i++) {
|
||||||
final contact = resolvedContacts[i];
|
final hopBytes = hopChunks[i];
|
||||||
|
final contact = i < resolvedContacts.length ? resolvedContacts[i] : null;
|
||||||
final resolvedPosition = _resolvePosition(contact);
|
final resolvedPosition = _resolvePosition(contact);
|
||||||
hops.add(
|
hops.add(
|
||||||
_PathHop(
|
_PathHop(
|
||||||
index: i + 1,
|
index: i + 1,
|
||||||
prefix: pathBytes[i],
|
prefix: hopBytes.isNotEmpty ? hopBytes[0] : 0,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
position: resolvedPosition,
|
position: resolvedPosition,
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
|
hopBytes: hopBytes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1809,14 +1872,35 @@ String _formatPrefix(int prefix) {
|
|||||||
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
|
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes, int hashByteWidth) {
|
||||||
return pathBytes
|
return PathHelper.splitPathBytes(
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
pathBytes,
|
||||||
.join(',');
|
hashByteWidth,
|
||||||
|
).map(PathHelper.formatHopHex).join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatHopCount(int count, AppLocalizations l10n) {
|
Uint8List _orientPathBytes(
|
||||||
return l10n.chat_hopsCount(count);
|
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) {
|
String _resolveName(Contact? contact, AppLocalizations l10n) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../utils/platform_info.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../helpers/cyr2lat.dart';
|
import '../helpers/cyr2lat.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
import '../helpers/reaction_helper.dart';
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../widgets/message_status_icon.dart';
|
import '../widgets/message_status_icon.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
@@ -776,17 +777,35 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _currentPathLabel(Contact contact) {
|
String _currentPathLabel(Contact contact) {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
|
||||||
// Check if user has set a path override
|
// Check if user has set a path override
|
||||||
if (contact.pathOverride != null) {
|
if (contact.pathOverride != null) {
|
||||||
if (contact.pathOverride! < 0) return context.l10n.chat_floodForced;
|
if (contact.pathOverride! < 0) return context.l10n.chat_floodForced;
|
||||||
if (contact.pathOverride == 0) return context.l10n.chat_directForced;
|
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
|
// Use device's path
|
||||||
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
|
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
|
||||||
if (contact.pathLength == 0) return context.l10n.chat_direct;
|
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) {
|
void _showContactInfo(BuildContext context) {
|
||||||
@@ -807,7 +826,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context.l10n.chat_path,
|
context.l10n.chat_path,
|
||||||
contact.pathLabel(context.l10n),
|
contact.pathLabel(
|
||||||
|
context.l10n,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context.l10n.contact_lastSeen,
|
context.l10n.contact_lastSeen,
|
||||||
|
|||||||
@@ -915,6 +915,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
return _ContactTileEntrance(
|
return _ContactTileEntrance(
|
||||||
index: index,
|
index: index,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
lastSeen: _resolveLastSeen(contact),
|
lastSeen: _resolveLastSeen(contact),
|
||||||
unreadCount: unreadCount,
|
unreadCount: unreadCount,
|
||||||
isFavorite: contact.isFavorite,
|
isFavorite: contact.isFavorite,
|
||||||
@@ -1371,7 +1372,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: context.l10n.contacts_repeaterPing,
|
title: context.l10n.contacts_repeaterPing,
|
||||||
path: Uint8List.fromList([contact.publicKey.first]),
|
path: contact.pathBytesForDisplay.isNotEmpty
|
||||||
|
? contact.pathBytesForDisplay
|
||||||
|
: _contactPathPrefix(contact, hw),
|
||||||
|
flipPathAround: true,
|
||||||
targetContact: contact,
|
targetContact: contact,
|
||||||
pathHashByteWidth: hw,
|
pathHashByteWidth: hw,
|
||||||
),
|
),
|
||||||
@@ -1405,8 +1409,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
: context.l10n.contacts_roomPing,
|
: context.l10n.contacts_roomPing,
|
||||||
path: contact.pathBytesForDisplay.isNotEmpty
|
path: contact.pathBytesForDisplay.isNotEmpty
|
||||||
? contact.pathBytesForDisplay
|
? contact.pathBytesForDisplay
|
||||||
: Uint8List.fromList([contact.publicKey.first]),
|
: _contactPathPrefix(contact, hw),
|
||||||
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
|
flipPathAround: true,
|
||||||
targetContact: contact,
|
targetContact: contact,
|
||||||
pathHashByteWidth: hw,
|
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(
|
void _confirmDelete(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
@@ -1549,6 +1563,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
|
|
||||||
class _ContactTile extends StatelessWidget {
|
class _ContactTile extends StatelessWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
final int pathHashByteWidth;
|
||||||
final DateTime lastSeen;
|
final DateTime lastSeen;
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
@@ -1557,6 +1572,7 @@ class _ContactTile extends StatelessWidget {
|
|||||||
|
|
||||||
const _ContactTile({
|
const _ContactTile({
|
||||||
required this.contact,
|
required this.contact,
|
||||||
|
required this.pathHashByteWidth,
|
||||||
required this.lastSeen,
|
required this.lastSeen,
|
||||||
required this.unreadCount,
|
required this.unreadCount,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
@@ -1676,7 +1692,10 @@ class _ContactTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
contact.pathLabel(context.l10n),
|
contact.pathLabel(
|
||||||
|
context.l10n,
|
||||||
|
pathHashByteWidth: pathHashByteWidth,
|
||||||
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -1763,6 +1782,7 @@ class _ContactTile extends StatelessWidget {
|
|||||||
class _ContactTileEntrance extends StatelessWidget {
|
class _ContactTileEntrance extends StatelessWidget {
|
||||||
final int index;
|
final int index;
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
final int pathHashByteWidth;
|
||||||
final DateTime lastSeen;
|
final DateTime lastSeen;
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
@@ -1772,6 +1792,7 @@ class _ContactTileEntrance extends StatelessWidget {
|
|||||||
const _ContactTileEntrance({
|
const _ContactTileEntrance({
|
||||||
required this.index,
|
required this.index,
|
||||||
required this.contact,
|
required this.contact,
|
||||||
|
required this.pathHashByteWidth,
|
||||||
required this.lastSeen,
|
required this.lastSeen,
|
||||||
required this.unreadCount,
|
required this.unreadCount,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
@@ -1785,6 +1806,7 @@ class _ContactTileEntrance extends StatelessWidget {
|
|||||||
index: index,
|
index: index,
|
||||||
child: _ContactTile(
|
child: _ContactTile(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
|
pathHashByteWidth: pathHashByteWidth,
|
||||||
lastSeen: lastSeen,
|
lastSeen: lastSeen,
|
||||||
unreadCount: unreadCount,
|
unreadCount: unreadCount,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
|
|||||||
+99
-39
@@ -5,13 +5,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.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/screens/path_trace_map.dart';
|
||||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
import '../models/app_settings.dart';
|
import '../models/app_settings.dart';
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -78,6 +79,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
bool _hasInitializedMap = false;
|
bool _hasInitializedMap = false;
|
||||||
bool _removedMarkersLoaded = false;
|
bool _removedMarkersLoaded = false;
|
||||||
final List<int> _pathTrace = [];
|
final List<int> _pathTrace = [];
|
||||||
|
final List<int> _pathTraceHopWidths = [];
|
||||||
final List<Contact> _pathTraceContacts = [];
|
final List<Contact> _pathTraceContacts = [];
|
||||||
final List<LatLng> _points = [];
|
final List<LatLng> _points = [];
|
||||||
final List<Polyline> _polylines = [];
|
final List<Polyline> _polylines = [];
|
||||||
@@ -426,17 +428,20 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
|
|
||||||
// Compute guessed locations with caching
|
// Compute guessed locations with caching
|
||||||
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
||||||
|
final pathHashByteWidth = connector.pathHashByteWidth
|
||||||
|
.clamp(1, 4)
|
||||||
|
.toInt();
|
||||||
final filteredKeys = guessCandidates
|
final filteredKeys = guessCandidates
|
||||||
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
||||||
.join(',');
|
.join(',');
|
||||||
final anchorKeys = allContactsWithLocation
|
final anchorKeys = allContactsWithLocation
|
||||||
.map(
|
.map(
|
||||||
(c) =>
|
(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(',');
|
.join(',');
|
||||||
final cacheKey =
|
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) {
|
if (cacheKey != _guessedLocationsCacheKey) {
|
||||||
_guessedLocationsCacheKey = cacheKey;
|
_guessedLocationsCacheKey = cacheKey;
|
||||||
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
||||||
@@ -445,6 +450,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
allContactsWithLocation,
|
allContactsWithLocation,
|
||||||
pathHistory,
|
pathHistory,
|
||||||
maxRangeKm,
|
maxRangeKm,
|
||||||
|
pathHashByteWidth,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
@@ -1006,23 +1012,19 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
List<Contact> withLocation,
|
List<Contact> withLocation,
|
||||||
PathHistoryService pathHistory,
|
PathHistoryService pathHistory,
|
||||||
double? maxRangeKm,
|
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 result = <_GuessedLocation>[];
|
||||||
|
final hopWidth = pathHashByteWidth.clamp(1, 4).toInt();
|
||||||
|
final anchorsByPrefix = <String, List<Contact>>{};
|
||||||
|
for (final repeater in withLocation) {
|
||||||
|
if (repeater.type != advTypeRepeater) continue;
|
||||||
|
if (repeater.publicKey.length < hopWidth) continue;
|
||||||
|
final prefix = PathHelper.formatHopHex(
|
||||||
|
repeater.publicKey.sublist(0, hopWidth),
|
||||||
|
);
|
||||||
|
anchorsByPrefix.putIfAbsent(prefix, () => []).add(repeater);
|
||||||
|
}
|
||||||
|
|
||||||
for (final contact in allContacts) {
|
for (final contact in allContacts) {
|
||||||
if (contact.hasLocation) continue;
|
if (contact.hasLocation) continue;
|
||||||
@@ -1036,21 +1038,23 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
|
|
||||||
// Collect the contact-side (last-hop) repeater from every known path.
|
// Collect the contact-side (last-hop) repeater from every known path.
|
||||||
// path = [device-side hop, ..., contact-side hop]
|
// path = [device-side hop, ..., contact-side hop]
|
||||||
// Only path.last is actually within radio range of the contact — using
|
// Only the last hop chunk is actually within radio range of the contact.
|
||||||
// earlier bytes would anchor against our own side of the network.
|
|
||||||
final pathSets = <List<int>>[
|
final pathSets = <List<int>>[
|
||||||
contact.path.toList(),
|
contact.path.toList(),
|
||||||
...pathHistory
|
...pathHistory
|
||||||
.getRecentPaths(contact.publicKeyHex)
|
.getRecentPaths(contact.publicKeyHex)
|
||||||
.map((r) => r.pathBytes),
|
.map((r) => r.pathBytes),
|
||||||
];
|
];
|
||||||
final lastHopBytes = <int>{};
|
|
||||||
for (final pathBytes in pathSets) {
|
for (final pathBytes in pathSets) {
|
||||||
if (pathBytes.isEmpty) continue;
|
if (pathBytes.isEmpty) continue;
|
||||||
final lastHop = pathBytes.last;
|
final lastHop = pathBytes.sublist(max(0, pathBytes.length - hopWidth));
|
||||||
lastHopBytes.add(lastHop);
|
if (lastHop.isEmpty) continue;
|
||||||
final r = repeaterByHash[lastHop];
|
|
||||||
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
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.
|
// 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
|
// Key-prefix overlaps are a visual highlight only: flag the repeaters/rooms
|
||||||
// whose first key byte collides with another repeater/room on the map.
|
// whose first key byte collides with another repeater/room on the map.
|
||||||
final overlapPrefixes = <int>{};
|
final overlapPrefixes = <String>{};
|
||||||
if (overlapsMode) {
|
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) {
|
for (final contact in contacts) {
|
||||||
if (contact.type == advTypeRepeater || contact.type == advTypeRoom) {
|
if ((contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||||
final prefix = contact.publicKey.first;
|
contact.publicKey.length >= hopWidth) {
|
||||||
|
final prefix = PathHelper.formatHopHex(
|
||||||
|
contact.publicKey.sublist(0, hopWidth),
|
||||||
|
);
|
||||||
counts[prefix] = (counts[prefix] ?? 0) + 1;
|
counts[prefix] = (counts[prefix] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1338,10 +1350,20 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
if (count > 1) overlapPrefixes.add(prefix);
|
if (count > 1) overlapPrefixes.add(prefix);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
final overlapHopWidth = context
|
||||||
|
.read<MeshCoreConnector>()
|
||||||
|
.pathHashByteWidth
|
||||||
|
.clamp(1, pubKeySize)
|
||||||
|
.toInt();
|
||||||
bool isOverlap(Contact contact) =>
|
bool isOverlap(Contact contact) =>
|
||||||
overlapsMode &&
|
overlapsMode &&
|
||||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
(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}) {
|
void addNode(Contact contact, {bool dot = false}) {
|
||||||
final overlap = isOverlap(contact);
|
final overlap = isOverlap(contact);
|
||||||
@@ -2385,7 +2407,10 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
),
|
),
|
||||||
_miniMeta(
|
_miniMeta(
|
||||||
context.l10n.map_path,
|
context.l10n.map_path,
|
||||||
contact.pathLabel(context.l10n),
|
contact.pathLabel(
|
||||||
|
context.l10n,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_miniMeta('ID', contact.publicKeyHex.substring(0, 12)),
|
_miniMeta('ID', contact.publicKeyHex.substring(0, 12)),
|
||||||
if (pos != null)
|
if (pos != null)
|
||||||
@@ -2789,7 +2814,10 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context.l10n.map_path,
|
context.l10n.map_path,
|
||||||
contact.pathLabel(context.l10n),
|
contact.pathLabel(
|
||||||
|
context.l10n,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (contact.hasLocation)
|
if (contact.hasLocation)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
@@ -3535,10 +3563,23 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
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(() {
|
setState(() {
|
||||||
_pathTrace.add(
|
_pathTrace.addAll(hopPrefix); // Add the hop-width pubkey prefix.
|
||||||
contact.publicKey[0],
|
_pathTraceHopWidths.add(hopWidth);
|
||||||
); // Add first 16 bytes of public key to path trace
|
|
||||||
_pathTraceContacts.add(
|
_pathTraceContacts.add(
|
||||||
contact.copyWith(
|
contact.copyWith(
|
||||||
latitude: position?.latitude ?? contact.latitude,
|
latitude: position?.latitude ?? contact.latitude,
|
||||||
@@ -3553,6 +3594,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isBuildingPathTrace = true;
|
_isBuildingPathTrace = true;
|
||||||
_pathTrace.clear();
|
_pathTrace.clear();
|
||||||
|
_pathTraceHopWidths.clear();
|
||||||
_pathTraceContacts.clear();
|
_pathTraceContacts.clear();
|
||||||
_points.clear();
|
_points.clear();
|
||||||
_polylines.clear();
|
_polylines.clear();
|
||||||
@@ -3562,8 +3604,19 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
|
|
||||||
void _removePath() {
|
void _removePath() {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
final recordedHopWidth = _pathTraceHopWidths.isNotEmpty
|
||||||
|
? _pathTraceHopWidths.removeLast()
|
||||||
|
: context.read<MeshCoreConnector>().pathHashByteWidth.clamp(
|
||||||
|
1,
|
||||||
|
pubKeySize,
|
||||||
|
);
|
||||||
|
final hopByteCount = min(recordedHopWidth, _pathTrace.length).toInt();
|
||||||
_pathTraceContacts.removeLast();
|
_pathTraceContacts.removeLast();
|
||||||
_pathTrace.removeLast(); // Remove last node from path trace
|
// A path trace hop can be wider than one byte; remove the full hash prefix.
|
||||||
|
_pathTrace.removeRange(
|
||||||
|
_pathTrace.length - hopByteCount,
|
||||||
|
_pathTrace.length,
|
||||||
|
);
|
||||||
_points.removeLast(); // Remove last point from points list
|
_points.removeLast(); // Remove last point from points list
|
||||||
_polylines.clear(); // Clear polylines
|
_polylines.clear(); // Clear polylines
|
||||||
});
|
});
|
||||||
@@ -3624,9 +3677,10 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SelectableText(
|
SelectableText(
|
||||||
_pathTrace
|
PathHelper.splitPathBytes(
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
_pathTrace,
|
||||||
.join(','),
|
context.read<MeshCoreConnector>().pathHashByteWidth,
|
||||||
|
).map(PathHelper.formatHopHex).join(','),
|
||||||
style: MeshTheme.mono(
|
style: MeshTheme.mono(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -3651,6 +3705,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
builder: (context) => PathTraceMapScreen(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: l10n.contacts_pathTrace,
|
title: l10n.contacts_pathTrace,
|
||||||
path: Uint8List.fromList(_pathTrace),
|
path: Uint8List.fromList(_pathTrace),
|
||||||
|
flipPathAround: false,
|
||||||
pathHashByteWidth: hashW,
|
pathHashByteWidth: hashW,
|
||||||
pathContacts: _pathTraceContacts,
|
pathContacts: _pathTraceContacts,
|
||||||
),
|
),
|
||||||
@@ -3673,6 +3728,10 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
title: l10n.contacts_pathTrace,
|
title: l10n.contacts_pathTrace,
|
||||||
path: Uint8List.fromList(_pathTrace),
|
path: Uint8List.fromList(_pathTrace),
|
||||||
flipPathAround: true,
|
flipPathAround: true,
|
||||||
|
pathHashByteWidth: context
|
||||||
|
.read<MeshCoreConnector>()
|
||||||
|
.pathHashByteWidth,
|
||||||
|
pathContacts: _pathTraceContacts,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -3695,6 +3754,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isBuildingPathTrace = false;
|
_isBuildingPathTrace = false;
|
||||||
_pathTrace.clear();
|
_pathTrace.clear();
|
||||||
|
_pathTraceHopWidths.clear();
|
||||||
_points.clear();
|
_points.clear();
|
||||||
_polylines.clear();
|
_polylines.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
+339
-162
@@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
import 'package:meshcore_open/connector/meshcore_protocol.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/l10n/l10n.dart';
|
||||||
import 'package:meshcore_open/models/app_settings.dart';
|
import 'package:meshcore_open/models/app_settings.dart';
|
||||||
import 'package:meshcore_open/models/contact.dart';
|
import 'package:meshcore_open/models/contact.dart';
|
||||||
@@ -26,9 +27,9 @@ export 'package:meshcore_open/widgets/path_map_ui.dart'
|
|||||||
show formatDistance, getPathDistanceMeters;
|
show formatDistance, getPathDistanceMeters;
|
||||||
|
|
||||||
class PathTraceData {
|
class PathTraceData {
|
||||||
final Uint8List pathData;
|
final List<Uint8List> pathData;
|
||||||
final List<double> snrData;
|
final List<double> snrData;
|
||||||
final Map<int, Contact> pathContacts;
|
final Map<String, Contact> pathContacts;
|
||||||
|
|
||||||
PathTraceData({
|
PathTraceData({
|
||||||
required this.pathData,
|
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 {
|
class PathTraceMapScreen extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Uint8List path;
|
final Uint8List path;
|
||||||
@@ -79,8 +94,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
bool _failed2Loaded = false;
|
bool _failed2Loaded = false;
|
||||||
bool _hasData = false;
|
bool _hasData = false;
|
||||||
PathTraceData? _traceData;
|
PathTraceData? _traceData;
|
||||||
// Inferred positions for hops that have no GPS location, keyed by hop byte.
|
// Inferred positions for hops that have no GPS location, keyed by hop prefix.
|
||||||
Map<int, LatLng> _inferredHopPositions = {};
|
Map<String, LatLng> _inferredHopPositions = {};
|
||||||
// Endpoint position for the target contact (GPS or guessed).
|
// Endpoint position for the target contact (GPS or guessed).
|
||||||
LatLng? _targetContactPosition;
|
LatLng? _targetContactPosition;
|
||||||
bool _targetContactIsGuessed = false;
|
bool _targetContactIsGuessed = false;
|
||||||
@@ -93,6 +108,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
double _pathDistanceMeters = 0.0;
|
double _pathDistanceMeters = 0.0;
|
||||||
bool _showNodeLabels = true;
|
bool _showNodeLabels = true;
|
||||||
Contact? _targetContact;
|
Contact? _targetContact;
|
||||||
|
Uint8List? _sentTagBytes;
|
||||||
// Live path resolved at trace time; used by the response handler for
|
// Live path resolved at trace time; used by the response handler for
|
||||||
// endpoint inference so it matches the path that was actually traced.
|
// endpoint inference so it matches the path that was actually traced.
|
||||||
Uint8List _tracedPath = Uint8List(0);
|
Uint8List _tracedPath = Uint8List(0);
|
||||||
@@ -102,17 +118,82 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
PathHistoryService? _pathHistory;
|
PathHistoryService? _pathHistory;
|
||||||
PathViewMode _viewMode = PathViewMode.single;
|
PathViewMode _viewMode = PathViewMode.single;
|
||||||
List<DisplayPath> _displayPaths = [];
|
List<DisplayPath> _displayPaths = [];
|
||||||
List<int> _primaryOutboundHops = [];
|
List<Uint8List> _primaryOutboundHops = [];
|
||||||
String _selectedPathId = 'primary';
|
String _selectedPathId = 'primary';
|
||||||
final Set<String> _hiddenPathIds = {};
|
final Set<String> _hiddenPathIds = {};
|
||||||
bool _panelCollapsed = false;
|
bool _panelCollapsed = false;
|
||||||
bool _animationEnabled = true;
|
bool _animationEnabled = true;
|
||||||
bool _followPacket = false;
|
bool _followPacket = false;
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes, [int? hashByteWidth]) {
|
||||||
return pathBytes
|
return PathHelper.splitPathBytes(
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
pathBytes,
|
||||||
.join(',');
|
hashByteWidth ?? widget.pathHashByteWidth,
|
||||||
|
).map(PathHelper.formatHopHex).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
int _traceHashByteWidth(int pathHashByteWidth) {
|
||||||
|
final width = pathHashByteWidth.clamp(1, pubKeySize).toInt();
|
||||||
|
if (width <= 1) return 1;
|
||||||
|
if (width == 2) return 2;
|
||||||
|
// Trace packets encode hash width as 1 << flags, so 3-byte path hashes
|
||||||
|
// must be traced with a 4-byte public-key prefix.
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _traceFlagsForHashWidth(int traceHashByteWidth) {
|
||||||
|
if (traceHashByteWidth <= 1) return 0;
|
||||||
|
if (traceHashByteWidth == 2) return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _reversePathByHop(Uint8List pathBytes, int hopWidth) {
|
||||||
|
final reversedHops = PathHelper.splitPathBytes(
|
||||||
|
pathBytes,
|
||||||
|
hopWidth,
|
||||||
|
).reversed;
|
||||||
|
final bytes = <int>[];
|
||||||
|
for (final hop in reversedHops) {
|
||||||
|
bytes.addAll(hop);
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List? _expandHopForTrace(
|
||||||
|
Uint8List hop,
|
||||||
|
int traceHashByteWidth,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
) {
|
||||||
|
if (hop.length == traceHashByteWidth) return hop;
|
||||||
|
|
||||||
|
final candidates = <Contact>[
|
||||||
|
...?widget.pathContacts,
|
||||||
|
if (widget.targetContact != null) widget.targetContact!,
|
||||||
|
...connector.allContactsUnfiltered,
|
||||||
|
];
|
||||||
|
for (final contact in candidates) {
|
||||||
|
if (contact.publicKey.length < traceHashByteWidth) continue;
|
||||||
|
if (!listEquals(contact.publicKey.sublist(0, hop.length), hop)) continue;
|
||||||
|
return Uint8List.fromList(
|
||||||
|
contact.publicKey.sublist(0, traceHashByteWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List? _tracePathFromBytes(
|
||||||
|
Uint8List pathBytes,
|
||||||
|
int traceHashByteWidth,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
) {
|
||||||
|
final hops = PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth);
|
||||||
|
final traceBytes = <int>[];
|
||||||
|
for (final hop in hops) {
|
||||||
|
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
|
||||||
|
if (traceHop == null) return null;
|
||||||
|
traceBytes.addAll(traceHop);
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(traceBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -232,45 +313,62 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List buildPath(Uint8List pathBytes) {
|
Uint8List? buildPath(
|
||||||
Uint8List traceBytes;
|
Uint8List pathBytes,
|
||||||
|
int traceHashByteWidth,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
) {
|
||||||
|
final pathHops = PathHelper.splitPathBytes(
|
||||||
|
pathBytes,
|
||||||
|
widget.pathHashByteWidth,
|
||||||
|
);
|
||||||
|
final hopWidth = traceHashByteWidth.clamp(1, pubKeySize).toInt();
|
||||||
|
|
||||||
if (pathBytes.isEmpty) {
|
// Compute targetPrefix if targetContact is provided
|
||||||
final pk = widget.targetContact?.publicKey;
|
Uint8List? targetPrefix;
|
||||||
final n = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
if (widget.targetContact != null) {
|
||||||
if (pk != null && pk.length >= n) {
|
final pk = widget.targetContact!.publicKey;
|
||||||
return Uint8List.fromList(pk.sublist(0, n));
|
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 ||
|
final outboundHops = <Uint8List>[];
|
||||||
widget.targetContact?.type == advTypeRoom) {
|
for (final hop in pathHops) {
|
||||||
final len = (pathBytes.length + pathBytes.length + 1);
|
final traceHop = _expandHopForTrace(hop, traceHashByteWidth, connector);
|
||||||
traceBytes = Uint8List(len);
|
if (traceHop == null) return null;
|
||||||
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
outboundHops.add(traceHop);
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
}
|
||||||
traceBytes[i] = pathBytes[i];
|
if (targetPrefix != null) {
|
||||||
if (i < pathBytes.length) {
|
// Check if targetPrefix is already the last hop in pathHops to avoid duplication
|
||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
bool alreadyEndedWithTarget = false;
|
||||||
|
if (outboundHops.isNotEmpty) {
|
||||||
|
if (listEquals(outboundHops.last, targetPrefix)) {
|
||||||
|
alreadyEndedWithTarget = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if (!alreadyEndedWithTarget) {
|
||||||
if (pathBytes.length < 2) {
|
outboundHops.add(targetPrefix);
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
/// 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 connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final traceHashByteWidth = _traceHashByteWidth(widget.pathHashByteWidth);
|
||||||
final livePath = _resolveLivePath(connector);
|
final livePath = _resolveLivePath(connector);
|
||||||
_tracedPath = livePath;
|
_tracedPath = livePath;
|
||||||
|
|
||||||
final pathTmp = widget.reversePathAround
|
final pathTmp = widget.reversePathAround
|
||||||
? Uint8List.fromList(livePath.reversed.toList())
|
? _reversePathByHop(livePath, widget.pathHashByteWidth)
|
||||||
: livePath;
|
: 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(
|
appLogger.info(
|
||||||
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
'Initiating path trace with path: '
|
||||||
|
'${_formatPathPrefixes(path, traceHashByteWidth)}',
|
||||||
tag: 'PathTraceMapScreen',
|
tag: 'PathTraceMapScreen',
|
||||||
noNotify: !mounted,
|
noNotify: !mounted,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final 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(
|
final frame = buildTraceReq(
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
sentTag,
|
||||||
0, //auth
|
0, // auth
|
||||||
0, //flag
|
flags, // flag
|
||||||
payload: path,
|
payload: tracePayload,
|
||||||
);
|
);
|
||||||
connector.sendFrame(frame);
|
connector.sendFrame(frame);
|
||||||
}
|
}
|
||||||
@@ -363,15 +484,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a binary response
|
// Check if it's a binary response
|
||||||
if (frame.length > 8 &&
|
if (frame.length >= 12 &&
|
||||||
code == pushCodeTraceData &&
|
code == pushCodeTraceData &&
|
||||||
listEquals(frame.sublist(4, 8), tagData)) {
|
(listEquals(frame.sublist(4, 8), _sentTagBytes) ||
|
||||||
|
listEquals(frame.sublist(4, 8), tagData))) {
|
||||||
_timeoutTimer?.cancel();
|
_timeoutTimer?.cancel();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
frameBuffer.skipBytes(3); //reserved + path length + flag
|
_handleTraceResponse(frame);
|
||||||
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
|
||||||
_handleTraceResponse(frame);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_timeoutTimer?.cancel();
|
_timeoutTimer?.cancel();
|
||||||
@@ -392,21 +511,30 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final buffer = BufferReader(frame);
|
final buffer = BufferReader(frame);
|
||||||
try {
|
try {
|
||||||
buffer.skipBytes(2); // Skip push code and reserved byte
|
buffer.skipBytes(2); // Skip push code and reserved byte
|
||||||
int pathLength = buffer.readUInt8();
|
final pathLenByte = buffer.readUInt8();
|
||||||
final int flags = buffer
|
final flags = buffer.readUInt8();
|
||||||
.readUInt8(); // path_sz = flags & 0x03 (path-hash mode, fw v1.11+)
|
|
||||||
buffer.skipBytes(4); // Skip tag data
|
buffer.skipBytes(4); // Skip tag data
|
||||||
buffer.skipBytes(4); // Skip auth code
|
buffer.skipBytes(4); // Skip auth code
|
||||||
final int pathSz = flags & 0x03;
|
var width = _traceHashByteWidth(1 << (flags & 0x03));
|
||||||
Uint8List pathData = buffer.readBytes(pathLength);
|
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).
|
// 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
|
List<double> snrData = buffer
|
||||||
.readBytes(snrCount)
|
.readBytes(snrCount)
|
||||||
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<int, Contact> pathContacts = {};
|
Map<String, Contact> pathContacts = {};
|
||||||
Contact lastContact = Contact(
|
Contact lastContact = Contact(
|
||||||
path: Uint8List(0),
|
path: Uint8List(0),
|
||||||
pathLength: 0,
|
pathLength: 0,
|
||||||
@@ -418,7 +546,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
lastSeen: DateTime.now(),
|
lastSeen: DateTime.now(),
|
||||||
);
|
);
|
||||||
if (widget.pathContacts != null) {
|
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 {
|
} else {
|
||||||
final contacts = connector.allContactsUnfiltered;
|
final contacts = connector.allContactsUnfiltered;
|
||||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||||
@@ -433,12 +566,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
_maxRepeaterMatchDistanceMeters) {
|
_maxRepeaterMatchDistanceMeters) {
|
||||||
return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches
|
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(
|
if (listEquals(
|
||||||
repeater.publicKey.sublist(0, 1),
|
repeater.publicKey.sublist(0, hopWidth),
|
||||||
Uint8List.fromList([repeaterData]),
|
repeaterData,
|
||||||
)) {
|
)) {
|
||||||
pathContacts[repeaterData] = repeater;
|
pathContacts[_hopKey(repeaterData)] = repeater;
|
||||||
lastContact = repeater;
|
lastContact = repeater;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,13 +582,20 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
|
|
||||||
// For hops with no GPS contact, infer position from other contacts
|
// For hops with no GPS contact, infer position from other contacts
|
||||||
// with known GPS that share the same last-hop byte.
|
// 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) {
|
for (final hop in pathData) {
|
||||||
final contact = pathContacts[hop];
|
final hopKey = _hopKey(hop);
|
||||||
|
final contact = pathContacts[hopKey];
|
||||||
if (contact != null && contact.hasLocation) continue;
|
if (contact != null && contact.hasLocation) continue;
|
||||||
final peers = connector.contacts
|
final peers = connector.contacts
|
||||||
.where(
|
.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();
|
.toList();
|
||||||
if (peers.isNotEmpty) {
|
if (peers.isNotEmpty) {
|
||||||
@@ -463,7 +605,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final lon =
|
final lon =
|
||||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||||
peers.length;
|
peers.length;
|
||||||
inferredPositions[hop] = LatLng(lat, lon);
|
inferredPositions[hopKey] = LatLng(lat, lon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,20 +627,31 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final tc = _targetContact!;
|
final tc = _targetContact!;
|
||||||
if (tc.hasLocation) {
|
if (tc.hasLocation) {
|
||||||
targetPos = LatLng(tc.latitude!, tc.longitude!);
|
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.
|
// Infer from the last hop: average GPS contacts sharing that hop.
|
||||||
// For a round-trip path (flipPathAround/reversePathAround), the target-side 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.
|
// 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
|
final lastHop = widget.reversePathAround
|
||||||
? _tracedPath.first
|
? hopsForEndpoint.first
|
||||||
: _tracedPath.last;
|
: hopsForEndpoint.last;
|
||||||
|
final lastHopKey = _hopKey(lastHop);
|
||||||
|
|
||||||
final peers = connector.allContacts
|
final peers = connector.allContacts
|
||||||
.where(
|
.where(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.hasLocation &&
|
c.hasLocation &&
|
||||||
c.path.isNotEmpty &&
|
c.path.isNotEmpty &&
|
||||||
c.path.last == lastHop,
|
_matchesHopPrefix(
|
||||||
|
_lastHopChunk(c.path, widget.pathHashByteWidth),
|
||||||
|
lastHop,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
if (peers.isNotEmpty) {
|
if (peers.isNotEmpty) {
|
||||||
@@ -515,9 +668,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
lon + offsetDeg * sin(angle),
|
lon + offsetDeg * sin(angle),
|
||||||
);
|
);
|
||||||
targetGuessed = true;
|
targetGuessed = true;
|
||||||
} else if (inferredPositions.containsKey(lastHop)) {
|
} else if (inferredPositions.containsKey(lastHopKey)) {
|
||||||
final lat = inferredPositions[lastHop]!.latitude;
|
final lat = inferredPositions[lastHopKey]!.latitude;
|
||||||
final lon = inferredPositions[lastHop]!.longitude;
|
final lon = inferredPositions[lastHopKey]!.longitude;
|
||||||
const offsetDeg = 0.003;
|
const offsetDeg = 0.003;
|
||||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||||
targetPos = LatLng(
|
targetPos = LatLng(
|
||||||
@@ -527,7 +680,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
targetGuessed = true;
|
targetGuessed = true;
|
||||||
} else {
|
} else {
|
||||||
// As a last resort, just place it at the same position as the last hop.
|
// 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) {
|
if (contact != null && contact.hasLocation) {
|
||||||
const offsetDeg = 0.003;
|
const offsetDeg = 0.003;
|
||||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||||
@@ -545,21 +698,22 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
|
|
||||||
_points = <LatLng>[];
|
_points = <LatLng>[];
|
||||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||||
int hopLast = 0;
|
String hopLast = '';
|
||||||
int hopLastLast = 0;
|
String hopLastLast = '';
|
||||||
for (final hop in _traceData!.pathData) {
|
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
|
break; //skip duplicate hops in round-trip paths
|
||||||
}
|
}
|
||||||
final contact = _traceData!.pathContacts[hop];
|
final contact = _traceData!.pathContacts[hopKey];
|
||||||
if (contact != null && contact.hasLocation) {
|
if (contact != null && contact.hasLocation) {
|
||||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||||
} else {
|
} else {
|
||||||
final inferred = inferredPositions[hop];
|
final inferred = inferredPositions[hopKey];
|
||||||
if (inferred != null) _points.add(inferred);
|
if (inferred != null) _points.add(inferred);
|
||||||
}
|
}
|
||||||
hopLastLast = hopLast;
|
hopLastLast = hopLast;
|
||||||
hopLast = hop;
|
hopLast = hopKey;
|
||||||
}
|
}
|
||||||
if (targetPos != null) {
|
if (targetPos != null) {
|
||||||
if (_targetContact != null && _targetContact!.type == advTypeChat) {
|
if (_targetContact != null && _targetContact!.type == advTypeChat) {
|
||||||
@@ -582,7 +736,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
|
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
|
||||||
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
|
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
|
||||||
_mapKey = ValueKey(
|
_mapKey = ValueKey(
|
||||||
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
|
'${context.l10n.pathTrace_you},${_traceData!.pathData.map(PathHelper.formatHopHex).join(',')}',
|
||||||
);
|
);
|
||||||
_pathDistanceMeters = getPathDistanceMeters(_points);
|
_pathDistanceMeters = getPathDistanceMeters(_points);
|
||||||
_primaryOutboundHops = _outboundHops(pathData);
|
_primaryOutboundHops = _outboundHops(pathData);
|
||||||
@@ -604,37 +758,47 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
|
|
||||||
/// Outbound hop bytes of the traced path, mirroring the round-trip
|
/// Outbound hop bytes of the traced path, mirroring the round-trip
|
||||||
/// dedup logic used when building [_points].
|
/// dedup logic used when building [_points].
|
||||||
List<int> _outboundHops(Uint8List pathData) {
|
List<Uint8List> _outboundHops(List<Uint8List> pathData) {
|
||||||
final hops = <int>[];
|
final hops = <Uint8List>[];
|
||||||
int hopLast = 0;
|
var hopLast = '';
|
||||||
int hopLastLast = 0;
|
var hopLastLast = '';
|
||||||
for (final hop in pathData) {
|
for (final hop in pathData) {
|
||||||
if (hop == hopLastLast && widget.flipPathAround) break;
|
final hopKey = _hopKey(hop);
|
||||||
|
if (hopKey == hopLastLast && widget.flipPathAround) break;
|
||||||
hops.add(hop);
|
hops.add(hop);
|
||||||
hopLastLast = hopLast;
|
hopLastLast = hopLast;
|
||||||
hopLast = hop;
|
hopLast = hopKey;
|
||||||
}
|
}
|
||||||
return hops;
|
return hops;
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact? _contactForHop(int hop, MeshCoreConnector connector) {
|
Contact? _contactForHop(Uint8List hop, MeshCoreConnector connector) {
|
||||||
final traced = _traceData?.pathContacts[hop];
|
final traced = _traceData?.pathContacts[_hopKey(hop)];
|
||||||
if (traced != null) return traced;
|
if (traced != null) return traced;
|
||||||
for (final c in connector.allContactsUnfiltered) {
|
for (final c in connector.allContactsUnfiltered) {
|
||||||
if (c.type != advTypeChat &&
|
if (c.type != advTypeChat &&
|
||||||
c.publicKey.isNotEmpty &&
|
c.publicKey.length >= hop.length &&
|
||||||
c.publicKey[0] == hop) {
|
listEquals(c.publicKey.sublist(0, hop.length), hop)) {
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
LatLng? _inferredPositionForHop(int hop, MeshCoreConnector connector) {
|
LatLng? _inferredPositionForHop(Uint8List hop, MeshCoreConnector connector) {
|
||||||
final cached = _inferredHopPositions[hop];
|
final hopKey = _hopKey(hop);
|
||||||
|
final cached = _inferredHopPositions[hopKey];
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
final peers = connector.contacts
|
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();
|
.toList();
|
||||||
if (peers.isEmpty) return null;
|
if (peers.isEmpty) return null;
|
||||||
final lat =
|
final lat =
|
||||||
@@ -642,10 +806,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final lon =
|
final lon =
|
||||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length;
|
peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length;
|
||||||
final pos = LatLng(lat, lon);
|
final pos = LatLng(lat, lon);
|
||||||
_inferredHopPositions[hop] = pos;
|
_inferredHopPositions[hopKey] = pos;
|
||||||
return 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
|
/// Rebuilds the renderable paths: the traced path as primary plus up to
|
||||||
/// four distinct alternates from the target contact's path history.
|
/// four distinct alternates from the target contact's path history.
|
||||||
void _rebuildDisplayPaths(MeshCoreConnector connector) {
|
void _rebuildDisplayPaths(MeshCoreConnector connector) {
|
||||||
@@ -663,18 +831,22 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final target = widget.targetContact;
|
final target = widget.targetContact;
|
||||||
final history = _pathHistory;
|
final history = _pathHistory;
|
||||||
if (target != null && history != null) {
|
if (target != null && history != null) {
|
||||||
final seen = <String>{_primaryOutboundHops.join(',')};
|
final seen = <String>{_pathKeyForHops(_primaryOutboundHops)};
|
||||||
var altIndex = 0;
|
var altIndex = 0;
|
||||||
for (final record in history.getRecentPaths(target.publicKeyHex)) {
|
for (final record in history.getRecentPaths(target.publicKeyHex)) {
|
||||||
if (record.pathBytes.isEmpty) continue;
|
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;
|
if (altIndex >= kAlternatePathColors.length) break;
|
||||||
final alt = _buildDisplayPath(
|
final alt = _buildDisplayPath(
|
||||||
id: 'alt-${record.pathBytes.join('-')}',
|
id: 'alt-${_pathKeyForHops(recordHops)}',
|
||||||
label: context.l10n.pathMap_alternate(altIndex + 1),
|
label: context.l10n.pathMap_alternate(altIndex + 1),
|
||||||
color: kAlternatePathColors[altIndex],
|
color: kAlternatePathColors[altIndex],
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
hops: record.pathBytes,
|
hops: recordHops,
|
||||||
record: record,
|
record: record,
|
||||||
connector: connector,
|
connector: connector,
|
||||||
);
|
);
|
||||||
@@ -699,7 +871,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
required String label,
|
required String label,
|
||||||
required Color color,
|
required Color color,
|
||||||
required bool isPrimary,
|
required bool isPrimary,
|
||||||
required List<int> hops,
|
required List<Uint8List> hops,
|
||||||
required MeshCoreConnector connector,
|
required MeshCoreConnector connector,
|
||||||
PathRecord? record,
|
PathRecord? record,
|
||||||
}) {
|
}) {
|
||||||
@@ -718,7 +890,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
|
|
||||||
for (var i = 0; i < hops.length; i++) {
|
for (var i = 0; i < hops.length; i++) {
|
||||||
final hop = hops[i];
|
final hop = hops[i];
|
||||||
final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
final contact = _contactForHop(hop, connector);
|
final contact = _contactForHop(hop, connector);
|
||||||
LatLng? pos;
|
LatLng? pos;
|
||||||
var isGps = false;
|
var isGps = false;
|
||||||
@@ -772,7 +944,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
label: label,
|
label: label,
|
||||||
color: color,
|
color: color,
|
||||||
isPrimary: isPrimary,
|
isPrimary: isPrimary,
|
||||||
hopBytes: List<int>.from(hops),
|
hopBytes: List<Uint8List>.from(hops),
|
||||||
points: points,
|
points: points,
|
||||||
pointLabels: labels,
|
pointLabels: labels,
|
||||||
pointConfirmed: confirmed,
|
pointConfirmed: confirmed,
|
||||||
@@ -933,29 +1105,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Marker> _buildHopMarkers(
|
List<Marker> _buildHopMarkers(
|
||||||
List<int> pathData, {
|
List<Uint8List> pathData, {
|
||||||
required bool showLabels,
|
required bool showLabels,
|
||||||
required Contact? target,
|
required Contact? target,
|
||||||
}) {
|
}) {
|
||||||
final markers = <Marker>[];
|
final markers = <Marker>[];
|
||||||
int hopLast = 0;
|
String hopLast = '';
|
||||||
int hopLastLast = 0;
|
String hopLastLast = '';
|
||||||
for (final hop in pathData) {
|
for (final hop in pathData) {
|
||||||
final contact = _traceData!.pathContacts[hop];
|
final hopKey = _hopKey(hop);
|
||||||
final inferred = _inferredHopPositions[hop];
|
final contact = _traceData!.pathContacts[hopKey];
|
||||||
|
final inferred = _inferredHopPositions[hopKey];
|
||||||
final hasGps = contact != null && contact.hasLocation;
|
final hasGps = contact != null && contact.hasLocation;
|
||||||
if (hop == hopLastLast && widget.flipPathAround) {
|
if (hopKey == hopLastLast && widget.flipPathAround) {
|
||||||
continue; //skip duplicate hops in round-trip paths
|
continue; //skip duplicate hops in round-trip paths
|
||||||
}
|
}
|
||||||
if (!hasGps && inferred == null) {
|
if (!hasGps && inferred == null) {
|
||||||
hopLastLast = hopLast;
|
hopLastLast = hopLast;
|
||||||
hopLast = hop;
|
hopLast = hopKey;
|
||||||
continue; //skip hops with no GPS and no inferred position
|
continue; //skip hops with no GPS and no inferred position
|
||||||
}
|
}
|
||||||
final point = hasGps
|
final point = hasGps
|
||||||
? LatLng(contact.latitude!, contact.longitude!)
|
? LatLng(contact.latitude!, contact.longitude!)
|
||||||
: inferred!;
|
: 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(
|
markers.add(
|
||||||
Marker(
|
Marker(
|
||||||
@@ -988,9 +1165,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
hasGps ? label : '~$label',
|
shortLabel,
|
||||||
style: MeshTheme.mono(
|
style: MeshTheme.mono(
|
||||||
fontSize: 10,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: hasGps ? MeshPalette.signal : MeshPalette.warn,
|
color: hasGps ? MeshPalette.signal : MeshPalette.warn,
|
||||||
),
|
),
|
||||||
@@ -1000,15 +1177,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
markers.add(
|
markers.add(_buildNodeLabelMarker(point: point, label: fullLabel));
|
||||||
_buildNodeLabelMarker(
|
|
||||||
point: point,
|
|
||||||
label: contact?.name ?? '~$label',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
hopLastLast = hopLast;
|
hopLastLast = hopLast;
|
||||||
hopLast = hop;
|
hopLast = hopKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
_addEndpointMarkers(markers, showLabels: showLabels, target: target);
|
_addEndpointMarkers(markers, showLabels: showLabels, target: target);
|
||||||
@@ -1136,17 +1308,22 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final markers = <Marker>[];
|
final markers = <Marker>[];
|
||||||
|
|
||||||
// Hop byte -> paths that use it, in display order.
|
// Hop prefix -> paths that use it, in display order.
|
||||||
final hopPaths = <int, List<DisplayPath>>{};
|
final hopPaths = <String, List<DisplayPath>>{};
|
||||||
|
final hopsByKey = <String, Uint8List>{};
|
||||||
for (final path in _visiblePaths) {
|
for (final path in _visiblePaths) {
|
||||||
for (final hop in path.hopBytes) {
|
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);
|
if (!list.contains(path)) list.add(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final entry in hopPaths.entries) {
|
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 paths = entry.value;
|
||||||
final contact = _contactForHop(hop, connector);
|
final contact = _contactForHop(hop, connector);
|
||||||
final hasGps = contact != null && contact.hasLocation;
|
final hasGps = contact != null && contact.hasLocation;
|
||||||
@@ -1154,7 +1331,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
? LatLng(contact.latitude!, contact.longitude!)
|
? LatLng(contact.latitude!, contact.longitude!)
|
||||||
: _inferredPositionForHop(hop, connector);
|
: _inferredPositionForHop(hop, connector);
|
||||||
if (point == null) continue;
|
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 baseColor = hasGps ? MeshPalette.signal : MeshPalette.warn;
|
||||||
final shared = paths.length > 1;
|
final shared = paths.length > 1;
|
||||||
|
|
||||||
@@ -1239,11 +1416,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showSharedNodeSheet(
|
void _showSharedNodeSheet(
|
||||||
int hop,
|
Uint8List hop,
|
||||||
Contact? contact,
|
Contact? contact,
|
||||||
List<DisplayPath> paths,
|
List<DisplayPath> paths,
|
||||||
) {
|
) {
|
||||||
final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
showSharedNodeSheet(
|
showSharedNodeSheet(
|
||||||
context,
|
context,
|
||||||
title:
|
title:
|
||||||
@@ -1290,29 +1467,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
String formatDirectionText(PathTraceData pathTraceData, int index) {
|
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) {
|
if (index == 0) {
|
||||||
return context.l10n.pathTrace_you;
|
return context.l10n.pathTrace_you;
|
||||||
} else {
|
} else {
|
||||||
final contactName = pathTraceData
|
final hop = pathTraceData.pathData.last;
|
||||||
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
||||||
1]]
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
?.name;
|
|
||||||
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
|
|
||||||
.toRadixString(16)
|
|
||||||
.padLeft(2, '0')
|
|
||||||
.toUpperCase();
|
|
||||||
return contactName != null
|
return contactName != null
|
||||||
? "$hex: $contactName"
|
? "$hex: $contactName"
|
||||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final contactName =
|
final hop = pathTraceData.pathData[index - 1];
|
||||||
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
||||||
final hex = pathTraceData.pathData[index - 1]
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
.toRadixString(16)
|
|
||||||
.padLeft(2, '0')
|
|
||||||
.toUpperCase();
|
|
||||||
return contactName != null
|
return contactName != null
|
||||||
? "$hex: $contactName"
|
? "$hex: $contactName"
|
||||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||||
@@ -1320,14 +1496,18 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
|
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) {
|
if (index == 0) {
|
||||||
final contactName =
|
final hop = pathTraceData.pathData.first;
|
||||||
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
||||||
final hex = pathTraceData.pathData[0]
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
.toRadixString(16)
|
|
||||||
.padLeft(2, '0')
|
|
||||||
.toUpperCase();
|
|
||||||
return contactName != null
|
return contactName != null
|
||||||
? "$hex: $contactName"
|
? "$hex: $contactName"
|
||||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||||
@@ -1335,12 +1515,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
return context.l10n.pathTrace_you;
|
return context.l10n.pathTrace_you;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final contactName =
|
final hop = pathTraceData.pathData[index];
|
||||||
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
||||||
final hex = pathTraceData.pathData[index]
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
.toRadixString(16)
|
|
||||||
.padLeft(2, '0')
|
|
||||||
.toUpperCase();
|
|
||||||
return contactName != null
|
return contactName != null
|
||||||
? "$hex: $contactName"
|
? "$hex: $contactName"
|
||||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||||
@@ -1669,11 +1846,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
||||||
final hopUseCount = <int, int>{};
|
final hopUseCount = <String, int>{};
|
||||||
if (_viewMode == PathViewMode.combined) {
|
if (_viewMode == PathViewMode.combined) {
|
||||||
for (final p in _visiblePaths) {
|
for (final p in _visiblePaths) {
|
||||||
for (final hop in p.hopBytes.toSet()) {
|
for (final hopKey in p.hopBytes.map(PathHelper.formatHopHex).toSet()) {
|
||||||
hopUseCount.update(hop, (v) => v + 1, ifAbsent: () => 1);
|
hopUseCount.update(hopKey, (v) => v + 1, ifAbsent: () => 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1689,7 +1866,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
Widget? trailing;
|
Widget? trailing;
|
||||||
if (index < path.hopBytes.length) {
|
if (index < path.hopBytes.length) {
|
||||||
final hop = path.hopBytes[index];
|
final hop = path.hopBytes[index];
|
||||||
final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
final hex = PathHelper.formatHopHex(hop);
|
||||||
final contact = _contactForHop(hop, connector);
|
final contact = _contactForHop(hop, connector);
|
||||||
title = contact != null
|
title = contact != null
|
||||||
? '$hex: ${contact.name}'
|
? '$hex: ${contact.name}'
|
||||||
@@ -1702,7 +1879,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen>
|
|||||||
: inferred
|
: inferred
|
||||||
? l10n.pathTrace_legendInferred
|
? l10n.pathTrace_legendInferred
|
||||||
: l10n.pathMap_noLocation;
|
: l10n.pathMap_noLocation;
|
||||||
final sharedCount = hopUseCount[hop] ?? 0;
|
final sharedCount = hopUseCount[_hopKey(hop)] ?? 0;
|
||||||
subtitle = sharedCount > 1
|
subtitle = sharedCount > 1
|
||||||
? '$status · ${l10n.pathMap_sharedNodeCount(sharedCount)}'
|
? '$status · ${l10n.pathMap_sharedNodeCount(sharedCount)}'
|
||||||
: status;
|
: status;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -31,6 +32,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final settingsService = context.watch<AppSettingsService>();
|
final settingsService = context.watch<AppSettingsService>();
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final chemistry = settingsService.batteryChemistryForRepeater(
|
final chemistry = settingsService.batteryChemistryForRepeater(
|
||||||
repeater.publicKeyHex,
|
repeater.publicKeyHex,
|
||||||
);
|
);
|
||||||
@@ -85,7 +87,10 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
repeater.pathLabel(l10n),
|
repeater.pathLabel(
|
||||||
|
l10n,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
|
),
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(color: scheme.onSurfaceVariant),
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -468,10 +468,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
? () => pushCompanionRadioStatsScreen(context)
|
? () => pushCompanionRadioStatsScreen(context)
|
||||||
: null,
|
: 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(
|
Widget _buildLocationCardContent(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MeshCoreConnector connector,
|
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) {
|
void _editLocation(BuildContext context, MeshCoreConnector connector) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final settingsService = context.read<AppSettingsService>();
|
final settingsService = context.read<AppSettingsService>();
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class NotificationService {
|
|||||||
|
|
||||||
AppLocalizations get _l10n => lookupAppLocalizations(_locale);
|
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
|
// Rate limiting to prevent notification storms
|
||||||
// (Added after getting notification-flooded while evaluating RF flood management. The irony.)
|
// (Added after getting notification-flooded while evaluating RF flood management. The irony.)
|
||||||
static const _minNotificationInterval = Duration(seconds: 3);
|
static const _minNotificationInterval = Duration(seconds: 3);
|
||||||
@@ -356,11 +361,11 @@ class NotificationService {
|
|||||||
String _getNotificationIdentifier(_PendingNotification n) {
|
String _getNotificationIdentifier(_PendingNotification n) {
|
||||||
switch (n.type) {
|
switch (n.type) {
|
||||||
case _NotificationType.advert:
|
case _NotificationType.advert:
|
||||||
return n.body;
|
return _logSafe(n.body);
|
||||||
case _NotificationType.message:
|
case _NotificationType.message:
|
||||||
return 'from: ${n.title}';
|
return 'from: ${_logSafe(n.title)}';
|
||||||
case _NotificationType.channelMessage:
|
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)
|
// Show first few device names in batch summary for debugging (only if adverts exist)
|
||||||
final deviceInfo = adverts.isNotEmpty
|
final deviceInfo = adverts.isNotEmpty
|
||||||
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
|
? ' (${adverts.take(5).map((n) => _logSafe(n.body)).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
|
||||||
: '';
|
: '';
|
||||||
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
|
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class ChannelMessageStore {
|
|||||||
'channelIndex': msg.channelIndex,
|
'channelIndex': msg.channelIndex,
|
||||||
'repeatCount': msg.repeatCount,
|
'repeatCount': msg.repeatCount,
|
||||||
'pathLength': msg.pathLength,
|
'pathLength': msg.pathLength,
|
||||||
|
'pathHashWidth': msg.pathHashWidth,
|
||||||
'pathBytes': base64Encode(msg.pathBytes),
|
'pathBytes': base64Encode(msg.pathBytes),
|
||||||
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
||||||
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
||||||
@@ -126,6 +127,38 @@ class ChannelMessageStore {
|
|||||||
ChannelMessage _messageFromJson(Map<String, dynamic> json) {
|
ChannelMessage _messageFromJson(Map<String, dynamic> json) {
|
||||||
final rawText = json['text'] as String;
|
final rawText = json['text'] as String;
|
||||||
final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText;
|
final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText;
|
||||||
|
|
||||||
|
final rawPathLength = json['pathLength'] as int?;
|
||||||
|
final rawPathBytes = json['pathBytes'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
|
||||||
|
: Uint8List(0);
|
||||||
|
final rawPathHashWidth = json['pathHashWidth'] as int?;
|
||||||
|
|
||||||
|
int? decodedPathLength = rawPathLength;
|
||||||
|
Uint8List decodedPathBytes = rawPathBytes;
|
||||||
|
int? decodedPathHashWidth = rawPathHashWidth;
|
||||||
|
|
||||||
|
if (rawPathLength != null) {
|
||||||
|
if (rawPathLength == 0xFF || rawPathLength < 0) {
|
||||||
|
decodedPathLength = -1;
|
||||||
|
decodedPathBytes = Uint8List(0);
|
||||||
|
} else if (rawPathLength >= 64) {
|
||||||
|
final mode = (rawPathLength & 0xC0) >> 6;
|
||||||
|
final hopCount = rawPathLength & 0x3F;
|
||||||
|
final width = mode + 1;
|
||||||
|
final byteLen = hopCount * width;
|
||||||
|
decodedPathLength = hopCount;
|
||||||
|
decodedPathHashWidth = width;
|
||||||
|
if (byteLen <= rawPathBytes.length) {
|
||||||
|
decodedPathBytes = rawPathBytes.sublist(0, byteLen);
|
||||||
|
} else {
|
||||||
|
decodedPathBytes = Uint8List(0);
|
||||||
|
}
|
||||||
|
} else if (rawPathLength == 0) {
|
||||||
|
decodedPathBytes = Uint8List(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ChannelMessage(
|
return ChannelMessage(
|
||||||
senderKey: json['senderKey'] != null
|
senderKey: json['senderKey'] != null
|
||||||
? Uint8List.fromList(base64Decode(json['senderKey']))
|
? Uint8List.fromList(base64Decode(json['senderKey']))
|
||||||
@@ -143,10 +176,9 @@ class ChannelMessageStore {
|
|||||||
isOutgoing: json['isOutgoing'] as bool,
|
isOutgoing: json['isOutgoing'] as bool,
|
||||||
status: ChannelMessageStatus.values[json['status'] as int],
|
status: ChannelMessageStatus.values[json['status'] as int],
|
||||||
repeatCount: (json['repeatCount'] as int?) ?? 0,
|
repeatCount: (json['repeatCount'] as int?) ?? 0,
|
||||||
pathLength: json['pathLength'] as int?,
|
pathLength: decodedPathLength,
|
||||||
pathBytes: json['pathBytes'] != null
|
pathHashWidth: decodedPathHashWidth,
|
||||||
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
|
pathBytes: decodedPathBytes,
|
||||||
: Uint8List(0),
|
|
||||||
pathVariants: (json['pathVariants'] as List<dynamic>?)
|
pathVariants: (json['pathVariants'] as List<dynamic>?)
|
||||||
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
|
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@@ -55,15 +55,40 @@ class ContactDiscoveryStore {
|
|||||||
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
||||||
final lastMessageMs = json['lastMessageAt'] as int?;
|
final lastMessageMs = json['lastMessageAt'] as int?;
|
||||||
final lastModifiedMs = json['lastModified'] as int?;
|
final lastModifiedMs = json['lastModified'] as int?;
|
||||||
|
|
||||||
|
final rawPathLength = json['pathLength'] as int? ?? -1;
|
||||||
|
final rawPath = json['path'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['path'] as String))
|
||||||
|
: Uint8List(0);
|
||||||
|
|
||||||
|
int decodedPathLength = rawPathLength;
|
||||||
|
Uint8List decodedPath = rawPath;
|
||||||
|
|
||||||
|
if (rawPathLength == 0xFF || rawPathLength < 0) {
|
||||||
|
decodedPathLength = -1;
|
||||||
|
decodedPath = Uint8List(0);
|
||||||
|
} else if (rawPathLength >= 64) {
|
||||||
|
final mode = (rawPathLength & 0xC0) >> 6;
|
||||||
|
final hopCount = rawPathLength & 0x3F;
|
||||||
|
final width = mode + 1;
|
||||||
|
final byteLen = hopCount * width;
|
||||||
|
decodedPathLength = hopCount;
|
||||||
|
if (byteLen <= rawPath.length) {
|
||||||
|
decodedPath = rawPath.sublist(0, byteLen);
|
||||||
|
} else {
|
||||||
|
decodedPath = Uint8List(0);
|
||||||
|
}
|
||||||
|
} else if (rawPathLength == 0) {
|
||||||
|
decodedPath = Uint8List(0);
|
||||||
|
}
|
||||||
|
|
||||||
return Contact(
|
return Contact(
|
||||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||||
name: json['name'] as String? ?? 'Unknown',
|
name: json['name'] as String? ?? 'Unknown',
|
||||||
type: json['type'] as int? ?? 0,
|
type: json['type'] as int? ?? 0,
|
||||||
flags: json['flags'] as int? ?? 0,
|
flags: json['flags'] as int? ?? 0,
|
||||||
pathLength: json['pathLength'] as int? ?? -1,
|
pathLength: decodedPathLength,
|
||||||
path: json['path'] != null
|
path: decodedPath,
|
||||||
? Uint8List.fromList(base64Decode(json['path'] as String))
|
|
||||||
: Uint8List(0),
|
|
||||||
pathOverride: json['pathOverride'] as int?,
|
pathOverride: json['pathOverride'] as int?,
|
||||||
pathOverrideBytes: json['pathOverrideBytes'] != null
|
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||||
? Uint8List.fromList(
|
? Uint8List.fromList(
|
||||||
|
|||||||
@@ -88,15 +88,40 @@ class ContactStore {
|
|||||||
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
||||||
final lastMessageMs = json['lastMessageAt'] as int?;
|
final lastMessageMs = json['lastMessageAt'] as int?;
|
||||||
final lastModifiedMs = json['lastModified'] as int?;
|
final lastModifiedMs = json['lastModified'] as int?;
|
||||||
|
|
||||||
|
final rawPathLength = json['pathLength'] as int? ?? -1;
|
||||||
|
final rawPath = json['path'] != null
|
||||||
|
? Uint8List.fromList(base64Decode(json['path'] as String))
|
||||||
|
: Uint8List(0);
|
||||||
|
|
||||||
|
int decodedPathLength = rawPathLength;
|
||||||
|
Uint8List decodedPath = rawPath;
|
||||||
|
|
||||||
|
if (rawPathLength == 0xFF || rawPathLength < 0) {
|
||||||
|
decodedPathLength = -1;
|
||||||
|
decodedPath = Uint8List(0);
|
||||||
|
} else if (rawPathLength >= 64) {
|
||||||
|
final mode = (rawPathLength & 0xC0) >> 6;
|
||||||
|
final hopCount = rawPathLength & 0x3F;
|
||||||
|
final width = mode + 1;
|
||||||
|
final byteLen = hopCount * width;
|
||||||
|
decodedPathLength = hopCount;
|
||||||
|
if (byteLen <= rawPath.length) {
|
||||||
|
decodedPath = rawPath.sublist(0, byteLen);
|
||||||
|
} else {
|
||||||
|
decodedPath = Uint8List(0);
|
||||||
|
}
|
||||||
|
} else if (rawPathLength == 0) {
|
||||||
|
decodedPath = Uint8List(0);
|
||||||
|
}
|
||||||
|
|
||||||
return Contact(
|
return Contact(
|
||||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||||
name: json['name'] as String? ?? 'Unknown',
|
name: json['name'] as String? ?? 'Unknown',
|
||||||
type: json['type'] as int? ?? 0,
|
type: json['type'] as int? ?? 0,
|
||||||
flags: json['flags'] as int? ?? 0,
|
flags: json['flags'] as int? ?? 0,
|
||||||
pathLength: json['pathLength'] as int? ?? -1,
|
pathLength: decodedPathLength,
|
||||||
path: json['path'] != null
|
path: decodedPath,
|
||||||
? Uint8List.fromList(base64Decode(json['path'] as String))
|
|
||||||
: Uint8List(0),
|
|
||||||
pathOverride: json['pathOverride'] as int?,
|
pathOverride: json['pathOverride'] as int?,
|
||||||
pathOverrideBytes: json['pathOverrideBytes'] != null
|
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||||
? Uint8List.fromList(
|
? Uint8List.fromList(
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ class MessageStore {
|
|||||||
final decodedText = isCli
|
final decodedText = isCli
|
||||||
? rawText
|
? rawText
|
||||||
: (Smaz.tryDecodePrefixed(rawText) ?? 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(
|
return Message(
|
||||||
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
|
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
|
||||||
text: decodedText,
|
text: decodedText,
|
||||||
@@ -138,10 +167,8 @@ class MessageStore {
|
|||||||
? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int)
|
? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int)
|
||||||
: null,
|
: null,
|
||||||
tripTimeMs: json['tripTimeMs'] as int?,
|
tripTimeMs: json['tripTimeMs'] as int?,
|
||||||
pathLength: json['pathLength'] as int?,
|
pathLength: decodedPathLength,
|
||||||
pathBytes: json['pathBytes'] != null
|
pathBytes: decodedPathBytes,
|
||||||
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
|
|
||||||
: Uint8List(0),
|
|
||||||
reactions:
|
reactions:
|
||||||
(json['reactions'] as Map<String, dynamic>?)?.map(
|
(json['reactions'] as Map<String, dynamic>?)?.map(
|
||||||
(key, value) => MapEntry(key, value as int),
|
(key, value) => MapEntry(key, value as int),
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ import '../models/contact.dart';
|
|||||||
class PathEditorSheet extends StatefulWidget {
|
class PathEditorSheet extends StatefulWidget {
|
||||||
final List<Contact> availableContacts;
|
final List<Contact> availableContacts;
|
||||||
final List<int> initialPath;
|
final List<int> initialPath;
|
||||||
|
final int pathHashByteWidth;
|
||||||
|
|
||||||
const PathEditorSheet({
|
const PathEditorSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.availableContacts,
|
required this.availableContacts,
|
||||||
this.initialPath = const [],
|
this.initialPath = const [],
|
||||||
|
this.pathHashByteWidth = pathHashSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<Uint8List?> show(
|
static Future<Uint8List?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<Contact> availableContacts,
|
required List<Contact> availableContacts,
|
||||||
List<int> initialPath = const [],
|
List<int> initialPath = const [],
|
||||||
|
int pathHashByteWidth = pathHashSize,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<Uint8List>(
|
return showModalBottomSheet<Uint8List>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -36,6 +39,7 @@ class PathEditorSheet extends StatefulWidget {
|
|||||||
child: PathEditorSheet(
|
child: PathEditorSheet(
|
||||||
availableContacts: availableContacts,
|
availableContacts: availableContacts,
|
||||||
initialPath: initialPath,
|
initialPath: initialPath,
|
||||||
|
pathHashByteWidth: pathHashByteWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -48,9 +52,9 @@ class PathEditorSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _Hop {
|
class _Hop {
|
||||||
final int id;
|
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> {
|
class _PathEditorSheetState extends State<PathEditorSheet> {
|
||||||
@@ -66,8 +70,11 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
for (final byte in widget.initialPath) {
|
for (final hop in PathHelper.splitPathBytes(
|
||||||
_hops.add(_Hop(_nextHopId++, byte));
|
widget.initialPath,
|
||||||
|
widget.pathHashByteWidth,
|
||||||
|
)) {
|
||||||
|
_hops.add(_Hop(_nextHopId++, hop));
|
||||||
}
|
}
|
||||||
_syncHexFromHops();
|
_syncHexFromHops();
|
||||||
}
|
}
|
||||||
@@ -90,9 +97,9 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
|||||||
|
|
||||||
void _syncHexFromHops() {
|
void _syncHexFromHops() {
|
||||||
_syncingHex = true;
|
_syncingHex = true;
|
||||||
_hexController.text = PathHelper.formatPathHex(
|
_hexController.text = _hops
|
||||||
_hops.map((h) => h.byte).toList(),
|
.map((h) => PathHelper.formatHopHex(h.bytes))
|
||||||
);
|
.join(',');
|
||||||
_syncingHex = false;
|
_syncingHex = false;
|
||||||
_hexError = null;
|
_hexError = null;
|
||||||
}
|
}
|
||||||
@@ -104,8 +111,13 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
|||||||
.split(RegExp(r'[,\s]+'))
|
.split(RegExp(r'[,\s]+'))
|
||||||
.where((t) => t.isNotEmpty)
|
.where((t) => t.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
final width = widget.pathHashByteWidth.clamp(1, pubKeySize).toInt();
|
||||||
|
final expectedChars = width * 2;
|
||||||
final invalid = tokens
|
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();
|
.toList();
|
||||||
setState(() {
|
setState(() {
|
||||||
if (invalid.isNotEmpty) {
|
if (invalid.isNotEmpty) {
|
||||||
@@ -120,15 +132,29 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
|||||||
_hops
|
_hops
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(
|
..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) {
|
void _addHop(Contact contact) {
|
||||||
if (_hops.length >= _maxHops) return;
|
if (_hops.length >= _maxHops) return;
|
||||||
|
final width = widget.pathHashByteWidth
|
||||||
|
.clamp(1, contact.publicKey.length)
|
||||||
|
.toInt();
|
||||||
setState(() {
|
setState(() {
|
||||||
_hops.add(_Hop(_nextHopId++, contact.publicKey.first));
|
_hops.add(
|
||||||
|
_Hop(
|
||||||
|
_nextHopId++,
|
||||||
|
Uint8List.fromList(contact.publicKey.sublist(0, width)),
|
||||||
|
),
|
||||||
|
);
|
||||||
_syncHexFromHops();
|
_syncHexFromHops();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,18 +175,31 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _save() {
|
void _save() {
|
||||||
Navigator.pop(
|
final bytes = <int>[];
|
||||||
context,
|
for (final hop in _hops) {
|
||||||
Uint8List.fromList(_hops.map((h) => h.byte).toList()),
|
bytes.addAll(hop.bytes);
|
||||||
);
|
}
|
||||||
|
Navigator.pop(context, Uint8List.fromList(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _hopTile(BuildContext context, int index) {
|
Widget _hopTile(BuildContext context, int index) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final hop = _hops[index];
|
final hop = _hops[index];
|
||||||
final hex = PathHelper.hopHex(hop.byte);
|
final hex = PathHelper.formatHopHex(hop.bytes);
|
||||||
final name = PathHelper.hopName(hop.byte, widget.availableContacts);
|
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(
|
return ListTile(
|
||||||
key: ValueKey(hop.id),
|
key: ValueKey(hop.id),
|
||||||
@@ -224,7 +263,7 @@ class _PathEditorSheetState extends State<PathEditorSheet> {
|
|||||||
),
|
),
|
||||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(
|
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),
|
trailing: const Icon(Icons.add_circle_outline),
|
||||||
onTap: full ? null : () => _addHop(contact),
|
onTap: full ? null : () => _addHop(contact),
|
||||||
|
|||||||
@@ -469,7 +469,10 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
repeater.pathLabel(context.l10n),
|
repeater.pathLabel(
|
||||||
|
context.l10n,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -405,7 +405,10 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
repeater.pathLabel(context.l10n),
|
repeater.pathLabel(
|
||||||
|
context.l10n,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -107,11 +107,13 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
|||||||
context,
|
context,
|
||||||
availableContacts: available,
|
availableContacts: available,
|
||||||
initialPath: initial,
|
initialPath: initial,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
);
|
);
|
||||||
if (result == null || !mounted) return;
|
if (result == null || !mounted) return;
|
||||||
|
final hopCount = result.length ~/ connector.pathHashByteWidth;
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
contact,
|
contact,
|
||||||
pathLen: result.length,
|
pathLen: hopCount,
|
||||||
pathBytes: result,
|
pathBytes: result,
|
||||||
);
|
);
|
||||||
await _verifyPath(connector, contact, result);
|
await _verifyPath(connector, contact, result);
|
||||||
@@ -123,9 +125,10 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
|||||||
PathRecord record,
|
PathRecord record,
|
||||||
) async {
|
) async {
|
||||||
final bytes = Uint8List.fromList(record.pathBytes);
|
final bytes = Uint8List.fromList(record.pathBytes);
|
||||||
|
final hopCount = bytes.length ~/ connector.pathHashByteWidth;
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
contact,
|
contact,
|
||||||
pathLen: bytes.length,
|
pathLen: hopCount,
|
||||||
pathBytes: bytes,
|
pathBytes: bytes,
|
||||||
);
|
);
|
||||||
await _verifyPath(connector, contact, bytes);
|
await _verifyPath(connector, contact, bytes);
|
||||||
@@ -157,11 +160,17 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
|||||||
setState(() => _syncStatus = context.l10n.chat_pathCleared);
|
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) {
|
if (record.pathBytes.isNotEmpty) {
|
||||||
final first = record.pathBytes.first;
|
|
||||||
for (var i = 0; i < ranked.length && i < 3; i++) {
|
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) {
|
return switch (i) {
|
||||||
0 => _PathQuality.strong,
|
0 => _PathQuality.strong,
|
||||||
1 => _PathQuality.good,
|
1 => _PathQuality.good,
|
||||||
@@ -229,14 +238,22 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
|||||||
case _RoutingMode.manual:
|
case _RoutingMode.manual:
|
||||||
final bytes = contact.pathOverrideBytes ?? Uint8List(0);
|
final bytes = contact.pathOverrideBytes ?? Uint8List(0);
|
||||||
if (bytes.isEmpty) return l10n.routing_directNoHops;
|
if (bytes.isEmpty) return l10n.routing_directNoHops;
|
||||||
return PathHelper.resolvePathNames(bytes, connector.allContacts);
|
return PathHelper.resolvePathNames(
|
||||||
|
bytes,
|
||||||
|
connector.allContacts,
|
||||||
|
connector.pathHashByteWidth,
|
||||||
|
);
|
||||||
case _RoutingMode.auto:
|
case _RoutingMode.auto:
|
||||||
if (contact.pathLength < 0) return l10n.routing_noPathYet;
|
if (contact.pathLength < 0) return l10n.routing_noPathYet;
|
||||||
if (contact.pathLength == 0) return l10n.routing_directNoHops;
|
if (contact.pathLength == 0) return l10n.routing_directNoHops;
|
||||||
if (contact.path.isEmpty) {
|
if (contact.path.isEmpty) {
|
||||||
return l10n.chat_hopsCount(contact.pathLength);
|
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,
|
List<int> pathBytes,
|
||||||
) {
|
) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
final formattedPath = PathHelper.splitPathBytes(
|
||||||
|
pathBytes,
|
||||||
|
connector.pathHashByteWidth,
|
||||||
|
).map(PathHelper.formatHopHex).join(',');
|
||||||
final resolvedNames = PathHelper.resolvePathNames(
|
final resolvedNames = PathHelper.resolvePathNames(
|
||||||
pathBytes,
|
pathBytes,
|
||||||
connector.allContacts,
|
connector.allContacts,
|
||||||
|
connector.pathHashByteWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -521,11 +542,21 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
|||||||
listEquals(record.pathBytes, contact.path)));
|
listEquals(record.pathBytes, contact.path)));
|
||||||
|
|
||||||
final title = hasBytes
|
final title = hasBytes
|
||||||
? PathHelper.resolvePathNames(record.pathBytes, connector.allContacts)
|
? PathHelper.resolvePathNames(
|
||||||
|
record.pathBytes,
|
||||||
|
connector.allContacts,
|
||||||
|
connector.pathHashByteWidth,
|
||||||
|
)
|
||||||
: l10n.chat_hopsCount(record.hopCount);
|
: l10n.chat_hopsCount(record.hopCount);
|
||||||
|
final displayHopCount = hasBytes
|
||||||
|
? PathHelper.splitPathBytes(
|
||||||
|
record.pathBytes,
|
||||||
|
connector.pathHashByteWidth,
|
||||||
|
).length
|
||||||
|
: record.hopCount;
|
||||||
|
|
||||||
final line1 =
|
final line1 =
|
||||||
'${l10n.chat_hopsCount(record.hopCount)} • ${_qualityLabel(context, quality)}';
|
'${l10n.chat_hopsCount(displayHopCount)} • ${_qualityLabel(context, quality)}';
|
||||||
final line2Parts = <String>[
|
final line2Parts = <String>[
|
||||||
record.timestamp != null
|
record.timestamp != null
|
||||||
? l10n.routing_lastWorked(_relativeTime(context, record.timestamp!))
|
? l10n.routing_lastWorked(_relativeTime(context, record.timestamp!))
|
||||||
@@ -621,7 +652,10 @@ class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
|||||||
pathService
|
pathService
|
||||||
.getRecentPaths(contact.publicKeyHex)
|
.getRecentPaths(contact.publicKeyHex)
|
||||||
.map(
|
.map(
|
||||||
(r) => (quality: _qualityOf(r, rankedRepeaters), record: r),
|
(r) => (
|
||||||
|
quality: _qualityOf(connector, r, rankedRepeaters),
|
||||||
|
record: r,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../theme/mesh_theme.dart';
|
import '../theme/mesh_theme.dart';
|
||||||
@@ -11,15 +13,27 @@ import 'signal_ui.dart';
|
|||||||
|
|
||||||
Contact? _getRepeaterPrefixMatchNearLocation(
|
Contact? _getRepeaterPrefixMatchNearLocation(
|
||||||
List<Contact> contacts,
|
List<Contact> contacts,
|
||||||
int pubkeyFirstByte, {
|
List<int> pubkeyPrefix, {
|
||||||
|
String? contactKeyHex,
|
||||||
LatLng? searchPoint,
|
LatLng? searchPoint,
|
||||||
bool preferFavorites = false,
|
bool preferFavorites = false,
|
||||||
}) {
|
}) {
|
||||||
|
if (contactKeyHex != null) {
|
||||||
|
for (final c in contacts) {
|
||||||
|
if (c.publicKeyHex == contactKeyHex) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final candidates = contacts
|
final candidates = contacts
|
||||||
.where(
|
.where(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.publicKey.isNotEmpty &&
|
c.publicKey.length >= pubkeyPrefix.length &&
|
||||||
c.publicKey.first == pubkeyFirstByte &&
|
listEquals(
|
||||||
|
c.publicKey.sublist(0, pubkeyPrefix.length),
|
||||||
|
pubkeyPrefix,
|
||||||
|
) &&
|
||||||
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -164,7 +178,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
),
|
),
|
||||||
if (directRepeater != null)
|
if (directRepeater != null)
|
||||||
Text(
|
Text(
|
||||||
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
|
'${directRepeaters.length}: ${directRepeater.pubkeyPrefixHex}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -234,15 +248,16 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
|
|
||||||
final contact = _getRepeaterPrefixMatchNearLocation(
|
final contact = _getRepeaterPrefixMatchNearLocation(
|
||||||
allContacts,
|
allContacts,
|
||||||
repeater.pubkeyFirstByte,
|
repeater.pubkeyPrefix,
|
||||||
|
contactKeyHex: repeater.contactKeyHex,
|
||||||
searchPoint: selfPoint,
|
searchPoint: selfPoint,
|
||||||
preferFavorites: true,
|
preferFavorites: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final name = contact?.name;
|
final name = contact?.name;
|
||||||
final hex = repeater.pubkeyFirstByte
|
final prefixLabel = PathHelper.formatHopHex(
|
||||||
.toRadixString(16)
|
repeater.pubkeyPrefix,
|
||||||
.padLeft(2, '0');
|
);
|
||||||
final snrColor = MeshTheme.snrColor(
|
final snrColor = MeshTheme.snrColor(
|
||||||
repeater.snr,
|
repeater.snr,
|
||||||
blocked: false,
|
blocked: false,
|
||||||
@@ -256,7 +271,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
AvatarCircle(
|
AvatarCircle(
|
||||||
name: name ?? hex,
|
name: name ?? prefixLabel,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: snrColor,
|
color: snrColor,
|
||||||
),
|
),
|
||||||
@@ -266,7 +281,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name ?? hex,
|
name ?? prefixLabel,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -22,15 +22,64 @@ Contact _contact({
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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 = [
|
final contacts = [
|
||||||
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
|
_contact(firstByte: 0xA1, name: 'Repeater1', type: advTypeRepeater),
|
||||||
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater),
|
_contact(firstByte: 0xC1, name: 'Room42', type: advTypeRoom),
|
||||||
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', 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 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/contact.dart';
|
||||||
import 'package:meshcore_open/models/path_history.dart';
|
import 'package:meshcore_open/models/path_history.dart';
|
||||||
import 'package:meshcore_open/models/app_settings.dart';
|
import 'package:meshcore_open/models/app_settings.dart';
|
||||||
import 'package:meshcore_open/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/services/app_settings_service.dart';
|
||||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||||
|
import 'package:meshcore_open/storage/message_store.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
// Builds a valid contact frame with the given pathLen and optional overrides.
|
// Builds a valid contact frame with the given pathLen and optional overrides.
|
||||||
@@ -40,6 +46,38 @@ Uint8List _buildContactFrame({
|
|||||||
return Uint8List.fromList(writer.toBytes());
|
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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -49,6 +87,67 @@ void main() {
|
|||||||
await PrefsManager.initialize();
|
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', () {
|
group('Contact.fromFrame — pathLen mapping', () {
|
||||||
test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () {
|
test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () {
|
||||||
final frame = _buildContactFrame(pathLen: 0);
|
final frame = _buildContactFrame(pathLen: 0);
|
||||||
@@ -64,11 +163,11 @@ void main() {
|
|||||||
expect(contact!.pathLength, equals(1));
|
expect(contact!.pathLength, equals(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pathLen == 64 (maxPathSize) → pathLength == 64', () {
|
test('pathLen == 64 (mode 1, 0 hops) → pathLength == 0', () {
|
||||||
final frame = _buildContactFrame(pathLen: maxPathSize);
|
final frame = _buildContactFrame(pathLen: 64);
|
||||||
final contact = Contact.fromFrame(frame);
|
final contact = Contact.fromFrame(frame);
|
||||||
expect(contact, isNotNull);
|
expect(contact, isNotNull);
|
||||||
expect(contact!.pathLength, equals(maxPathSize));
|
expect(contact!.pathLength, equals(0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pathLen == 0xFF → pathLength == -1 (flood)', () {
|
test('pathLen == 0xFF → pathLength == -1 (flood)', () {
|
||||||
@@ -78,11 +177,18 @@ void main() {
|
|||||||
expect(contact!.pathLength, equals(-1));
|
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 frame = _buildContactFrame(pathLen: 65);
|
||||||
final contact = Contact.fromFrame(frame);
|
final contact = Contact.fromFrame(frame);
|
||||||
expect(contact, isNotNull);
|
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', () {
|
group('AppSettingsService — gps interval fallback', () {
|
||||||
test('resolvedGpsIntervalSeconds prefers device custom var', () {
|
test('resolvedGpsIntervalSeconds prefers device custom var', () {
|
||||||
final service = AppSettingsService();
|
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');
|
final pubKey = _hex('w004');
|
||||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3);
|
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3);
|
||||||
|
|
||||||
svc.recordPathResult(
|
// The service requires failureCount >= 3 to remove a path
|
||||||
pubKey,
|
for (int i = 0; i < 3; i++) {
|
||||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
svc.recordPathResult(
|
||||||
success: false,
|
pubKey,
|
||||||
failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove
|
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||||
);
|
success: false,
|
||||||
|
failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove
|
||||||
|
);
|
||||||
|
}
|
||||||
await _flush();
|
await _flush();
|
||||||
|
|
||||||
final paths = svc.getRecentPaths(pubKey);
|
final paths = svc.getRecentPaths(pubKey);
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -33,6 +37,10 @@
|
|||||||
"settings_regionFetchRegions",
|
"settings_regionFetchRegions",
|
||||||
"settings_regionFetchRegionsFail",
|
"settings_regionFetchRegionsFail",
|
||||||
"settings_regionFetchRegionsAlreadyExists",
|
"settings_regionFetchRegionsAlreadyExists",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"chat_receivedGif",
|
"chat_receivedGif",
|
||||||
"repeater_keySettings",
|
"repeater_keySettings",
|
||||||
"repeater_keySettingsSubtitle",
|
"repeater_keySettingsSubtitle",
|
||||||
@@ -59,6 +67,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -89,6 +101,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -119,6 +135,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -149,6 +169,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -179,6 +203,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -209,6 +237,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -239,6 +271,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -269,6 +305,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -299,6 +339,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -329,6 +373,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -359,6 +407,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -389,6 +441,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -419,6 +475,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -449,6 +509,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
@@ -479,6 +543,10 @@
|
|||||||
"settings_regionDeleted",
|
"settings_regionDeleted",
|
||||||
"settings_deleteRegion",
|
"settings_deleteRegion",
|
||||||
"settings_deleteRegionConfirm",
|
"settings_deleteRegionConfirm",
|
||||||
|
"repeater_pathHashModeOption0",
|
||||||
|
"repeater_pathHashModeOption1",
|
||||||
|
"repeater_pathHashModeOption2",
|
||||||
|
"repeater_pathHashModeOption3",
|
||||||
"channels_regionSetTo",
|
"channels_regionSetTo",
|
||||||
"channels_regionNotSet",
|
"channels_regionNotSet",
|
||||||
"channels_regionSelect_Title",
|
"channels_regionSelect_Title",
|
||||||
|
|||||||
Reference in New Issue
Block a user