Fix DM private media sending

This commit is contained in:
Janez T
2026-03-04 19:55:53 +01:00
parent 1460bdb51f
commit f31083694f
6 changed files with 409 additions and 275 deletions

View File

@@ -14,6 +14,7 @@ import 'app_localizations_hr.dart';
import 'app_localizations_it.dart'; import 'app_localizations_it.dart';
import 'app_localizations_sl.dart'; import 'app_localizations_sl.dart';
import 'app_localizations_zh.dart'; import 'app_localizations_zh.dart';
// ignore_for_file: type=lint // ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations /// Callers can lookup localized strings with an instance of AppLocalizations

File diff suppressed because it is too large Load Diff

View File

@@ -535,10 +535,12 @@ class _MessagesTabState extends State<MessagesTab> {
final isChannel = final isChannel =
_destinationType == _destinationType ==
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
final channelIdx = isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null;
final recipient = _selectedRecipient;
final placeholder = Message( final placeholder = Message(
id: msgId, id: msgId,
messageType: isChannel ? MessageType.channel : MessageType.contact, messageType: isChannel ? MessageType.channel : MessageType.contact,
channelIdx: isChannel ? 0 : null, channelIdx: channelIdx,
senderPublicKeyPrefix: deviceKey.sublist(0, 6), senderPublicKeyPrefix: deviceKey.sublist(0, 6),
pathLen: 0, pathLen: 0,
textType: MessageTextType.plain, textType: MessageTextType.plain,
@@ -546,30 +548,37 @@ class _MessagesTabState extends State<MessagesTab> {
text: envelope.encode(), text: envelope.encode(),
receivedAt: DateTime.now(), receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(placeholder); messagesProvider.addSentMessage(placeholder);
// Send IE1 envelope via normal message path. // Send IE1 envelope via normal message path.
final envelopeText = envelope.encode(); final envelopeText = envelope.encode();
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) { if (isChannel) {
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: 0, channelIdx: channelIdx ?? 0,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
); );
} else if (_selectedRecipient != null) { } else if (recipient != null) {
final sent = await connectionProvider.sendTextMessage( final sent = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey, contactPublicKey: recipient.publicKey,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
contact: _selectedRecipient!, contact: recipient,
); );
if (!sent) { if (!sent) {
messagesProvider.markMessageFailed(msgId); messagesProvider.markMessageFailed(msgId);
if (!mounted) return; if (!mounted) return;
ToastLogger.error(context, 'Failed to announce image'); ToastLogger.error(context, 'Failed to announce image');
return;
} }
} else {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'No recipient selected');
return;
} }
debugPrint( debugPrint(
@@ -577,6 +586,22 @@ class _MessagesTabState extends State<MessagesTab> {
'${fragments.length} fragments, ${compressed.length}B, ' '${fragments.length} fragments, ${compressed.length}B, '
'chunk=${imageDataBytesPerFragment}B', 'chunk=${imageDataBytesPerFragment}B',
); );
// Push all fragments immediately for direct contacts.
// For channels, fragments are served on demand via IR1 fetch requests.
if (!isChannel && recipient != null) {
// Small delay so the IE1 envelope can propagate before fragments arrive.
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
final served = await imageProvider.serveSessionTo(
sessionId: sessionId,
requester: recipient,
);
debugPrint(
'📷 [Image] Pushed ${ served ? fragments.length : 0} '
'fragments to ${recipient.advName}',
);
}
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (!mounted) return; if (!mounted) return;
@@ -755,9 +780,11 @@ class _MessagesTabState extends State<MessagesTab> {
final isChannel = final isChannel =
_destinationType == _destinationType ==
MessageDestinationPreferences.destinationTypeChannel; MessageDestinationPreferences.destinationTypeChannel;
final recipient = _selectedRecipient;
final channelIdx = isChannel ? (recipient?.publicKey[1] ?? 0) : null;
final sentMsg = Message( final sentMsg = Message(
id: msgId, id: msgId,
messageType: (!isChannel && _selectedRecipient != null) messageType: (!isChannel && recipient != null)
? MessageType.contact ? MessageType.contact
: MessageType.channel, : MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix, senderPublicKeyPrefix: senderPublicKeyPrefix,
@@ -769,8 +796,8 @@ class _MessagesTabState extends State<MessagesTab> {
deliveryStatus: MessageDeliveryStatus.sent, deliveryStatus: MessageDeliveryStatus.sent,
isVoice: true, isVoice: true,
voiceId: sessionId, voiceId: sessionId,
channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null, channelIdx: channelIdx,
recipientPublicKey: _selectedRecipient?.publicKey, recipientPublicKey: isChannel ? null : recipient?.publicKey,
); );
messagesProvider.addSentMessage(sentMsg); messagesProvider.addSentMessage(sentMsg);
@@ -834,30 +861,27 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
if (isChannel) { if (isChannel) {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: channelIdx, channelIdx: channelIdx ?? 0,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
); );
} else if (_selectedRecipient != null) { } else if (recipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey, contactPublicKey: recipient.publicKey,
text: envelopeText, text: envelopeText,
messageId: msgId, messageId: msgId,
contact: _selectedRecipient, contact: recipient,
); );
if (!sentSuccessfully) { if (!sentSuccessfully) {
messagesProvider.markMessageFailed(msgId); messagesProvider.markMessageFailed(msgId);
return; return;
} }
} else { } else {
// Fallback to public channel if destination cannot be resolved. messagesProvider.markMessageFailed(msgId);
await connectionProvider.sendChannelMessage( if (!mounted) return;
channelIdx: 0, ToastLogger.error(context, 'No recipient selected');
text: envelopeText, return;
messageId: msgId,
);
} }
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Voice] envelope send error: $e\n$st'); debugPrint('❌ [Voice] envelope send error: $e\n$st');

