I spent the last year building SmenaFon — a Windows desktop app that lets phone retail staff transfer photos, videos, contacts and files between customer phones over local Wi-Fi. No cloud, no USB, no app on the iPhone. Here's everything I ran into that wasn't obvious from the docs.
Why not WebRTC?
The obvious first design is WebRTC: have the phones talk directly, skip the PC entirely. I built a prototype. It worked fine on home networks and broke immediately in salon environments.
The problem is AP isolation (also called "client isolation") — a Wi-Fi router setting that prevents devices on the same network from talking directly to each other. It's on by default on most commercial-grade routers because it protects customers in shared networks. Most phone retail salons use exactly these routers.
With AP isolation, STUN-based WebRTC fails because the direct peer path is blocked. TURN works but routes data through an external server — which defeats the entire "no cloud" requirement.
Solution: a Windows PC on the same network as both phones, acting as a relay at the application layer. Both phones connect to the PC over HTTP; the PC buffers and re-serves. No external server in the data path.
The NanoHTTPD reverse-DNS surprise
The Windows agent uses NanoHTTPD — a lightweight embedded Java/Kotlin HTTP server. It worked great in development. In production, the first customer phone to connect would hang for 5–8 seconds before anything loaded.
Root cause: NanoHTTPD's default request handler calls inetAddress.getHostName() synchronously in the connection thread. On Windows, this triggers a reverse-DNS lookup for every connecting device. For phones on a local network, Windows sends the lookup to the configured DNS server, waits for the timeout (usually 5 seconds), and only then gives up and proceeds. Every. Single. Connection.
The fix is to override createClientHandler() and use the 3-argument HTTPSession constructor that skips the hostname resolution:
override fun createClientHandler(
finalAccept: Socket,
inputStream: InputStream
): ClientHandler = object : ClientHandler(inputStream, finalAccept) {
override fun run() {
// Capture real IP from socket before NanoHTTPD touches it
val realIp = runCatching {
finalAccept.inetAddress?.hostAddress
}.getOrNull() ?: "?"
remoteIpTl.set(realIp)
// ... rest of handler
}
}
The 3-arg constructor sets remoteIp to "127.0.0.1" unconditionally, which is fine because we only use it for logging — and we capture the real IP ourselves from the accepted socket before anything else runs.
iOS file access limitations
Safari on iOS has no webkitdirectory attribute support. You can't ask the user to pick a folder — only individual files. For 1,000 photos, asking users to tap each one individually is obviously a non-starter.
The solution for photos: input[type=file] with accept="image/" and multiple. This opens the system photo picker (not the file browser), which has a "Select All" button. Users tap Select All, confirm, done.
<input type="file" accept="image/" multiple id="photo-picker">
For videos: accept="video/*" with multiple works the same way. We separate photos and videos in the UI because customers often only want one or the other.
For contacts: there is no browser API for reading contacts on iOS. The only reliable path is to ask the user to export a .vcf file from the Contacts app (Share → Export vCard) and upload it. It's an extra step but it's the only step that actually works cross-platform.
For arbitrary files (documents, PDFs, etc.): input[type=file] with no accept filter works fine — iOS shows the Files app browser.
Reliable file upload: .part files and SHA-256
Salon Wi-Fi is not always stable. If a 50MB video upload drops halfway through, you don't want a corrupt half-file sitting on disk looking like it was delivered.
Every upload streams into a .part file first. We compute SHA-256 incrementally as we write:
val part = File(target.parentFile, target.name + ".part")
val md = MessageDigest.getInstance("SHA-256")
var read = 0L
part.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (read < contentLength) {
val n = body.read(buf, 0, minOf(buf.size.toLong(), contentLength - read).toInt())
if (n <= 0) break
md.update(buf, 0, n)
out.write(buf, 0, n)
read += n
}
}
After the stream ends: if bytes_read != Content-Length, delete the .part and return {error: "size_mismatch"}. If the client sent an X-Sha256 header and it doesn't match, same — delete and return {error: "sha_mismatch"}. Only on a clean finish do we rename .part to the final filename atomically.
The browser uploader checks a /api/uploaded?name=&size= probe before each file. If the server already has a file with that name and size, the upload is skipped. This gives us resume-after-network-drop for free.
Android receiver: writing to MediaStore instead of Downloads
If the receiving Android phone uses its browser to download files, they land in the Downloads folder. Users then have to manually move photos to the Gallery and import contacts. This is a bad experience.
The native Kotlin APK receiver writes directly to MediaStore:
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/SmenaFon")
}
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
)
contentResolver.openOutputStream(uri!!)?.use { out ->
response.body?.byteStream()?.copyTo(out)
}
RELATIVE_PATH requires Android 10+ (API 29). For contacts, we write the .vcf to a temp file and fire an Intent:
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(vcfUri, "text/x-vcard")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
This opens the system Contacts app with an import preview — the user taps "Import" once. Less magic than a fully silent import, but works on every Android version without requiring WRITE_CONTACTS permission upfront.
Streaming ZIP for iOS receiver
iOS Safari refuses chained programmatic downloads — it shows a "Download new file?" prompt for each one and silently aborts the rest after the first. For a transfer with 300 photos, sending 300 individual download links on iOS is a dead end.
Solution: a single /pickup/all.zip endpoint that streams a ZIP on the fly using a piped thread:
val pipe = PipedInputStream(64 * 1024)
val out = PipedOutputStream(pipe)
Thread({
ZipOutputStream(out).use { zos ->
for ((file, name) in entries) {
zos.putNextEntry(ZipEntry(name).apply { time = file.lastModified() })
file.inputStream().use { it.copyTo(zos, 64 * 1024) }
zos.closeEntry()
}
}
}, "zip-streamer").apply { isDaemon = true }.start()
return newChunkedResponse(Response.Status.OK, "application/zip", pipe)
iOS downloads the ZIP, and the Files app unzips it natively into whatever folder the user picks. Not the most seamless UX, but it's the only approach that works within Safari's constraints.
Result
SmenaFon is live at smenafon.ru. 30-day free trial for salons, no card required. The codebase covers a surprisingly wide surface area for what sounds like a simple "copy files over Wi-Fi" app — iOS sandbox restrictions, Android MediaStore API, NanoHTTPD internals and Windows networking all have their own gotchas.
Happy to go deeper on any of these topics in the comments.
Top comments (0)