diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index ec74a62..64b114b 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -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,29 +767,29 @@ class MessagesProvider with ChangeNotifier { return false; } - 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); - if (existingSenderName != null && - incomingSenderName != null && - existingSenderName == incomingSenderName) { - return true; - } - - 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 && + incomingSenderKey != null && + existingSenderKey == incomingSenderKey) { + return true; + } + + final existingSenderName = _normalizedResolvedSenderName(existing); + final incomingSenderName = _normalizedResolvedSenderName(message); + return existingSenderName != null && + incomingSenderName != null && + existingSenderName == incomingSenderName; + } + /// Add multiple messages void addMessages(List messages) { int addedCount = 0; diff --git a/lib/screens/add_contact_screen.dart b/lib/screens/add_contact_screen.dart index 1e3cdfc..2691bd3 100644 --- a/lib/screens/add_contact_screen.dart +++ b/lib/screens/add_contact_screen.dart @@ -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 createState() => _AddContactScreenState(); @@ -21,7 +23,12 @@ class _AddContactScreenState extends State { @override void initState() { super.initState(); - _loadClipboardIfPresent(); + if (widget.initialAdvert != null && + widget.initialAdvert!.trim().isNotEmpty) { + _advertController.text = widget.initialAdvert!.trim(); + } else { + _loadClipboardIfPresent(); + } } @override @@ -79,9 +86,9 @@ class _AddContactScreenState extends State { 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 { 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; }); diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 8228465..68089be 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -667,7 +667,6 @@ class _MessagesTabState extends State { } _textController.clear(); - _focusNode.unfocus(); try { // Check destination type and send accordingly diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index bac6336..480cc36 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -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 { + 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'(? _linkRecognizers = []; + + @override + void dispose() { + _disposeLinkRecognizers(); + super.dispose(); + } @override void didUpdateWidget(MessageBubble oldWidget) { @@ -100,14 +122,108 @@ class _MessageBubbleState extends State { }); } + 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 _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 { ); } + 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 { ), ), ); + } 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 { - spans.add(TextSpan(text: match.group(0), style: baseBodyStyle)); + spans.add(TextSpan(text: rawMatch, style: baseBodyStyle)); } cursor = match.end; diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 33354e7..bb962eb 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -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]); diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart index c52ce4e..50635dc 100644 --- a/test/widgets/message_bubble_test.dart +++ b/test/widgets/message_bubble_test.dart @@ -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 = []; + 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.from( + call.arguments as Map, + ); + 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(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()); + (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(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()); + (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(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()); + (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 _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 []) { + final match = _findTextSpan(child, predicate); + if (match != null) { + return match; + } + } + return null; +}