package dev.stave.cast import android.content.Context import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStream import java.net.Inet4Address import java.net.NetworkInterface import java.net.ServerSocket import java.net.Socket import java.net.URLDecoder import java.net.URLEncoder import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton /** * Lightweight HTTP proxy between UPnP media servers (MinimServer, etc.) * and Chromecast speaker groups. Cast group leaders make HTTP Range * requests to synchronize playback across members. When fetching the * raw UPnP URL directly, the server's responses can cause broken audio * on group members. The proxy normalizes HTTP behavior — proper * Content-Length, Accept-Ranges, Content-Range headers on every * response — so the group leader can seek and sync reliably. * * Single speakers don't need this; they handle server quirks * internally. The proxy is only used when [CastIntegration] detects * the target is a speaker group. * * Same architecture as BubbleUPnP's internal cast-proxy. */ @Singleton class CastStreamProxy @Inject constructor( @ApplicationContext private val context: Context, ) { private val tag = "EarhornProxy" private val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .build() private var server: ServerSocket? = null private var port: Int = 0 @Volatile private var running = false /** design decision 2026-09-02 (I.32 / review N.4): the proxy used to * relay ANY url= a LAN client named and serve ANY content:// URI the * app could open, from the first cast until the process died. Now every * link the app hands the receiver carries a random token; requests * without a known token get 403; and [stop] is called on cast end. */ private val streamTokens = java.util.concurrent.ConcurrentHashMap() // token -> upstream URL private val localTokens = java.util.concurrent.ConcurrentHashMap>() // token -> (content uri, mime) /** The upstream URL or content URI a proxied link stands for, or null if * the link is not one this proxy issued. Lets the resume check compare * the receiver's contentId against the phone's queue when group casts * go through tokened links (review C.3, 2026-09-02). */ fun originalFor(proxiedUrl: String): String? { val token = proxiedUrl.substringAfter("t=", "").substringBefore('&') if (token.isEmpty()) return null return streamTokens[token] ?: localTokens[token]?.first } private val rng = java.security.SecureRandom() private fun newToken(): String { val b = ByteArray(16); rng.nextBytes(b) return b.joinToString("") { "%02x".format(it) } } /** Start the proxy on a random port, bound to the LAN interface only * when it can be determined. Idempotent. */ fun start() { if (running) return val bindAddr = runCatching { getLocalIp()?.let { java.net.InetAddress.getByName(it) } }.getOrNull() val ss = if (bindAddr != null) ServerSocket(0, 50, bindAddr) else ServerSocket(0) port = ss.localPort server = ss running = true Log.i(tag, "Started on port $port (bound to ${bindAddr?.hostAddress ?: "all interfaces"})") Thread(null, { while (running && !ss.isClosed) { try { val socket = ss.accept() Thread(null, { handleConnection(socket) }, "cast-proxy-req").start() } catch (e: Exception) { if (running) Log.w(tag, "Accept error", e) } } }, "cast-proxy-accept").start() } fun stop() { running = false runCatching { server?.close() } server = null streamTokens.clear() localTokens.clear() Log.i(tag, "Stopped") } /** Wrap a UPnP resource URL so the Cast device fetches from this * proxy instead. Starts the proxy if not already running. */ fun proxyUrl(originalUrl: String): String { start() val ip = getLocalIp() ?: "127.0.0.1" val token = newToken() streamTokens[token] = originalUrl return "http://$ip:$port/stream?t=$token" } /** Create a proxy URL for a local content:// URI so the Chromecast * can stream local files over HTTP. [contentUri] is the MediaStore * content:// URI; [mimeType] is passed through as Content-Type * so the Cast receiver knows how to decode. */ fun localProxyUrl(contentUri: String, mimeType: String?): String { start() val ip = getLocalIp() ?: "127.0.0.1" val token = newToken() localTokens[token] = contentUri to (mimeType ?: "audio/mpeg") return "http://$ip:$port/local?t=$token" } private fun handleConnection(socket: Socket) { try { socket.soTimeout = 30_000 socket.sendBufferSize = 262144 // 256KB — matches the read buffer socket.tcpNoDelay = true // disable Nagle — send chunks immediately val input = BufferedReader(InputStreamReader(socket.getInputStream())) val output = socket.getOutputStream() val requestLine = input.readLine() ?: return val parts = requestLine.split(' ') val method = parts.getOrNull(0) ?: "GET" val path = parts.getOrNull(1) ?: "" // Read headers val headers = mutableMapOf() var line = input.readLine() while (line != null && line.isNotBlank()) { val colon = line.indexOf(':') if (colon > 0) { headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim() } line = input.readLine() } // Extract Range header — needed by both local and UPnP paths val rangeHeader = headers["range"] // Only links the app itself issued are served: /local?t=… and // /stream?t=…. Anything else is refused. val token = path.substringAfter("t=", "").substringBefore('&').substringBefore(' ') // ── Local file path: /local?t= ── if (path.startsWith("/local")) { val entry = localTokens[token] if (entry == null) { sendError(output, 403, "Forbidden") return } val contentUri = android.net.Uri.parse(entry.first) val contentType = entry.second Log.i(tag, "$method LOCAL $contentUri mime=$contentType range=$rangeHeader") handleLocalFile(socket, output, method, contentUri, contentType, rangeHeader) return } // ── UPnP proxy path: /stream?t= ── val originalUrl = streamTokens[token] if (originalUrl == null) { sendError(output, 403, "Forbidden") return } Log.i(tag, "$method $originalUrl range=$rangeHeader") // First: HEAD request to get Content-Length if we don't have Range // For Range requests, pass through directly val reqBuilder = Request.Builder().url(originalUrl) if (rangeHeader != null) { reqBuilder.header("Range", rangeHeader) } if (method == "HEAD") { reqBuilder.head() } val response = client.newCall(reqBuilder.build()).execute() val body = response.body val contentType = response.header("Content-Type") ?: "application/octet-stream" val contentLength = response.header("Content-Length") val contentRange = response.header("Content-Range") val acceptRanges = response.header("Accept-Ranges") ?: "bytes" val statusCode = response.code // Relay the real reason phrase (I.33: everything used to be "OK"). val statusText = response.message.ifBlank { when (statusCode) { 200 -> "OK"; 206 -> "Partial Content"; 404 -> "Not Found"; else -> "Error" } } // Build response val sb = StringBuilder() sb.append("HTTP/1.1 $statusCode $statusText\r\n") sb.append("Content-Type: $contentType\r\n") sb.append("Accept-Ranges: bytes\r\n") sb.append("Access-Control-Allow-Origin: *\r\n") if (contentLength != null) { sb.append("Content-Length: $contentLength\r\n") } if (contentRange != null) { sb.append("Content-Range: $contentRange\r\n") } sb.append("Connection: close\r\n") sb.append("\r\n") output.write(sb.toString().toByteArray()) output.flush() // Stream body for GET (not HEAD) if (method != "HEAD" && body != null) { val buf = ByteArray(32768) val bodyStream = body.byteStream() try { var read: Int while (bodyStream.read(buf).also { read = it } != -1) { output.write(buf, 0, read) } output.flush() } catch (e: Exception) { // Client disconnected — normal during seek Log.d(tag, "Stream interrupted: ${e.message}") } finally { bodyStream.close() } } body?.close() response.close() } catch (e: Exception) { Log.w(tag, "Connection error", e) } finally { runCatching { socket.close() } } } /** Serve a local content:// file to the Chromecast via HTTP. * Supports Range requests so Cast can seek within the track. */ private fun handleLocalFile( socket: Socket, output: OutputStream, method: String, contentUri: android.net.Uri, contentType: String, rangeHeader: String?, ) { try { val afd = context.contentResolver.openAssetFileDescriptor(contentUri, "r") if (afd == null) { sendError(output, 404, "File not found") return } val totalSize = afd.length // -1 if unknown val rangeStart: Long val rangeEnd: Long val statusCode: Int val statusText: String if (rangeHeader != null && totalSize > 0) { val rangeParts = rangeHeader.removePrefix("bytes=").split('-') rangeStart = rangeParts.getOrNull(0)?.toLongOrNull() ?: 0L rangeEnd = rangeParts.getOrNull(1)?.toLongOrNull() ?.coerceAtMost(totalSize - 1) ?: (totalSize - 1) statusCode = 206 statusText = "Partial Content" } else { rangeStart = 0L rangeEnd = if (totalSize > 0) totalSize - 1 else -1L statusCode = 200 statusText = "OK" } val contentLength = if (rangeEnd >= 0) rangeEnd - rangeStart + 1 else totalSize val sb = StringBuilder() sb.append("HTTP/1.1 $statusCode $statusText\r\n") sb.append("Content-Type: $contentType\r\n") sb.append("Accept-Ranges: bytes\r\n") sb.append("Access-Control-Allow-Origin: *\r\n") if (contentLength > 0) sb.append("Content-Length: $contentLength\r\n") if (statusCode == 206 && totalSize > 0) { sb.append("Content-Range: bytes $rangeStart-$rangeEnd/$totalSize\r\n") } sb.append("Connection: close\r\n\r\n") // Wrap in BufferedOutputStream — raw socket writes of small // chunks thrash the TCP stack and cause Cast buffer underruns // on high-bitrate formats like FLAC. val buffered = java.io.BufferedOutputStream(output, 262144) buffered.write(sb.toString().toByteArray()) buffered.flush() if (method == "HEAD") { afd.close() return } // Stream from the file descriptor directly (no double-open). // FileInputStream from the AFD supports efficient skip via // FileChannel.position() under the hood. val stream = afd.createInputStream() if (stream == null) { Log.w(tag, "Failed to open input stream for $contentUri") afd.close() return } stream.use { input -> // Use FileChannel.position() for O(1) seeking instead of // InputStream.skip() which reads and discards bytes — on a // 50MB FLAC file, skip(40MB) reads 40MB of data before sending // anything, causing the Cast receiver to starve. if (rangeStart > 0 && input is java.io.FileInputStream) { input.channel.position(rangeStart) } else if (rangeStart > 0) { // Fallback for non-FileInputStream (shouldn't happen // with ContentResolver but be safe) var toSkip = rangeStart while (toSkip > 0) { val skipped = input.skip(toSkip) if (skipped <= 0) break toSkip -= skipped } } // 256KB read buffer. Flush after every write so the Cast // receiver gets data continuously rather than in bursts. // Without per-write flushes, BufferedOutputStream holds // partial chunks until 256KB accumulates, creating gaps // in the TCP stream that the receiver hears as dropouts // on data-heavy FLAC passages. val buf = ByteArray(262144) var remaining = if (contentLength > 0) contentLength else Long.MAX_VALUE try { while (remaining > 0) { val toRead = minOf(buf.size.toLong(), remaining).toInt() val read = input.read(buf, 0, toRead) if (read == -1) break buffered.write(buf, 0, read) buffered.flush() remaining -= read } } catch (e: Exception) { Log.d(tag, "Local stream interrupted: ${e.message}") } } afd.close() } catch (e: Exception) { Log.w(tag, "Local file error for $contentUri", e) runCatching { sendError(output, 500, "Internal error") } } finally { runCatching { socket.close() } } } private fun sendError(output: OutputStream, code: Int, message: String) { val body = message.toByteArray() val response = "HTTP/1.1 $code $message\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: ${body.size}\r\n" + "Connection: close\r\n" + "\r\n" output.write(response.toByteArray()) output.write(body) output.flush() } /** Phone's local IPv4 address on WiFi/ethernet. */ private fun getLocalIp(): String? { try { for (iface in NetworkInterface.getNetworkInterfaces()) { if (iface.isLoopback || !iface.isUp) continue // Prefer wlan/eth interfaces val name = iface.name.lowercase() if (!name.startsWith("wlan") && !name.startsWith("eth") && !name.startsWith("en")) continue for (addr in iface.inetAddresses) { if (addr is Inet4Address && !addr.isLoopbackAddress) { return addr.hostAddress } } } // Fallback: any non-loopback IPv4 for (iface in NetworkInterface.getNetworkInterfaces()) { if (iface.isLoopback || !iface.isUp) continue for (addr in iface.inetAddresses) { if (addr is Inet4Address && !addr.isLoopbackAddress) { return addr.hostAddress } } } } catch (e: Exception) { Log.w(tag, "Failed to get local IP", e) } return null } }