fix(#151): widen DirectRepeater to configured path-hash width (Nearby Repeaters)

DirectRepeater stored only pubkeyFirstByte (1 byte of the last hop), so the SNR
indicator + Nearby Repeaters dialog showed a 1-byte prefix and matched the wrong
repeater on 2-/3-byte path-hash meshes. Capture the last hop's full configured-
width prefix and use it for display, contact-match, and dedup.

- DirectRepeater.pubkeyPrefix (Uint8List) + prefixHex getter; pubkeyFirstByte
  retained for the path-management / chat matching sites (those are #156).
- _updateDirectRepeater captures path[len-width..] (or the pubkey prefix when no
  path); dedup by listEquals(pubkeyPrefix).
- snr_indicator: status-bar + dialog render prefixHex; matcher keys on the full
  width prefix so the correct repeater name resolves.

Bumps build to +45. analyze clean; 6 DirectRepeater tests (2 new); Gemini: ship.

Closes #151

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/201/head
Strycher 3 weeks ago
parent f6ebeef4f7
commit f25848d16c

@ -51,15 +51,23 @@ import 'meshcore_protocol.dart';
class DirectRepeater {
static const int maxAgeMinutes = 30; // Max age for direct repeater info
final int pubkeyFirstByte;
// Last-hop prefix at the device's configured path-hash width (1/2/3 bytes), so
// 2-/3-byte meshes display + match the full repeater prefix, not just 1 byte. (#151)
final Uint8List pubkeyPrefix;
double snr;
DateTime lastUpdated;
DirectRepeater({
required this.pubkeyFirstByte,
required this.pubkeyPrefix,
required this.snr,
DateTime? lastUpdated,
}) : lastUpdated = lastUpdated ?? DateTime.now();
/// Last-hop prefix as lowercase hex (2 chars per byte). (#151)
String get prefixHex =>
pubkeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
void update(double newSNR) {
snr = newSNR;
lastUpdated = DateTime.now();
@ -6655,6 +6663,20 @@ class MeshCoreConnector extends ChangeNotifier {
final pubkeyFirstByte = path.isNotEmpty
? path.last
: contact.publicKey.first;
// Capture the last hop's FULL configured-width prefix (the directly-heard
// repeater), not just one byte, so wide-prefix meshes resolve it correctly. (#151)
final width = _pathHashByteWidth;
final Uint8List pubkeyPrefix;
if (path.isNotEmpty) {
pubkeyPrefix = Uint8List.fromList(
path.sublist(path.length >= width ? path.length - width : 0),
);
} else {
final pk = contact.publicKey;
pubkeyPrefix = Uint8List.fromList(
pk.length >= width ? pk.sublist(0, width) : pk,
);
}
_directRepeaters.removeWhere((r) => r.isStale());
@ -6666,7 +6688,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
final isTracked = _directRepeaters.where(
(r) => r.pubkeyFirstByte == pubkeyFirstByte,
(r) => listEquals(r.pubkeyPrefix, pubkeyPrefix),
);
final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters)
@ -6686,7 +6708,11 @@ class MeshCoreConnector extends ChangeNotifier {
repeater.update(snr);
} else if (_directRepeaters.length < 5) {
_directRepeaters.add(
DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
DirectRepeater(
pubkeyFirstByte: pubkeyFirstByte,
pubkeyPrefix: pubkeyPrefix,
snr: snr,
),
);
}
notifyListeners();

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
@ -10,15 +11,18 @@ import 'signal_ui.dart';
Contact? _getRepeaterPrefixMatchNearLocation(
List<Contact> contacts,
int pubkeyFirstByte, {
Uint8List pubkeyPrefix, {
LatLng? searchPoint,
bool preferFavorites = false,
}) {
final candidates = contacts
.where(
(c) =>
c.publicKey.isNotEmpty &&
c.publicKey.first == pubkeyFirstByte &&
c.publicKey.length >= pubkeyPrefix.length &&
listEquals(
c.publicKey.sublist(0, pubkeyPrefix.length),
pubkeyPrefix,
) &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
@ -170,7 +174,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
),
if (directRepeater != null)
Text(
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
'${directRepeaters.length}: ${directRepeater.prefixHex}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@ -244,7 +248,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
final contact = _getRepeaterPrefixMatchNearLocation(
allContacts,
repeater.pubkeyFirstByte,
repeater.pubkeyPrefix,
searchPoint: selfPoint,
preferFavorites: true,
);
@ -255,12 +259,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
children: [
ListTile(
leading: Icon(snrUi.icon, color: snrUi.color),
title: Text(
name ??
repeater.pubkeyFirstByte
.toRadixString(16)
.padLeft(2, '0'),
),
title: Text(name ?? repeater.prefixHex),
subtitle: Text(
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
),

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.2-rc.1+44
version: 1.1.2-rc.1+45
environment:
sdk: ^3.9.2

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
@ -7,11 +9,13 @@ void main() {
// 5 dB better SNR, but heard 24 min earlier signal margin must win.
final strongOld = DirectRepeater(
pubkeyFirstByte: 1,
pubkeyPrefix: Uint8List.fromList([1]),
snr: 10.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 25)),
);
final weakRecent = DirectRepeater(
pubkeyFirstByte: 2,
pubkeyPrefix: Uint8List.fromList([2]),
snr: 5.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)),
);
@ -21,11 +25,13 @@ void main() {
test('recency breaks ties between equal-SNR repeaters', () {
final older = DirectRepeater(
pubkeyFirstByte: 1,
pubkeyPrefix: Uint8List.fromList([1]),
snr: 10.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 20)),
);
final newer = DirectRepeater(
pubkeyFirstByte: 2,
pubkeyPrefix: Uint8List.fromList([2]),
snr: 10.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)),
);
@ -35,6 +41,7 @@ void main() {
test('a stale repeater ranks -1', () {
final stale = DirectRepeater(
pubkeyFirstByte: 1,
pubkeyPrefix: Uint8List.fromList([1]),
snr: 30.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)),
);
@ -46,15 +53,37 @@ void main() {
// stale sentinel (-1) has to sit below every live ranking.
final weakLive = DirectRepeater(
pubkeyFirstByte: 1,
pubkeyPrefix: Uint8List.fromList([1]),
snr: -30.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)),
);
final strongStale = DirectRepeater(
pubkeyFirstByte: 2,
pubkeyPrefix: Uint8List.fromList([2]),
snr: 30.0,
lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)),
);
expect(weakLive.ranking, greaterThan(strongStale.ranking));
});
});
group('DirectRepeater.prefixHex (#151)', () {
test('renders the full configured-width prefix as hex', () {
final r = DirectRepeater(
pubkeyFirstByte: 0xf4,
pubkeyPrefix: Uint8List.fromList([0xf4, 0xab]),
snr: 12.0,
);
expect(r.prefixHex, 'f4ab');
});
test('single-byte prefix renders two hex chars', () {
final r = DirectRepeater(
pubkeyFirstByte: 0x84,
pubkeyPrefix: Uint8List.fromList([0x84]),
snr: 12.0,
);
expect(r.prefixHex, '84');
});
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.