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 <noreply@anthropic.com>
pull/201/head
Strycher 3 weeks ago
parent 09e29f6782
commit d2d7930858

@ -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.",

@ -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.

@ -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:';

@ -1513,6 +1513,7 @@ class _ChatScreenState extends State<ChatScreen> {
final result = await PathSelectionDialog.show(
context,
availableContacts: availableContacts,
pathHashByteWidth: connector.pathHashByteWidth,
initialPath: pathForInput.isEmpty ? null : pathForInput,
title: context.l10n.chat_setCustomPath,
currentPathLabel: currentPathLabel,

@ -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,

@ -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<Uint8List?> show(
BuildContext context, {
required List<Contact> 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<int> parsePathPrefixes(
String text,
int hashWidth,
List<String> invalid,
) {
final hexLen = (hashWidth < 1 ? 1 : hashWidth) * 2;
final bytes = <int>[];
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<PathSelectionDialog> {
@ -51,6 +82,12 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
final List<Contact> _selectedContacts = [];
List<Contact> _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<PathSelectionDialog> {
}
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<PathSelectionDialog> {
return;
}
// Parse comma-separated hex prefixes
final pathIds = path
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
final pathBytesList = <int>[];
// Parse comma-separated hex prefixes, each exactly one path-hash hop wide.
final invalidPrefixes = <String>[];
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<PathSelectionDialog> {
}
// 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<PathSelectionDialog> {
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<PathSelectionDialog> {
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

@ -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 = <String>[];
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 = <String>[];
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 = <String>[];
// 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 = <String>[];
PathSelectionDialog.parsePathPrefixes('ZZ', 1, invalid);
expect(invalid, ['ZZ']);
});
test('width < 1 is clamped to 1', () {
final invalid = <String>[];
expect(PathSelectionDialog.parsePathPrefixes('A1', 0, invalid), [0xA1]);
expect(invalid, isEmpty);
});
test('empty input → empty path', () {
final invalid = <String>[];
expect(PathSelectionDialog.parsePathPrefixes('', 2, invalid), isEmpty);
expect(invalid, isEmpty);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.