mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Support MeshCore message links #123
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user