/// Receive-side state, persistence and decode scheduling for AEIC images that /// arrive over `PAYLOAD_TYPE_GRP_DATA` (see `image_chunk_transport.dart`). /// /// This file holds the whole receive model: /// * [ReceivedImageRef] the sentinel that a channel message carries in its /// `text` field instead of the pixels. /// * [ReceivedImageState] the state machine every incoming image walks. /// * [ReceivedImageEntry] one immutable snapshot of an image's state. /// * [ReceivedImageStore] the `ChangeNotifier` the message list listens to: /// intake, persistence, eviction and the decode queue. /// /// ## Deliberate dependency shape /// /// There is no `dart:io` here and no `package:flutter/material.dart`. Bytes go /// through the [ReceivedImageBlobStore] seam (default: /// [InMemoryReceivedImageBlobStore]) and decoding goes through the /// [ReceivedImageDecoder] seam, which `ImageCodecService` satisfies /// member-for-member (the wiring agent supplies the one-class adapter — this /// file deliberately does not import the service, so a broken edit over there /// cannot break the receive tests). That keeps the store unit-testable with no /// temp directories, no `path_provider` and no 872 MB model, and it keeps the /// file compilable on web where neither seam has an implementation. /// /// `FileReceivedImageBlobStore` (`received_image_blob_store_io.dart`) is the /// file-backed implementation; pass it as `blobs:` and received images survive /// a restart. With the default in-memory store everything below works exactly /// as specified but forgets its bytes on restart. Nothing else changes. /// /// ## Decode policy (four gates) /// /// The bitstream is ~156 B and already in hand, so decoding is only ever gated /// on memory, never on bandwidth. Gates, all enforced here: /// 0. [processAutomatically] — when false, an arrival is parked as /// `reassembled + needsManualDecode` and waits /// for a tap. Mirrors the "Process images /// automatically" setting, which ships OFF. /// 1. foreground only — [setForeground]; background decode is a ~2.7 GiB /// peak and an OOM kill waiting to happen. /// 2. strictly one at a time — and never while the codec is busy encoding for /// the compose UI. /// 3. burst cap — at most [burstCap] auto-decodes are queued; the /// rest wait for a tap ([ReceivedImageEntry.needsManualDecode]). library; import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../models/image_codec_support.dart'; import '../widgets/image_send_codec_binding.dart'; import 'image_chunk_transport.dart'; // --------------------------------------------------------------------------- // Sentinel // --------------------------------------------------------------------------- /// Encodes/parses the sentinel string a `ChannelMessage.text` carries in place /// of an image. /// /// Mirrors `GifHelper.encodeGif` (`lib/helpers/gif_helper.dart`): the message /// record stays a short string, the bytes live beside it. The sentinel never /// changes for the life of the image, so `ChannelMessage`'s `text.hashCode` /// derived id stays stable and a per-chunk state change does not rewrite the /// channel's whole prefs blob. class ReceivedImageRef { ReceivedImageRef._(); /// Versioned on purpose: a future wire-format change ships `aeic:2:`. static const String scheme = 'aeic:1:'; static final RegExp _pattern = RegExp(r'^aeic:1:([0-9a-f]{14})$'); static String encode(String streamId) => '$scheme$streamId'; /// Returns the stream id, or null when [text] is not an image sentinel. static String? parse(String text) => _pattern.firstMatch(text.trim())?.group(1); /// `%04x%02x%08x` — 4 hex sender prefix, 2 hex img id, 8 hex epoch seconds. /// /// 14 lowercase hex characters, unique in practice, and it doubles as the /// on-disk filename stem. static String streamIdFor({ required int senderPrefix, required int imgId, required DateTime firstSeen, }) { final seconds = firstSeen.millisecondsSinceEpoch ~/ 1000; return (senderPrefix & 0xFFFF).toRadixString(16).padLeft(4, '0') + (imgId & 0xFF).toRadixString(16).padLeft(2, '0') + (seconds & 0xFFFFFFFF).toRadixString(16).padLeft(8, '0'); } } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /// Lifecycle of one received image. Legal transitions are enforced by /// [ReceivedImageStore]; see the class doc there. enum ReceivedImageState { /// Chunks are arriving; nothing is on disk yet. receiving, /// The bitstream is complete and persisted, but not decoded. reassembled, /// A decode is running right now. decoding, /// A PNG exists. This is the ONLY state that shows synthesized pixels, and /// therefore the only state that carries the R6 provenance label. decoded, /// The stream expired with chunks missing. Never retryable — the partial /// bodies only ever lived in the reassembler's memory. failedIncomplete, /// The bitstream arrived whole but the decoder rejected it. User-retryable. failedCorrupt, /// No usable decoder (feature off, no weights, web, or no backend). decoderUnavailable, /// The PNG was reclaimed by the disk budget. Re-decodable while the /// bitstream survives. evicted, } extension ReceivedImageStateValue on ReceivedImageState { String get value => name; /// True when the UI must show the R6 synthesized-content label. bool get showsSynthesizedPixels => this == ReceivedImageState.decoded; bool get isFailure => this == ReceivedImageState.failedIncomplete || this == ReceivedImageState.failedCorrupt; } ReceivedImageState _parseState(Object? raw) { if (raw is String) { for (final state in ReceivedImageState.values) { if (state.name == raw) return state; } } return ReceivedImageState.failedIncomplete; } /// Immutable snapshot of one image. Handed to the widget layer; every mutation /// goes through [ReceivedImageStore]. @immutable class ReceivedImageEntry { /// 14 hex chars; see [ReceivedImageRef.streamIdFor]. Also the filename stem. final String streamId; final int senderPrefix; final int imgId; final int channelIndex; /// Local clock at the first chunk — code 27 carries no timestamp. final DateTime firstSeen; final ReceivedImageState state; /// Data chunks accepted so far (parity chunks never counted). final int receivedChunks; /// Data chunks the sender announced (`total` in the chunk header). final int totalChunks; final AeicRatePoint rate; final int resolution; /// Index into [kImageAspectCodes]: the shape the sender's photo was before /// the codec stretched it into a square. Used to letterbox back on render. /// Defaults to 0 (square) when the sender said nothing. final int aspectCode; /// The source `width / height` to render at, or null to render square. double? get displayAspectRatio { final e = kImageAspectCodes[aspectCode & 0x0F]; final r = e[0] / e[1]; return r == 1.0 ? null : r; } /// True when chunk 0's metadata byte was unknown/unavailable and [rate] and /// [resolution] are the shipped defaults rather than the sender's word. final bool metadataAssumed; /// One chunk was rebuilt from the XOR parity chunk. final bool recoveredWithParity; /// This is the local user's own send. Its PNG is their real 512x512 crop, so /// [synthesized] is false and the R6 label must NOT be shown. final bool isOutgoing; /// Whether the pixels are generative-model output. Derived, never supplied: /// `synthesized == !isOutgoing`. bool get synthesized => !isOutgoing; final int? decodeMs; final String? error; /// Auto-decode was skipped (burst cap); the bubble offers "Tap to decode". final bool needsManualDecode; final bool bitstreamStored; final bool pngStored; final int bitstreamByteCount; final int pngByteCount; /// Decoded PNG, cached in memory once read. Not persisted in the sidecar and /// not part of [==]; callers must not hand these bytes onward without the R6 /// caption (see `received_image_message.dart`). final Uint8List? pngBytes; const ReceivedImageEntry({ required this.streamId, required this.senderPrefix, required this.imgId, required this.channelIndex, required this.firstSeen, required this.state, required this.receivedChunks, required this.totalChunks, this.rate = AeicRatePoint.ft32, this.resolution = kImageCodecSquareSize, this.aspectCode = 0, this.metadataAssumed = true, this.recoveredWithParity = false, this.isOutgoing = false, this.decodeMs, this.error, this.needsManualDecode = false, this.bitstreamStored = false, this.pngStored = false, this.bitstreamByteCount = 0, this.pngByteCount = 0, this.pngBytes, }); ImageStreamKey get key => ImageStreamKey( senderPrefix: senderPrefix, imgId: imgId, channelIndex: channelIndex, ); /// Bytes this entry is currently costing on disk. int get storedBytes => (bitstreamStored ? bitstreamByteCount : 0) + (pngStored ? pngByteCount : 0); /// `evicted`/`failedCorrupt` can be decoded again only while the ~156 B /// bitstream survives. bool get canRetryDecode => bitstreamStored; static const Object _unset = Object(); ReceivedImageEntry copyWith({ ReceivedImageState? state, int? receivedChunks, int? totalChunks, AeicRatePoint? rate, int? resolution, int? aspectCode, bool? metadataAssumed, bool? recoveredWithParity, Object? decodeMs = _unset, Object? error = _unset, bool? needsManualDecode, bool? bitstreamStored, bool? pngStored, int? bitstreamByteCount, int? pngByteCount, Object? pngBytes = _unset, }) { return ReceivedImageEntry( streamId: streamId, senderPrefix: senderPrefix, imgId: imgId, channelIndex: channelIndex, firstSeen: firstSeen, state: state ?? this.state, receivedChunks: receivedChunks ?? this.receivedChunks, totalChunks: totalChunks ?? this.totalChunks, rate: rate ?? this.rate, resolution: resolution ?? this.resolution, aspectCode: aspectCode ?? this.aspectCode, metadataAssumed: metadataAssumed ?? this.metadataAssumed, recoveredWithParity: recoveredWithParity ?? this.recoveredWithParity, isOutgoing: isOutgoing, decodeMs: identical(decodeMs, _unset) ? this.decodeMs : decodeMs as int?, error: identical(error, _unset) ? this.error : error as String?, needsManualDecode: needsManualDecode ?? this.needsManualDecode, bitstreamStored: bitstreamStored ?? this.bitstreamStored, pngStored: pngStored ?? this.pngStored, bitstreamByteCount: bitstreamByteCount ?? this.bitstreamByteCount, pngByteCount: pngByteCount ?? this.pngByteCount, pngBytes: identical(pngBytes, _unset) ? this.pngBytes : pngBytes as Uint8List?, ); } Map toJson() => { 'streamId': streamId, 'senderPrefix': senderPrefix, 'imgId': imgId, 'channelIndex': channelIndex, 'firstSeenMs': firstSeen.millisecondsSinceEpoch, 'state': state.name, 'receivedChunks': receivedChunks, 'totalChunks': totalChunks, 'rate': rate.wireValue, 'resolution': resolution, 'aspect_code': aspectCode, 'metadataAssumed': metadataAssumed, 'recoveredWithParity': recoveredWithParity, 'isOutgoing': isOutgoing, 'synthesized': synthesized, 'needsManualDecode': needsManualDecode, 'bitstreamStored': bitstreamStored, 'pngStored': pngStored, 'bitstreamByteCount': bitstreamByteCount, 'pngByteCount': pngByteCount, if (decodeMs != null) 'decodeMs': decodeMs, if (error != null) 'error': error, }; factory ReceivedImageEntry.fromJson(Map json) { return ReceivedImageEntry( streamId: json['streamId'] as String? ?? '', senderPrefix: json['senderPrefix'] as int? ?? 0, imgId: json['imgId'] as int? ?? 0, channelIndex: json['channelIndex'] as int? ?? 0, firstSeen: DateTime.fromMillisecondsSinceEpoch( json['firstSeenMs'] as int? ?? 0, ), state: _parseState(json['state']), receivedChunks: json['receivedChunks'] as int? ?? 0, totalChunks: json['totalChunks'] as int? ?? 1, rate: parseAeicRatePoint(json['rate'] as int? ?? 4), resolution: json['resolution'] as int? ?? kImageCodecSquareSize, aspectCode: json['aspect_code'] as int? ?? 0, metadataAssumed: json['metadataAssumed'] as bool? ?? true, recoveredWithParity: json['recoveredWithParity'] as bool? ?? false, isOutgoing: json['isOutgoing'] as bool? ?? false, decodeMs: json['decodeMs'] as int?, error: json['error'] as String?, needsManualDecode: json['needsManualDecode'] as bool? ?? false, bitstreamStored: json['bitstreamStored'] as bool? ?? false, pngStored: json['pngStored'] as bool? ?? false, bitstreamByteCount: json['bitstreamByteCount'] as int? ?? 0, pngByteCount: json['pngByteCount'] as int? ?? 0, ); } @override String toString() => 'ReceivedImageEntry($streamId, ${state.name}, ' '$receivedChunks/$totalChunks, ${rate.name})'; } // --------------------------------------------------------------------------- // Byte persistence seam // --------------------------------------------------------------------------- /// Where the three files of an image live. One implementation per platform. /// /// Layout the file-backed implementation uses (application *support* dir, not /// documents — these are derived caches, not user files): /// `received_images/.aeic|.png|.json`. See /// `received_image_blob_store_io.dart`. abstract class ReceivedImageBlobStore { Future writeBitstream(String streamId, Uint8List bytes); Future readBitstream(String streamId); Future deleteBitstream(String streamId); Future writePng(String streamId, Uint8List bytes); Future readPng(String streamId); Future deletePng(String streamId); /// Byte length of the stored bitstream, or null when there is none. /// /// Concrete on purpose: [load] only needs to know whether the bytes exist and /// how big they are, and the default below would read every 400 KB PNG on the /// device into memory at startup (200 images => ~80 MB of pointless I/O) just /// to call `.length` on it. A file-backed store overrides both with `stat`. Future bitstreamSize(String streamId) async => (await readBitstream(streamId))?.length; /// Byte length of the stored PNG, or null when there is none. Future pngSize(String streamId) async => (await readPng(streamId))?.length; /// Sidecar write must be atomic (tmp + rename) so a kill cannot leave a /// half-written record that `readSidecars` then discards. Future writeSidecar(String streamId, String json); Future deleteSidecar(String streamId); /// streamId -> raw sidecar JSON, for the startup scan. Future> readSidecars(); /// Absolute path of the PNG, or null where the platform has no file system. String? pngPath(String streamId); } /// Default implementation: keeps everything in RAM. /// /// Used by tests, by web (where there is no decoder either, so nothing ever /// reaches [ReceivedImageState.decoded]) and as the fallback until the /// file-backed store is wired in. class InMemoryReceivedImageBlobStore implements ReceivedImageBlobStore { final Map _bitstreams = {}; final Map _pngs = {}; final Map _sidecars = {}; @override Future writeBitstream(String streamId, Uint8List bytes) async { _bitstreams[streamId] = bytes; } @override Future readBitstream(String streamId) async => _bitstreams[streamId]; @override Future deleteBitstream(String streamId) async { _bitstreams.remove(streamId); } @override Future bitstreamSize(String streamId) async => _bitstreams[streamId]?.length; @override Future pngSize(String streamId) async => _pngs[streamId]?.length; @override Future writePng(String streamId, Uint8List bytes) async { _pngs[streamId] = bytes; } @override Future readPng(String streamId) async => _pngs[streamId]; @override Future deletePng(String streamId) async { _pngs.remove(streamId); } @override Future writeSidecar(String streamId, String json) async { _sidecars[streamId] = json; } @override Future deleteSidecar(String streamId) async { _sidecars.remove(streamId); } @override Future> readSidecars() async => Map.from(_sidecars); @override String? pngPath(String streamId) => null; // Test/debug helpers. bool hasBitstream(String streamId) => _bitstreams.containsKey(streamId); bool hasPng(String streamId) => _pngs.containsKey(streamId); bool hasSidecar(String streamId) => _sidecars.containsKey(streamId); } // --------------------------------------------------------------------------- // Decoder seam // --------------------------------------------------------------------------- /// The slice of `ImageCodecService` the receive path needs. /// /// `ImageCodecService` satisfies this shape member-for-member, so the adapter /// the wiring agent writes is pure delegation: /// /// ```dart /// class ImageCodecServiceDecoder implements ReceivedImageDecoder { /// final ImageCodecService service; /// const ImageCodecServiceDecoder(this.service); /// @override ImageCodecAvailability get availability => service.availability; /// @override bool get isBusy => service.isBusy; /// @override Future decodeBitstream({ /// required Uint8List bitstream, /// required AeicRatePoint ratePoint, /// required int resolution, /// }) => service.decodeBitstream( /// bitstream: bitstream, ratePoint: ratePoint, resolution: resolution); /// @override void cancelCodecJob() => service.cancelCodecJob(); /// } /// ``` /// /// The interface also exists so the store can be tested without a 872 MB ONNX /// graph. abstract class ReceivedImageDecoder { ImageCodecAvailability get availability; /// True while an encode (compose UI) or another decode is running. bool get isBusy; /// Returns null when the codec cannot decode at all; a result with /// `status == failed` when the bitstream was rejected. Future decodeBitstream({ required Uint8List bitstream, required AeicRatePoint ratePoint, required int resolution, }); /// Cooperative cancel, used when we background mid-decode. void cancelCodecJob(); } // --------------------------------------------------------------------------- // The store // --------------------------------------------------------------------------- /// Owns every received image: its state, its bytes, its place in the decode /// queue and its share of the disk budget. /// /// Legal transitions (nothing else is performed): /// ``` /// (none) -> receiving first non-duplicate chunk /// receiving -> receiving another data chunk /// receiving -> reassembled completed; bitstream written FIRST /// receiving -> failedIncomplete TTL/overflow eviction /// reassembled -> decoding dequeued /// reassembled -> decoderUnavailable no usable decoder at dequeue /// decoding -> decoded completed && pngBytes != null /// decoding -> failedCorrupt failed / throw / bad resolution /// decoding -> decoderUnavailable decodeBitstream returned null /// decoding -> reassembled cancelled (background / pressure) /// decoderUnavailable -> decoding retry, or availability recovered /// failedCorrupt -> decoding explicit user retry only /// decoded -> evicted budget reclaimed the PNG /// evicted -> decoding "Decode again", bitstream survives /// ``` /// `failedIncomplete` is terminal: the partial bodies are gone. class ReceivedImageStore extends ChangeNotifier { final ReceivedImageBlobStore blobs; ReceivedImageDecoder? decoder; /// Hard cap on stored PNGs. final int maxImages; /// Hard cap on total bytes across all stored files. final int maxBytes; /// Images older than this are dropped from disk entirely. final Duration maxAge; /// How many auto-decodes may be queued before the rest need a tap. final int burstCap; /// When false, a completed bitstream is parked as /// `reassembled + needsManualDecode` and waits for a tap. Mirrors the /// "Process images automatically" app setting. /// /// Defaults to TRUE at the store level on purpose: that is this class's /// documented eager contract and what its unit tests assert. Production is /// off-by-default because `main.dart` passes the (false-by-default) setting /// explicitly and re-assigns this field whenever the setting changes. /// /// Assignment affects FUTURE arrivals only. It deliberately does not kick the /// queue and does not backfill: turning the setting on must not retro-decode /// a backlog, which would be an unbounded burst of ~2.16 GiB jobs. bool processAutomatically; final DateTime Function() _clock; final Map _entries = {}; final Map _byKey = {}; final Map> _listenables = >{}; final List _queue = []; Future? _pump; /// Set synchronously for as long as [_drain]'s body is running. A plain /// `_pump != null` check is NOT enough: the future stays non-null for one /// extra microtask after the body has finished, and a chunk arriving in that /// window used to be dropped on the floor with the queue non-empty and /// nothing running. bool _draining = false; /// True when the queue stopped because the codec was busy encoding. Cleared /// by anything that could plausibly have freed it. bool _parkedOnBusyDecoder = false; bool _foreground = true; bool _disposed = false; ReceivedImageStore({ ReceivedImageBlobStore? blobs, this.decoder, this.maxImages = 200, this.maxBytes = 64 * 1024 * 1024, this.maxAge = const Duration(days: 30), this.burstCap = 3, this.processAutomatically = true, DateTime Function()? clock, }) : blobs = blobs ?? InMemoryReceivedImageBlobStore(), _clock = clock ?? DateTime.now; // ---- reads --------------------------------------------------------------- /// Newest first. List get entries { final list = _entries.values.toList() ..sort((a, b) => b.firstSeen.compareTo(a.firstSeen)); return List.unmodifiable(list); } ReceivedImageEntry? entryFor(String streamId) => _entries[streamId]; ReceivedImageEntry? entryForKey(ImageStreamKey key) { final id = _byKey[key]; return id == null ? null : _entries[id]; } /// Per-image listenable so one arriving chunk rebuilds one bubble instead of /// the whole reversed `ListView`. ValueListenable listenableFor(String streamId) { return _listenables.putIfAbsent( streamId, () => ValueNotifier(_entries[streamId]), ); } /// Bytes currently held on disk by all entries. int get totalBytes => _entries.values.fold(0, (sum, e) => sum + e.storedBytes); int get storedImageCount => _entries.values.where((e) => e.pngStored).length; /// Stream ids waiting for an automatic decode, oldest request first. List get decodeQueue => List.unmodifiable(_queue); bool get isForeground => _foreground; /// Availability of the decoder seam, or `unavailable` when none is wired. /// /// Read-only passthrough so a bubble can pick its tap action (decode now vs. /// send the user to the model download) without importing /// `ImageCodecService`. ImageCodecAvailability get decoderAvailability => decoder?.availability ?? ImageCodecAvailability.unavailable; /// True while a decode is running or queued. Callers that want to grey out a /// "Tap to process" affordance can use it; decodes are strictly serial, so a /// second tap only lengthens the wall clock, never the peak RSS. bool get isDecoding => _draining || _queue.isNotEmpty; // ---- startup ------------------------------------------------------------- /// Loads sidecars and repairs states that cannot survive a process death. /// /// receiving -> failedIncomplete (partials were never persisted) /// decoding -> reassembled if the bitstream is there, else failedIncomplete /// decoded -> evicted if the PNG is gone Future load() async { final sidecars = await blobs.readSidecars(); for (final raw in sidecars.values) { Map json; try { final decoded = jsonDecode(raw); if (decoded is! Map) continue; json = decoded; } catch (_) { continue; } var entry = ReceivedImageEntry.fromJson(json); if (entry.streamId.isEmpty) continue; // Sizes, not bytes: reattaching 200 images must not read ~80 MB of PNG // into RAM on the startup path. The pixels are read lazily by // [ensurePng] when a bubble actually scrolls into view. final bitstreamBytes = await blobs.bitstreamSize(entry.streamId); final pngBytes = await blobs.pngSize(entry.streamId); entry = entry.copyWith( bitstreamStored: bitstreamBytes != null, bitstreamByteCount: bitstreamBytes ?? 0, pngStored: pngBytes != null, pngByteCount: pngBytes ?? entry.pngByteCount, ); switch (entry.state) { case ReceivedImageState.receiving: entry = entry.copyWith(state: ReceivedImageState.failedIncomplete); case ReceivedImageState.decoding: entry = entry.copyWith( state: entry.bitstreamStored ? ReceivedImageState.reassembled : ReceivedImageState.failedIncomplete, ); case ReceivedImageState.decoded: if (!entry.pngStored) { entry = entry.copyWith(state: ReceivedImageState.evicted); } case ReceivedImageState.reassembled: if (!entry.bitstreamStored) { entry = entry.copyWith(state: ReceivedImageState.failedIncomplete); } else if (!processAutomatically) { // load() never enqueues, so a `reassembled` entry restored with // needsManualDecode == false (written by a session that had the // setting on, or by an older build) would sit on "Waiting to // decode" forever with no tap target. Give it one. entry = entry.copyWith(needsManualDecode: true); } case ReceivedImageState.evicted: case ReceivedImageState.failedIncomplete: case ReceivedImageState.failedCorrupt: case ReceivedImageState.decoderUnavailable: break; } _entries[entry.streamId] = entry; _byKey[entry.key] = entry.streamId; _publish(entry); await _persist(entry); } await evictToBudget(); _notify(); } // ---- intake -------------------------------------------------------------- /// Feeds one [ImageReassembler] outcome. /// /// The caller owns the reassembler (the send path already builds one) and is /// responsible for having dropped nothing: `malformed` and `fromSelf` are /// ignored here too, so an unfiltered firehose is safe. Future handleOutcome( ImageChunkOutcome outcome, { required int channelIndex, DateTime? at, }) async { final now = at ?? _clock(); switch (outcome.status) { case ImageChunkStatus.malformed: case ImageChunkStatus.fromSelf: return null; case ImageChunkStatus.duplicate: final header = outcome.header; if (header == null) return null; return entryForKey( ImageStreamKey( senderPrefix: header.senderPrefix, imgId: header.imgId, channelIndex: channelIndex, ), ); case ImageChunkStatus.accepted: case ImageChunkStatus.conflicting: final header = outcome.header; if (header == null) return null; final key = ImageStreamKey( senderPrefix: header.senderPrefix, imgId: header.imgId, channelIndex: channelIndex, ); final counts = header.isParity ? 0 : 1; final existing = entryForKey(key); // `conflicting` means the reassembler threw the old stream away and // restarted from this chunk. A stream that had already been surfaced // keeps its id (and therefore its message) only if it was still // receiving; anything else gets a fresh entry. final resets = outcome.status == ImageChunkStatus.conflicting; if (existing != null && (!resets || existing.state == ReceivedImageState.receiving)) { final updated = existing.copyWith( state: ReceivedImageState.receiving, receivedChunks: resets ? counts : (existing.receivedChunks + counts) .clamp(0, header.total) .toInt(), totalChunks: header.total, error: null, ); return _store(updated); } if (existing != null) { _byKey.remove(key); } final streamId = _uniqueStreamId( senderPrefix: header.senderPrefix, imgId: header.imgId, firstSeen: now, ); final created = ReceivedImageEntry( streamId: streamId, senderPrefix: header.senderPrefix, imgId: header.imgId, channelIndex: channelIndex, firstSeen: now, state: ReceivedImageState.receiving, receivedChunks: counts, totalChunks: header.total, ); return _store(created); case ImageChunkStatus.unsupportedFormat: // Every chunk arrived but chunk 0 names a rate point or resolution this // build cannot decode. The transport has already DISCARDED the bytes, // and there is nothing to retry, so no bitstream is stored. final header = outcome.header; if (header == null) return null; final entry = entryForKey( ImageStreamKey( senderPrefix: header.senderPrefix, imgId: header.imgId, channelIndex: channelIndex, ), ); if (entry == null) return null; _queue.remove(entry.streamId); return _store( entry.copyWith( state: ReceivedImageState.failedCorrupt, bitstreamStored: false, error: 'Unsupported image format.', ), ); case ImageChunkStatus.completed: final result = outcome.result; if (result == null) return null; return _handleCompleted(result, now); } } Future _handleCompleted( ImageReassemblyResult result, DateTime now, ) async { final key = result.key; var entry = entryForKey(key); if (entry == null) { final streamId = _uniqueStreamId( senderPrefix: key.senderPrefix, imgId: key.imgId, firstSeen: now, ); entry = ReceivedImageEntry( streamId: streamId, senderPrefix: key.senderPrefix, imgId: key.imgId, channelIndex: key.channelIndex, firstSeen: now, state: ReceivedImageState.receiving, receivedChunks: result.chunkCount, totalChunks: result.chunkCount, ); } final metadata = result.metadata; // Bitstream first, state second: a kill between the two must be // recoverable, and `reassembled` with no file on disk is a lie. await blobs.writeBitstream(entry.streamId, result.data); final updated = entry.copyWith( state: ReceivedImageState.reassembled, receivedChunks: result.chunkCount, totalChunks: result.chunkCount, rate: metadata == null ? AeicRatePoint.ft32 : aeicRatePointForUi(metadata.rate), aspectCode: metadata?.aspectCode ?? 0, resolution: metadata?.squareSize ?? kImageCodecSquareSize, metadataAssumed: metadata == null, recoveredWithParity: result.recoveredWithParity, bitstreamStored: true, bitstreamByteCount: result.data.length, error: null, ); if (!processAutomatically) { // Park it: the card shows "N bytes · M packets" and "Tap to process". // A 2.16 GiB decode is never started off the back of a radio packet // unless the user asked for that. return _store(updated.copyWith(needsManualDecode: true)); } final stored = await _store(updated); _enqueue(stored.streamId); return stored; } /// A stream the reassembler gave up on (TTL or concurrency overflow). Future handleFailure( ImageReassemblyFailure failure, ) async { final entry = entryForKey(failure.key); if (entry == null) return null; if (entry.state != ReceivedImageState.receiving) { // Already completed by a later chunk; the failure is stale. return entry; } return _store( entry.copyWith( // `isCorrupt` means every chunk arrived but the bytes were unusable // (CRC-16 mismatch or an undecodable metadata byte). The UI must say // "corrupt", not "incomplete". state: failure.isCorrupt ? ReceivedImageState.failedCorrupt : ReceivedImageState.failedIncomplete, receivedChunks: failure.receivedDataChunks, totalChunks: failure.total, error: failure.isCorrupt ? failure.reason.name : null, ), ); } /// Records the local user's own send so the outgoing bubble can show the real /// 512x512 crop. [previewPng] is a photograph, not a decode, so the entry is /// never [ReceivedImageEntry.synthesized] and carries no R6 label. /// /// The send path must call this BEFORE it posts the message, and put the /// returned id in the message text — that sentinel is the only link between /// the `ChannelMessage` and these pixels: /// /// ```dart /// final entry = await receivedImageStore.registerOutgoing( /// channelIndex: channelIndex, /// senderPrefix: selfPrefix, // same 16 bits the chunk header carries /// imgId: chunkSet.imgId, /// previewPng: preview.croppedPngBytes, // the 512x512 crop, NOT a decode /// rate: kImageSendRatePoint, /// chunkCount: chunkSet.dataChunkCount, /// ); /// await connector.sendChannelMessage( /// channelIndex, ReceivedImageRef.encode(entry.streamId)); /// ``` /// /// Nothing is ever decoded for an outgoing entry: it lands in `decoded` /// directly and never enters the queue, so sending an image costs no model /// memory beyond the encode that already ran. Future registerOutgoing({ required int channelIndex, required int senderPrefix, required int imgId, required Uint8List previewPng, required AeicRatePoint rate, required int chunkCount, DateTime? at, }) async { final now = at ?? _clock(); final streamId = _uniqueStreamId( senderPrefix: senderPrefix, imgId: imgId, firstSeen: now, ); await blobs.writePng(streamId, previewPng); final entry = ReceivedImageEntry( streamId: streamId, senderPrefix: senderPrefix, imgId: imgId, channelIndex: channelIndex, firstSeen: now, state: ReceivedImageState.decoded, receivedChunks: chunkCount, totalChunks: chunkCount, rate: rate, metadataAssumed: false, isOutgoing: true, pngStored: true, pngByteCount: previewPng.length, pngBytes: previewPng, ); final stored = await _store(entry); await evictToBudget(protect: stored.streamId); return stored; } // ---- decode scheduling --------------------------------------------------- /// App lifecycle gate. Backgrounding cancels an in-flight decode and returns /// the entry to `reassembled` — never to a failure state. void setForeground(bool value) { if (_foreground == value) return; _foreground = value; if (!value) { decoder?.cancelCodecJob(); } else { _parkedOnBusyDecoder = false; _kick(); } _notify(); } /// Call when the codec's availability or busy flag changes: a queue parked on /// a busy encoder resumes, and images that were parked as /// `decoderUnavailable` get another go now that a decoder exists. void notifyDecoderChanged() { _parkedOnBusyDecoder = false; final decoder = this.decoder; if (decoder != null && decoder.availability == ImageCodecAvailability.ready) { for (final entry in _entries.values.toList()) { if (entry.state != ReceivedImageState.decoderUnavailable) continue; if (!entry.bitstreamStored) continue; _storeSync( entry.copyWith( state: ReceivedImageState.reassembled, needsManualDecode: !processAutomatically, ), ); // Without this guard, finishing an 835 MB download would immediately // fire one decode per image received while the model was missing — // exactly what the setting exists to prevent. if (processAutomatically) _queue.add(entry.streamId); } } _kick(); } /// Cancels the running decode and stops the queue; the entry goes back to /// `reassembled` and will be picked up again later. Future handleMemoryPressure() async { decoder?.cancelCodecJob(); _queue.clear(); for (final entry in _entries.values.toList()) { if (entry.state == ReceivedImageState.reassembled && !entry.needsManualDecode) { await _store(entry.copyWith(needsManualDecode: true)); } } } /// User tapped "Decode" / "Try again" / "Decode again". Future requestDecode(String streamId) async { final entry = _entries[streamId]; if (entry == null) return; switch (entry.state) { case ReceivedImageState.reassembled: case ReceivedImageState.failedCorrupt: case ReceivedImageState.decoderUnavailable: case ReceivedImageState.evicted: break; case ReceivedImageState.receiving: case ReceivedImageState.decoding: case ReceivedImageState.decoded: case ReceivedImageState.failedIncomplete: return; } if (!entry.bitstreamStored) return; await _store( entry.copyWith( state: ReceivedImageState.reassembled, needsManualDecode: false, error: null, ), ); _parkedOnBusyDecoder = false; _enqueue(streamId, force: true); await settle(); } /// Completes when the decode queue has stopped making progress. Test hook; /// production code never needs to await it. Future settle() async { var guard = 0; while (guard++ < 1000) { final pump = _pump; if (pump != null) { await pump; continue; } if (_disposed || _queue.isEmpty || _draining || _parkedOnBusyDecoder || !_foreground) { return; } _kick(); if (_pump == null) return; } } void _enqueue(String streamId, {bool force = false}) { if (_queue.contains(streamId)) return; // A new arrival is worth one more look at a codec that was busy. _parkedOnBusyDecoder = false; _queue.add(streamId); if (!force) { // Burst cap: keep the newest `burstCap` requests, park the rest behind a // tap so a repeater storm cannot spend minutes of CPU. while (_queue.length > burstCap) { final dropped = _queue.removeAt(0); final entry = _entries[dropped]; if (entry != null && entry.state == ReceivedImageState.reassembled) { _storeSync(entry.copyWith(needsManualDecode: true)); } } } _kick(); } void _kick() { if (_disposed) return; if (_queue.isEmpty) return; if (_draining) return; if (_parkedOnBusyDecoder) return; _draining = true; _pump = _drain().whenComplete(() { _pump = null; }); } Future _drain() async { try { while (_queue.isNotEmpty && _foreground && !_disposed) { final decoder = this.decoder; final streamId = _queue.first; final entry = _entries[streamId]; if (entry == null || entry.state != ReceivedImageState.reassembled) { _queue.removeAt(0); continue; } if (decoder == null || decoder.availability != ImageCodecAvailability.ready) { _queue.removeAt(0); await _store( entry.copyWith(state: ReceivedImageState.decoderUnavailable), ); continue; } if (decoder.isBusy) { // The compose UI is encoding. Leave the queue intact; the next // notifyDecoderChanged()/chunk resumes it. _parkedOnBusyDecoder = true; return; } _queue.removeAt(0); await _decodeOne(entry, decoder); } } finally { // Synchronous with the end of the body, unlike the future's completion. _draining = false; } } /// Belt to [_draining]'s braces. A decode peaks around 2.16 GiB, so two at /// once is not a slow app, it is an OOM kill. If this ever trips, the queue /// invariant is broken somewhere and the right answer is still to run one. bool _decodeInFlight = false; Future _decodeOne( ReceivedImageEntry entry, ReceivedImageDecoder decoder, ) async { if (_decodeInFlight) { assert(false, 'ReceivedImageStore: concurrent decode attempted'); debugPrint('received_image_store: refusing a concurrent decode'); _queue.remove(entry.streamId); return; } _decodeInFlight = true; try { await _decodeOneExclusive(entry, decoder); } finally { _decodeInFlight = false; } } Future _decodeOneExclusive( ReceivedImageEntry entry, ReceivedImageDecoder decoder, ) async { final bitstream = await blobs.readBitstream(entry.streamId); if (bitstream == null || bitstream.isEmpty) { await _store( entry.copyWith( state: ReceivedImageState.failedCorrupt, bitstreamStored: false, error: 'Bitstream missing.', ), ); return; } final started = _clock(); await _store(entry.copyWith(state: ReceivedImageState.decoding)); ImageCodecResult? result; try { result = await decoder.decodeBitstream( bitstream: bitstream, ratePoint: entry.rate, resolution: entry.resolution, ); } catch (error) { final current = _entries[entry.streamId] ?? entry; if (!_foreground) { await _store(current.copyWith(state: ReceivedImageState.reassembled)); } else { await _store( current.copyWith( state: ReceivedImageState.failedCorrupt, error: error.toString(), ), ); } return; } final current = _entries[entry.streamId] ?? entry; if (result == null) { // canDecode false — the codec cannot run at all. await _store( current.copyWith(state: ReceivedImageState.decoderUnavailable), ); return; } final png = result.pngBytes; if (result.status != ImageCodecStatus.completed || png == null) { await _store( current.copyWith( state: ReceivedImageState.failedCorrupt, error: 'Decoder rejected the bitstream.', ), ); return; } if (!_foreground) { // Backgrounded mid-decode: keep the pixels, but do not claim a decode we // may have half-cancelled. await _store(current.copyWith(state: ReceivedImageState.reassembled)); return; } await blobs.writePng(current.streamId, png); await _store( current.copyWith( state: ReceivedImageState.decoded, pngStored: true, pngByteCount: png.length, pngBytes: png, decodeMs: result.durationMs > 0 ? result.durationMs : _clock().difference(started).inMilliseconds, needsManualDecode: false, error: null, ), ); // Without `protect` the freshly decoded image is the budget's own victim // whenever it is also the oldest: decode -> evict -> "Decode again" -> // evict, forever. await evictToBudget(protect: current.streamId); } // ---- pixels -------------------------------------------------------------- /// Reads (and caches) the decoded PNG for a `decoded` entry. Returns null for /// every other state, so a failed or corrupt image can never present as /// decoded pixels. Future ensurePng(String streamId) async { final entry = _entries[streamId]; if (entry == null) return null; if (entry.state != ReceivedImageState.decoded) return null; final cached = entry.pngBytes; if (cached != null) return cached; if (!entry.pngStored) return null; final bytes = await blobs.readPng(streamId); if (bytes == null) { await _store(entry.copyWith(state: ReceivedImageState.evicted, pngStored: false)); return null; } await _store(entry.copyWith(pngBytes: bytes, pngByteCount: bytes.length)); return bytes; } /// Absolute PNG path, for a share/save action. Null on platforms with no file /// system, and null unless the entry is decoded. String? pngPath(String streamId) { final entry = _entries[streamId]; if (entry == null || entry.state != ReceivedImageState.decoded) return null; return blobs.pngPath(streamId); } // ---- deletion and eviction ---------------------------------------------- /// Removes an image and all three of its files. Called when the user deletes /// the message (or clears the conversation), otherwise ~400 KB is orphaned /// forever. Future deleteImage(String streamId) async { final entry = _entries.remove(streamId); _queue.remove(streamId); if (entry != null) { _byKey.remove(entry.key); } await blobs.deletePng(streamId); await blobs.deleteBitstream(streamId); await blobs.deleteSidecar(streamId); final listenable = _listenables[streamId]; if (listenable != null) { listenable.value = null; } _notify(); } /// Deletes every image of one channel. Call this when the conversation is /// cleared or the channel removed, otherwise each image leaks ~400 KB until /// the 30-day age budget finally reaps it. /// /// Returns the stream ids that were removed, so the caller can strip their /// sentinels from the channel's message list in one pass. Future> deleteImagesForChannel(int channelIndex) async { final doomed = _entries.values .where((e) => e.channelIndex == channelIndex) .map((e) => e.streamId) .toList(); for (final streamId in doomed) { await deleteImage(streamId); } return doomed; } /// Deletes an image for each sentinel that is still present in a channel's /// message list, and drops everything else this store holds for that channel. /// /// Convenience for the single-message delete path: the caller has a /// `ChannelMessage.text`, not a stream id. Future deleteImageForSentinel(String text) async { final streamId = ReceivedImageRef.parse(text); if (streamId == null) return; await deleteImage(streamId); } /// Brings the store back inside [maxImages] / [maxBytes] / [maxAge]. /// /// Order matters: PNGs (~400 KB) go before bitstreams (~156 B), and oldest /// `firstSeen` first. An evicted entry keeps its message and its /// `evicted` state so the bubble can offer "Decode again". Future> evictToBudget({String? protect}) async { final evicted = []; final now = _clock(); // 1. Age budget: nothing survives, not even the bitstream. for (final entry in _entries.values.toList()) { if (now.difference(entry.firstSeen) <= maxAge) continue; if (!entry.pngStored && !entry.bitstreamStored) continue; await blobs.deletePng(entry.streamId); await blobs.deleteBitstream(entry.streamId); await _store( entry.copyWith( state: ReceivedImageState.evicted, pngStored: false, bitstreamStored: false, pngBytes: null, ), ); _queue.remove(entry.streamId); evicted.add(entry.streamId); } // 2. Size/count budget. var guard = 0; while ((storedImageCount > maxImages || totalBytes > maxBytes) && guard++ < 10000) { final pngHolders = _entries.values .where((e) => e.pngStored && e.streamId != protect) .toList() ..sort((a, b) => a.firstSeen.compareTo(b.firstSeen)); if (pngHolders.isNotEmpty) { final victim = pngHolders.first; await blobs.deletePng(victim.streamId); await _store( victim.copyWith( state: victim.state == ReceivedImageState.decoded ? ReceivedImageState.evicted : victim.state, pngStored: false, pngBytes: null, ), ); evicted.add(victim.streamId); continue; } // Only bitstreams of already-evicted entries may go; a `reassembled` // entry still needs its bytes to decode. final bitstreamHolders = _entries.values .where( (e) => e.bitstreamStored && (e.state == ReceivedImageState.evicted || e.state == ReceivedImageState.failedCorrupt), ) .toList() ..sort((a, b) => a.firstSeen.compareTo(b.firstSeen)); if (bitstreamHolders.isEmpty) break; final victim = bitstreamHolders.first; await blobs.deleteBitstream(victim.streamId); await _store( victim.copyWith( state: ReceivedImageState.evicted, bitstreamStored: false, ), ); evicted.add(victim.streamId); } if (evicted.isNotEmpty) _notify(); return evicted; } // ---- plumbing ------------------------------------------------------------ String _uniqueStreamId({ required int senderPrefix, required int imgId, required DateTime firstSeen, }) { // Second-resolution ids collide when the same sender reuses an img_id // inside one second (id wrap, or a restart re-rolling the allocator seed). var at = firstSeen; var id = ReceivedImageRef.streamIdFor( senderPrefix: senderPrefix, imgId: imgId, firstSeen: at, ); var bump = 0; while (_entries.containsKey(id) && bump++ < 64) { at = at.add(const Duration(seconds: 1)); id = ReceivedImageRef.streamIdFor( senderPrefix: senderPrefix, imgId: imgId, firstSeen: at, ); } return id; } Future _store(ReceivedImageEntry entry) async { _storeSync(entry); await _persist(entry); return entry; } void _storeSync(ReceivedImageEntry entry) { _entries[entry.streamId] = entry; _byKey[entry.key] = entry.streamId; _publish(entry); _notify(); } Future _persist(ReceivedImageEntry entry) async { try { await blobs.writeSidecar(entry.streamId, jsonEncode(entry.toJson())); } catch (error) { debugPrint('received_image_store: sidecar write failed: $error'); } } void _publish(ReceivedImageEntry entry) { final listenable = _listenables[entry.streamId]; if (listenable != null) { listenable.value = entry; } } void _notify() { if (_disposed) return; notifyListeners(); } @override void dispose() { _disposed = true; _queue.clear(); for (final listenable in _listenables.values) { listenable.dispose(); } _listenables.clear(); super.dispose(); } }