Your workout is on your phone. You want it on your laptop. Both devices are right there, and the phone already has every byte the laptop needs.
Requiring an account and a server for that exchange would make our infrastructure a dependency of moving your own data. We built another route: display the data as a sequence of QR codes, point the other device’s camera at it, and collect the pieces.
We called it the QR fountain. We built a fountain codec for it. Then we replaced that codec with a loop. And eventually, we leaned away from the camera workflow itself, toward documents that could be packaged and shared beyond nearby devices.
QR cycles are fun airgapped data transfer. They make a better demo than an everyday sharing workflow. Getting them to work taught us both things.
The screen is a transport
A single QR code has a limited payload. A recording or a batch of database changes can exceed it. Animation gives us more capacity: each frame carries another piece, and the receiver assembles the original bytes.
The camera will miss frames. It can start halfway through the sequence. It may read the same frame several times while the screen is holding still. None of those should require starting the transfer over.
That makes fountain coding an attractive starting point. Instead of sending a fixed list of pieces once, a fountain encoder produces encoded symbols made from combinations of source blocks. Once the receiver has enough useful symbols, it can reconstruct the source. Missing a particular frame need not mean waiting for that exact frame to return.
Our first implementation used Luby Transform coding: a seeded random generator, a distribution for choosing how many blocks to combine, XOR, and a peeling decoder that worked backward from the combinations it could solve.
That was a substantial mechanism for a transfer between one screen and one camera.
Decoding had a user interface cost
With the peeling decoder, receiving a symbol and recovering a source block were different events. A symbol could be useful without immediately revealing a new block. Later, one newly solvable block could unlock several others.
The progress display inherited that behavior. It could appear stuck, then jump as the decoder resolved a cascade. Counting received symbols would produce a smoother bar, but would measure a different thing from recovered data.
The implementation notes also recorded more decode overhead than the original plan expected. The combination of that overhead, uneven visible progress, and the maintenance cost prompted a simpler question: what if we just kept sending the original blocks?
Four blocks and a missed frame
The cycle codec divides the compressed payload into equal-sized blocks and sends them in a repeating sequence. The block address is the frame index modulo the number of blocks:
blockIndex = frameIndex % blockCount
Here is an illustrative transfer with four source blocks. The camera misses B on the first pass:
| Frame | Sender displays | Receiver has | Complete |
|---|---|---|---|
| 0 | A | A | 25% |
| 1 | B | A | 25% |
| 2 | C | A, C | 50% |
| 3 | D | A, C, D | 75% |
| 4 | A | A, C, D | 75% |
| 5 | B | A, B, C, D | 100% |
Duplicate frames add no progress. Each newly recovered block adds exactly one block’s worth. The receiver can start at any position because each frame carries the context needed to identify the transfer and its block.
The tradeoff is visible in the table: after missing B, we spend time seeing blocks we already have. A fountain code can avoid dependence on a specific missing source block. The cycle codec accepts that repetition in return for straightforward state and an exact count of recovered blocks.
Persistent losses at the same position in every cycle can prevent completion. Repeating forever does not guarantee that the camera eventually sees everything. That matters when testing: a periodic drop pattern can starve a block every time, while independently dropped frames exercise a different failure mode.
The bytes still need an envelope
Repeating blocks simplifies recovery, but it does not remove framing.
Each frame includes a version, transfer identifier, frame index, block size, original payload length, and checksum. The envelope adds 21 bytes around the block. The receiver rejects malformed frames and uses the original length to trim padding from the final block.
Those bytes are binary. Our camera component uses the QR decoder’s binary output rather than routing the payload through a text string. Arbitrary compressed bytes are not necessarily valid UTF-8, and a text conversion can damage them before the packet decoder ever gets a chance to run.
The checksum detects accidental corruption. It does not encrypt the data or authenticate the sender; those are separate properties from getting the bytes across intact.
QR moves the packet without defining its meaning
The pipeline is:
Sync packet
↓ encode and gzip
Compressed bytes
↓ split into repeating QR blocks
Screen → camera
↓ recover and decompress
Sync packet
Conflict handling belongs to the sync layer. QR carries the packet; it does not invent another policy for applying it. Keeping that separation lets us test the transport without needing a camera for every protocol change.
The current tests cover clean recovery in exactly one pass, duplicate frames, incomplete transfers, and frames from another transfer. An integration test sends a generated sync packet through gzip, the cycle codec, seeded 20% frame loss, and decompression, then compares the recovered packet with the original.
That establishes a software round trip under simulated loss. Camera performance also depends on focus, lighting, display refresh, QR density, and decoder speed. A passing byte test cannot supply a universal scan-time claim.
A better demo than a daily workflow
Watching data move from a screen into a camera is satisfying. It makes an abstract promise visible: these devices can exchange the payload without a network connection or a server carrying it.
But the demonstration starts with a convenient assumption: both devices are right there. Real sharing might mean sending a routine to someone across town, passing a session summary to a coach, or leaving something for your other device to open tomorrow. Pointing a camera at a screen adds coordination to a task that should fit into the rest of your day.
Simplifying the codec improved the transfer. It did not remove that constraint.
A document can travel beyond the room
We have leaned toward the document model: package the thing being shared so it can be saved, attached to a message, moved through a shared folder, and opened later. The sender and receiver can be in different places and use it at different times.
The existing .tensr share-file path illustrates that direction. It packages a
share artifact as a file and feeds it into the same intake used for pasted data.
The package includes the custom movements and exact routine version the artifact
needs, giving the receiver the context to interpret it.
A scoped share document also has a different job from exporting the whole database. Someone sharing a routine or session summary should be able to package that particular thing. A complete backup serves another purpose.
This changes where we put the engineering effort: defining what belongs in the document, keeping its meaning stable across versions, and making opening it predictable. The file can then use the delivery channels people already use.
QR cycles still have an appealing place as airgapped transfer and a tangible demonstration of data portability. The document model fits the broader use we care about: training data you can package, keep, and pass along when the other device is somewhere else.