diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 46e9881..d0df2b1 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/utils/transmission_target_resolver.dart b/lib/utils/transmission_target_resolver.dart new file mode 100644 index 0000000..61354b1 --- /dev/null +++ b/lib/utils/transmission_target_resolver.dart @@ -0,0 +1,157 @@ +import 'dart:typed_data'; + +import '../models/contact.dart'; +import '../providers/contacts_provider.dart'; + +enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar } + +class TransmissionTargetResolution { + final Contact? target; + final TransmissionTargetFailure? failure; + final int maxHops; + + const TransmissionTargetResolution({ + required this.target, + required this.failure, + required this.maxHops, + }); + + int get hops => target?.outPathLen ?? -1; + bool get isValid => target != null && failure == null; +} + +class TransmissionTargetResolver { + const TransmissionTargetResolver._(); + + static Contact? resolveLocalTarget({ + required ContactsProvider contactsProvider, + required bool isSentByMe, + Uint8List? recipientPublicKey, + Uint8List? senderPublicKeyPrefix, + String? senderKey6FromEnvelope, + String? senderName, + }) { + if (isSentByMe) { + final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey); + if (recipient != null) return recipient; + } + + final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope); + if (byEnvelope != null) return byEnvelope; + + final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix); + if (byPrefix != null) return byPrefix; + + return _findByName(contactsProvider, senderName); + } + + static Future resolveFetchTarget({ + required ContactsProvider contactsProvider, + required Future Function() refreshContacts, + required bool isSentByMe, + Uint8List? recipientPublicKey, + Uint8List? senderPublicKeyPrefix, + String? senderKey6FromEnvelope, + String? senderName, + required int maxFetchHops, + }) async { + var target = resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: isSentByMe, + recipientPublicKey: recipientPublicKey, + senderPublicKeyPrefix: senderPublicKeyPrefix, + senderKey6FromEnvelope: senderKey6FromEnvelope, + senderName: senderName, + ); + + if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) { + await refreshContacts(); + target = resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: isSentByMe, + recipientPublicKey: recipientPublicKey, + senderPublicKeyPrefix: senderPublicKeyPrefix, + senderKey6FromEnvelope: senderKey6FromEnvelope, + senderName: senderName, + ); + } + + if (target == null) { + return TransmissionTargetResolution( + target: null, + failure: TransmissionTargetFailure.unknownContact, + maxHops: maxFetchHops, + ); + } + if (target.outPathLen < 0) { + return TransmissionTargetResolution( + target: target, + failure: TransmissionTargetFailure.unknownRoute, + maxHops: maxFetchHops, + ); + } + if (target.outPathLen > maxFetchHops) { + return TransmissionTargetResolution( + target: target, + failure: TransmissionTargetFailure.tooFar, + maxHops: maxFetchHops, + ); + } + return TransmissionTargetResolution( + target: target, + failure: null, + maxHops: maxFetchHops, + ); + } + + static Contact? _findByRecipientKey( + ContactsProvider contactsProvider, + Uint8List? recipientKey, + ) { + if (recipientKey == null || recipientKey.isEmpty) return null; + final byKey = contactsProvider.findContactByKey(recipientKey); + if (byKey != null) return byKey; + if (recipientKey.length >= 6) { + return contactsProvider.findContactByPrefix( + Uint8List.fromList(recipientKey.sublist(0, 6)), + ); + } + return null; + } + + static Contact? _findByEnvelopeKey6( + ContactsProvider contactsProvider, + String? senderKey6FromEnvelope, + ) { + if (senderKey6FromEnvelope == null || senderKey6FromEnvelope.isEmpty) { + return null; + } + return contactsProvider.findContactByPrefixHex(senderKey6FromEnvelope); + } + + static Contact? _findByPrefix( + ContactsProvider contactsProvider, + Uint8List? senderPublicKeyPrefix, + ) { + if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) { + return null; + } + return contactsProvider.findContactByPrefix( + Uint8List.fromList(senderPublicKeyPrefix.sublist(0, 6)), + ); + } + + static Contact? _findByName( + ContactsProvider contactsProvider, + String? senderName, + ) { + final normalized = senderName?.trim(); + if (normalized == null || normalized.isEmpty) return null; + for (final contact in contactsProvider.contacts) { + if (contact.advName.trim().toLowerCase() == normalized.toLowerCase()) { + return contact; + } + } + return null; + } +} diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 4896dd7..cd03bb3 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -3,12 +3,12 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_avif/flutter_avif.dart'; import 'package:provider/provider.dart'; -import '../../models/contact.dart'; import '../../models/message.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/image_provider.dart' as ip; import '../../utils/image_message_parser.dart'; +import '../../utils/transmission_target_resolver.dart'; import 'transfer_timeout.dart'; /// A message bubble that shows a received or sent image. @@ -58,8 +58,16 @@ class _ImageMessageBubbleState extends State { return Consumer( builder: (context, imageProvider, _) { + final contactsProvider = context.read(); final session = imageProvider.session(envelope.sessionId); - final sender = _resolveSender(envelope); + final sender = TransmissionTargetResolver.resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderKey6FromEnvelope: envelope.senderKey6, + senderName: widget.message.senderName, + ); final effectivePathLen = sender != null && sender.outPathLen >= 0 ? sender.outPathLen @@ -213,35 +221,42 @@ class _ImageMessageBubbleState extends State { }) async { if (_isRequesting) return; final conn = context.read(); - var sender = _resolveSender(envelope); - if (sender == null || - sender.outPathLen < 0 || - sender.outPathLen > _maxFetchHops) { - await conn.getContacts(); - if (!mounted) return; - sender = _resolveSender(envelope); - } - if (sender == null) { + final contactsProvider = context.read(); + final resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: conn.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderKey6FromEnvelope: envelope.senderKey6, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + + if (resolution.failure == TransmissionTargetFailure.unknownContact) { await _showBlockingAlert( 'Cannot fetch image', 'Sender contact is unknown. Sync contacts first.', ); return; } - if (sender.outPathLen < 0) { + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { await _showBlockingAlert( 'Cannot fetch image', 'Sender route is unknown. Sync contacts/path first.', ); return; } - if (sender.outPathLen > _maxFetchHops) { + if (resolution.failure == TransmissionTargetFailure.tooFar) { await _showBlockingAlert( 'Cannot fetch image', - 'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).', + 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', ); return; } + + final sender = resolution.target!; if (sender.outPathLen >= 2) { _showToast( 'Image fetch over ${sender.outPathLen} hops may take a while.', @@ -335,47 +350,6 @@ class _ImageMessageBubbleState extends State { ); } - Contact? _resolveSender(ImageEnvelope envelope) { - final contactsProvider = context.read(); - - // For sent direct messages, fetch must target the recipient peer first. - final recipientKey = widget.message.recipientPublicKey; - if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) { - final byKey = contactsProvider.findContactByKey(recipientKey); - if (byKey != null) return byKey; - if (recipientKey.length >= 6) { - final byPrefix = contactsProvider.findContactByPrefix( - Uint8List.fromList(recipientKey.sublist(0, 6)), - ); - if (byPrefix != null) return byPrefix; - } - } - - final contact = contactsProvider.findContactByPrefixHex( - envelope.senderKey6, - ); - if (contact != null) return contact; - - final senderPrefix = widget.message.senderPublicKeyPrefix; - if (senderPrefix != null && senderPrefix.length >= 6) { - final c = contactsProvider.findContactByPrefix( - Uint8List.fromList(senderPrefix.sublist(0, 6)), - ); - if (c != null) return c; - } - - final senderName = widget.message.senderName?.trim(); - if (senderName != null && senderName.isNotEmpty) { - for (final c in contactsProvider.contacts) { - if (c.advName.trim().toLowerCase() == senderName.toLowerCase()) { - return c; - } - } - } - - return null; - } - void _showToast(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index 28260d7..bdffb3c 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -1,13 +1,12 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; -import '../../models/contact.dart'; import '../../models/message.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/voice_provider.dart'; +import '../../utils/transmission_target_resolver.dart'; import '../../utils/voice_message_parser.dart'; import 'transfer_timeout.dart'; @@ -55,9 +54,17 @@ class _VoiceMessageBubbleState extends State { return Consumer( builder: (context, voiceProvider, _) { + final contactsProvider = context.read(); final session = voiceProvider.session(voiceId); final envelope = VoiceEnvelope.tryParseText(widget.message.text); - final sender = _resolveSenderContact(); + final sender = TransmissionTargetResolver.resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderKey6FromEnvelope: envelope?.senderKey6, + senderName: widget.message.senderName, + ); final effectivePathLen = sender != null && sender.outPathLen >= 0 ? sender.outPathLen @@ -208,35 +215,42 @@ class _VoiceMessageBubbleState extends State { }) async { if (_isRequesting) return; final connectionProvider = context.read(); - var sender = _resolveSenderContact(); - if (sender == null || - sender.outPathLen < 0 || - sender.outPathLen > _maxFetchHops) { - await connectionProvider.getContacts(); - if (!mounted) return; - sender = _resolveSenderContact(); - } - if (sender == null) { + final contactsProvider = context.read(); + final resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: connectionProvider.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderKey6FromEnvelope: envelope?.senderKey6, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + + if (resolution.failure == TransmissionTargetFailure.unknownContact) { await _showBlockingAlert( 'Cannot fetch voice', 'Sender contact is unknown. Sync contacts first.', ); return; } - if (sender.outPathLen < 0) { + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { await _showBlockingAlert( 'Cannot fetch voice', 'Sender route is unknown. Sync contacts/path first.', ); return; } - if (sender.outPathLen > _maxFetchHops) { + if (resolution.failure == TransmissionTargetFailure.tooFar) { await _showBlockingAlert( 'Cannot fetch voice', - 'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).', + 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', ); return; } + + final sender = resolution.target!; if (sender.outPathLen >= 2) { _showToast( 'Voice fetch over ${sender.outPathLen} hops may take a while.', @@ -319,50 +333,6 @@ class _VoiceMessageBubbleState extends State { }); } - Contact? _resolveSenderContact() { - final contactsProvider = context.read(); - - // For sent direct messages, fetch must target the recipient peer first. - final recipientKey = widget.message.recipientPublicKey; - if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) { - final byKey = contactsProvider.findContactByKey(recipientKey); - if (byKey != null) return byKey; - if (recipientKey.length >= 6) { - final byPrefix = contactsProvider.findContactByPrefix( - Uint8List.fromList(recipientKey.sublist(0, 6)), - ); - if (byPrefix != null) return byPrefix; - } - } - - final senderPrefix = widget.message.senderPublicKeyPrefix; - if (senderPrefix != null && senderPrefix.length >= 6) { - final contact = contactsProvider.findContactByPrefix( - Uint8List.fromList(senderPrefix.sublist(0, 6)), - ); - if (contact != null) return contact; - } - - final envelope = VoiceEnvelope.tryParseText(widget.message.text); - if (envelope != null) { - final contact = contactsProvider.findContactByPrefixHex( - envelope.senderKey6, - ); - if (contact != null) return contact; - } - - final senderName = widget.message.senderName?.trim(); - if (senderName != null && senderName.isNotEmpty) { - for (final contact in contactsProvider.contacts) { - if (contact.advName.trim().toLowerCase() == senderName.toLowerCase()) { - return contact; - } - } - } - - return null; - } - void _showToast(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(