fix(#497): @[name] is a verbatim wire token, remove the trim

Owner ruling 2026-08-01. Mention autocomplete trimmed the contact name
when building the @[...] token while the device stores and matches it
byte-for-byte, so any name with leading or trailing whitespace was
unmentionable and never beeped. Confirmed on the wire: the contact
record keeps the 0x20, the outgoing mention drops it.

@[...] is a wire token, not display text. Entry, firmware memcpy,
advert encode, advert parse and the contact record are all verbatim by
design; this trim was the only transformation applied to a node name
anywhere, and it changed the identity of the addressee.

- MentionCandidate now separates the two concerns: `name` is raw and
  goes on the wire, `label` is display-only and may be tidied. The
  const constructor is preserved, so existing const call sites still
  compile.
- The candidate builder keeps sender and contact names raw. Emptiness is
  probed on a trimmed copy; the stored value is untouched.
- _mentionsSelf drops its own trim as a direct consequence: a raw token
  requires a raw comparison, or this node stops recognising mentions of
  its own name.
- Contract clause written at both the insertion site and _mentionsSelf,
  stating the token is byte-for-byte and that a future .trim() tidy-up
  is forbidden. The contract was silent on raw vs normalised, which is
  why both sides were reasonable and incompatible.

Deliberately out of scope per the ruling: no entry-side trim in
settings_screen. It fixes no deployed name and would silently alter
deliberate spacing.

Test updated to assert the new truth: '  Ben  ' does NOT match @[Ben],
and DOES match @[  Ben  ]. Full suite 732 pass, analyze clean.

Agent: CalmBay (session d14220d9)
feat/483-button-buzzer-ui
Strycher 2 months ago
parent 9de6621ec9
commit a48edb209f

@ -6178,17 +6178,25 @@ class MeshCoreConnector extends ChangeNotifier {
/// change that ships only in an aligned client + firmware build pair, never /// change that ships only in an aligned client + firmware build pair, never
/// unilaterally. /// unilaterally.
/// ///
/// The three properties firmware must match, none obvious from the rule name: /// ⚠ THE NAME IS COMPARED VERBATIM. Owner ruling 2026-08-01 (#497): the
/// the name is trimmed and an empty name matches nothing (it does not fall /// `@[name]` token carries the advert name BYTE-FOR-BYTE, unnormalised. No
/// through to matching everything); the match is a plain substring `contains`, /// trimming, no Unicode normalisation. Leading and trailing whitespace are
/// neither anchored nor word-boundary aware; folding is ASCII-only per /// part of a name's identity, and every other hop is already verbatim, so a
/// [_foldAscii]. /// `.trim()` here makes this node stop recognising mentions of itself. That
/// is what stopped mentions beeping. Do not reintroduce it.
///
/// The properties firmware must match, none obvious from the rule name: an
/// empty name matches nothing (it does not fall through to matching
/// everything); the match is a plain substring `contains`, neither anchored
/// nor word-boundary aware; folding is ASCII-only per [_foldAscii] and is the
/// ONLY normalisation applied to either side.
/// ///
/// Bare `@Name` is deliberately NOT matched: it false-positives on ordinary /// Bare `@Name` is deliberately NOT matched: it false-positives on ordinary
/// text and cannot be delimited for names containing spaces. /// text and cannot be delimited for names containing spaces.
static bool mentionsName(String text, String? selfName) { static bool mentionsName(String text, String? selfName) {
final name = selfName?.trim(); final name = selfName;
if (name == null || name.isEmpty) return false; // Emptiness is probed on a trimmed copy; the COMPARISON uses the raw name.
if (name == null || name.trim().isEmpty) return false;
return _foldAscii(text).contains('@[${_foldAscii(name)}]'); return _foldAscii(text).contains('@[${_foldAscii(name)}]');
} }

@ -1297,11 +1297,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final seen = <String>{}; final seen = <String>{};
// Recent senders in this channel, keyed to their most recent timestamp. // Recent senders in this channel, keyed to their most recent timestamp.
// Names are carried VERBATIM: the `@[name]` token must byte-match the
// advert name the device stores, whitespace included (#497). Emptiness is
// tested on a trimmed copy, but the raw name is what gets kept.
final recentTime = <String, DateTime>{}; final recentTime = <String, DateTime>{};
for (final message in connector.getChannelMessages(_currentChannel)) { for (final message in connector.getChannelMessages(_currentChannel)) {
if (message.isOutgoing) continue; if (message.isOutgoing) continue;
final name = message.senderName.trim(); final name = message.senderName;
if (name.isEmpty || name == 'Unknown') continue; final probe = name.trim();
if (probe.isEmpty || probe == 'Unknown') continue;
final existing = recentTime[name]; final existing = recentTime[name];
if (existing == null || message.timestamp.isAfter(existing)) { if (existing == null || message.timestamp.isAfter(existing)) {
recentTime[name] = message.timestamp; recentTime[name] = message.timestamp;
@ -1314,10 +1318,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
seen.add(name.toLowerCase()); seen.add(name.toLowerCase());
}); });
// Known contacts not already present as a recent sender. // Known contacts not already present as a recent sender. Same rule: the
// contact's name is kept raw so the inserted token matches the device.
for (final contact in connector.allContacts) { for (final contact in connector.allContacts) {
final name = contact.name.trim(); final name = contact.name;
if (name.isEmpty) continue; if (name.trim().isEmpty) continue;
if (!seen.add(name.toLowerCase())) continue; if (!seen.add(name.toLowerCase())) continue;
candidates.add(MentionCandidate(name: name, recent: false)); candidates.add(MentionCandidate(name: name, recent: false));
} }

@ -5,6 +5,17 @@ import 'byte_count_input.dart';
/// A candidate name for `@`-mention autocomplete. /// A candidate name for `@`-mention autocomplete.
class MentionCandidate { class MentionCandidate {
/// The advert name EXACTLY as the device holds it, including any leading or
/// trailing whitespace.
///
/// ⚠ CROSS-REPO CONTRACT (client #497, firmware #510). The `@[name]` token
/// carries the advert name BYTE-FOR-BYTE, unnormalised: no trimming, no
/// Unicode normalisation, no case folding when composing it. Whitespace is
/// part of a name's identity, and every other hop (entry, firmware memcpy,
/// advert encode, advert parse, contact record) is already verbatim. A
/// `.trim()` here changes the identity of the addressee and makes the
/// mention unmatchable on the device, which is exactly the bug that stopped
/// mentions beeping. Do not "tidy" this.
final String name; final String name;
/// True when this name is a recent sender in the current channel. /// True when this name is a recent sender in the current channel.
@ -13,11 +24,17 @@ class MentionCandidate {
/// Most-recent time this name was seen (recent candidates only). /// Most-recent time this name was seen (recent candidates only).
final DateTime? lastSeen; final DateTime? lastSeen;
final String? _label;
/// Display-only form. Safe to trim, because it never reaches the wire.
String get label => _label ?? name.trim();
const MentionCandidate({ const MentionCandidate({
required this.name, required this.name,
String? label,
required this.recent, required this.recent,
this.lastSeen, this.lastSeen,
}); }) : _label = label;
} }
/// One row in the autocomplete dropdown. /// One row in the autocomplete dropdown.
@ -248,8 +265,11 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
} }
_matches = combined _matches = combined
.map( .map(
// label is display, insert is the wire token. They are deliberately
// different fields: the token must carry the raw name verbatim while
// the list may show a tidied one. (#497)
(c) => _Entry( (c) => _Entry(
label: c.name, label: c.label,
icon: c.recent ? Icons.history : Icons.person_outline, icon: c.recent ? Icons.history : Icons.person_outline,
insert: '@[${c.name}] ', insert: '@[${c.name}] ',
), ),

@ -26,8 +26,17 @@ void main() {
expect(MeshCoreConnector.mentionsName('x@[Ben]y', 'Ben'), isTrue); expect(MeshCoreConnector.mentionsName('x@[Ben]y', 'Ben'), isTrue);
}); });
test('name is trimmed before matching', () { test('the name is compared VERBATIM, whitespace included (#497)', () {
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' Ben '), isTrue); // Owner ruling 2026-08-01: @[name] is a wire token carrying the advert
// name byte-for-byte. A name with surrounding spaces is a DIFFERENT
// name, and trimming it here is what stopped mentions beeping.
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' Ben '), isFalse);
expect(
MeshCoreConnector.mentionsName('yo @[ Ben ]', ' Ben '),
isTrue,
);
expect(MeshCoreConnector.mentionsName('yo @[Ben ]', 'Ben '), isTrue);
expect(MeshCoreConnector.mentionsName('yo @[Ben]', 'Ben '), isFalse);
}); });
test('empty or whitespace-only self-name matches nothing', () { test('empty or whitespace-only self-name matches nothing', () {

Loading…
Cancel
Save

Powered by TurnKey Linux.