Strycher 4 days ago committed by GitHub
commit a369a5af1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1002,6 +1002,15 @@ class MeshCoreConnector extends ChangeNotifier {
int get maxContacts => _maxContacts;
int get maxChannels => _maxChannels;
Set<String> get knownContactKeys => Set.unmodifiable(_knownContactKeys);
/// O(1) membership test for a contact by public key hex.
///
/// Use this from a widget `build`, never [knownContactKeys], which copies the
/// whole set on every call, nor a scan of [contacts]. A received contact card
/// asks this question once per rendered chip on every connector
/// notification. (#610)
bool isKnownContact(String publicKeyHex) =>
_knownContactKeys.contains(publicKeyHex);
double? get contactSyncProgress {
final total = _contactSyncTotal;
if (!_isLoadingContacts || total == null || total <= 0) return null;

@ -2,8 +2,37 @@ import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
// `flutter/widgets.dart` re-exports package:characters, which supplies the
// grapheme-cluster iteration used by utf8TruncateToBytes below.
import 'package:flutter/widgets.dart';
/// Encodes [s] as UTF-8, truncated to at most [maxBytes], never splitting a
/// character. (#636)
///
/// Truncation stops on a **grapheme cluster** boundary, not a byte boundary and
/// not merely a codepoint boundary. Cutting on bytes puts invalid UTF-8 on the
/// wire, which every reader then renders as replacement characters. Cutting
/// between codepoints is valid UTF-8 but still wrong: a ZWJ emoji sequence is
/// several codepoints joined by U+200D plus a variation selector, so a
/// codepoint-safe cut can still leave a bare glyph, a dangling joiner, or an
/// orphaned selector.
///
/// The on-wire name field is 32 bytes including a null terminator, so callers
/// pass 31. That holds roughly 10 CJK characters, or two ZWJ emoji.
Uint8List utf8TruncateToBytes(String s, int maxBytes) {
if (maxBytes <= 0) return Uint8List(0);
final whole = utf8.encode(s);
if (whole.length <= maxBytes) return Uint8List.fromList(whole);
final out = <int>[];
for (final cluster in s.characters) {
final encoded = utf8.encode(cluster);
if (out.length + encoded.length > maxBytes) break;
out.addAll(encoded);
}
return Uint8List.fromList(out);
}
// Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader {
int _pointer = 0;
@ -132,8 +161,10 @@ class BufferWriter {
void writeCString(String string, int maxLength) {
final bytes = Uint8List(maxLength);
final encoded = utf8.encode(string);
for (var i = 0; i < maxLength - 1 && i < encoded.length; i++) {
// Grapheme-safe: a raw byte copy would cut a multi-byte character in half
// and put invalid UTF-8 on the wire. (#636)
final encoded = utf8TruncateToBytes(string, maxLength - 1);
for (var i = 0; i < encoded.length; i++) {
bytes[i] = encoded[i];
}
writeBytes(bytes);
@ -1091,13 +1122,14 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
// Build CMD_SET_ADVERT_NAME frame
// Format: [cmd][name...]
Uint8List buildSetAdvertNameFrame(String name) {
final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < maxNameSize
? nameBytes.length
: maxNameSize - 1;
// Grapheme-safe. This one matters most of the three: it sets THIS device's
// own advert name, which is what every other node on the mesh sees, what is
// embedded in the contact cards this device emits, and what other clients
// match against. Corrupt it once and the corruption propagates outward.
// (#636)
final writer = BufferWriter();
writer.writeByte(cmdSetAdvertName);
writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
writer.writeBytes(utf8TruncateToBytes(name, maxNameSize - 1));
return writer.toBytes();
}

@ -2528,6 +2528,23 @@
"contacts_verifiedByMessage": "Key confirmed. A message with this contact went through, which only works with the matching key.",
"contacts_verifiedKeyOnly": "Added from a key. Nothing has confirmed it on air yet.",
"contacts_lastSeenNever": "Not heard yet",
"contacts_cardAddContact": "Add {name}",
"@contacts_cardAddContact": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"contacts_cardAlreadyAdded": "{name}",
"@contacts_cardAlreadyAdded": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"contacts_cardAlreadyAddedTooltip": "Already in your contacts",
"chat_attachTooltip": "Add to message",
"chat_attachGif": "GIF",
"chat_attachMyContact": "My contact card",

@ -7588,6 +7588,24 @@ abstract class AppLocalizations {
/// **'Not heard yet'**
String get contacts_lastSeenNever;
/// No description provided for @contacts_cardAddContact.
///
/// In en, this message translates to:
/// **'Add {name}'**
String contacts_cardAddContact(String name);
/// No description provided for @contacts_cardAlreadyAdded.
///
/// In en, this message translates to:
/// **'{name}'**
String contacts_cardAlreadyAdded(String name);
/// No description provided for @contacts_cardAlreadyAddedTooltip.
///
/// In en, this message translates to:
/// **'Already in your contacts'**
String get contacts_cardAlreadyAddedTooltip;
/// No description provided for @chat_attachTooltip.
///
/// In en, this message translates to:

@ -4431,6 +4431,19 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4443,6 +4443,19 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4365,6 +4365,19 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4432,6 +4432,19 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4454,6 +4454,19 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4449,6 +4449,19 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4437,6 +4437,19 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4234,6 +4234,19 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4236,6 +4236,19 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4417,6 +4417,19 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4447,6 +4447,19 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4429,6 +4429,19 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4439,6 +4439,19 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4413,6 +4413,19 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4407,6 +4407,19 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4386,6 +4386,19 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4438,6 +4438,19 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -4137,6 +4137,19 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get contacts_lastSeenNever => 'Not heard yet';
@override
String contacts_cardAddContact(String name) {
return 'Add $name';
}
@override
String contacts_cardAlreadyAdded(String name) {
return '$name';
}
@override
String get contacts_cardAlreadyAddedTooltip => 'Already in your contacts';
@override
String get chat_attachTooltip => 'Add to message';

@ -296,6 +296,13 @@ class Contact {
'&public_key=$publicKeyHex'
'&type=$type';
/// The body of one compact channel share, `<key:type:name>`, captured
/// without its delimiters. `[^>]*` cannot cross a closing bracket, so each
/// card matches individually even when several sit in one message.
static final RegExp _channelShareBody = RegExp(
r'<([0-9a-fA-F]{64}:\d+:[^>]*)>',
);
/// Compact contact share for a CHANNEL message, `<key:type:name>`. (#611)
///
/// This is a second, different format from [toShareUri], and deliberately so.
@ -338,12 +345,17 @@ class Contact {
/// Rendering a received card as a tappable Add Contact affordance is #610 and
/// is separate; this only handles text pasted or scanned into the add flow.
static Contact? fromChannelShare(String text) {
final trimmed = text.trim();
// Tolerate a card embedded in a longer message, which is how it arrives.
final start = trimmed.indexOf('<');
final end = trimmed.lastIndexOf('>');
if (start < 0 || end <= start) return null;
final body = trimmed.substring(start + 1, end);
// Tolerate a card embedded in a longer message, which is how it arrives,
// and take the FIRST well-formed one.
//
// Deliberately not a scan from the first `<` to the last `>`: a message
// carrying two cards would then be read as a single span running from the
// first key to the last name and parse as garbage. That is reachable,
// because the add dialog passes raw pasted text straight to here.
// (Gemini review, #610)
final match = _channelShareBody.firstMatch(text);
if (match == null) return null;
final body = match.group(1)!;
final firstColon = body.indexOf(':');
if (firstColon < 0) return null;

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import 'add_contact_by_key_dialog.dart';
/// Renders a received contact share card as a tappable Add Contact affordance
/// instead of the raw `<key:type:name>` text. (#610)
///
/// This is the receive half of the exchange. The send half (#611) already
/// emits this format, and the stock app already renders it as a native Add
/// Contact button, so until now sharing worked outbound only: a stock user
/// could add an Offband user from a card, but not the reverse.
///
/// Tapping opens the add dialog seeded with the card, so the user sees the key,
/// name and type before anything is written to the radio. It does NOT add
/// silently: a contact is an identity, and adding one should be a deliberate
/// act with the details visible.
class ContactCardChip extends StatelessWidget {
const ContactCardChip({super.key, required this.card, required this.style});
/// The raw matched card text, `<key:type:name>`.
final String card;
final TextStyle style;
@override
Widget build(BuildContext context) {
final parsed = Contact.fromChannelShare(card);
final scheme = Theme.of(context).colorScheme;
final l10n = context.l10n;
// The regex matched the shape but the parser rejected the contents, e.g. a
// type outside the documented range. Show the original text rather than a
// chip that would lie about being addable.
if (parsed == null) return Text(card, style: style);
// Mirrors what stock does: it warned the owner when the contact was
// already held rather than silently re-adding.
//
// O(1) against the connector's maintained key set. Scanning `contacts`
// here would run per rendered chip on every notification, which is
// hundreds of comparisons in a message list on a device with a large
// contact book. (Gemini review, #610)
final known = context.select<MeshCoreConnector, bool>(
(c) => c.isKnownContact(parsed.publicKeyHex),
);
final label = known
? l10n.contacts_cardAlreadyAdded(parsed.name)
: l10n.contacts_cardAddContact(parsed.name);
// A WidgetSpan gives its child unbounded width, so Flexible needs a real
// ceiling to ellipsize against. Without this the chip grows to whatever
// name the sender chose, which overflowed the message layout.
//
// Scaled off the viewport rather than a fixed pixel count, so a large
// accessibility font on a wide screen is not ellipsized while space
// remains, and a narrow phone still gets a sane bound. (Gemini recheck)
final maxChipWidth = MediaQuery.sizeOf(context).width * 0.7;
final chip = ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxChipWidth),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: known
? scheme.onSurface.withValues(alpha: 0.08)
: scheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
known ? Icons.how_to_reg : Icons.person_add_alt_1,
size: 15,
color: known
? scheme.onSurfaceVariant
: scheme.onPrimaryContainer,
),
const SizedBox(width: 5),
// Bounded and ellipsized. The name comes off a public radio channel
// and is attacker-controlled, so an unbounded label lets anyone
// overflow the message layout by posting a card with a long name.
// Reproduced before this: a 405 pixel RenderFlex overflow.
// (Gemini review prompted the adversarial case, #610)
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(
fontWeight: FontWeight.w500,
color: known
? scheme.onSurfaceVariant
: scheme.onPrimaryContainer,
),
),
),
],
),
),
);
if (known) {
return Tooltip(
message: l10n.contacts_cardAlreadyAddedTooltip,
child: chip,
);
}
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => showAddContactByKeyDialog(context, initialKeyText: card),
child: chip,
);
}
}

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../helpers/link_handler.dart';
import 'contact_card_chip.dart';
class TranslatedMessageContent extends StatelessWidget {
final String displayText;
@ -25,6 +26,15 @@ class TranslatedMessageContent extends StatelessWidget {
// A leading `@[Name] ` reply prefix.
static final RegExp _replyPrefix = RegExp(r'^@\[([^\]]+)\]\s+');
/// A contact share card, `<64-hex key:type:name>`, rendered as a tappable
/// Add Contact chip rather than the raw text it used to show. (#610)
///
/// This is the format real clients put on the air, confirmed because the
/// stock app renders it as a native Add Contact button. The name is the final
/// field and may contain colons and spaces, so it is matched greedily to the
/// closing bracket; brackets themselves are stripped by the emitter.
static final RegExp _contactCard = RegExp(r'<[0-9a-fA-F]{64}:\d+:[^>]*>');
/// The name of a leading `@[Name]` reply mention, or null. Lets callers show
/// a reply chip above content that isn't rendered as text (e.g. a reply-gif,
/// #232).
@ -50,27 +60,44 @@ class TranslatedMessageContent extends StatelessWidget {
}
Widget _buildText(BuildContext context, String text, TextStyle textStyle) {
if (!_mention.hasMatch(text)) {
final hasMention = _mention.hasMatch(text);
final hasCard = _contactCard.hasMatch(text);
if (!hasMention && !hasCard) {
return LinkHandler.buildLinkifyText(
context: context,
text: text,
style: textStyle,
);
}
// Mentions can't be interleaved with the Linkify widget, so a message that
// contains a mention renders as rich text with chip spans (links inside a
// mention message are not tappable, same as the prior leading-mention
// path). Messages without a mention keep full link support above.
// Neither chip can be interleaved with the Linkify widget, so a message
// containing one renders as rich text with chip spans (links inside such a
// message are not tappable, same as the prior leading-mention path).
// Messages with neither keep full link support above.
//
// Both patterns are collected and sorted by position, so a message
// carrying a mention AND a contact card renders both in the right order.
// They cannot overlap: a mention is `@[...]`, a card is `<...>`.
// Each match carries which pattern produced it. Inferring the kind from
// the text, e.g. `text[m.start] == '<'`, would silently mis-route the day
// a third bracketed pattern is added. (Gemini review, #610)
final matches = <(Match, bool isCard)>[
..._mention.allMatches(text).map((m) => (m, false)),
..._contactCard.allMatches(text).map((m) => (m, true)),
]..sort((a, b) => a.$1.start.compareTo(b.$1.start));
final spans = <InlineSpan>[];
var last = 0;
for (final m in _mention.allMatches(text)) {
for (final (m, isCard) in matches) {
if (m.start < last) continue;
if (m.start > last) {
spans.add(TextSpan(text: text.substring(last, m.start)));
}
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: mentionChip(context, m.group(1)!, textStyle),
child: isCard
? ContactCardChip(card: m.group(0)!, style: textStyle)
: mentionChip(context, m.group(1)!, textStyle),
),
);
last = m.end;

@ -0,0 +1,124 @@
// Grapheme-safe name truncation (#636).
//
// The on-wire name field is 32 bytes including a null terminator, so 31 are
// usable. Before this fix the three writers copied raw bytes up to that
// boundary, cutting multi-byte characters in half and putting invalid UTF-8 on
// the wire. ASCII names were unaffected, which is why it went unnoticed.
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
/// Decodes strictly. Throws if the bytes are not valid UTF-8, which is exactly
/// the failure this fix prevents.
String strictDecode(List<int> bytes) =>
utf8.decode(bytes, allowMalformed: false);
void main() {
group('utf8TruncateToBytes (#636)', () {
test('leaves anything that already fits completely alone', () {
for (final s in ['', 'Bob', 'Roger KY4RS', 'a' * 31]) {
expect(utf8TruncateToBytes(s, 31), utf8.encode(s), reason: s);
}
});
test('never emits invalid UTF-8, whatever the cut point', () {
// The core property. Sweep every budget across a string whose characters
// are 1, 3 and 4 bytes, so a byte-wise cut would land mid-character at
// many of these lengths.
const s = 'ab中文🧙cd漢字';
for (var budget = 0; budget <= utf8.encode(s).length + 2; budget++) {
final out = utf8TruncateToBytes(s, budget);
expect(out.length, lessThanOrEqualTo(budget));
// Would throw on a split character.
expect(
() => strictDecode(out),
returnsNormally,
reason: 'budget $budget',
);
expect(
s.startsWith(strictDecode(out)),
isTrue,
reason: 'budget $budget',
);
}
});
test('CJK truncates on a character boundary, not a byte one', () {
// 11 CJK characters is 33 bytes against a 31-byte budget.
const name = '中文节点名称测试一二三';
expect(utf8.encode(name).length, 33);
final out = utf8TruncateToBytes(name, 31);
// 10 characters at 3 bytes each fit; the 11th does not.
expect(out.length, 30);
expect(strictDecode(out), '中文节点名称测试一二');
});
test('a ZWJ emoji sequence is kept whole or dropped whole', () {
// The mage is 4 codepoints joined by ZWJ plus a variation selector, 13
// bytes. A codepoint-safe cut would still be wrong here: it could leave a
// bare mage, a dangling joiner, or an orphaned selector.
const mage = '\u{1F9D9}‍♂️';
expect(utf8.encode(mage).length, 13);
// One byte short of fitting: the whole cluster must go.
expect(utf8TruncateToBytes(mage, 12), isEmpty);
// Exactly fitting: kept intact.
expect(strictDecode(utf8TruncateToBytes(mage, 13)), mage);
// And no partial cluster survives at any budget below 13.
for (var b = 0; b < 13; b++) {
expect(utf8TruncateToBytes(mage, b), isEmpty, reason: 'budget $b');
}
});
test('a zero or negative budget yields nothing', () {
expect(utf8TruncateToBytes('anything', 0), isEmpty);
expect(utf8TruncateToBytes('anything', -5), isEmpty);
});
});
group('the three writers are grapheme-safe (#636)', () {
// Reads a fixed-width, null-padded name field back out.
String nameFrom(Uint8List frame, int offset, int width) {
final slice = frame.sublist(offset, offset + width);
final end = slice.indexOf(0);
return strictDecode(slice.sublist(0, end < 0 ? slice.length : end));
}
const cjk = '中文节点名称测试一二三';
test('buildSetAdvertNameFrame: own advert name survives intact', () {
// The most externally visible of the three: this is the name the whole
// mesh sees and the name embedded in emitted contact cards.
final frame = buildSetAdvertNameFrame(cjk);
final decoded = strictDecode(frame.sublist(1));
expect(decoded, '中文节点名称测试一二');
expect(frame.length - 1, lessThanOrEqualTo(maxNameSize - 1));
});
test('buildUpdateContactPathFrame: contact name survives intact', () {
final frame = buildUpdateContactPathFrame(
Uint8List(pubKeySize),
Uint8List(0),
-1,
name: cjk,
);
expect(nameFrom(frame, contactNameOffset, maxNameSize), '中文节点名称测试一二');
});
test('buildSetChannelFrame: channel name survives intact', () {
final frame = buildSetChannelFrame(0, cjk, Uint8List(16));
// [cmd][idx][name x32][psk x16]
expect(nameFrom(frame, 2, maxNameSize), '中文节点名称测试一二');
});
test('ASCII names are byte-identical to the old behaviour', () {
// No regression for the overwhelmingly common case.
final frame = buildSetAdvertNameFrame('Roger KY4RS');
expect(strictDecode(frame.sublist(1)), 'Roger KY4RS');
});
});
}

@ -0,0 +1,148 @@
// Receiving a contact card (#610).
//
// Before this, an incoming <key:type:name> rendered as raw text while the stock
// app showed a native Add Contact button for the very same payload. Sharing
// worked outbound only.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/widgets/translated_message_content.dart';
const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
const _card = '<$_key:1:Ka8sbi>';
class _Conn extends MeshCoreConnector {
_Conn({this.known = const {}});
/// Public key hexes already in contacts. Mirrors the connector's own
/// `_knownContactKeys` set, which is what the chip now consults.
final Set<String> known;
@override
bool isKnownContact(String publicKeyHex) => known.contains(publicKeyHex);
}
Future<void> _pump(WidgetTester tester, String text, _Conn conn) =>
tester.pumpWidget(
ChangeNotifierProvider<MeshCoreConnector>.value(
value: conn,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: TranslatedMessageContent(
displayText: text,
style: const TextStyle(fontSize: 14),
),
),
),
),
);
void main() {
testWidgets('a received card renders as an actionable chip, not raw text', (
tester,
) async {
await _pump(tester, 'here is mine $_card', _Conn());
// The raw payload must not be shown.
expect(find.textContaining(_key), findsNothing);
// An actionable affordance is offered instead.
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
expect(find.byType(InkWell), findsOneWidget);
// The caption survives alongside it.
expect(find.textContaining('here is mine'), findsOneWidget);
});
testWidgets('a card for a known contact does not offer to re-add', (
tester,
) async {
// Mirrors stock, which warned rather than silently re-adding.
await _pump(tester, _card, _Conn(known: {_key}));
expect(find.byIcon(Icons.how_to_reg), findsOneWidget);
expect(find.byIcon(Icons.person_add_alt_1), findsNothing);
// Not tappable, so it cannot be double-added.
expect(find.byType(InkWell), findsNothing);
});
testWidgets('a malformed card falls back to plain text rather than lying', (
tester,
) async {
// Right shape, impossible type. Rendering an Add chip here would promise
// something the parser will refuse.
const bad = '<$_key:9:Bob>';
await _pump(tester, bad, _Conn());
expect(find.byIcon(Icons.person_add_alt_1), findsNothing);
expect(find.textContaining(bad), findsOneWidget);
});
testWidgets('a mention and a card in one message both render', (
tester,
) async {
// The two patterns are collected and sorted by position, so this is the
// case that would break a naive single-regex implementation.
await _pump(tester, '@[Bob] add this $_card', _Conn());
expect(find.textContaining('@Bob'), findsOneWidget);
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
expect(find.textContaining(_key), findsNothing);
});
testWidgets('a plain message is untouched and keeps link support', (
tester,
) async {
await _pump(tester, 'just a normal message', _Conn());
expect(find.byIcon(Icons.person_add_alt_1), findsNothing);
expect(find.textContaining('just a normal message'), findsOneWidget);
});
testWidgets('two cards in one message render as two separate chips', (
tester,
) async {
// Regression for the Gemini review: parsing from the first `<` to the last
// `>` would have read this as one span running from the first key to the
// second name, and produced garbage.
const other =
'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
await _pump(tester, '$_card and <$other:2:Bob>', _Conn());
expect(find.byIcon(Icons.person_add_alt_1), findsNWidgets(2));
expect(find.textContaining('Ka8sbi'), findsOneWidget);
expect(find.textContaining('Bob'), findsOneWidget);
expect(find.textContaining(_key), findsNothing);
});
testWidgets('a nested bracket mess yields one card, and it is the outer key', (
tester,
) async {
// Adversarial input from a public channel. `[^>]*` cannot cross a `>`, so
// the match ends at the inner closing bracket and the nested text becomes
// part of the OUTER card's name. Exactly one add is offered, for the outer
// key, and the tap opens the dialog where the key is visible before
// anything is written to the radio. No second key is silently smuggled in.
const other =
'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
await _pump(tester, '<$_key:1:Name <$other:2:Inner>>', _Conn());
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
// The parser took the outer card, so the nested text is shown as the name
// rather than being treated as a second identity.
expect(find.textContaining('Name <'), findsOneWidget);
});
testWidgets('names with spaces and emoji survive into the chip label', (
tester,
) async {
await _pump(tester, '<$_key:2:Roger KY4RS 🧙>', _Conn());
expect(find.textContaining('Roger KY4RS 🧙'), findsOneWidget);
expect(find.byIcon(Icons.person_add_alt_1), findsOneWidget);
});
}

@ -117,6 +117,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -323,6 +326,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -529,6 +535,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -735,6 +744,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -941,6 +953,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -1147,6 +1162,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -1353,6 +1371,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -1559,6 +1580,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -1765,6 +1789,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -1971,6 +1998,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -2177,6 +2207,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -2383,6 +2416,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -2589,6 +2625,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -2795,6 +2834,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -3001,6 +3043,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -3207,6 +3252,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",
@ -3413,6 +3461,9 @@
"contacts_verifiedByMessage",
"contacts_verifiedKeyOnly",
"contacts_lastSeenNever",
"contacts_cardAddContact",
"contacts_cardAlreadyAdded",
"contacts_cardAlreadyAddedTooltip",
"chat_attachTooltip",
"chat_attachGif",
"chat_attachMyContact",

Loading…
Cancel
Save

Powered by TurnKey Linux.