The problem starts when someone taps Play
A video can be fully uploaded and safely stored in the bucket. It can still fail at the exact moment someone wants to watch it.
A person opens beisammen and taps Play. The app knows the video, the circle key and the encrypted file format. The player on the device knows none of those things. It expects an ordinary MP4 file or an HTTP URL that supports random access to its bytes.
beisammen encrypts photos and videos on the phone before uploading them. The bucket stores ciphertext. I cannot read the files. Someone running their own instance cannot read them either. The keys stay with the people in the circle.
AVPlayer on iOS and ExoPlayer on Android are built for ordinary media files. They send range requests, look for the moov atom and jump around the file. They cannot do that with our XChaCha20-encrypted chunks.
Download everything
The first frame arrives after the last chunk - and a complete plaintext copy sits on the device.
Two native adapters
Direct access to the media pipelines, but every change is built and maintained twice.
Local HTTP bridge
The first frame arrives after the first chunks - decryption happens only in memory.
The available options
There were three realistic ways to connect the encrypted file to the native player.
-
Download the whole video and write a decrypted file
Advantage:
expo-videoreceives an ordinaryfile://source. AVPlayer and ExoPlayer can play it without special handling. Save and Share already use this path.Disadvantage: Playback starts only after the complete download. Seeking early is impossible. It also creates a full plaintext copy that consumes storage and remains on the device until cleanup.
-
Build a native data adapter for each platform
Advantage: iOS and Android could connect encrypted chunks directly to their respective media pipelines. Local HTTP would not be needed.
Disadvantage: The logic would have to be built and maintained twice. Part of playback would move out of the shared Expo and TypeScript path into two native implementations. Failures would behave differently on each platform.
-
Give both players a local HTTP source
Advantage: AVPlayer and ExoPlayer already speak HTTP and range requests. One shared implementation can fetch the required chunks and decrypt them in memory. Playback can begin before the entire video arrives.
Disadvantage: The proxy must answer HTTP precisely. Android needs a narrow exception for local cleartext traffic. Sessions, tokens and abandoned connections require careful lifecycle handling.
We chose the third option. It puts the additional complexity into a small adapter whose protocol both native players already understand. On a cache miss no complete plaintext file has to be created. Only the required chunks are fetched and decrypted in memory.
The decision is not “plaintext must never touch device storage”. If a decrypted cache file already exists, the player uses it. The decision is this: we do not create a complete plaintext copy only to start playback.
Where Expo ends and native playback begins
beisammen is an Expo app. The interface, key handling and the decision about which source to play live in React Native.
A hook first checks whether a decrypted cache file already exists. If it does not, openEncryptedVideoStream opens an encrypted streaming session and returns a URL on 127.0.0.1. useVideoPlayerSource keeps one stable player from expo-video and applies the URL with replaceAsync once it is ready.
The platform takes over from there. On iOS expo-video hands the source to AVPlayer. On Android the same URL reaches ExoPlayer. Both native players treat it as a normal HTTP source and send their requests back to a local server inside the app.
Checks the cache and opens the encrypted streaming session.
Hands the loopback URL to the platform player via replaceAsync.
Sends range requests to the proxy inside the same app process.
Fetches ciphertext from the bucket, decrypts in memory, returns exact MP4 bytes.
This is the unusual loop. The Expo layer gives the native player a URL. The player does not leave the app to load it. It calls a server running in the same app process.
The local bridge
The app starts a small TCP server through react-native-tcp-socket when it is needed. It binds only to 127.0.0.1. The operating system chooses an available port. To the native player the source looks roughly like this:
http://127.0.0.1:49152/v/…
The last part is a random token with 144 bits of entropy and not the asset ID. There is no complete file behind the URL. The token points to an in-memory streaming session.
The server understands GET, HEAD and one byte range. Every response uses Connection: close. It is not a general-purpose web server. It is a narrow adapter between the player’s HTTP model and the app’s encrypted file format.
How a byte range becomes encrypted chunks
The session fetches ciphertext straight from the bucket. Convex only issues a short-lived signed URL. It is refreshed before expiry and requested once more after a 403.
The BSE1 format starts with a 36-byte header. Independently readable 1 MiB chunks follow it. The session calculates which chunks cover the player’s requested byte range. It fetches only those ciphertext ranges and decrypts them in memory. It then slices out the exact MP4 bytes requested by the player.
iOS: AVPlayer defines the exact response
AVPlayer does not read from beginning to end before showing the first frame. It probes the head and the tail of the file. It asks for some ranges more than once. The session warms up the head and tail and keeps eight ciphertext chunks in a small LRU cache.
Every response length must be correct. If a 206 Partial Content response contains fewer bytes than promised, CoreMedia stops with -12939: content range mismatch. The proxy therefore returns the exact valid range requested by AVPlayer.
At the same time bytes=0- can mean practically the entire file. The proxy writes at the pace of the socket and waits for drain when backpressure builds. It stops as soon as AVPlayer closes the connection.
AVPlayer’s stall heuristic caused another delay. On a slow connection it buffered so far ahead that the first frame could take up to ten seconds. The Expo layer therefore sets waitsToMinimizeStalling to false for this source.
Android: ExoPlayer cannot reach loopback automatically
ExoPlayer receives the same URL from expo-video. The streaming model stays the same. Android blocks the request at a different layer.
Since Android API 28 cleartext HTTP is blocked by default. That also applies when the target is 127.0.0.1. The release build therefore needs a Network Security Config that allows cleartext only for 127.0.0.1 and localhost. Every other cleartext connection remains blocked.
Debug builds are deliberately different because they must reach Metro over the local network. An Expo config plugin generates a strict resource for release and a permissive resource for Debug as well as Debug Optimized.
iOS and Android receive the same proxy URL. Most of the iOS work comes from AVPlayer’s read pattern. Android adds the platform permission for local HTTP.
Cache and download remain separate
If a decrypted cache file already exists, the player uses it directly. Otherwise it receives the proxy URL. Multiple consumers share the same streaming session so that opening another screen does not fetch the same ciphertext chunks again.
The download path remains separate. Save and Share expect ciphertext and decrypt it after the full download. The proxy URL already serves plaintext. It must never enter that path.
What the proxy solves
The native player believes it is streaming an ordinary MP4. A local server is actually translating its range requests into encrypted chunks. Playback can begin without downloading the whole video first or writing a complete plaintext copy to disk. The rest of the encryption model is material for posts of its own.
More on beisammen: project page · beisammen.app