Add toast for image fetch failures

This commit is contained in:
Janez T
2026-03-05 13:07:53 +01:00
parent 9709511e90
commit ab0c531a4b
4 changed files with 219 additions and 118 deletions

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.387742">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="112.078035">
<testcase classname="fastlane.lanes" name="2: build_app" time="102.185635">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="215.877264">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="3.215591">
</testcase>

View File

@@ -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<TransmissionTargetResolution> resolveFetchTarget({
required ContactsProvider contactsProvider,
required Future<void> 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;
}
}

View File

@@ -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<ImageMessageBubble> {
return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) {
final contactsProvider = context.read<ContactsProvider>();
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<ImageMessageBubble> {
}) async {
if (_isRequesting) return;
final conn = context.read<ConnectionProvider>();
var sender = _resolveSender(envelope);
if (sender == null ||
sender.outPathLen < 0 ||
sender.outPathLen > _maxFetchHops) {
await conn.getContacts();
final contactsProvider = context.read<ContactsProvider>();
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;
sender = _resolveSender(envelope);
}
if (sender == null) {
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<ImageMessageBubble> {
);
}
Contact? _resolveSender(ImageEnvelope envelope) {
final contactsProvider = context.read<ContactsProvider>();
// 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(

View File

@@ -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<VoiceMessageBubble> {
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final contactsProvider = context.read<ContactsProvider>();
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<VoiceMessageBubble> {
}) async {
if (_isRequesting) return;
final connectionProvider = context.read<ConnectionProvider>();
var sender = _resolveSenderContact();
if (sender == null ||
sender.outPathLen < 0 ||
sender.outPathLen > _maxFetchHops) {
await connectionProvider.getContacts();
final contactsProvider = context.read<ContactsProvider>();
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;
sender = _resolveSenderContact();
}
if (sender == null) {
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<VoiceMessageBubble> {
});
}
Contact? _resolveSenderContact() {
final contactsProvider = context.read<ContactsProvider>();
// 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(