Architecture

Receiver internals

The worker isolate that owns decoding, and the disk hydration that makes a transfer resumable.

The receiver has one architectural decision worth understanding: decoding does not happen on the UI thread.

Why

Decoding a scanned chunk means fountain peeling, GF(2) elimination, gzip decompression, SHA-256, and per-chunk disk I/O. That ran synchronously inside Assembler.ingest on the UI isolate, and a real 109 MB / ~26,000-chunk transfer exposed it: the UI froze during decode bursts, and rewriting metadata.json on every scan added blocking disk I/O to each frame.

Why not compute()

The obvious Flutter answer is compute() or Isolate.run(), which spins up a fresh isolate per call. That is the wrong shape here.

The Assembler and FountainDecoder hold state that must persist across thousands of calls — recovered blocks, pending fountain symbols, per-transfer maps. Re-creating that per QR scan would mean serialising the entire decode state and shipping it across the isolate boundary on every frame, which costs more than the problem it solves.

The shape

One long-lived worker isolate, spawned once via AssemblerWorker.spawn, owns the real Assembler for the app's lifetime.

UI isolate                    Worker isolate
  AssemblerWorker  ──event──►   Assembler
   (thin handle)                FountainDecoder
                                ChunkStorage
                 ◄─snapshot──   (all disk I/O)

State lives entirely in the worker. Only small ProgressSnapshot values cross back — enough to draw a progress bar, and nothing else.

Disk hydration

Chunks are written to disk as they resolve, one chunk_NNNNNN.bin per recovered block, under <outputDirectory>/<transfer.id>/chunks/. A cold start therefore has enough on disk to resume without re-scanning.

The question is what to trust when rebuilding in-memory state.

Filenames, not metadata

metadata.json is cheap to read but can lag reality: writes are debounced to at most once every five seconds, for the same UI-blocking reason above. A crash between a chunk write and the next metadata flush under-reports what is genuinely on disk.

Chunk filenames cannot be stale in that way. Writes are single-shot writeAsBytes, not incremental, so a file that exists was fully written. Hydration reads the filenames as ground truth and consults metadata.json only for fields it cannot derive from them.

Not eagerly

The first implementation read every hydrated chunk's bytes into memory during the scan. That crashed the receiver outright against a real multi-thousand-chunk transfer — two in-progress transfers were enough to exhaust memory before the app finished starting.

Hydration now records what exists without loading it.

Known gap

flutter/third_party/mobile_scanner is a vendored fork pinned at 7.2.0 via dependency_overrides, carrying a local patch that adds macOS external-camera enumeration and selection — something upstream does not support.

Upstream has moved to 7.4.0. Re-diffing the patch is real effort with real risk to camera selection, which is the entire reason the fork exists, for a changelog that is mostly camera lifecycle and orientation fixes. It is recorded as a gap rather than silently carried.

Copyright © 2026