diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt index cb61cb4..7996be3 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt @@ -1,11 +1,15 @@ package xyz.cottongin.radio247.audio +import android.util.Log import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request import okhttp3.Response +import java.io.BufferedInputStream import java.io.IOException import java.io.InputStream +import java.net.Socket +import java.net.URL import java.time.Duration class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause) @@ -31,31 +35,81 @@ class StreamConnection(private val url: String) { var streamInfo: StreamInfo? = null private set private var response: Response? = null + private var rawSocket: Socket? = null fun open() { + try { + openWithOkHttp() + } catch (e: IOException) { + if (looksLikeIcyProtocolError(e)) { + Log.i(TAG, "OkHttp rejected response (likely ICY protocol), falling back to raw socket") + openWithRawSocket() + } else { + throw ConnectionFailed("Network error", e) + } + } + } + + private fun openWithOkHttp() { val request = Request.Builder() .url(url) .header("Icy-MetaData", "1") .header("User-Agent", "Radio247/1.0") .build() + val resp = client.newCall(request).execute() + if (!resp.isSuccessful) { + resp.close() + throw ConnectionFailed("HTTP ${resp.code}") + } + response = resp + metaint = resp.header("icy-metaint")?.toIntOrNull() + streamInfo = StreamInfo( + bitrate = resp.header("icy-br")?.toIntOrNull(), + ssl = url.startsWith("https", ignoreCase = true), + contentType = resp.header("Content-Type") + ) + inputStream = resp.body?.byteStream() + ?: throw ConnectionFailed("Empty response body") + } + + private fun openWithRawSocket() { try { - val resp = client.newCall(request).execute() - if (!resp.isSuccessful) { - resp.close() - throw ConnectionFailed("HTTP ${resp.code}") - } - response = resp - metaint = resp.header("icy-metaint")?.toIntOrNull() + val parsed = URL(url) + val host = parsed.host ?: throw ConnectionFailed("No host in URL") + val port = if (parsed.port != -1) parsed.port else parsed.defaultPort + val path = if (parsed.path.isNullOrEmpty()) "/" else parsed.path + + val socket = Socket(host, port) + socket.soTimeout = 30_000 + rawSocket = socket + + val out = socket.getOutputStream() + val requestStr = "GET $path HTTP/1.0\r\n" + + "Host: $host\r\n" + + "User-Agent: Radio247/1.0\r\n" + + "Icy-MetaData: 1\r\n" + + "Accept: */*\r\n" + + "\r\n" + out.write(requestStr.toByteArray(Charsets.US_ASCII)) + out.flush() + + val buffered = BufferedInputStream(socket.getInputStream(), 16384) + val headers = parseIcyHeaders(buffered) + + metaint = headers["icy-metaint"]?.toIntOrNull() streamInfo = StreamInfo( - bitrate = resp.header("icy-br")?.toIntOrNull(), + bitrate = headers["icy-br"]?.toIntOrNull(), ssl = url.startsWith("https", ignoreCase = true), - contentType = resp.header("Content-Type") + contentType = headers["content-type"] ) - inputStream = resp.body?.byteStream() - ?: throw ConnectionFailed("Empty response body") + inputStream = buffered + } catch (e: ConnectionFailed) { + closeRawSocket() + throw e } catch (e: IOException) { - throw ConnectionFailed("Network error", e) + closeRawSocket() + throw ConnectionFailed("Network error (raw socket)", e) } } @@ -66,9 +120,74 @@ class StreamConnection(private val url: String) { try { response?.close() } catch (_: IOException) {} + closeRawSocket() response = null inputStream = null metaint = null streamInfo = null } + + private fun closeRawSocket() { + try { + rawSocket?.close() + } catch (_: IOException) {} + rawSocket = null + } + + companion object { + private const val TAG = "StreamConnection" + + /** + * Shoutcast v1 servers respond with `ICY 200 OK` instead of a valid HTTP + * status line. OkHttp surfaces this as a ProtocolException. + */ + internal fun looksLikeIcyProtocolError(e: IOException): Boolean { + val msg = e.message ?: return false + return msg.contains("unexpected status line", ignoreCase = true) || + msg.contains("http/0.9", ignoreCase = true) || + e.javaClass.simpleName == "ProtocolException" + } + + /** + * Reads headers line-by-line from an ICY response until the blank line. + * Returns a map of lowercased header names to values. Throws + * [ConnectionFailed] if the status line is not `ICY 200 OK` or `HTTP/1.x 200`. + */ + internal fun parseIcyHeaders(input: InputStream): Map { + val statusLine = readLine(input) + if (!statusLine.startsWith("ICY ", ignoreCase = true) && + !statusLine.startsWith("HTTP/", ignoreCase = true) + ) { + throw ConnectionFailed("Unexpected status line: $statusLine") + } + + val headers = mutableMapOf() + while (true) { + val line = readLine(input) + if (line.isEmpty()) break + val colon = line.indexOf(':') + if (colon > 0) { + headers[line.substring(0, colon).trim().lowercase()] = + line.substring(colon + 1).trim() + } + } + return headers + } + + private fun readLine(input: InputStream): String { + val sb = StringBuilder(128) + while (true) { + val b = input.read() + if (b == -1) break + if (b == '\n'.code) break + if (b == '\r'.code) { + input.mark(1) + if (input.read() != '\n'.code) input.reset() + break + } + sb.append(b.toChar()) + } + return sb.toString() + } + } } diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/StreamConnectionTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/StreamConnectionTest.kt index 06c4dde..17bafe2 100644 --- a/app/src/test/java/xyz/cottongin/radio247/audio/StreamConnectionTest.kt +++ b/app/src/test/java/xyz/cottongin/radio247/audio/StreamConnectionTest.kt @@ -9,6 +9,8 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test +import java.io.ByteArrayInputStream +import java.net.ProtocolException class StreamConnectionTest { @@ -120,4 +122,105 @@ class StreamConnectionTest { conn.close() } + + // --- ICY protocol header parsing (Shoutcast v1 support) --- + + @Test + fun parseIcyHeaders_extracts_standard_shoutcast_headers() { + val raw = "ICY 200 OK\r\n" + + "icy-notice1:
This stream requires Winamp
\r\n" + + "icy-name:My Station\r\n" + + "icy-genre:Rock\r\n" + + "content-type:audio/mpeg\r\n" + + "icy-pub:1\r\n" + + "icy-metaint:8192\r\n" + + "icy-br:128\r\n" + + "\r\n" + + "audio bytes here" + val input = ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII)) + + val headers = StreamConnection.parseIcyHeaders(input) + + assertEquals("8192", headers["icy-metaint"]) + assertEquals("128", headers["icy-br"]) + assertEquals("audio/mpeg", headers["content-type"]) + assertEquals("My Station", headers["icy-name"]) + assertEquals("Rock", headers["icy-genre"]) + + val remaining = input.readBytes().toString(Charsets.US_ASCII) + assertEquals("audio bytes here", remaining) + } + + @Test + fun parseIcyHeaders_accepts_http_status_line() { + val raw = "HTTP/1.0 200 OK\r\n" + + "icy-metaint:16000\r\n" + + "Content-Type:audio/mpeg\r\n" + + "\r\n" + val headers = StreamConnection.parseIcyHeaders( + ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII)) + ) + assertEquals("16000", headers["icy-metaint"]) + assertEquals("audio/mpeg", headers["content-type"]) + } + + @Test(expected = ConnectionFailed::class) + fun parseIcyHeaders_rejects_garbage_status_line() { + val raw = "GARBAGE\r\n\r\n" + StreamConnection.parseIcyHeaders( + ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII)) + ) + } + + @Test + fun parseIcyHeaders_handles_missing_metaint() { + val raw = "ICY 200 OK\r\n" + + "content-type:audio/mpeg\r\n" + + "icy-br:64\r\n" + + "\r\n" + val headers = StreamConnection.parseIcyHeaders( + ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII)) + ) + assertNull(headers["icy-metaint"]) + assertEquals("64", headers["icy-br"]) + } + + @Test + fun parseIcyHeaders_handles_lf_only_line_endings() { + val raw = "ICY 200 OK\n" + + "icy-metaint:4096\n" + + "content-type:audio/mpeg\n" + + "\n" + val headers = StreamConnection.parseIcyHeaders( + ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII)) + ) + assertEquals("4096", headers["icy-metaint"]) + } + + @Test + fun looksLikeIcyProtocolError_matches_protocol_exception() { + assertTrue( + StreamConnection.looksLikeIcyProtocolError( + ProtocolException("unexpected status line: ICY 200 OK") + ) + ) + } + + @Test + fun looksLikeIcyProtocolError_matches_http09_message() { + assertTrue( + StreamConnection.looksLikeIcyProtocolError( + java.io.IOException("Received HTTP/0.9 when not allowed") + ) + ) + } + + @Test + fun looksLikeIcyProtocolError_does_not_match_unrelated_io_error() { + assertTrue( + !StreamConnection.looksLikeIcyProtocolError( + java.io.IOException("Connection reset by peer") + ) + ) + } }