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) { if (message.isContactMessage) {
// Match by sender key + sender timestamp (matches official app's DB // Match by sender key + sender timestamp (matches official app's DB
// uniqueness: contactPublicKey + senderTimestamp + text + txtType). // uniqueness: contactPublicKey + senderTimestamp + text + txtType).
return existing.senderKeyShort == message.senderKeyShort && if (existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp; 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) { if (message.isChannelMessage) {
@@ -760,29 +767,29 @@ class MessagesProvider with ChangeNotifier {
return false; return false;
} }
final existingSenderKey = existing.senderKeyShort; return _matchesDuplicateSenderIdentity(existing, message);
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
return true;
}
return false;
} }
// System messages and other types: never deduplicate by scope alone. // System messages and other types: never deduplicate by scope alone.
return false; return false;
} }
bool _matchesDuplicateSenderIdentity(Message existing, Message message) {
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
return existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName;
}
/// Add multiple messages /// Add multiple messages
void addMessages(List<Message> messages) { void addMessages(List<Message> messages) {
int addedCount = 0; int addedCount = 0;

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/sar_marker.dart'; import '../../models/sar_marker.dart';
@@ -31,6 +33,7 @@ import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart'; import '../../models/message_transfer_details.dart';
import '../../screens/add_contact_screen.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart'; import 'tictactoe_message_bubble.dart';
@@ -72,9 +75,28 @@ class MessageBubble extends StatefulWidget {
} }
class _MessageBubbleState extends State<MessageBubble> { class _MessageBubbleState extends State<MessageBubble> {
static const int _meshcoreAdvertMinBytes = 98;
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]'); 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 _isExpanded = false;
bool _showReceivedStats = false; bool _showReceivedStats = false;
final List<TapGestureRecognizer> _linkRecognizers = [];
@override
void dispose() {
_disposeLinkRecognizers();
super.dispose();
}
@override @override
void didUpdateWidget(MessageBubble oldWidget) { 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) { Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList(); _disposeLinkRecognizers();
final matches = _messageTokenPattern.allMatches(text).toList();
if (matches.isEmpty) { if (matches.isEmpty) {
return Text(text, style: baseBodyStyle); return Text(text, style: baseBodyStyle);
} }
final textColor = final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final linkColor = Theme.of(context).colorScheme.primary;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1; final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of( final backgroundColor = Theme.of(
context, context,
@@ -129,8 +245,9 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
final rawMatch = match.group(0) ?? '';
final mentionName = match.group(1)?.trim() ?? ''; final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) { if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) {
spans.add( spans.add(
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, 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: rawMatch, style: baseBodyStyle));
}
} else { } else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle)); spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
} }
cursor = match.end; 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', () { test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]); 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/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
@@ -22,8 +23,36 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); 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', ( testWidgets('received bubbles show signal chips on double tap', (
@@ -153,6 +182,151 @@ void main() {
await _disposeHarness(tester, harness); 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) { Widget _buildApp(_TestHarness harness, Message message) {
@@ -242,3 +416,22 @@ Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
await tester.tap(finder); await tester.tap(finder);
await tester.pump(); 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;
}