From 31f850530bf2adc54e69f404f0679cebd6842978 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 25 Jun 2026 14:54:54 -0400 Subject: [PATCH] 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 --- lib/connector/meshcore_protocol.dart | 18 +++++++- lib/screens/channel_chat_screen.dart | 9 ++-- lib/screens/channel_message_path_screen.dart | 46 ++++++++------------ lib/screens/settings_screen.dart | 2 +- pubspec.yaml | 2 +- test/connector/path_hash_test.dart | 32 ++++++++++++++ 6 files changed, 73 insertions(+), 36 deletions(-) create mode 100644 test/connector/path_hash_test.dart diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 66c7bb9..719cc2f 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -445,12 +445,26 @@ int readInt32LE(Uint8List data, int offset) { return val; } -// Path-length byte: firmware packs hop count + hash width into one byte. -// Low 6 bits = hop count (0-63); high 2 bits = hash-size mode 0..2 -> 1..3 bytes/hop. +// Path-length byte from the firmware. Low 6 bits = the path length field +// (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). int pathHopCount(int pathLenRaw) => pathLenRaw & 0x3F; 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 String ackHashToHex(int ackHash) { return ackHash.toRadixString(16).padLeft(8, '0'); diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 84648ea..fb6dc33 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -494,6 +494,7 @@ class _ChannelChatScreenState extends State { Widget _buildMessageBubble(ChannelMessage message, double textScale) { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; + final hashWidth = context.read().pathHashByteWidth; final isOutgoing = message.isOutgoing; final gifId = GifHelper.parseGif(message.text); final poi = parseMarkerText(message.text); @@ -634,10 +635,7 @@ class _ChannelChatScreenState extends State { : EdgeInsets.zero, child: Text( context.l10n.channels_via( - _formatPathPrefixes( - displayPath, - message.pathHashSize, - ), + _formatPathPrefixes(displayPath, hashWidth), ), style: TextStyle( fontSize: 11, @@ -1337,7 +1335,8 @@ class _ChannelChatScreenState extends State { } Widget _buildHopBadge(BuildContext context, ChannelMessage message) { - final hops = message.hopCount ?? 0; + final hashWidth = context.read().pathHashByteWidth; + final hops = realHopCount(message.hopCount, hashWidth) ?? 0; final color = Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : Colors.grey[600]; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 510d438..b328503 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,16 +40,12 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - final hops = _buildPathHops( - primaryPath, - connector, - l10n, - message.pathHashSize, - ); + final hashWidth = connector.pathHashByteWidth; + final hops = _buildPathHops(primaryPath, connector, l10n, hashWidth); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( - primaryPath.length ~/ message.pathHashSize, - message.hopCount, + primaryPath.length ~/ hashWidth, + realHopCount(message.hopCount, hashWidth), l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); @@ -125,6 +121,7 @@ class ChannelMessagePathScreen extends StatelessWidget { Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { final l10n = context.l10n; + final hashWidth = context.read().pathHashByteWidth; return Card( child: Padding( padding: const EdgeInsets.all(12), @@ -148,7 +145,7 @@ class ChannelMessagePathScreen extends StatelessWidget { ), _buildDetailRow( l10n.channelPath_pathLabelTitle, - _formatPathLabel(message.hopCount, l10n), + _formatPathLabel(realHopCount(message.hopCount, hashWidth), l10n), ), if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), @@ -160,6 +157,7 @@ class ChannelMessagePathScreen extends StatelessWidget { Widget _buildPathVariants(BuildContext context, List variants) { final l10n = context.l10n; + final hashWidth = context.read().pathHashByteWidth; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -171,15 +169,10 @@ class ChannelMessagePathScreen extends StatelessWidget { title: Text( l10n.channelPath_observedPathTitle( i + 1, - _formatHopCount( - variants[i].length ~/ message.pathHashSize, - l10n, - ), + _formatHopCount(variants[i].length ~/ hashWidth, l10n), ), ), - subtitle: Text( - _formatPathPrefixes(variants[i], message.pathHashSize), - ), + subtitle: Text(_formatPathPrefixes(variants[i], hashWidth)), trailing: const Icon(Icons.map_outlined, size: 20), onTap: () => _openPathMap( context, @@ -474,11 +467,12 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); + final hashWidth = connector.pathHashByteWidth; final hops = _buildPathHops( selectedPath, connector, context.l10n, - widget.message.pathHashSize, + hashWidth, ); final points = []; @@ -520,7 +514,7 @@ class _ChannelMessagePathMapScreenState ? LatLngBounds.fromPoints(points) : null; final mapKey = ValueKey( - '${_formatPathPrefixes(selectedPath, widget.message.pathHashSize)},${context.l10n.pathTrace_you}', + '${_formatPathPrefixes(selectedPath, hashWidth)},${context.l10n.pathTrace_you}', ); _pathDistance = _getPathDistance(points); @@ -633,6 +627,7 @@ class _ChannelMessagePathMapScreenState ValueChanged onSelected, ) { final l10n = context.l10n; + final hashWidth = context.read().pathHashByteWidth; final selectedPath = paths[selectedIndex]; final label = selectedPath.isPrimary ? l10n.channelPath_primaryPath(selectedIndex + 1) @@ -663,7 +658,7 @@ class _ChannelMessagePathMapScreenState value: i, child: Text( '${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( l10n.channelPath_selectedPathLabel( label, - _formatPathPrefixes( - selectedPath.pathBytes, - widget.message.pathHashSize, - ), + _formatPathPrefixes(selectedPath.pathBytes, hashWidth), ), style: TextStyle(color: Colors.grey[700], fontSize: 12), ), @@ -936,14 +928,14 @@ List<_PathHop> _buildPathHops( ) { if (pathBytes.isEmpty) return const []; final w = hashWidth < 1 ? 1 : hashWidth; - final candidatesByPrefix = >{}; + final candidatesByPrefix = >{}; final allContacts = connector.allContacts; for (final contact in allContacts) { - if (contact.publicKey.isEmpty) continue; + if (contact.publicKey.length < w) continue; if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { continue; } - final prefix = contact.publicKey.first; + final prefix = _formatHash(contact.publicKey.sublist(0, w)); candidatesByPrefix.putIfAbsent(prefix, () => []).add(contact); } for (final candidates in candidatesByPrefix.values) { @@ -960,7 +952,7 @@ List<_PathHop> _buildPathHops( final hops = <_PathHop>[]; for (var i = 0; i + w <= pathBytes.length; i += w) { final hash = pathBytes.sublist(i, i + w); - final prefixKey = hash[0]; + final prefixKey = _formatHash(hash); final searchPoint = i == 0 ? startPoint : previousPosition; final candidates = candidatesByPrefix[prefixKey]; Contact? contact; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 943b0c9..2998977 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -57,7 +57,7 @@ class _SettingsScreenState extends State { final packageInfo = await PackageInfo.fromPlatform(); if (!mounted) return; setState(() { - _appVersion = packageInfo.version; + _appVersion = '${packageInfo.version}+${packageInfo.buildNumber}'; }); } diff --git a/pubspec.yaml b/pubspec.yaml index 6be1b2f..0b5dbe2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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-beta.3+33 +version: 1.1.2-beta.4+36 environment: sdk: ^3.9.2 diff --git a/test/connector/path_hash_test.dart b/test/connector/path_hash_test.dart new file mode 100644 index 0000000..fbe3224 --- /dev/null +++ b/test/connector/path_hash_test.dart @@ -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); + }); + }); +}