fix(#115): decode multi-byte path hashes at the device width

Packet Path mis-identified repeaters: a 2-byte path hash like 6A3D was
sliced into two 1-byte hops (6A + 3D), so the first byte collided with an
unrelated repeater (Freemasons 6A..) and the second matched nothing
("Unknown Repeater").

- meshcore_protocol: realHopCount() converts the firmware path BYTE length
  to true hops via the device hash width.
- channel_message_path_screen: slice/count/match at the device
  pathHashByteWidth (not the unreliable per-message high-bits); bucket and
  match on the full w-byte hash (mirrors _pathMatchesContact), not hash[0].
- channel_chat_screen: same width fix on the per-message via-line + hop badge.
- settings: show the build number alongside the version name.
- bump 1.1.2-beta.4+36.
- test: realHopCount regression coverage.

Gemini review (llm-consult): fix-then-ship; 2 findings clarification-only
(_formatHash is canonical hex used identically both sides; pathHashByteWidth
is static per-connection).

Part of epic #112.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/130/head
Strycher 4 weeks ago
parent 59da762c76
commit 31f850530b

@ -445,12 +445,26 @@ int readInt32LE(Uint8List data, int offset) {
return val; return val;
} }
// Path-length byte: firmware packs hop count + hash width into one byte. // Path-length byte from the firmware. Low 6 bits = the path length field
// Low 6 bits = hop count (0-63); high 2 bits = hash-size mode 0..2 -> 1..3 bytes/hop. // (a BYTE count of the hop-hash array, 0-63); high 2 bits carry an optional
// hash-size mode hint (0..2 -> 1..3 bytes/hop) that is not reliably populated,
// so the device's configured hash width (MeshCoreConnector.pathHashByteWidth)
// is authoritative for slicing the path. realHopCount() converts the byte
// length to a true hop count at that width. (#112)
// TX counterpart: buildSetPathHashModeFrame (CMD_SET_PATH_HASH_MODE). // TX counterpart: buildSetPathHashModeFrame (CMD_SET_PATH_HASH_MODE).
int pathHopCount(int pathLenRaw) => pathLenRaw & 0x3F; int pathHopCount(int pathLenRaw) => pathLenRaw & 0x3F;
int pathHashSizeBytes(int pathLenRaw) => ((pathLenRaw >> 6) & 0x03) + 1; int pathHashSizeBytes(int pathLenRaw) => ((pathLenRaw >> 6) & 0x03) + 1;
/// Real hop count from the firmware path byte-length and the device hash width.
/// `pathHopCount` returns the path BYTE length, so at a 2-byte hash width a
/// single 2-byte hop reports 2 divide by the width to get the true hop count.
/// Null (unknown) and negative (flood) sentinels pass through unchanged. (#112)
int? realHopCount(int? rawByteLen, int hashWidth) {
if (rawByteLen == null || rawByteLen < 0) return rawByteLen;
final w = hashWidth < 1 ? 1 : hashWidth;
return rawByteLen ~/ w;
}
// Helper to convert uint32 to hex string // Helper to convert uint32 to hex string
String ackHashToHex(int ackHash) { String ackHashToHex(int ackHash) {
return ackHash.toRadixString(16).padLeft(8, '0'); return ackHash.toRadixString(16).padLeft(8, '0');

@ -494,6 +494,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildMessageBubble(ChannelMessage message, double textScale) { Widget _buildMessageBubble(ChannelMessage message, double textScale) {
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing; final enableTracing = settingsService.settings.enableMessageTracing;
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
final isOutgoing = message.isOutgoing; final isOutgoing = message.isOutgoing;
final gifId = GifHelper.parseGif(message.text); final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text); final poi = parseMarkerText(message.text);
@ -634,10 +635,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: EdgeInsets.zero, : EdgeInsets.zero,
child: Text( child: Text(
context.l10n.channels_via( context.l10n.channels_via(
_formatPathPrefixes( _formatPathPrefixes(displayPath, hashWidth),
displayPath,
message.pathHashSize,
),
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@ -1337,7 +1335,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
Widget _buildHopBadge(BuildContext context, ChannelMessage message) { Widget _buildHopBadge(BuildContext context, ChannelMessage message) {
final hops = message.hopCount ?? 0; final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
final hops = realHopCount(message.hopCount, hashWidth) ?? 0;
final color = Theme.of(context).brightness == Brightness.dark final color = Theme.of(context).brightness == Brightness.dark
? Colors.grey[400] ? Colors.grey[400]
: Colors.grey[600]; : Colors.grey[600];

@ -40,16 +40,12 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final hops = _buildPathHops( final hashWidth = connector.pathHashByteWidth;
primaryPath, final hops = _buildPathHops(primaryPath, connector, l10n, hashWidth);
connector,
l10n,
message.pathHashSize,
);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length ~/ message.pathHashSize, primaryPath.length ~/ hashWidth,
message.hopCount, realHopCount(message.hopCount, hashWidth),
l10n, l10n,
); );
final extraPaths = _otherPaths(primaryPath, message.pathVariants); final extraPaths = _otherPaths(primaryPath, message.pathVariants);
@ -125,6 +121,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
final l10n = context.l10n; final l10n = context.l10n;
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -148,7 +145,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
_buildDetailRow( _buildDetailRow(
l10n.channelPath_pathLabelTitle, l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.hopCount, l10n), _formatPathLabel(realHopCount(message.hopCount, hashWidth), l10n),
), ),
if (observedLabel != null) if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel), _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
@ -160,6 +157,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) { Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
final l10n = context.l10n; final l10n = context.l10n;
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -171,15 +169,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
title: Text( title: Text(
l10n.channelPath_observedPathTitle( l10n.channelPath_observedPathTitle(
i + 1, i + 1,
_formatHopCount( _formatHopCount(variants[i].length ~/ hashWidth, l10n),
variants[i].length ~/ message.pathHashSize,
l10n,
),
), ),
), ),
subtitle: Text( subtitle: Text(_formatPathPrefixes(variants[i], hashWidth)),
_formatPathPrefixes(variants[i], message.pathHashSize),
),
trailing: const Icon(Icons.map_outlined, size: 20), trailing: const Icon(Icons.map_outlined, size: 20),
onTap: () => _openPathMap( onTap: () => _openPathMap(
context, context,
@ -474,11 +467,12 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hashWidth = connector.pathHashByteWidth;
final hops = _buildPathHops( final hops = _buildPathHops(
selectedPath, selectedPath,
connector, connector,
context.l10n, context.l10n,
widget.message.pathHashSize, hashWidth,
); );
final points = <LatLng>[]; final points = <LatLng>[];
@ -520,7 +514,7 @@ class _ChannelMessagePathMapScreenState
? LatLngBounds.fromPoints(points) ? LatLngBounds.fromPoints(points)
: null; : null;
final mapKey = ValueKey( final mapKey = ValueKey(
'${_formatPathPrefixes(selectedPath, widget.message.pathHashSize)},${context.l10n.pathTrace_you}', '${_formatPathPrefixes(selectedPath, hashWidth)},${context.l10n.pathTrace_you}',
); );
_pathDistance = _getPathDistance(points); _pathDistance = _getPathDistance(points);
@ -633,6 +627,7 @@ class _ChannelMessagePathMapScreenState
ValueChanged<int> onSelected, ValueChanged<int> onSelected,
) { ) {
final l10n = context.l10n; final l10n = context.l10n;
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
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)
@ -663,7 +658,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 ~/ widget.message.pathHashSize, l10n)}', '${_formatHopCount(paths[i].pathBytes.length ~/ hashWidth, l10n)}',
), ),
), ),
], ],
@ -677,10 +672,7 @@ class _ChannelMessagePathMapScreenState
Text( Text(
l10n.channelPath_selectedPathLabel( l10n.channelPath_selectedPathLabel(
label, label,
_formatPathPrefixes( _formatPathPrefixes(selectedPath.pathBytes, hashWidth),
selectedPath.pathBytes,
widget.message.pathHashSize,
),
), ),
style: TextStyle(color: Colors.grey[700], fontSize: 12), style: TextStyle(color: Colors.grey[700], fontSize: 12),
), ),
@ -936,14 +928,14 @@ List<_PathHop> _buildPathHops(
) { ) {
if (pathBytes.isEmpty) return const []; if (pathBytes.isEmpty) return const [];
final w = hashWidth < 1 ? 1 : hashWidth; final w = hashWidth < 1 ? 1 : hashWidth;
final candidatesByPrefix = <int, List<Contact>>{}; final candidatesByPrefix = <String, List<Contact>>{};
final allContacts = connector.allContacts; final allContacts = connector.allContacts;
for (final contact in allContacts) { for (final contact in allContacts) {
if (contact.publicKey.isEmpty) continue; if (contact.publicKey.length < w) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue; continue;
} }
final prefix = contact.publicKey.first; final prefix = _formatHash(contact.publicKey.sublist(0, w));
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact); candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
} }
for (final candidates in candidatesByPrefix.values) { for (final candidates in candidatesByPrefix.values) {
@ -960,7 +952,7 @@ List<_PathHop> _buildPathHops(
final hops = <_PathHop>[]; final hops = <_PathHop>[];
for (var i = 0; i + w <= pathBytes.length; i += w) { for (var i = 0; i + w <= pathBytes.length; i += w) {
final hash = pathBytes.sublist(i, i + w); final hash = pathBytes.sublist(i, i + w);
final prefixKey = hash[0]; final prefixKey = _formatHash(hash);
final searchPoint = i == 0 ? startPoint : previousPosition; final searchPoint = i == 0 ? startPoint : previousPosition;
final candidates = candidatesByPrefix[prefixKey]; final candidates = candidatesByPrefix[prefixKey];
Contact? contact; Contact? contact;

@ -57,7 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_appVersion = packageInfo.version; _appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
}); });
} }

@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.2-beta.3+33 version: 1.1.2-beta.4+36
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

@ -0,0 +1,32 @@
// Path-hash width / hop-count decode (#112). The firmware path-length byte is a
// BYTE count of the hop-hash array, not a hop count, so at a 2-byte hash width a
// single 2-byte hop reports 2. realHopCount divides by the device width to get
// true hops; the Packet Path screen slices and matches at the device width so a
// 2-byte hash like 6A3D resolves to one repeater instead of two 1-byte hops.
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
void main() {
group('realHopCount', () {
test('divides path byte-length by hash width', () {
// The bug: one 2-byte hop (6A3D) reported byteLen 2 -> shown as "2 hops".
expect(realHopCount(2, 2), 1);
expect(realHopCount(4, 2), 2);
expect(realHopCount(6, 3), 2);
});
test('is a no-op at 1-byte width (no regression for legacy nets)', () {
expect(realHopCount(3, 1), 3);
});
test('treats width < 1 as 1', () {
expect(realHopCount(3, 0), 3);
});
test('passes null (unknown) and negative (flood) sentinels through', () {
expect(realHopCount(null, 2), isNull);
expect(realHopCount(-1, 2), -1);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.