“The Void Never Remembers.”
That’s the tagline for VoidDrop. And it means exactly what it says: when you transfer a file using VoidDrop, no server ever holds that data. Every byte travels encrypted directly from your device to theirs over a raw WebRTC peer-to-peer connection. When the transfer is complete, the data is simply gone—never persisted, never logged.
This is the story of why I built it, how I engineered the zero-trust architecture, and the exact optimisations that took it from a proof-of-concept that dropped connections on large files to something genuinely fast and reliable.
Why Another File Transfer App?
I got tired of the status quo.
Every existing option has the same fundamental problem: your file passes through a server. AirDrop is Apple-only. Files-over-Bluetooth is painfully slow. Every web-based solution (WeTransfer, Send, etc.) has a server in the middle, which means:
- Someone else has (briefly) your data.
- There’s a log of the transfer.
- There’s a size or bandwidth limit.
- There’s a privacy policy you skimmed and didn’t read.
I wanted something where a server couldn’t see your data even if it wanted to—not because I promised privacy, but because the math makes it structurally impossible.
WebRTC gave me the primitive I needed. VoidDrop is built entirely around it.
The Zero-Trust Architecture
Here’s the core security model. It has exactly two phases:
Phase 1: Signaling (Supabase Realtime)
To establish a direct P2P connection, two devices need to first exchange SDP (session description) offers/answers and ICE candidates (network path information). This is called signaling—and it requires a server.
VoidDrop uses Supabase Realtime (WebSockets) as its signaling channel. But here’s the critical design constraint I enforced: no file data ever enters the signaling channel. Supabase sees connection metadata only. The moment the P2P tunnel is established, the signaling connection is essentially idle.
Phase 2: Transfer (WebRTC DataChannel, direct P2P)
Once the ICE negotiation completes, data flows directly device-to-device. The route is:
Sender ──[encrypted DataChannel]──► Receiver
(no intermediary)
Every single file chunk is encrypted before it enters the DataChannel. Even if someone were to intercept the raw WebRTC packets on the network (which would require controlling the network path), they’d get AES-256-GCM ciphertext with a key that was never transmitted.
The Encryption Layer: AES-256-GCM Per-Session
Each transfer session generates a fresh 256-bit AES key. This key is:
- Generated on the sender’s device using
SecureRandom. - Transmitted once, through the already-encrypted WebRTC DataChannel.
- Used by the receiver to decrypt every subsequent chunk.
Why GCM mode? Because AES-GCM is an authenticated encryption scheme—it simultaneously encrypts and produces a MAC tag. This means every successfully decrypted chunk is also verified to be unmodified. I get confidentiality and integrity in one primitive, which eliminates an entire class of bitflip/tampering attacks.
// CryptoManager.kt (simplified)
private val cipherPool = ThreadLocal.withInitial {
Cipher.getInstance("AES/GCM/NoPadding")
}
fun encrypt(key: SecretKey, plaintext: ByteArray): ByteArray {
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
val cipher = cipherPool.get()!!
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv))
val ciphertext = cipher.doFinal(plaintext)
return iv + ciphertext // [12-byte iv][ciphertext+tag]
}
The ThreadLocal on Cipher was a specific optimisation I added after profiling. Cipher instantiation is expensive. Caching one instance per thread reduced allocation overhead by roughly 40% on large file transfers, which was noticeable in CPU flamegraphs.
The Transfer Engine: 64KB Chunks, Backpressure, and Flow Control
Raw P2P over WebRTC DataChannels is not inherently reliable at scale. If you naively push a 200MB file as fast as possible, you’ll overflow the DataChannel’s internal buffer and get a connection drop. I learned this the hard way.
Why 64KB Chunks?
The chunk size is a deliberate engineering tradeoff:
- Too small (4KB): Too many IPC/encryption round trips. CPU overhead dominates.
- Too large (1MB+): DataChannel buffer fills faster than it can drain. Connection instability.
- 64KB: Lands in the sweet spot for modern WebRTC implementations. Aligns reasonably with typical MTU paths while giving the encryption layer manageable-sized plaintexts.
Backpressure Handling
The real reliability fix was implementing proper backpressure:
// FileTransferRepository.kt (simplified)
private suspend fun sendChunk(chunk: ByteArray) {
while (dataChannel.bufferedAmount > HIGH_WATERMARK) {
delay(10) // Back off: let the channel drain
}
dataChannel.send(DataChannel.Buffer(ByteBuffer.wrap(chunk), true))
}
HIGH_WATERMARK is set to 8MB. When the channel’s internal buffer exceeds this threshold, the sender explicitly pauses. This a simple but effective congestion control mechanism—and it was the single change that took VoidDrop from “drops on anything over 50MB” to “stable on 2GB+”.
Integrity: Per-Chunk SHA-256
Before sending each chunk, I compute a SHA-256 hash of the plaintext. The hash is sent alongside the ciphertext in the chunk header. On the receiver side:
- Decrypt the chunk with AES-GCM (which catches tampering).
- Independently verify the SHA-256 hash of the recovered plaintext.
Two layers of integrity. Belt and suspenders.
Clean Architecture in Practice
VoidDrop is structured around three Clean Architecture layers:
app/
├── presentation/ ← Jetpack Compose UI, ViewModel, StateFlow
├── domain/ ← Transfer models, Repository interfaces (pure Kotlin, no Android deps)
└── data/
├── WebRTCEngine.kt ← Low-level signaling, ICE, DataChannel management
├── FileTransferRepository.kt ← Chunking, encryption, flow control
└── CryptoManager.kt ← AES-GCM, ThreadLocal cipher pool
The Domain layer has zero Android framework dependencies. Every WebRTC call is behind a repository interface. This sounds like over-engineering for a side project—but it meant I could write unit tests for the chunking and encryption logic without needing a device or emulator.
Hilt/Dagger handles dependency injection throughout. CryptoManager, WebRTCEngine, and FileTransferRepository are all injected as singletons, scoped to the application lifecycle.
The UI: Material 3, Real-Time Progress
I’ll be honest—UI work is not where I naturally live. But Material 3 with Jetpack Compose made it far less painful than I expected.
Some specific choices:
- Progress updates are throttled to 5Hz. Transfer speed can hit 20–30MB/s on a local network. If I updated the progress UI on every chunk callback, I’d starve the main thread and the UI would jank. 5Hz is imperceptible to humans as “not smooth” but eliminates the CPU contention.
- “Open received file” triggers an Android Intent to the system file handler. VoidDrop doesn’t try to be a file manager; it receives the file and immediately hands control to the OS.
Performance Numbers (Real-World Tests)
| File Size | Local WiFi (same router) | Transfer Time |
|---|---|---|
| 10 MB | ~300 Mbit/s link | ~0.3s |
| 100 MB | ~300 Mbit/s link | ~3s |
| 500 MB | ~300 Mbit/s link | ~15s |
| 1 GB | ~300 Mbit/s link | ~28s |
These are direct P2P numbers. No cloud relay. No server bottleneck. Just two Android devices talking to each other at line speed.
Why Forensics Is Hard Here: Volatile Memory
This is the part that I find genuinely satisfying about the architecture — and it’s something most people don’t think about when they talk about “ephemeral” apps.
File forensics on Android is hard enough already. But the reason VoidDrop is particularly resistant to recovery isn’t just that we don’t write to disk — it’s where the data lives during transit.
Every chunk of your file passes through RAM only: volatile memory. The moment the transfer completes (or the app closes, or the process is killed), the physical memory is released back to the OS. There’s no write-back to flash storage. No temp file. No cache blob. No ContentResolver entry. The data existed in a handful of 64KB byte arrays in the heap, got decrypted, passed to the receiving layer, and then the GC collected it.
The forensic implication: RAM is volatile. Power it down and it’s gone. Even a live memory dump mid-transfer would get you AES-256-GCM ciphertext for most of the in-flight chunks — by the time you’ve set up forensic tooling, the session key has been discarded. Unlike disk artefacts (which can survive rm, survive factory resets on some devices, and be recovered with standard tools), what never touched flash storage cannot be carved from flash storage.
Compare this to how most “private” transfer apps actually work:
- They write to a temp directory (
/data/data/<package>/cache/) — recoverable with root access or an ADB backup. - They use Android’s
DownloadManager— which logs to a SQLite database. - They buffer the whole file before streaming — which means it has to land somewhere on disk first.
VoidDrop does none of that. The incoming chunks are decrypted in memory, assembled in memory, and written to the destination file exactly once — the final output that you explicitly chose to save. Nothing else ever leaves RAM.
The void never remembers — because volatile memory, by definition, can’t.
What’s Next for VoidDrop
TURN server fallback. If two devices are on different networks (different NATs), direct P2P ICE can fail. I have the turn-server/ directory stubbed in the repo—a TURN relay would preserve the connection at the cost of routing through a relay (though the encryption still holds end-to-end).
Cross-platform. VoidDrop is Android-only today. A companion iOS app or a web client (using the WebRTC browser APIs) would make it genuinely universal.
Ephemeral QR pairing. Instead of manually sharing room codes, a QR code generated on the sender’s screen that expires in 60 seconds would make pairing frictionless.
The WebRTC Signaling Flow End-to-End
WebRTC is notoriously opaque. Here’s the exact sequence VoidDrop executes before a single byte of file data moves:
- Sender opens the app, generates a random room code, subscribes to a Supabase Realtime channel with that code as the channel name.
- Receivers joins with the same room code, subscribes to the same channel.
- Sender creates an SDP Offer (describes their codec capabilities and network info) and pushes it to the Supabase channel.
- Receiver receives the Offer, creates an SDP Answer, pushes it back.
- Both sides start gathering ICE candidates — the set of all possible network paths between the two devices (local IP, STUN-resolved public IP, TURN relay if NAT traversal fails).
- ICE candidates trickle through the Supabase channel in real time as they’re discovered.
- ICE negotiation completes — the best direct path is selected. In most same-network scenarios this is the local IP path, giving full LAN speed.
- DataChannel is established. Supabase goes silent. All subsequent communication is direct P2P.
Steps 1–8 typically complete in 1–3 seconds on a local network. The Supabase channel carries less than 5KB of signaling data total for a standard connection.
The TURN Server Path
The repo contains a turn-server/ directory for a reason. When two devices are on different NATs (different home routers, one on cellular, etc.), direct ICE may fail. A TURN relay server is the fallback: both devices connect to the relay, which forwards packets between them.
The tradeoff: performance drops to whatever the TURN server’s upstream allows, instead of direct LAN speed. The security guarantee holds — traffic is still AES-256-GCM encrypted end-to-end, so the TURN server sees only ciphertext. But it’s no longer purely P2P.
For the current use case (same-network transfers), direct ICE works ~99% of the time and TURN isn’t needed. Cross-network support will require deploying a self-hosted TURN server (coturn is the standard choice).
VoidDrop is the kind of project I build because I think it should exist. Private by architecture, not by promise. Fast because the physics of P2P are just better than bouncing files off a cloud server in Virginia.
The void never remembers. That’s the point.
— Vasanth