fix: remove all auto-retry image fetch, user-initiated only

- No automatic IR1 requests; fragments arrive only when user taps load
- Bubble sends IR1 with missing indices only if partial session exists
  (partial resume: only missing fragments requested, not the whole image)
- 30s timeout resets the loading spinner so user can tap retry again
- Saves LoRa airtime by never transmitting IR1 without explicit user action
This commit is contained in:
Janez T
2026-03-04 19:47:01 +01:00
parent 537d98e2d4
commit 1460bdb51f
2 changed files with 37 additions and 121 deletions

View File

@@ -48,15 +48,10 @@ class AppProvider with ChangeNotifier {
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _imageFragmentTimeout = Duration(seconds: 10);
static const int _maxPacketRetryAttempts = 4;
static const int _maxImageRetryAttempts = 5;
final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, int> _imageMissingRetryAttempts = {};
AppProvider({
required this.connectionProvider,
@@ -595,17 +590,7 @@ class AppProvider with ChangeNotifier {
// Image envelope (IE1): announce image availability.
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
if (imageEnvelope != null) {
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
.senderKey6
.toLowerCase();
imageProvider.registerEnvelope(imageEnvelope);
// Start 10s idle timer immediately so we request fragments even if
// none arrive at all (sender may need an explicit IR1 pull request).
_scheduleImageMissingRetry(
imageEnvelope.sessionId,
justComplete: false,
newSession: true,
);
messagesProvider.addMessage(
enrichedMessage,
contactLookup: (name) {
@@ -692,12 +677,11 @@ class AppProvider with ChangeNotifier {
if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId);
final justComplete = imageProvider.addFragment(
imageProvider.addFragment(
frag,
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_scheduleImageMissingRetry(frag.sessionId, justComplete: justComplete);
return;
}
@@ -1103,94 +1087,6 @@ class AppProvider with ChangeNotifier {
});
}
/// Called on every received fragment (and when IE1 envelope arrives).
/// Debounces a 10s idle timeout — if no fragment arrives within the window,
/// sends IR1 requesting only the still-missing indices (partial resume).
/// The attempt counter is NOT reset per-fragment; it only resets when a new
/// session starts (IE1 received), so retries are bounded even when fragments
/// trickle in slowly.
void _scheduleImageMissingRetry(
String sessionId, {
required bool justComplete,
bool newSession = false,
}) {
if (justComplete || imageProvider.isComplete(sessionId)) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
return;
}
// Reset attempt counter only when a brand-new session starts (IE1 arrived).
if (newSession) _imageMissingRetryAttempts[sessionId] = 0;
// Debounce: restart the 10s idle timer on every received fragment.
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_imageFragmentTimeout, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
Future<void> _requestMissingImageFragments(String sessionId) async {
if (imageProvider.isComplete(sessionId)) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
return;
}
final attempt = _imageMissingRetryAttempts[sessionId] ?? 0;
if (attempt >= _maxImageRetryAttempts) {
debugPrint(
'⚠️ [AppProvider] Image re-request limit ($attempt) reached for $sessionId',
);
_imageMissingRetryTimers.remove(sessionId)?.cancel();
return;
}
final senderKey6 = _imageSessionSenderKey6[sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (sender == null || deviceKey == null || deviceKey.length < 6) return;
final missing = imageProvider.missingFragmentIndices(sessionId);
if (missing.isEmpty) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
debugPrint(
'📷 [AppProvider] Requesting ${missing.length} missing fragments '
'for $sessionId (attempt ${attempt + 1}/$_maxImageRetryAttempts): $missing',
);
final request = ImageFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encode(),
contact: sender,
);
if (!sent) return;
_imageMissingRetryAttempts[sessionId] = attempt + 1;
// Wait another full timeout window for the sender to retransmit.
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_imageFragmentTimeout, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
/// Insert or update a voice placeholder message for binary raw-data packets.
///
@@ -1325,15 +1221,9 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
_voiceMissingRetryTimers.clear();
_imageMissingRetryTimers.clear();
_voiceMissingRetryAttempts.clear();
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
notifyListeners();
}
@@ -1365,9 +1255,6 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
super.dispose();
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
@@ -31,6 +32,13 @@ class ImageMessageBubble extends StatefulWidget {
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
bool _isRequesting = false;
String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -176,8 +184,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
var sender = _resolveSender(envelope);
if (sender == null) {
final conn = context.read<ConnectionProvider>();
// Retry once after refreshing contacts; resumable sessions may outlive
// the in-memory contact cache.
await conn.getContacts();
if (!mounted) return;
sender = _resolveSender(envelope);
@@ -188,6 +194,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
setState(() => _errorText = 'Device key unavailable');
@@ -198,11 +205,24 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final request = ImageFetchRequest(
sessionId: envelope.sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
// If we already have some fragments, request only what's missing.
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
final isPartialResume = missing.isNotEmpty &&
missing.length < envelope.total;
final request = isPartialResume
? ImageFetchRequest(
sessionId: envelope.sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
)
: ImageFetchRequest(
sessionId: envelope.sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
setState(() {
_isRequesting = true;
@@ -219,7 +239,16 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_isRequesting = false;
_errorText = 'Image unavailable right now';
});
return;
}
// Reset after 30s so user can retry if transfer stalls.
_requestTimeoutTimer?.cancel();
_requestTimeoutTimer = Timer(const Duration(seconds: 30), () {
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) {
setState(() => _isRequesting = false);
}
});
}
Contact? _resolveSender(ImageEnvelope envelope) {