mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix DM private media sending
This commit is contained in:
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
24
lib/widgets/messages/transfer_timeout.dart
Normal file
24
lib/widgets/messages/transfer_timeout.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user