import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; import 'helpers/raw_session_retransmit.dart'; import '../utils/image_message_parser.dart'; /// Reassembly state for one incoming image session. class ImageSession { final String sessionId; final ImageFormat format; final int total; final int width; final int height; final List fragments; // indexed by fragment.index DateTime? firstFragmentAt; DateTime? lastFragmentAt; ImageSession({ required this.sessionId, required this.format, required this.total, required this.width, required this.height, }) : fragments = List.filled(total, null); int get receivedCount => fragments.where((f) => f != null).length; bool get isComplete => receivedCount == total; Duration? estimateRemaining() { if (isComplete) return Duration.zero; if (firstFragmentAt == null || lastFragmentAt == null) return null; if (receivedCount < 2) return null; final elapsedMs = lastFragmentAt! .difference(firstFragmentAt!) .inMilliseconds; if (elapsedMs <= 0) return null; final avgMsPerFragment = elapsedMs / (receivedCount - 1); final remaining = total - receivedCount; if (remaining <= 0) return Duration.zero; return Duration(milliseconds: (avgMsPerFragment * remaining).round()); } /// Reassemble the complete image bytes, or null if any fragment is missing. Uint8List? get imageBytes => reassembleImage(fragments); } /// Manages incoming image sessions and outgoing image caches. /// /// Mirrors [VoiceProvider] in architecture: on-demand fetch, deferred serving, /// persistent storage of both incoming and outgoing session data. class ImageProvider with ChangeNotifier { static const String _storageKey = 'stored_image_sessions_v1'; static const Duration _outgoingTtl = Duration(minutes: 15); static const int maxDirectPayloadHops = 3; /// Incoming sessions keyed by sessionId. final Map _sessions = {}; /// Outgoing sessions cached for deferred serving. final Map _outgoing = {}; /// Hook for sending a raw binary payload to a contact. Future Function({ required Uint8List contactPath, required int contactPathLen, required Uint8List payload, })? sendRawPacketCallback; Future Function({ required String sessionId, required int index, Duration timeout, })? waitForFragmentAckCallback; ImageProvider() { _restore(); } // ── Accessors ──────────────────────────────────────────────────────────── ImageSession? session(String sessionId) => _sessions[sessionId]; bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false; bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); Duration? estimateRemainingTransferTime(String sessionId) => _sessions[sessionId]?.estimateRemaining(); List missingFragmentIndices(String sessionId) { final session = _sessions[sessionId]; if (session == null) return const []; final missing = []; for (var i = 0; i < session.total; i++) { if (session.fragments[i] == null) missing.add(i); } return missing; } // ── Incoming fragment reception ────────────────────────────────────────── /// Add a received [fragment]. Creates the session on first fragment using /// metadata from the fragment itself (requires envelope to have been /// announced first; if not, defaults width/height to 0 — corrected on save). /// /// Returns true when the session just became complete. bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) { _sessions.putIfAbsent( fragment.sessionId, () => ImageSession( sessionId: fragment.sessionId, format: fragment.format, total: fragment.total, width: width, height: height, ), ); final session = _sessions[fragment.sessionId]!; if (fragment.index < session.total) { final wasMissing = session.fragments[fragment.index] == null; session.fragments[fragment.index] = fragment; if (wasMissing) { final now = DateTime.now(); session.firstFragmentAt ??= now; session.lastFragmentAt = now; } } final justComplete = session.isComplete; unawaited(_persist()); notifyListeners(); return justComplete; } /// Register envelope metadata for a session (called when IE1 is received /// before any binary fragments arrive). void registerEnvelope(ImageEnvelope envelope) { final existing = _sessions[envelope.sessionId]; if (existing == null) { _sessions[envelope.sessionId] = ImageSession( sessionId: envelope.sessionId, format: envelope.format, total: envelope.total, width: envelope.width, height: envelope.height, ); unawaited(_persist()); notifyListeners(); return; } final needsMerge = existing.width == 0 || existing.height == 0 || existing.total != envelope.total || existing.format != envelope.format; if (!needsMerge) { notifyListeners(); return; } final merged = ImageSession( sessionId: envelope.sessionId, format: envelope.format, total: envelope.total, width: envelope.width, height: envelope.height, ); merged.firstFragmentAt = existing.firstFragmentAt; merged.lastFragmentAt = existing.lastFragmentAt; for (final fragment in existing.fragments) { if (fragment == null) continue; if (fragment.index < merged.total) { merged.fragments[fragment.index] = fragment; } } _sessions[envelope.sessionId] = merged; unawaited(_persist()); notifyListeners(); } // ── Outgoing session management ────────────────────────────────────────── /// Cache encoded fragments for deferred serving. /// /// Also registers the session as complete in [_sessions] so the local /// bubble can display the sent image immediately without a fetch round-trip. void cacheOutgoingSession( String sessionId, List fragments, ImageEnvelope envelope, ) { if (fragments.isEmpty) return; _evictExpiredOutgoing(); _outgoing[sessionId] = _OutgoingSession( sessionId: sessionId, fragments: List.from(fragments), envelope: envelope, cachedAt: DateTime.now(), ); // Populate incoming session so the bubble shows the image right away. final session = ImageSession( sessionId: sessionId, format: envelope.format, total: fragments.length, width: envelope.width, height: envelope.height, ); for (final f in fragments) { if (f.index < session.total) session.fragments[f.index] = f; } _sessions[sessionId] = session; unawaited(_persist()); notifyListeners(); } /// Stream cached image fragments to [requester] via raw binary packets. Future serveSessionTo({ required String sessionId, required Contact requester, Set? requestedIndices, }) async { final cached = _outgoing[sessionId]; if (cached == null) { debugPrint('⚠️ [ImageProvider] No cached session for $sessionId'); return false; } return serveCachedSessionFragments( providerLabel: 'ImageProvider', sessionId: sessionId, requester: requester, fragments: cached.fragments, maxDirectPayloadHops: maxDirectPayloadHops, indexOf: (fragment) => fragment.index, encodeBinary: (fragment) => fragment.encodeBinary(), sendRawPacket: sendRawPacketCallback, waitForFragmentAck: waitForFragmentAckCallback, requestedIndices: requestedIndices, ); } // ── Persistence ────────────────────────────────────────────────────────── Future clearAll() async { _sessions.clear(); _outgoing.clear(); notifyListeners(); try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_storageKey); } catch (e) { debugPrint('❌ [ImageProvider] Failed to clear storage: $e'); } } void _evictExpiredOutgoing() { final now = DateTime.now(); _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl); } Future _persist() async { try { _evictExpiredOutgoing(); final prefs = await SharedPreferences.getInstance(); final payload = { 'incoming': _sessions.values .map( (s) => { 'sessionId': s.sessionId, 'fmtId': s.format.id, 'total': s.total, 'width': s.width, 'height': s.height, 'fragments': s.fragments .map( (f) => f == null ? null : base64.encode(f.encodeBinary()), ) .toList(), }, ) .toList(), 'outgoing': _outgoing.values .map( (s) => { 'sessionId': s.sessionId, 'cachedAt': s.cachedAt.millisecondsSinceEpoch, 'envelope': s.envelope.encode(), 'fragments': s.fragments .map((f) => base64.encode(f.encodeBinary())) .toList(), }, ) .toList(), }; await prefs.setString(_storageKey, jsonEncode(payload)); } catch (e) { debugPrint('❌ [ImageProvider] Failed to persist: $e'); } } Future _restore() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_storageKey); if (raw == null || raw.isEmpty) return; final parsed = jsonDecode(raw) as Map; for (final item in (parsed['incoming'] as List? ?? [])) { final map = item as Map; final sessionId = map['sessionId'] as String?; final fmtId = map['fmtId'] as int?; final total = map['total'] as int?; final width = map['width'] as int? ?? 256; final height = map['height'] as int? ?? 256; if (sessionId == null || fmtId == null || total == null || total <= 0) { continue; } final session = ImageSession( sessionId: sessionId, format: ImageFormat.fromId(fmtId), total: total, width: width, height: height, ); final frags = map['fragments'] as List? ?? []; for (var i = 0; i < frags.length && i < total; i++) { final enc = frags[i] as String?; if (enc == null || enc.isEmpty) continue; final pkt = ImagePacket.tryParseBinary(base64.decode(enc)); if (pkt != null && pkt.index < total) { session.fragments[pkt.index] = pkt; } } _sessions[sessionId] = session; } for (final item in (parsed['outgoing'] as List? ?? [])) { final map = item as Map; final sessionId = map['sessionId'] as String?; final cachedMs = map['cachedAt'] as int?; final envelopeText = map['envelope'] as String?; if (sessionId == null || cachedMs == null || envelopeText == null) { continue; } final envelope = ImageEnvelope.tryParse(envelopeText); if (envelope == null) continue; final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedMs); if (DateTime.now().difference(cachedAt) > _outgoingTtl) continue; final fragsRaw = map['fragments'] as List? ?? []; final fragments = []; for (final enc in fragsRaw) { final pkt = ImagePacket.tryParseBinary( base64.decode((enc ?? '') as String), ); if (pkt != null) fragments.add(pkt); } if (fragments.isNotEmpty) { _outgoing[sessionId] = _OutgoingSession( sessionId: sessionId, fragments: fragments, envelope: envelope, cachedAt: cachedAt, ); } } if (_sessions.isNotEmpty || _outgoing.isNotEmpty) { debugPrint( '📷 [ImageProvider] Restored ${_sessions.length} incoming, ' '${_outgoing.length} outgoing sessions', ); notifyListeners(); } } catch (e) { debugPrint('❌ [ImageProvider] Failed to restore: $e'); } } } class _OutgoingSession { final String sessionId; final List fragments; final ImageEnvelope envelope; final DateTime cachedAt; const _OutgoingSession({ required this.sessionId, required this.fragments, required this.envelope, required this.cachedAt, }); }