Marshall Frith

Projects

Moving Files Phone to Phone With Nothing But a Screen

One handset blinks animated QR codes, the other films it, and a byte-identical file comes out. Fountain codes, because there is no back channel.

Two phones, no network, no cables, no pairing. One screen blinks a sequence of QR codes and the other one films it. A file comes out the far end, byte for byte identical to what went in.

I built this to push ATAK data packages between handsets in places where there is no network to push them over. It generalises to anything under about half a megabyte.

The number that sets every constraint

A single QR code holds 2,953 bytes. That is version 40 at the lowest error correction level, and it is a hard ceiling in the specification, not an implementation limit. Every design decision downstream follows from it.

At version 25 with low ECC, half a megabyte is roughly 423 frames. At ten frames a second on a clean scan that is about a minute of holding two phones steady. That is the honest performance envelope.

Base45, not raw bytes

The obvious encoding is QR byte mode. It does not survive the trip.

Android's BarcodeDetector hands back rawValue as a string, so arbitrary binary gets mangled by the platform decoder before your code ever sees it. Base45 encodes into the QR alphanumeric character set, costs about 3% in density, and keeps the fastest decoder on the target device usable. That trade is worth taking every time.

Fountain codes, because a lamp cannot hear you

The naive approach numbers the chunks: frame 1 of 400, frame 2 of 400, and so on. Then the receiver misses frame 137 to a passing shadow and has to wait for the whole cycle to come round again.

There is no back channel between a screen and a camera. The receiver cannot ask for a retransmission, so the transmitter has to be useful without knowing what was received.

Luby transform codes solve exactly this. Each frame is the XOR of a pseudo-randomly chosen subset of source blocks, identified by seed. The receiver collects any sufficient set of frames and solves for the original, regardless of which ones it missed.

javascript
// Seeds below K are a systematic first pass: frame i is simply block i.
// A clean scan therefore finishes in exactly K frames with zero overhead.
const blocks = seed < K ? [seed] : sampleDegree(seed, K);
const payload = blocks.reduce((acc, i) => xor(acc, source[i]), zeroes(BLOCK));

That systematic prefix matters. Pure fountain coding pays a 5 to 10 percent overhead even on a perfect channel, and a perfect channel is the common case when someone is holding two phones on a table.

Two decoders

The platform BarcodeDetector is used when it exists. When it does not, there is a hand-written decoder in qrdecode.js.

The fallback reaches parity with Apple Vision for versions 10 through 25 up to roughly 0.2 of keystone distortion. Vision still wins on small versions at steep angles, which is the honest limit. Writing a QR decoder is less work than it sounds and pays for itself the first time you meet a browser that lacks the API.

The HTTPS requirement is not optional

getUserMedia demands a secure context. A plain HTTP origin on the local network can send frames all day and can never receive them, because the receiving phone will not be granted a camera.

bash
python3 server.py --tls --port 8778

Install it once as a progressive web app and it runs fully offline after that.

Three bugs that passed their tests

All of them produced green tests while being wrong, which is the class of bug worth naming.

  • The round trip test encoded and decoded with the same code, so a symmetric error in the bit packing cancelled itself out perfectly.
  • Frame timing was validated against the requested rate rather than the rate the display actually achieved, so dropped frames were invisible.
  • The synthetic camera input was pixel-perfect, so the perspective correction was never exercised until a real phone was pointed at a real screen.

Verify against something you did not write. Your own decoder agreeing with your own encoder proves that you were consistent, not that you were correct.

Try it

bash
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
bash
.venv/bin/python server.py --tls

Open the address on both phones, accept the certificate warning once, pick a file on the sender, point the receiver at it. Start at 8 frames per second and version 20 before you get ambitious.

Comments

Plain text only. Held for review before it appears.