fix: Support MeshCore message links #123

This commit is contained in:
Janez T
2026-03-21 18:36:56 +01:00
parent 5cc81bef65
commit 20a1df94eb
6 changed files with 412 additions and 31 deletions

View File

@@ -737,8 +737,15 @@ class MessagesProvider with ChangeNotifier {
if (message.isContactMessage) {
// Match by sender key + sender timestamp (matches official app's DB
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
return existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp;
if (existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp) {
return true;
}
// Fallback dedup when retransmits surface as separate inbound rows
// without a stable timestamp/message id, but still carry the same
// visible sender identity and payload.
return _matchesDuplicateSenderIdentity(existing, message);
}
if (message.isChannelMessage) {
@@ -760,6 +767,14 @@ class MessagesProvider with ChangeNotifier {
return false;
}
return _matchesDuplicateSenderIdentity(existing, message);
}
// System messages and other types: never deduplicate by scope alone.
return false;
}
bool _matchesDuplicateSenderIdentity(Message existing, Message message) {
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
@@ -770,17 +785,9 @@ class MessagesProvider with ChangeNotifier {
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existingSenderName != null &&
return existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
return true;
}
return false;
}
// System messages and other types: never deduplicate by scope alone.
return false;
existingSenderName == incomingSenderName;
}
/// Add multiple messages

View File

@@ -6,7 +6,9 @@ import '../providers/connection_provider.dart';
import '../l10n/app_localizations.dart';
class AddContactScreen extends StatefulWidget {
const AddContactScreen({super.key});
final String? initialAdvert;
const AddContactScreen({super.key, this.initialAdvert});
@override
State<AddContactScreen> createState() => _AddContactScreenState();
@@ -21,8 +23,13 @@ class _AddContactScreenState extends State<AddContactScreen> {
@override
void initState() {
super.initState();
if (widget.initialAdvert != null &&
widget.initialAdvert!.trim().isNotEmpty) {
_advertController.text = widget.initialAdvert!.trim();
} else {
_loadClipboardIfPresent();
}
}
@override
void dispose() {
@@ -79,9 +86,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
final text = clipboardData?.text;
if (text == null || text.trim().isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)),
);
return;
}
@@ -140,9 +147,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)),
);
setState(() {
_importSucceeded = true;
});

View File

@@ -667,7 +667,6 @@ class _MessagesTabState extends State<MessagesTab> {
}
_textController.clear();
_focusNode.unfocus();
try {
// Check destination type and send accordingly

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart';
import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../models/sar_marker.dart';
@@ -31,6 +33,7 @@ import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart';
import '../../screens/add_contact_screen.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart';
@@ -72,9 +75,28 @@ class MessageBubble extends StatefulWidget {
}
class _MessageBubbleState extends State<MessageBubble> {
static const int _meshcoreAdvertMinBytes = 98;
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
static final RegExp _linkPattern = RegExp(
r'(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+',
caseSensitive: false,
);
static final RegExp _rawMeshcoreAdvertPattern = RegExp(
r'(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
);
static final RegExp _messageTokenPattern = RegExp(
r'@\[(.+?)\]|(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+|(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
caseSensitive: false,
);
bool _isExpanded = false;
bool _showReceivedStats = false;
final List<TapGestureRecognizer> _linkRecognizers = [];
@override
void dispose() {
_disposeLinkRecognizers();
super.dispose();
}
@override
void didUpdateWidget(MessageBubble oldWidget) {
@@ -100,14 +122,108 @@ class _MessageBubbleState extends State<MessageBubble> {
});
}
void _disposeLinkRecognizers() {
for (final recognizer in _linkRecognizers) {
recognizer.dispose();
}
_linkRecognizers.clear();
}
String _trimTrailingUrlPunctuation(String value) {
var trimmed = value;
while (trimmed.isNotEmpty) {
final lastChar = trimmed[trimmed.length - 1];
final shouldTrim = switch (lastChar) {
'.' || ',' || '!' || '?' || ':' || ';' => true,
')' => '('.allMatches(trimmed).length < ')'.allMatches(trimmed).length,
']' => '['.allMatches(trimmed).length < ']'.allMatches(trimmed).length,
'}' => '{'.allMatches(trimmed).length < '}'.allMatches(trimmed).length,
_ => false,
};
if (!shouldTrim) {
break;
}
trimmed = trimmed.substring(0, trimmed.length - 1);
}
return trimmed;
}
Uri? _parseMessageLink(String value) {
final trimmed = _trimTrailingUrlPunctuation(value);
if (trimmed.isEmpty) {
return null;
}
if (_isRawMeshcoreAdvert(trimmed)) {
return Uri.parse('meshcore://$trimmed');
}
final normalized = trimmed.startsWith('www.')
? 'https://$trimmed'
: trimmed;
final uri = Uri.tryParse(normalized);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
return uri;
}
bool _isRawMeshcoreAdvert(String value) {
if (!_rawMeshcoreAdvertPattern.hasMatch(value)) {
return false;
}
return value.length.isEven && value.length >= _meshcoreAdvertMinBytes * 2;
}
Future<void> _openMessageLink(String rawUrl) async {
final uri = _parseMessageLink(rawUrl);
if (uri == null) {
ToastLogger.error(context, 'Invalid link');
return;
}
if (uri.scheme.toLowerCase() == 'meshcore') {
if (!mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => AddContactScreen(initialAdvert: rawUrl),
),
);
return;
}
try {
if (!await canLaunchUrl(uri)) {
if (!mounted) {
return;
}
ToastLogger.error(context, 'Cannot open link');
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (error) {
if (!mounted) {
return;
}
ToastLogger.error(context, 'Failed to open link');
}
}
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList();
_disposeLinkRecognizers();
final matches = _messageTokenPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final linkColor = Theme.of(context).colorScheme.primary;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of(
context,
@@ -129,8 +245,9 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
final rawMatch = match.group(0) ?? '';
final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) {
if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@@ -154,8 +271,34 @@ class _MessageBubbleState extends State<MessageBubble> {
),
),
);
} else if (_linkPattern.hasMatch(rawMatch) ||
_isRawMeshcoreAdvert(rawMatch)) {
final trimmedUrl = _trimTrailingUrlPunctuation(rawMatch);
final trailingText = rawMatch.substring(trimmedUrl.length);
final uri = _parseMessageLink(rawMatch);
if (uri != null) {
final recognizer = TapGestureRecognizer()
..onTap = () => _openMessageLink(rawMatch);
_linkRecognizers.add(recognizer);
spans.add(
TextSpan(
text: trimmedUrl,
style: baseBodyStyle?.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
recognizer: recognizer,
),
);
if (trailingText.isNotEmpty) {
spans.add(TextSpan(text: trailingText, style: baseBodyStyle));
}
} else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
}
} else {
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
}
cursor = match.end;

