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

@@ -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), () {
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) {
setState(() => _isRequesting = false);
}
});
_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() {