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_sl.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// 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 =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
final channelIdx = isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null;
final recipient = _selectedRecipient;
final placeholder = Message(
id: msgId,
messageType: isChannel ? MessageType.channel : MessageType.contact,
channelIdx: isChannel ? 0 : null,
channelIdx: channelIdx,
senderPublicKeyPrefix: deviceKey.sublist(0, 6),
pathLen: 0,
textType: MessageTextType.plain,
@@ -546,30 +548,37 @@ class _MessagesTabState extends State<MessagesTab> {
text: envelope.encode(),
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
);
messagesProvider.addSentMessage(placeholder);
// Send IE1 envelope via normal message path.
final envelopeText = envelope.encode();
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
if (isChannel) {
await connectionProvider.sendChannelMessage(
channelIdx: 0,
channelIdx: channelIdx ?? 0,
text: envelopeText,
messageId: msgId,
);
} else if (_selectedRecipient != null) {
} else if (recipient != null) {
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
contactPublicKey: recipient.publicKey,
text: envelopeText,
messageId: msgId,
contact: _selectedRecipient!,
contact: recipient,
);
if (!sent) {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'Failed to announce image');
return;
}
} else {
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'No recipient selected');
return;
}
debugPrint(
@@ -577,6 +586,22 @@ class _MessagesTabState extends State<MessagesTab> {
'${fragments.length} fragments, ${compressed.length}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) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
if (!mounted) return;
@@ -755,9 +780,11 @@ class _MessagesTabState extends State<MessagesTab> {
final isChannel =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
final recipient = _selectedRecipient;
final channelIdx = isChannel ? (recipient?.publicKey[1] ?? 0) : null;
final sentMsg = Message(
id: msgId,
messageType: (!isChannel && _selectedRecipient != null)
messageType: (!isChannel && recipient != null)
? MessageType.contact
: MessageType.channel,
senderPublicKeyPrefix: senderPublicKeyPrefix,
@@ -769,8 +796,8 @@ class _MessagesTabState extends State<MessagesTab> {
deliveryStatus: MessageDeliveryStatus.sent,
isVoice: true,
voiceId: sessionId,
channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null,
recipientPublicKey: _selectedRecipient?.publicKey,
channelIdx: channelIdx,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
);
messagesProvider.addSentMessage(sentMsg);
@@ -834,30 +861,27 @@ class _MessagesTabState extends State<MessagesTab> {
try {
if (isChannel) {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
channelIdx: channelIdx ?? 0,
text: envelopeText,
messageId: msgId,
);
} else if (_selectedRecipient != null) {
} else if (recipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
contactPublicKey: recipient.publicKey,
text: envelopeText,
messageId: msgId,
contact: _selectedRecipient,
contact: recipient,
);
if (!sentSuccessfully) {
messagesProvider.markMessageFailed(msgId);
return;
}
} else {
// Fallback to public channel if destination cannot be resolved.
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: envelopeText,
messageId: msgId,
);
messagesProvider.markMessageFailed(msgId);
if (!mounted) return;
ToastLogger.error(context, 'No recipient selected');
return;
}
} catch (e, 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/image_provider.dart' as ip;
import '../../utils/image_message_parser.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a received or sent image.
///
@@ -90,6 +91,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received,
total: total,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
),
),
const SizedBox(height: 4),
@@ -131,6 +135,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int received,
required int total,
required ImageEnvelope envelope,
required int? radioBw,
required int? radioSf,
required int? radioCr,
}) {
if (isComplete && imageBytes != null) {
return AspectRatio(
@@ -167,7 +174,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
] else ...[
// Tap-to-load icon.
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),
color: Colors.white70,
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;
var sender = _resolveSender(envelope);
if (sender == null) {
@@ -242,13 +261,26 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
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 = Timer(const Duration(seconds: 30), () {
_requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate,
onTimeout: () {
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) {
setState(() => _isRequesting = false);
}
});
},
);
}
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 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -8,6 +9,7 @@ import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart';
import '../../utils/voice_message_parser.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatefulWidget {
@@ -28,6 +30,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _isRequesting = false;
bool _autoPlayWhenReady = false;
String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -106,7 +115,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
await voiceProvider.play(voiceId);
return;
}
await _requestAndPlayVoice(voiceId);
await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
);
},
borderRadius: BorderRadius.circular(24),
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;
var sender = _resolveSenderContact();
if (sender == null) {
final connectionProvider = context.read<ConnectionProvider>();
// Retry once after refreshing contacts; resumable sessions may outlive
// the in-memory contact cache.
await connectionProvider.getContacts();
if (!mounted) return;
sender = _resolveSenderContact();
@@ -221,7 +242,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
);
if (!sent) {
_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() {