diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 71e57f3..0adf65d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -6148,19 +6148,52 @@ class MeshCoreConnector extends ChangeNotifier { return 'Channel $channelIndex'; } - /// True when [text] carries a canonical mention of this node: the `@[Name]` + /// Case-fold ASCII `A-Z` only, leaving every other code unit byte-exact. + /// + /// Deliberately NOT `String.toLowerCase()`, which applies full Unicode case + /// mapping. Firmware #510 implements the same match rule with a byte-wise + /// fold that does not do Unicode mapping, so a node name carrying any + /// non-ASCII character would get one self-mention verdict on the client and + /// the opposite on the device for the SAME message: a silent wrong answer, + /// not a visible failure. Owner decision 2026-07-31 (#475): both sides fold + /// ASCII only, so non-ASCII names compare case-sensitively. (#486) + static String _foldAscii(String s) { + final units = s.codeUnits; + final folded = List.filled(units.length, 0); + for (var i = 0; i < units.length; i++) { + final unit = units[i]; + folded[i] = (unit >= 0x41 && unit <= 0x5A) ? unit + 0x20 : unit; + } + return String.fromCharCodes(folded); + } + + /// True when [text] carries a canonical mention of [selfName]: the `@[Name]` /// form the composer inserts and the chat renders as a chip (#235). /// - /// Matched case-insensitively, since a hand-typed mention need not match the - /// advert's casing. Bare `@Name` is deliberately NOT matched: it false- - /// positives on ordinary text and cannot be delimited for names containing - /// spaces. - bool _mentionsSelf(String text) { - final name = _selfName?.trim(); + /// ⚠ CROSS-REPO CONTRACT (client #475, firmware #510). The firmware + /// implements this exact rule against `NodePrefs::node_name` so that both + /// sides agree on what "Self" means for the device notification scope. + /// Changing it in any way (accepting a bare `@name`, restoring Unicode + /// folding, anchoring the match at a word boundary) is a BREAKING cross-repo + /// 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]. + /// + /// 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; - return text.toLowerCase().contains('@[${name.toLowerCase()}]'); + return _foldAscii(text).contains('@[${_foldAscii(name)}]'); } + bool _mentionsSelf(String text) => mentionsName(text, _selfName); + void _maybeNotifyChannelMessage( ChannelMessage message, { String? channelName, diff --git a/test/connector/mention_contract_test.dart b/test/connector/mention_contract_test.dart new file mode 100644 index 0000000..a9844ee --- /dev/null +++ b/test/connector/mention_contract_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; + +/// Cross-repo contract tests for the `@[name]` self-mention rule (client #486, +/// firmware #510). Firmware implements the identical rule, so any change that +/// breaks one of these breaks agreement with the device and must ship in an +/// aligned build pair. +void main() { + group('@[name] matching', () { + test('matches the canonical bracketed form', () { + expect( + MeshCoreConnector.mentionsName('hey @[Ben] you there', 'Ben'), + isTrue, + ); + }); + + test('bare @name does not match', () { + expect( + MeshCoreConnector.mentionsName('hey @Ben you there', 'Ben'), + isFalse, + ); + }); + + test('matches as a plain substring, not anchored or word-bounded', () { + // Deliberate: the rule is `contains`, and firmware must agree. + expect(MeshCoreConnector.mentionsName('x@[Ben]y', 'Ben'), isTrue); + }); + + test('name is trimmed before matching', () { + expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' Ben '), isTrue); + }); + + test('empty or whitespace-only self-name matches nothing', () { + expect(MeshCoreConnector.mentionsName('yo @[Ben]', ''), isFalse); + expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' '), isFalse); + expect(MeshCoreConnector.mentionsName('yo @[Ben]', null), isFalse); + }); + + test('a name that is not mentioned does not match', () { + expect(MeshCoreConnector.mentionsName('yo @[Alice]', 'Ben'), isFalse); + expect( + MeshCoreConnector.mentionsName('no mentions here', 'Ben'), + isFalse, + ); + }); + }); + + group('ASCII-only case folding (owner decision 2026-07-31, #486)', () { + test('ASCII names match case-insensitively in both directions', () { + expect(MeshCoreConnector.mentionsName('yo @[BEN]', 'ben'), isTrue); + expect(MeshCoreConnector.mentionsName('yo @[ben]', 'BEN'), isTrue); + expect(MeshCoreConnector.mentionsName('yo @[BeN]', 'bEn'), isTrue); + }); + + test('non-ASCII names compare case-sensitively', () { + // The deliberate divergence from String.toLowerCase(): firmware folds + // bytes and cannot do Unicode case mapping, so the client must not + // either, or the two sides disagree on the same message. + expect(MeshCoreConnector.mentionsName('yo @[Érik]', 'Érik'), isTrue); + expect(MeshCoreConnector.mentionsName('yo @[érik]', 'Érik'), isFalse); + expect(MeshCoreConnector.mentionsName('yo @[ÉRIK]', 'érik'), isFalse); + }); + + test('ASCII folding still applies around non-ASCII characters', () { + // "Ben-Érik": the ASCII half folds, the accented character does not. + expect( + MeshCoreConnector.mentionsName('yo @[BEN-Érik]', 'ben-Érik'), + isTrue, + ); + expect( + MeshCoreConnector.mentionsName('yo @[BEN-érik]', 'ben-Érik'), + isFalse, + ); + }); + + test('non-letter ASCII is untouched by the fold', () { + expect(MeshCoreConnector.mentionsName('yo @[Node_7]', 'node_7'), isTrue); + expect(MeshCoreConnector.mentionsName('yo @[N0DE-7]', 'n0de-7'), isTrue); + }); + + test( + 'names containing spaces work, which bare @name could not delimit', + () { + expect( + MeshCoreConnector.mentionsName('yo @[Base Station]', 'base station'), + isTrue, + ); + }, + ); + }); +}