From d2d7930858f1e89b166d6dbd0bb61190f8d34104 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 29 Jun 2026 02:24:27 -0400 Subject: [PATCH] fix(#155): manual path entry honors the configured path-hash width The "Set Path" dialog parsed, built, and displayed hop prefixes as a fixed 1 byte (substring(0,2)), so on a 2-/3-byte path-hash mesh manual entry produced wrong-width routing paths and the helper text claimed "1 byte". Make it width- aware from connector.pathHashByteWidth. - parsePathPrefixes(text, width, invalid): static + testable; each entry must be EXACTLY width*2 hex chars (wrong-length / malformed go to invalid, never silently truncated); width clamped >= 1. - _updateTextFromContacts / display / maxLength group by the configured width; the display substring is guarded against short keys. - show() takes pathHashByteWidth; the chat + path-management callers pass connector.pathHashByteWidth. - l10n: reworded the path-entry helper / example / instructions to be width- agnostic (dropped the "2 hex / 1 byte / first byte" claims). The 17 non-English locales keep their prior strings, flagged for re-translation (English fixed). analyze clean; 6 parsePathPrefixes tests (incl. exact-length + clamp). Gemini v1 BLOCKER (unguarded substring) + MAJOR (silent truncation) fixed; v2 MAJORs were the pre-existing refresh button (out of scope), v2 MINOR (1-byte example) fixed. Closes #155 Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_en.arb | 6 +- lib/l10n/app_localizations.dart | 6 +- lib/l10n/app_localizations_en.dart | 7 +- lib/screens/chat_screen.dart | 1 + lib/widgets/path_management_dialog.dart | 1 + lib/widgets/path_selection_dialog.dart | 76 +++++++++++++------- test/widgets/path_selection_dialog_test.dart | 57 +++++++++++++++ 7 files changed, 117 insertions(+), 37 deletions(-) create mode 100644 test/widgets/path_selection_dialog_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 936e0b4..1a4838d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1151,10 +1151,10 @@ }, "path_enterCustomPath": "Enter Custom Path", "path_currentPathLabel": "Current path", - "path_hexPrefixInstructions": "Enter 2-character hex prefixes for each hop, separated by commas.", - "path_hexPrefixExample": "Example: A1,F2,3C (each node uses first byte of its public key)", + "path_hexPrefixInstructions": "Enter each hop's hex prefix, separated by commas.", + "path_hexPrefixExample": "Tap nodes below, or enter each hop's hex prefix, comma-separated.", "path_labelHexPrefixes": "Path (hex prefixes)", - "path_helperMaxHops": "Max 64 hops. Each prefix is 2 hex characters (1 byte)", + "path_helperMaxHops": "Max 64 hops.", "path_selectFromContacts": "Or select from contacts:", "path_noRepeatersFound": "No repeaters or room servers found.", "path_customPathsRequire": "Custom paths require intermediate hops that can relay messages.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 020ed4c..885eff6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3834,13 +3834,13 @@ abstract class AppLocalizations { /// No description provided for @path_hexPrefixInstructions. /// /// In en, this message translates to: - /// **'Enter 2-character hex prefixes for each hop, separated by commas.'** + /// **'Enter each hop\'s hex prefix, separated by commas.'** String get path_hexPrefixInstructions; /// No description provided for @path_hexPrefixExample. /// /// In en, this message translates to: - /// **'Example: A1,F2,3C (each node uses first byte of its public key)'** + /// **'Tap nodes below, or enter each hop\'s hex prefix, comma-separated.'** String get path_hexPrefixExample; /// No description provided for @path_labelHexPrefixes. @@ -3852,7 +3852,7 @@ abstract class AppLocalizations { /// No description provided for @path_helperMaxHops. /// /// In en, this message translates to: - /// **'Max 64 hops. Each prefix is 2 hex characters (1 byte)'** + /// **'Max 64 hops.'** String get path_helperMaxHops; /// No description provided for @path_selectFromContacts. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 31d4642..fdb19cf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2104,18 +2104,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get path_hexPrefixInstructions => - 'Enter 2-character hex prefixes for each hop, separated by commas.'; + 'Enter each hop\'s hex prefix, separated by commas.'; @override String get path_hexPrefixExample => - 'Example: A1,F2,3C (each node uses first byte of its public key)'; + 'Tap nodes below, or enter each hop\'s hex prefix, comma-separated.'; @override String get path_labelHexPrefixes => 'Path (hex prefixes)'; @override - String get path_helperMaxHops => - 'Max 64 hops. Each prefix is 2 hex characters (1 byte)'; + String get path_helperMaxHops => 'Max 64 hops.'; @override String get path_selectFromContacts => 'Or select from contacts:'; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index e0d3550..2bf23ad 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1513,6 +1513,7 @@ class _ChatScreenState extends State { final result = await PathSelectionDialog.show( context, availableContacts: availableContacts, + pathHashByteWidth: connector.pathHashByteWidth, initialPath: pathForInput.isEmpty ? null : pathForInput, title: context.l10n.chat_setCustomPath, currentPathLabel: currentPathLabel, diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 784bcf1..6c9117a 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -152,6 +152,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { final result = await PathSelectionDialog.show( context, availableContacts: availableContacts, + pathHashByteWidth: connector.pathHashByteWidth, initialPath: pathForInput.isEmpty ? null : pathForInput, currentPathLabel: currentContact.pathLabel(l10n), onRefresh: connector.isConnected ? connector.getContacts : null, diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 44ae58f..c3b430b 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -12,11 +12,13 @@ class PathSelectionDialog extends StatefulWidget { final String? initialPath; final String? currentPathLabel; final VoidCallback? onRefresh; + final int pathHashByteWidth; const PathSelectionDialog({ super.key, required this.availableContacts, required this.title, + required this.pathHashByteWidth, this.initialPath, this.currentPathLabel, this.onRefresh, @@ -28,6 +30,7 @@ class PathSelectionDialog extends StatefulWidget { static Future show( BuildContext context, { required List availableContacts, + required int pathHashByteWidth, String? title, String? initialPath, String? currentPathLabel, @@ -37,6 +40,7 @@ class PathSelectionDialog extends StatefulWidget { context: context, builder: (context) => PathSelectionDialog( availableContacts: availableContacts, + pathHashByteWidth: pathHashByteWidth, title: title ?? context.l10n.path_enterCustomPath, initialPath: initialPath, currentPathLabel: currentPathLabel, @@ -44,6 +48,33 @@ class PathSelectionDialog extends StatefulWidget { ), ); } + + /// Parses comma-separated hex hop prefixes, each exactly [hashWidth] bytes + /// wide (clamped >= 1). Wrong-length or malformed entries go to [invalid] and + /// are skipped — never silently truncated. (#155) + static List parsePathPrefixes( + String text, + int hashWidth, + List invalid, + ) { + final hexLen = (hashWidth < 1 ? 1 : hashWidth) * 2; + final bytes = []; + for (final id + in text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty)) { + if (id.length != hexLen) { + invalid.add(id); + continue; + } + try { + for (var i = 0; i < hexLen; i += 2) { + bytes.add(int.parse(id.substring(i, i + 2), radix: 16)); + } + } catch (_) { + invalid.add(id); + } + } + return bytes; + } } class _PathSelectionDialogState extends State { @@ -51,6 +82,12 @@ class _PathSelectionDialogState extends State { final List _selectedContacts = []; List _validContacts = []; + // Hop prefix width in bytes (clamped >= 1) and in hex chars, from the device's + // configured path-hash width. (#155) + int get _hopWidth => + widget.pathHashByteWidth < 1 ? 1 : widget.pathHashByteWidth; + int get _hexLen => _hopWidth * 2; + @override void initState() { super.initState(); @@ -73,10 +110,11 @@ class _PathSelectionDialogState extends State { } void _updateTextFromContacts() { + final hexLen = _hexLen; final pathParts = _selectedContacts .map((contact) { - if (contact.publicKeyHex.length >= 2) { - return contact.publicKeyHex.substring(0, 2); + if (contact.publicKeyHex.length >= hexLen) { + return contact.publicKeyHex.substring(0, hexLen); } return ''; }) @@ -112,29 +150,13 @@ class _PathSelectionDialogState extends State { return; } - // Parse comma-separated hex prefixes - final pathIds = path - .split(',') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); - final pathBytesList = []; + // Parse comma-separated hex prefixes, each exactly one path-hash hop wide. final invalidPrefixes = []; - - for (final id in pathIds) { - if (id.length < 2) { - invalidPrefixes.add(id); - continue; - } - - final prefix = id.substring(0, 2); - try { - final byte = int.parse(prefix, radix: 16); - pathBytesList.add(byte); - } catch (e) { - invalidPrefixes.add(id); - } - } + final pathBytesList = PathSelectionDialog.parsePathPrefixes( + path, + widget.pathHashByteWidth, + invalidPrefixes, + ); if (!mounted) return; @@ -150,7 +172,7 @@ class _PathSelectionDialogState extends State { } // Check max path length (64 hops) - if (pathBytesList.length > 64) { + if (pathBytesList.length > 64 * _hopWidth) { showDismissibleSnackBar( context, content: Text(l10n.path_tooLong), @@ -227,7 +249,7 @@ class _PathSelectionDialogState extends State { helperText: l10n.path_helperMaxHops, ), textCapitalization: TextCapitalization.characters, - maxLength: 191, // 64 hops * 2 chars + 63 commas + maxLength: 64 * _hexLen + 63, // 64 hops * (width*2) + 63 commas ), const SizedBox(height: 16), const Divider(), @@ -312,7 +334,7 @@ class _PathSelectionDialogState extends State { style: const TextStyle(fontSize: 14), ), subtitle: Text( - '${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, 2)}', + '${contact.typeLabel(l10n)} • ${contact.publicKeyHex.length >= _hexLen ? contact.publicKeyHex.substring(0, _hexLen) : contact.publicKeyHex}', style: const TextStyle(fontSize: 10), ), trailing: isSelected diff --git a/test/widgets/path_selection_dialog_test.dart b/test/widgets/path_selection_dialog_test.dart new file mode 100644 index 0000000..7f4a08b --- /dev/null +++ b/test/widgets/path_selection_dialog_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/widgets/path_selection_dialog.dart'; + +void main() { + group('PathSelectionDialog.parsePathPrefixes (#155)', () { + test('width 1 — single-byte hops', () { + final invalid = []; + expect(PathSelectionDialog.parsePathPrefixes('A1,F2,3C', 1, invalid), [ + 0xA1, + 0xF2, + 0x3C, + ]); + expect(invalid, isEmpty); + }); + + test('width 2 — two-byte hops, each entry 4 hex chars', () { + final invalid = []; + expect(PathSelectionDialog.parsePathPrefixes('84AB,C1D2', 2, invalid), [ + 0x84, + 0xAB, + 0xC1, + 0xD2, + ]); + expect(invalid, isEmpty); + }); + + test('wrong-length entries are rejected, never silently truncated', () { + final invalid = []; + // too short (1 byte) and too long (3 bytes) on a 2-byte mesh + final bytes = PathSelectionDialog.parsePathPrefixes( + '84,AABBCC', + 2, + invalid, + ); + expect(bytes, isEmpty); + expect(invalid, ['84', 'AABBCC']); + }); + + test('non-hex entry is rejected', () { + final invalid = []; + PathSelectionDialog.parsePathPrefixes('ZZ', 1, invalid); + expect(invalid, ['ZZ']); + }); + + test('width < 1 is clamped to 1', () { + final invalid = []; + expect(PathSelectionDialog.parsePathPrefixes('A1', 0, invalid), [0xA1]); + expect(invalid, isEmpty); + }); + + test('empty input → empty path', () { + final invalid = []; + expect(PathSelectionDialog.parsePathPrefixes('', 2, invalid), isEmpty); + expect(invalid, isEmpty); + }); + }); +}