mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -48,15 +48,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
|
||||||
|
|
||||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||||
static const Duration _imageFragmentTimeout = Duration(seconds: 10);
|
|
||||||
static const int _maxPacketRetryAttempts = 4;
|
static const int _maxPacketRetryAttempts = 4;
|
||||||
static const int _maxImageRetryAttempts = 5;
|
|
||||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||||
final Map<String, String> _imageSessionSenderKey6 = {};
|
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, Timer> _imageMissingRetryTimers = {};
|
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
final Map<String, int> _imageMissingRetryAttempts = {};
|
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
@@ -595,17 +590,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Image envelope (IE1): announce image availability.
|
// Image envelope (IE1): announce image availability.
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
|
|
||||||
.senderKey6
|
|
||||||
.toLowerCase();
|
|
||||||
imageProvider.registerEnvelope(imageEnvelope);
|
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(
|
messagesProvider.addMessage(
|
||||||
enrichedMessage,
|
enrichedMessage,
|
||||||
contactLookup: (name) {
|
contactLookup: (name) {
|
||||||
@@ -692,12 +677,11 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (frag == null) return;
|
if (frag == null) return;
|
||||||
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
|
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
|
||||||
final session = imageProvider.session(frag.sessionId);
|
final session = imageProvider.session(frag.sessionId);
|
||||||
final justComplete = imageProvider.addFragment(
|
imageProvider.addFragment(
|
||||||
frag,
|
frag,
|
||||||
width: session?.width ?? 0,
|
width: session?.width ?? 0,
|
||||||
height: session?.height ?? 0,
|
height: session?.height ?? 0,
|
||||||
);
|
);
|
||||||
_scheduleImageMissingRetry(frag.sessionId, justComplete: justComplete);
|
|
||||||
return;
|
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.
|
/// 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) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
for (final timer in _imageMissingRetryTimers.values) {
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
_voiceMissingRetryTimers.clear();
|
_voiceMissingRetryTimers.clear();
|
||||||
_imageMissingRetryTimers.clear();
|
|
||||||
_voiceMissingRetryAttempts.clear();
|
_voiceMissingRetryAttempts.clear();
|
||||||
_imageMissingRetryAttempts.clear();
|
|
||||||
_voiceSessionSenderKey6.clear();
|
_voiceSessionSenderKey6.clear();
|
||||||
_imageSessionSenderKey6.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,9 +1255,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
for (final timer in _voiceMissingRetryTimers.values) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
for (final timer in _imageMissingRetryTimers.values) {
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
@@ -31,6 +32,13 @@ class ImageMessageBubble extends StatefulWidget {
|
|||||||
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||||
bool _isRequesting = false;
|
bool _isRequesting = false;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
Timer? _requestTimeoutTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_requestTimeoutTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -176,8 +184,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
var sender = _resolveSender(envelope);
|
var sender = _resolveSender(envelope);
|
||||||
if (sender == null) {
|
if (sender == null) {
|
||||||
final conn = context.read<ConnectionProvider>();
|
final conn = context.read<ConnectionProvider>();
|
||||||
// Retry once after refreshing contacts; resumable sessions may outlive
|
|
||||||
// the in-memory contact cache.
|
|
||||||
await conn.getContacts();
|
await conn.getContacts();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
sender = _resolveSender(envelope);
|
sender = _resolveSender(envelope);
|
||||||
@@ -188,6 +194,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final conn = context.read<ConnectionProvider>();
|
final conn = context.read<ConnectionProvider>();
|
||||||
|
final imageProvider = context.read<ip.ImageProvider>();
|
||||||
final deviceKey = conn.deviceInfo.publicKey;
|
final deviceKey = conn.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
setState(() => _errorText = 'Device key unavailable');
|
setState(() => _errorText = 'Device key unavailable');
|
||||||
@@ -198,11 +205,24 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
final request = ImageFetchRequest(
|
|
||||||
sessionId: envelope.sessionId,
|
// If we already have some fragments, request only what's missing.
|
||||||
requesterKey6: requesterKey6,
|
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
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(() {
|
setState(() {
|
||||||
_isRequesting = true;
|
_isRequesting = true;
|
||||||
@@ -219,7 +239,16 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
_errorText = 'Image unavailable right now';
|
_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) {
|
Contact? _resolveSender(ImageEnvelope envelope) {
|
||||||
|
|||||||
Reference in New Issue
Block a user