feat(#486): ASCII-only case folding for the @[name] self-mention contract

_mentionsSelf folded with String.toLowerCase(), which applies full
Unicode case mapping. Firmware adopts this same match rule with a
byte-wise fold that does not, so a node name carrying any non-ASCII
character could produce 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: both sides fold ASCII A-Z only, so non-ASCII
names compare case-sensitively.

Extracts the rule into a testable static (mentionsName) and documents it
as a cross-repo contract: widening it, whether by accepting a bare
@name, restoring Unicode folding, or anchoring the match, is a breaking
change that ships only in an aligned client and firmware build pair.

Behaviour change: notifications for non-ASCII node names go from
case-insensitive to case-sensitive. Deliberate, per the decision above.

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

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

@ -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,
);
},
);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.