fix: image transfer 10s idle timeout with partial resume

- Replace 1.2s fixed retry with 10s idle debounce timer
- Timer resets on every received fragment (debounce)
- Timer also starts on IE1 envelope receipt (handles pull-only senders)
- IR1 always requests only missing indices (partial resume)
- Attempt counter no longer resets per fragment; bounded to 5 retries
- Max attempts raised to 5 (was 4)
This commit is contained in:
Janez T
2026-03-04 19:42:18 +01:00
parent 7ffc46438f
commit 537d98e2d4

View File

@@ -48,7 +48,9 @@ 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 = {};
@@ -597,6 +599,13 @@ class AppProvider with ChangeNotifier {
.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) {
@@ -1094,9 +1103,16 @@ 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();
@@ -1104,9 +1120,12 @@ class AppProvider with ChangeNotifier {
return;
}
_imageMissingRetryAttempts[sessionId] = 0;
// 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(_packetRetryDelay, () {
_imageMissingRetryTimers[sessionId] = Timer(_imageFragmentTimeout, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
@@ -1119,9 +1138,9 @@ class AppProvider with ChangeNotifier {
}
final attempt = _imageMissingRetryAttempts[sessionId] ?? 0;
if (attempt >= _maxPacketRetryAttempts) {
if (attempt >= _maxImageRetryAttempts) {
debugPrint(
'⚠️ [AppProvider] Image re-request limit reached for $sessionId',
'⚠️ [AppProvider] Image re-request limit ($attempt) reached for $sessionId',
);
_imageMissingRetryTimers.remove(sessionId)?.cancel();
return;
@@ -1145,6 +1164,11 @@ class AppProvider with ChangeNotifier {
.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',
@@ -1161,8 +1185,9 @@ class AppProvider with ChangeNotifier {
if (!sent) return;
_imageMissingRetryAttempts[sessionId] = attempt + 1;
// Wait another full timeout window for the sender to retransmit.
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
_imageMissingRetryTimers[sessionId] = Timer(_imageFragmentTimeout, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}