View File

@@ -390,6 +390,38 @@ void main() {
);
});
test('incoming contact messages dedupe by sender handle and text', () {
final provider = MessagesProvider();
provider.addMessage(
Message(
id: 'handle-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000800,
text: 'same payload',
senderName: 'Radio Alpha',
receivedAt: DateTime.now(),
),
);
provider.addMessage(
Message(
id: 'handle-2',
messageType: MessageType.contact,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000810,
text: 'same payload',
senderName: 'Radio Alpha',
receivedAt: DateTime.now(),
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('handle-1'));
});
test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);

View File

@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/message.dart';
@@ -22,8 +23,36 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() {
SharedPreferences.setMockInitialValues({});
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
});
testWidgets('received bubbles show signal chips on double tap', (
@@ -153,6 +182,151 @@ void main() {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble detects and opens links', (tester) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'message-link',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(31),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Check https://example.com/docs, then ping @[Rescue Team].',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere(
(widget) =>
widget.text.toPlainText().contains('https://example.com/docs'),
);
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == 'https://example.com/docs',
);
expect(richText.text.toPlainText(), contains('https://example.com/docs'));
expect(find.text('@Rescue Team'), findsOneWidget);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pump();
expect(launchedUrls, ['https://example.com/docs']);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble opens meshcore links in add contact screen', (
tester,
) async {
final harness = await _TestHarness.create();
try {
const payloadSegment = '00112233445566778899aabbccddeeff';
final advert =
'meshcore://'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'0011';
final message = Message(
id: 'message-meshcore-link',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(41),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Import $advert',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == advert,
);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pumpAndSettle();
expect(find.text('Import a shared contact advert'), findsOneWidget);
expect(find.text(advert), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble linkifies raw meshcore adverts', (tester) async {
final harness = await _TestHarness.create();
try {
const advert =
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'0011';
final message = Message(
id: 'message-meshcore-raw',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(51),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Import raw advert: $advert',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == advert,
);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pumpAndSettle();
expect(find.text('Import a shared contact advert'), findsOneWidget);
expect(find.text(advert), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
}
Widget _buildApp(_TestHarness harness, Message message) {
@@ -242,3 +416,22 @@ Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pump();
}
TextSpan? _findTextSpan(
InlineSpan span,
bool Function(TextSpan span) predicate,
) {
if (span is! TextSpan) {
return null;
}
if (predicate(span)) {
return span;
}
for (final child in span.children ?? const <InlineSpan>[]) {
final match = _findTextSpan(child, predicate);
if (match != null) {
return match;
}
}
return null;
}