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<String, String>() // token -> upstream URL
private val localTokens = java.util.concurrent.ConcurrentHashMap<String, Pair<String, String>>() // 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<String, String>()
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=<token> ──
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=<token> ──
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
}
}