From 1460bdb51f42f178aef9e5e77b212a63484af2a1 Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 4 Mar 2026 19:47:01 +0100 Subject: [PATCH] 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 --- lib/providers/app_provider.dart | 115 +----------------- .../messages/image_message_bubble.dart | 43 +++++-- 2 files changed, 37 insertions(+), 121 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index a704867..ff376be 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -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 _voiceSessionSenderKey6 = {}; - final Map _imageSessionSenderKey6 = {}; final Map _voiceMissingRetryTimers = {}; - final Map _imageMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; - final Map _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 _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(); } } diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 75a4d86..760c1aa 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -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 { 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 { var sender = _resolveSender(envelope); if (sender == null) { final conn = context.read(); - // 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 { } final conn = context.read(); + final imageProvider = context.read(); final deviceKey = conn.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { setState(() => _errorText = 'Device key unavailable'); @@ -198,11 +205,24 @@ class _ImageMessageBubbleState extends State { .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 { _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) {