Files
meshcore-open/lib/services/received_image_store.dart
T
2026-08-10 23:20:46 -07:00

1488 lines
52 KiB
Dart

/// 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<String, dynamic> toJson() => <String, dynamic>{
'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<String, dynamic> 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/<streamId>.aeic|.png|.json`. See
/// `received_image_blob_store_io.dart`.
abstract class ReceivedImageBlobStore {
Future<void> writeBitstream(String streamId, Uint8List bytes);
Future<Uint8List?> readBitstream(String streamId);
Future<void> deleteBitstream(String streamId);
Future<void> writePng(String streamId, Uint8List bytes);
Future<Uint8List?> readPng(String streamId);
Future<void> 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<int?> bitstreamSize(String streamId) async =>
(await readBitstream(streamId))?.length;
/// Byte length of the stored PNG, or null when there is none.
Future<int?> 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<void> writeSidecar(String streamId, String json);
Future<void> deleteSidecar(String streamId);
/// streamId -> raw sidecar JSON, for the startup scan.
Future<Map<String, String>> 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<String, Uint8List> _bitstreams = <String, Uint8List>{};
final Map<String, Uint8List> _pngs = <String, Uint8List>{};
final Map<String, String> _sidecars = <String, String>{};
@override
Future<void> writeBitstream(String streamId, Uint8List bytes) async {
_bitstreams[streamId] = bytes;
}
@override
Future<Uint8List?> readBitstream(String streamId) async =>
_bitstreams[streamId];
@override
Future<void> deleteBitstream(String streamId) async {
_bitstreams.remove(streamId);
}
@override
Future<int?> bitstreamSize(String streamId) async =>
_bitstreams[streamId]?.length;
@override
Future<int?> pngSize(String streamId) async => _pngs[streamId]?.length;
@override
Future<void> writePng(String streamId, Uint8List bytes) async {
_pngs[streamId] = bytes;
}
@override
Future<Uint8List?> readPng(String streamId) async => _pngs[streamId];
@override
Future<void> deletePng(String streamId) async {
_pngs.remove(streamId);
}
@override
Future<void> writeSidecar(String streamId, String json) async {
_sidecars[streamId] = json;
}
@override
Future<void> deleteSidecar(String streamId) async {
_sidecars.remove(streamId);
}
@override
Future<Map<String, String>> readSidecars() async =>
Map<String, String>.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<ImageCodecResult?> 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<ImageCodecResult?> 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<String, ReceivedImageEntry> _entries = <String, ReceivedImageEntry>{};
final Map<ImageStreamKey, String> _byKey = <ImageStreamKey, String>{};
final Map<String, ValueNotifier<ReceivedImageEntry?>> _listenables =
<String, ValueNotifier<ReceivedImageEntry?>>{};
final List<String> _queue = <String>[];
Future<void>? _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<ReceivedImageEntry> get entries {
final list = _entries.values.toList()
..sort((a, b) => b.firstSeen.compareTo(a.firstSeen));
return List<ReceivedImageEntry>.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<ReceivedImageEntry?> listenableFor(String streamId) {
return _listenables.putIfAbsent(
streamId,
() => ValueNotifier<ReceivedImageEntry?>(_entries[streamId]),
);
}
/// Bytes currently held on disk by all entries.
int get totalBytes =>
_entries.values.fold<int>(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<String> get decodeQueue => List<String>.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<void> load() async {
final sidecars = await blobs.readSidecars();
for (final raw in sidecars.values) {
Map<String, dynamic> json;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) 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<ReceivedImageEntry?> 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<ReceivedImageEntry> _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<ReceivedImageEntry?> 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<ReceivedImageEntry> 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<void> 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<void> 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<void> 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<void> _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<void> _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<void> _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<Uint8List?> 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<void> 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<List<String>> 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<void> 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<List<String>> evictToBudget({String? protect}) async {
final evicted = <String>[];
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<ReceivedImageEntry> _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<void> _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();
}
}