From a48edb209f736e3980c342e2265ac542fa48f9d8 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 1 Aug 2026 16:59:32 -0400 Subject: [PATCH] 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) --- lib/connector/meshcore_connector.dart | 22 ++++++++++++++------- lib/screens/channel_chat_screen.dart | 15 +++++++++----- lib/widgets/mention_autocomplete.dart | 24 +++++++++++++++++++++-- test/connector/mention_contract_test.dart | 13 ++++++++++-- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0adf65d..21b5653 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -6178,17 +6178,25 @@ class MeshCoreConnector extends ChangeNotifier { /// change that ships only in an aligned client + firmware build pair, never /// unilaterally. /// - /// The three properties firmware must match, none obvious from the rule name: - /// the name is trimmed and 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]. + /// ⚠ THE NAME IS COMPARED VERBATIM. Owner ruling 2026-08-01 (#497): the + /// `@[name]` token carries the advert name BYTE-FOR-BYTE, unnormalised. No + /// trimming, no Unicode normalisation. Leading and trailing whitespace are + /// part of a name's identity, and every other hop is already verbatim, so a + /// `.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 /// text and cannot be delimited for names containing spaces. static bool mentionsName(String text, String? selfName) { - final name = selfName?.trim(); - if (name == null || name.isEmpty) return false; + final name = selfName; + // 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)}]'); } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 651bbc4..1d7aa58 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1297,11 +1297,15 @@ class _ChannelChatScreenState extends State { final seen = {}; // 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 = {}; for (final message in connector.getChannelMessages(_currentChannel)) { if (message.isOutgoing) continue; - final name = message.senderName.trim(); - if (name.isEmpty || name == 'Unknown') continue; + final name = message.senderName; + final probe = name.trim(); + if (probe.isEmpty || probe == 'Unknown') continue; final existing = recentTime[name]; if (existing == null || message.timestamp.isAfter(existing)) { recentTime[name] = message.timestamp; @@ -1314,10 +1318,11 @@ class _ChannelChatScreenState extends State { 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) { - final name = contact.name.trim(); - if (name.isEmpty) continue; + final name = contact.name; + if (name.trim().isEmpty) continue; if (!seen.add(name.toLowerCase())) continue; candidates.add(MentionCandidate(name: name, recent: false)); } diff --git a/lib/widgets/mention_autocomplete.dart b/lib/widgets/mention_autocomplete.dart index e7cfc25..4fc03d2 100644 --- a/lib/widgets/mention_autocomplete.dart +++ b/lib/widgets/mention_autocomplete.dart @@ -5,6 +5,17 @@ import 'byte_count_input.dart'; /// A candidate name for `@`-mention autocomplete. 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; /// 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). 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({ required this.name, + String? label, required this.recent, this.lastSeen, - }); + }) : _label = label; } /// One row in the autocomplete dropdown. @@ -248,8 +265,11 @@ class _MentionAutocompleteFieldState extends State { } _matches = combined .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( - label: c.name, + label: c.label, icon: c.recent ? Icons.history : Icons.person_outline, insert: '@[${c.name}] ', ), diff --git a/test/connector/mention_contract_test.dart b/test/connector/mention_contract_test.dart index a9844ee..dc6ba00 100644 --- a/test/connector/mention_contract_test.dart +++ b/test/connector/mention_contract_test.dart @@ -26,8 +26,17 @@ void main() { expect(MeshCoreConnector.mentionsName('x@[Ben]y', 'Ben'), isTrue); }); - test('name is trimmed before matching', () { - expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' Ben '), isTrue); + test('the name is compared VERBATIM, whitespace included (#497)', () { + // 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', () {