mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-19 07:10:51 +10:00
UI fix
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
@@ -9,8 +11,29 @@ class PathHelper {
|
|||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String formatHopHex(List<int> hopBytes) {
|
||||||
|
return hopBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
|
.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Uint8List> splitPathBytes(List<int> pathBytes, int hashByteWidth) {
|
||||||
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
|
||||||
|
final width = hashByteWidth.clamp(1, 8);
|
||||||
|
final hops = <Uint8List>[];
|
||||||
|
for (int i = 0; i < pathBytes.length; i += width) {
|
||||||
|
final endIdx = (i + width).clamp(0, pathBytes.length);
|
||||||
|
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.
|
/// Resolves path bytes to contact names, supporting multi-byte hash widths.
|
||||||
///
|
|
||||||
/// Groups path bytes according to [hashByteWidth]:
|
/// Groups path bytes according to [hashByteWidth]:
|
||||||
/// - 1: Single byte per hop (256 unique nodes)
|
/// - 1: Single byte per hop (256 unique nodes)
|
||||||
/// - 2: Two bytes per hop (65K unique nodes)
|
/// - 2: Two bytes per hop (65K unique nodes)
|
||||||
@@ -21,22 +44,12 @@ class PathHelper {
|
|||||||
int hashByteWidth,
|
int hashByteWidth,
|
||||||
) {
|
) {
|
||||||
if (pathBytes.isEmpty) return '';
|
if (pathBytes.isEmpty) return '';
|
||||||
|
|
||||||
final width = hashByteWidth.clamp(1, 8);
|
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
|
|
||||||
// Group bytes according to width
|
for (final hopBytes in splitPathBytes(pathBytes, hashByteWidth)) {
|
||||||
for (int i = 0; i < pathBytes.length; i += width) {
|
final hex = formatHopHex(hopBytes);
|
||||||
final endIdx = (i + width).clamp(0, pathBytes.length);
|
|
||||||
final hopBytes = pathBytes.sublist(i, endIdx);
|
|
||||||
|
|
||||||
if (hopBytes.isEmpty) continue;
|
|
||||||
|
|
||||||
// Format hex for display
|
|
||||||
final hex = hopBytes
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
// Find matching contacts by comparing public key prefix
|
// Find matching contacts by comparing public key prefix
|
||||||
final matches = allContacts
|
final matches = allContacts
|
||||||
.where((c) {
|
.where((c) {
|
||||||
|
|||||||
@@ -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,14 +43,22 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
: primaryPathTmp;
|
: primaryPathTmp;
|
||||||
final hops = _buildPathHops(primaryPath, connector, l10n);
|
final hops = _buildPathHops(primaryPath, connector, l10n);
|
||||||
final hasHopDetails = primaryPath.isNotEmpty;
|
final hasHopDetails = primaryPath.isNotEmpty;
|
||||||
|
|
||||||
// Convert path byte length to hop count based on device width
|
// Convert path byte length to hop count based on device width
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 8);
|
final width = connector.pathHashByteWidth.clamp(1, 8);
|
||||||
final observedHopCount = primaryPath.length ~/ width;
|
final observedHopCount = _hopCountFromBytes(primaryPath.length, width);
|
||||||
|
final reportedHopCount = message.pathLength == null
|
||||||
|
? null
|
||||||
|
: (message.pathLength! < 0
|
||||||
|
? message.pathLength
|
||||||
|
: _hopCountFromBytes(message.pathLength!, width));
|
||||||
|
final effectiveHopCount = observedHopCount > 0
|
||||||
|
? observedHopCount
|
||||||
|
: reportedHopCount;
|
||||||
|
|
||||||
final observedLabel = _formatObservedHops(
|
final observedLabel = _formatObservedHops(
|
||||||
observedHopCount,
|
observedHopCount,
|
||||||
message.pathLength != null ? message.pathLength! ~/ width : null,
|
effectiveHopCount,
|
||||||
l10n,
|
l10n,
|
||||||
);
|
);
|
||||||
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
||||||
@@ -92,7 +101,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryCard(context, observedLabel: observedLabel),
|
_buildSummaryCard(
|
||||||
|
context,
|
||||||
|
observedLabel: observedLabel,
|
||||||
|
effectiveHopCount: effectiveHopCount,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (extraPaths.isNotEmpty) ...[
|
if (extraPaths.isNotEmpty) ...[
|
||||||
Text(
|
Text(
|
||||||
@@ -100,7 +113,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildPathVariants(context, extraPaths),
|
_buildPathVariants(context, extraPaths, width),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
Text(
|
Text(
|
||||||
@@ -123,7 +136,11 @@ 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;
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -148,7 +165,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
l10n.channelPath_pathLabelTitle,
|
l10n.channelPath_pathLabelTitle,
|
||||||
_formatPathLabel(message.pathLength, l10n),
|
_formatPathLabel(effectiveHopCount, l10n),
|
||||||
),
|
),
|
||||||
if (observedLabel != null)
|
if (observedLabel != null)
|
||||||
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||||
@@ -158,7 +175,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,
|
||||||
@@ -171,10 +192,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
title: Text(
|
title: Text(
|
||||||
l10n.channelPath_observedPathTitle(
|
l10n.channelPath_observedPathTitle(
|
||||||
i + 1,
|
i + 1,
|
||||||
_formatHopCount(variants[i].length, l10n),
|
_formatHopCount(variants[i].length, hashByteWidth, l10n),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(_formatPathPrefixes(variants[i])),
|
subtitle: Text(_formatPathPrefixes(variants[i], hashByteWidth)),
|
||||||
trailing: const Icon(Icons.map_outlined, size: 20),
|
trailing: const Icon(Icons.map_outlined, size: 20),
|
||||||
onTap: () => _openPathMap(
|
onTap: () => _openPathMap(
|
||||||
context,
|
context,
|
||||||
@@ -228,31 +249,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(String label, String value) {
|
Widget _buildDetailRow(String label, String value) {
|
||||||
@@ -470,6 +491,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
|
|
||||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||||
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
||||||
|
final width = connector.pathHashByteWidth.clamp(1, 8);
|
||||||
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
|
|
||||||
@@ -510,7 +532,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);
|
||||||
|
|
||||||
@@ -623,6 +645,10 @@ class _ChannelMessagePathMapScreenState
|
|||||||
ValueChanged<int> onSelected,
|
ValueChanged<int> onSelected,
|
||||||
) {
|
) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final width = context.read<MeshCoreConnector>().pathHashByteWidth.clamp(
|
||||||
|
1,
|
||||||
|
8,
|
||||||
|
);
|
||||||
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)
|
||||||
@@ -653,7 +679,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)}',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -667,7 +693,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
Text(
|
Text(
|
||||||
l10n.channelPath_selectedPathLabel(
|
l10n.channelPath_selectedPathLabel(
|
||||||
label,
|
label,
|
||||||
_formatPathPrefixes(selectedPath.pathBytes),
|
_formatPathPrefixes(selectedPath.pathBytes, width),
|
||||||
),
|
),
|
||||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||||
),
|
),
|
||||||
@@ -925,11 +951,11 @@ List<_PathHop> _buildPathHops(
|
|||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
) {
|
) {
|
||||||
if (pathBytes.isEmpty) return const [];
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 8);
|
final width = connector.pathHashByteWidth.clamp(1, 8);
|
||||||
final candidatesByHashBytes = <List<int>, List<Contact>>{};
|
final candidatesByHashBytes = <String, List<Contact>>{};
|
||||||
final allContacts = connector.allContacts;
|
final allContacts = connector.allContacts;
|
||||||
|
|
||||||
// Build lookup map using hash byte sequences
|
// Build lookup map using hash byte sequences
|
||||||
for (final contact in allContacts) {
|
for (final contact in allContacts) {
|
||||||
if (contact.publicKey.isEmpty) continue;
|
if (contact.publicKey.isEmpty) continue;
|
||||||
@@ -941,14 +967,15 @@ List<_PathHop> _buildPathHops(
|
|||||||
0,
|
0,
|
||||||
min(width, contact.publicKey.length),
|
min(width, contact.publicKey.length),
|
||||||
);
|
);
|
||||||
candidatesByHashBytes.putIfAbsent(keyBytes, () => <Contact>[]).add(contact);
|
final keyHex = PathHelper.formatHopHex(keyBytes);
|
||||||
|
candidatesByHashBytes.putIfAbsent(keyHex, () => <Contact>[]).add(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort candidates by last seen
|
// Sort candidates by last seen
|
||||||
for (final candidates in candidatesByHashBytes.values) {
|
for (final candidates in candidatesByHashBytes.values) {
|
||||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||||
}
|
}
|
||||||
|
|
||||||
final startPoint =
|
final startPoint =
|
||||||
(connector.selfLatitude != null && connector.selfLongitude != null)
|
(connector.selfLatitude != null && connector.selfLongitude != null)
|
||||||
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
||||||
@@ -958,17 +985,18 @@ List<_PathHop> _buildPathHops(
|
|||||||
var lastDistance = 0.0;
|
var lastDistance = 0.0;
|
||||||
var bestDistance = 0.0;
|
var bestDistance = 0.0;
|
||||||
final hops = <_PathHop>[];
|
final hops = <_PathHop>[];
|
||||||
|
|
||||||
// Process path in hop-sized chunks
|
// Process path in hop-sized chunks
|
||||||
for (var hopIdx = 0; hopIdx * width < pathBytes.length; hopIdx++) {
|
for (var hopIdx = 0; hopIdx * width < pathBytes.length; hopIdx++) {
|
||||||
final startByte = hopIdx * width;
|
final startByte = hopIdx * width;
|
||||||
final endByte = min(startByte + width, pathBytes.length);
|
final endByte = min(startByte + width, pathBytes.length);
|
||||||
final hopBytes = pathBytes.sublist(startByte, endByte);
|
final hopBytes = pathBytes.sublist(startByte, endByte);
|
||||||
|
final hopKey = PathHelper.formatHopHex(hopBytes);
|
||||||
|
|
||||||
final searchPoint = hopIdx == 0 ? startPoint : previousPosition;
|
final searchPoint = hopIdx == 0 ? startPoint : previousPosition;
|
||||||
final candidates = candidatesByHashBytes[hopBytes.toList()];
|
final candidates = candidatesByHashBytes[hopKey];
|
||||||
Contact? contact;
|
Contact? contact;
|
||||||
|
|
||||||
if (candidates != null && candidates.isNotEmpty) {
|
if (candidates != null && candidates.isNotEmpty) {
|
||||||
var bestIndex = 0;
|
var bestIndex = 0;
|
||||||
if (searchPoint != null) {
|
if (searchPoint != null) {
|
||||||
@@ -992,7 +1020,7 @@ List<_PathHop> _buildPathHops(
|
|||||||
}
|
}
|
||||||
contact = candidates.removeAt(bestIndex);
|
contact = candidates.removeAt(bestIndex);
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
candidatesByHashBytes.remove(hopBytes.toList());
|
candidatesByHashBytes.remove(hopKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,7 +1028,7 @@ List<_PathHop> _buildPathHops(
|
|||||||
if (resolvedPosition != null) {
|
if (resolvedPosition != null) {
|
||||||
previousPosition = resolvedPosition;
|
previousPosition = resolvedPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the best candidate is much farther than the previous hop, it's likely not the correct match.
|
// If the best candidate is much farther than the previous hop, it's likely not the correct match.
|
||||||
if (lastDistance + bestDistance > 50000 &&
|
if (lastDistance + bestDistance > 50000 &&
|
||||||
candidates != null &&
|
candidates != null &&
|
||||||
@@ -1038,14 +1066,20 @@ 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(pathBytes, hashByteWidth)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
.map(PathHelper.formatHopHex)
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatHopCount(int count, AppLocalizations l10n) {
|
int _hopCountFromBytes(int byteCount, int hashByteWidth) {
|
||||||
return l10n.chat_hopsCount(count);
|
if (byteCount <= 0) return 0;
|
||||||
|
final width = hashByteWidth.clamp(1, 8);
|
||||||
|
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) {
|
||||||
|
|||||||
+109
-100
@@ -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';
|
||||||
@@ -37,9 +38,9 @@ String formatDistance(double distanceMeters, {required bool isImperial}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -48,6 +49,8 @@ class PathTraceData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _hopKey(Uint8List hopBytes) => PathHelper.formatHopHex(hopBytes);
|
||||||
|
|
||||||
class PathTraceMapScreen extends StatefulWidget {
|
class PathTraceMapScreen extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Uint8List path;
|
final Uint8List path;
|
||||||
@@ -89,8 +92,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;
|
||||||
@@ -105,8 +108,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
Contact? _targetContact;
|
Contact? _targetContact;
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||||
return pathBytes
|
return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
.map(PathHelper.formatHopHex)
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,44 +191,43 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Uint8List buildPath(Uint8List pathBytes) {
|
Uint8List buildPath(Uint8List pathBytes) {
|
||||||
Uint8List traceBytes;
|
final pathHops = PathHelper.splitPathBytes(
|
||||||
|
pathBytes,
|
||||||
|
widget.pathHashByteWidth,
|
||||||
|
);
|
||||||
|
final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
||||||
|
|
||||||
if (pathBytes.isEmpty) {
|
if (pathHops.isEmpty) {
|
||||||
final pk = widget.targetContact?.publicKey;
|
final pk = widget.targetContact?.publicKey;
|
||||||
final n = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
if (pk != null && pk.length >= hopWidth) {
|
||||||
if (pk != null && pk.length >= n) {
|
return Uint8List.fromList(pk.sublist(0, hopWidth));
|
||||||
return Uint8List.fromList(pk.sublist(0, n));
|
|
||||||
}
|
}
|
||||||
traceBytes = Uint8List(1);
|
return Uint8List.fromList(
|
||||||
traceBytes[0] = pk?[0] ?? 0;
|
pk != null && pk.isNotEmpty ? [pk[0]] : const [0],
|
||||||
return traceBytes;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final mirroredHops = <Uint8List>[...pathHops];
|
||||||
if (widget.targetContact?.type == advTypeRepeater ||
|
if (widget.targetContact?.type == advTypeRepeater ||
|
||||||
widget.targetContact?.type == advTypeRoom) {
|
widget.targetContact?.type == advTypeRoom) {
|
||||||
final len = (pathBytes.length + pathBytes.length + 1);
|
final pk = widget.targetContact?.publicKey;
|
||||||
traceBytes = Uint8List(len);
|
if (pk != null && pk.length >= hopWidth) {
|
||||||
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
mirroredHops.add(Uint8List.fromList(pk.sublist(0, hopWidth)));
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
} else {
|
||||||
traceBytes[i] = pathBytes[i];
|
mirroredHops.add(
|
||||||
if (i < pathBytes.length) {
|
Uint8List.fromList(
|
||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
pk != null && pk.isNotEmpty ? [pk[0]] : const [0],
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
} else {
|
|
||||||
if (pathBytes.length < 2) {
|
|
||||||
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
|
|
||||||
}
|
|
||||||
final len = (pathBytes.length + pathBytes.length - 1);
|
|
||||||
traceBytes = Uint8List(len);
|
|
||||||
for (int i = 0; i < pathBytes.length; i++) {
|
|
||||||
traceBytes[i] = pathBytes[i];
|
|
||||||
if (i < pathBytes.length - 1) {
|
|
||||||
traceBytes[len - 1 - i] = pathBytes[i];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return traceBytes;
|
mirroredHops.addAll(pathHops.reversed);
|
||||||
|
|
||||||
|
final traceBytes = <int>[];
|
||||||
|
for (final hop in mirroredHops) {
|
||||||
|
traceBytes.addAll(hop);
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(traceBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doPathTrace() async {
|
Future<void> _doPathTrace() async {
|
||||||
@@ -329,13 +331,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
int pathLength = buffer.readUInt8();
|
int pathLength = buffer.readUInt8();
|
||||||
buffer.skipBytes(5); // Skip Flag byte and tag data
|
buffer.skipBytes(5); // Skip Flag byte and tag data
|
||||||
buffer.skipBytes(4); // Skip auth code
|
buffer.skipBytes(4); // Skip auth code
|
||||||
Uint8List pathData = buffer.readBytes(pathLength);
|
final pathBytes = buffer.readBytes(pathLength);
|
||||||
|
final pathData = PathHelper.splitPathBytes(
|
||||||
|
pathBytes,
|
||||||
|
widget.pathHashByteWidth,
|
||||||
|
);
|
||||||
List<double> snrData = buffer
|
List<double> snrData = buffer
|
||||||
.readRemainingBytes()
|
.readRemainingBytes()
|
||||||
.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,
|
||||||
@@ -347,7 +353,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 = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
||||||
|
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) {
|
||||||
@@ -362,12 +373,11 @@ 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) {
|
||||||
if (listEquals(
|
final hopWidth = repeaterData.length;
|
||||||
repeater.publicKey.sublist(0, 1),
|
if (repeater.publicKey.length < hopWidth) continue;
|
||||||
Uint8List.fromList([repeaterData]),
|
if (listEquals(repeater.publicKey.sublist(0, hopWidth), repeaterData)) {
|
||||||
)) {
|
pathContacts[_hopKey(repeaterData)] = repeater;
|
||||||
pathContacts[repeaterData] = repeater;
|
|
||||||
lastContact = repeater;
|
lastContact = repeater;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,13 +386,17 @@ 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 &&
|
||||||
|
_hopKey(Uint8List.fromList([c.path.last])) == hopKey,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
if (peers.isNotEmpty) {
|
if (peers.isNotEmpty) {
|
||||||
@@ -392,7 +406,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,20 +428,21 @@ 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 (widget.path.length > 1) {
|
} else if (pathData.length > 1) {
|
||||||
// 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 lastHop = widget.reversePathAround
|
final lastHop = widget.reversePathAround
|
||||||
? widget.path.first
|
? pathData.first
|
||||||
: widget.path.last;
|
: pathData.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,
|
_hopKey(Uint8List.fromList([c.path.last])) == lastHopKey,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
if (peers.isNotEmpty) {
|
if (peers.isNotEmpty) {
|
||||||
@@ -444,9 +459,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(
|
||||||
@@ -456,7 +471,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;
|
||||||
@@ -474,21 +489,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) {
|
||||||
@@ -511,7 +527,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);
|
||||||
});
|
});
|
||||||
@@ -613,29 +629,30 @@ 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);
|
||||||
|
|
||||||
markers.add(
|
markers.add(
|
||||||
Marker(
|
Marker(
|
||||||
@@ -679,7 +696,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
hopLastLast = hopLast;
|
hopLastLast = hopLast;
|
||||||
hopLast = hop;
|
hopLast = hopKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
||||||
@@ -807,29 +824,24 @@ 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 - 1) {
|
||||||
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}";
|
||||||
@@ -837,14 +849,14 @@ 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 - 1) {
|
||||||
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}";
|
||||||
@@ -852,12 +864,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}";
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class NotificationService {
|
|||||||
|
|
||||||
AppLocalizations get _l10n => lookupAppLocalizations(_locale);
|
AppLocalizations get _l10n => lookupAppLocalizations(_locale);
|
||||||
|
|
||||||
|
String _logSafe(String value) {
|
||||||
|
return Uri.encodeComponent(value.replaceAll('\n', ' '));
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -322,11 +326,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)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +576,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');
|
||||||
|
|
||||||
|
|||||||
@@ -126,17 +126,19 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
.toList();
|
.toList();
|
||||||
final pathBytesList = <int>[];
|
final pathBytesList = <int>[];
|
||||||
final invalidPrefixes = <String>[];
|
final invalidPrefixes = <String>[];
|
||||||
|
final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 8) * 2;
|
||||||
|
|
||||||
for (final id in pathIds) {
|
for (final id in pathIds) {
|
||||||
if (id.length < 2) {
|
if (id.length < hexCharsPerHop) {
|
||||||
invalidPrefixes.add(id);
|
invalidPrefixes.add(id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final prefix = id.substring(0, 2);
|
final prefix = id.substring(0, hexCharsPerHop);
|
||||||
try {
|
try {
|
||||||
final byte = int.parse(prefix, radix: 16);
|
for (int i = 0; i < prefix.length; i += 2) {
|
||||||
pathBytesList.add(byte);
|
pathBytesList.add(int.parse(prefix.substring(i, i + 2), radix: 16));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
invalidPrefixes.add(id);
|
invalidPrefixes.add(id);
|
||||||
}
|
}
|
||||||
@@ -318,7 +320,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, 2)}',
|
'${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, widget.pathHashByteWidth.clamp(1, 8) * 2)}',
|
||||||
style: const TextStyle(fontSize: 10),
|
style: const TextStyle(fontSize: 10),
|
||||||
),
|
),
|
||||||
trailing: isSelected
|
trailing: isSelected
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ Contact _contact({
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
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', () {
|
test('resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths', () {
|
||||||
final contacts = [
|
final contacts = [
|
||||||
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
|
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
|
||||||
|
|||||||
Reference in New Issue
Block a user