This commit is contained in:
PacoX
2026-05-13 08:49:24 +02:00
parent 6e2abfeb2d
commit ca02197414
11 changed files with 238 additions and 167 deletions
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

+29 -16
View File
@@ -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) {
+76 -42
View File
@@ -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
View File
@@ -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}";
+8 -4
View File
@@ -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');
+7 -5
View File
@@ -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
+9
View File
@@ -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),