View File

@@ -9,6 +9,7 @@ import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip; import '../../providers/image_provider.dart' as ip;
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a received or sent image. /// A message bubble that shows a received or sent image.
/// ///
@@ -90,6 +91,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received, received: received,
total: total, total: total,
envelope: envelope, envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -131,6 +135,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int received, required int received,
required int total, required int total,
required ImageEnvelope envelope, required ImageEnvelope envelope,
required int? radioBw,
required int? radioSf,
required int? radioCr,
}) { }) {
if (isComplete && imageBytes != null) { if (isComplete && imageBytes != null) {
return AspectRatio( return AspectRatio(
@@ -167,7 +174,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
] else ...[ ] else ...[
// Tap-to-load icon. // Tap-to-load icon.
IconButton( IconButton(
onPressed: () => _requestAndFetch(envelope), onPressed: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
),
icon: const Icon(Icons.download_rounded, size: 40), icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70, color: Colors.white70,
tooltip: 'Load image', tooltip: 'Load image',
@@ -179,7 +192,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
); );
} }
Future<void> _requestAndFetch(ImageEnvelope envelope) async { Future<void> _requestAndFetch(
ImageEnvelope envelope, {
int? radioBw,
int? radioSf,
int? radioCr,
int pathLen = 0,
}) async {
if (_isRequesting) return; if (_isRequesting) return;
var sender = _resolveSender(envelope); var sender = _resolveSender(envelope);
if (sender == null) { if (sender == null) {
@@ -242,13 +261,26 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return; return;
} }
// Reset after 30s so user can retry if transfer stalls. // Timeout = 2× estimated LoRa airtime (min 30s).
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty
? envelope.sizeBytes
: (envelope.sizeBytes * missing.length / envelope.total).round(),
pathLen: pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
_requestTimeoutTimer?.cancel(); _requestTimeoutTimer?.cancel();
_requestTimeoutTimer = Timer(const Duration(seconds: 30), () { _requestTimeoutTimer = TransferTimeout.start(
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) { txEstimate: txEstimate,
setState(() => _isRequesting = false); onTimeout: () {
} if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) {
}); setState(() => _isRequesting = false);
}
},
);
} }
Contact? _resolveSender(ImageEnvelope envelope) { Contact? _resolveSender(ImageEnvelope envelope) {

View File

@@ -0,0 +1,24 @@
import 'dart:async';
/// Calculates a transfer timeout as 2× the estimated LoRa airtime,
/// with a minimum of 30 seconds.
///
/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the
/// "loading" spinner when a transfer stalls, allowing the user to retry.
class TransferTimeout {
static const Duration _minimum = Duration(seconds: 30);
/// Start a one-shot timer based on [txEstimate] × 2 (min 30s).
///
/// [onTimeout] is called on the UI thread when the timer fires.
/// Returns the [Timer] so the caller can cancel it (e.g. on dispose or
/// when the transfer completes).
static Timer start({
required Duration txEstimate,
required void Function() onTimeout,
}) {
final timeout = txEstimate * 2;
final effective = timeout < _minimum ? _minimum : timeout;
return Timer(effective, onTimeout);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -8,6 +9,7 @@ import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart'; import '../../providers/voice_provider.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a voice recording with play/stop controls. /// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatefulWidget { class VoiceMessageBubble extends StatefulWidget {
@@ -28,6 +30,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _isRequesting = false; bool _isRequesting = false;
bool _autoPlayWhenReady = false; bool _autoPlayWhenReady = false;
String? _errorText; String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -106,7 +115,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
await voiceProvider.play(voiceId); await voiceProvider.play(voiceId);
return; return;
} }
await _requestAndPlayVoice(voiceId); await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
);
}, },
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: Container( child: Container(
@@ -174,13 +190,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
} }
Future<void> _requestAndPlayVoice(String sessionId) async { Future<void> _requestAndPlayVoice(
String sessionId, {
VoiceEnvelope? envelope,
int? radioBw,
int? radioSf,
int? radioCr,
int pathLen = 0,
}) async {
if (_isRequesting) return; if (_isRequesting) return;
var sender = _resolveSenderContact(); var sender = _resolveSenderContact();
if (sender == null) { if (sender == null) {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
// Retry once after refreshing contacts; resumable sessions may outlive
// the in-memory contact cache.
await connectionProvider.getContacts(); await connectionProvider.getContacts();
if (!mounted) return; if (!mounted) return;
sender = _resolveSenderContact(); sender = _resolveSenderContact();
@@ -221,7 +242,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
if (!sent) { if (!sent) {
_setUnavailable(); _setUnavailable();
return;
} }
// Timeout = 2× estimated LoRa airtime (min 30s).
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
mode: envelope.mode,
durationMs: envelope.durationMs,
pathLen: pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: const Duration(seconds: 15);
_requestTimeoutTimer?.cancel();
_requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate,
onTimeout: () {
if (mounted && _isRequesting) {
_setUnavailable();
}
},
);
} }
void _setUnavailable() { void _setUnavailable() {