diff --git a/.gitignore b/.gitignore index 77d60bd..2338f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ local.properties .idea/ *.iml .kotlin/ +tests/runs/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1117f7..fb40884 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 22 - versionName = "0.8.6" + versionCode = 23 + versionName = "0.9.0" } // Release signing from environment (see ~/.keys/castarr-release.env on the @@ -51,6 +51,20 @@ android { } + // Global layout rule: everything test-related lives under tests/. + sourceSets { + getByName("test") { + java.setSrcDirs(listOf("../tests/unit")) + } + } + + testOptions { + unitTests.all { + it.reports.junitXml.outputLocation.set(file("../tests/runs/junit")) + it.reports.html.outputLocation.set(file("../tests/runs/junit-html")) + } + } + buildFeatures { compose = true buildConfig = true @@ -82,6 +96,7 @@ dependencies { implementation("org.nanohttpd:nanohttpd-websocket:2.3.1") implementation("com.google.zxing:core:3.5.3") implementation("io.coil-kt:coil-compose:2.7.0") + testImplementation("junit:junit:4.13.2") } // Full JDK for javac via toolchain (host may only have a JRE); resolved by diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb04416..4ed26d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ { state.connected = false; state.authorized = false; renderConnection(); + state.ws = null; setTimeout(connect, state.retryDelay); state.retryDelay = Math.min(state.retryDelay * 1.6, 10000); }; @@ -340,7 +341,7 @@ if (msg.token) localStorage.setItem('castarr_token', msg.token); $('tv-name').textContent = msg.device || 'TV'; if (msg.status) { state.status = msg.status; } - if (msg.channels) { state.channels = msg.channels; } + if (msg.channels) { state.channels = msg.channels.map((c, i) => { c._idx = i; return c; }); } if (msg.extras) { state.extras = msg.extras; } state.playlistUrl = msg.playlistUrl || ''; $('pair-error').textContent = ''; @@ -358,10 +359,10 @@ case 'status': state.status = msg; renderStatus(); - renderChannels(); + renderChannelsIfChanged(); break; case 'channels': - state.channels = msg.channels || []; + state.channels = (msg.channels || []).map((c, i) => { c._idx = i; return c; }); state.playlistUrl = msg.playlistUrl || ''; if (msg.extras) { state.extras = msg.extras; } renderChannels(); @@ -446,6 +447,7 @@ $('setup').classList.add('open'); return; } + list.innerHTML = ''; const favSet = new Set(state.extras.favorites || []); const favOn = state.extras.favoritesSupported && state.favOnly; const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered; @@ -462,9 +464,18 @@ }); list.appendChild(bar); } + if (!shown.length) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = favOn + ? 'Keine Favoriten — Stern auf einem Sender antippen.' + : 'Keine Sender gefunden.'; + list.appendChild(empty); + return; + } const frag = document.createDocumentFragment(); shown.slice(0, 500).forEach((c) => { - const idx = state.channels.indexOf(c); + const idx = c._idx; const btn = document.createElement('button'); btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : ''); const num = document.createElement('span'); @@ -517,12 +528,27 @@ btn.addEventListener('click', () => playChannel(c)); frag.appendChild(btn); }); - list.innerHTML = ''; list.appendChild(frag); - if (state.playlistUrl) $('playlist-input').value = state.playlistUrl; + // Never overwrite an input the user is typing in. + const pl = $('playlist-input'); + if (state.playlistUrl && document.activeElement !== pl) pl.value = state.playlistUrl; } - function renderAll() { renderStatus(); renderChannels(); } + // Rebuilding the list on every 2s status push swallowed taps that landed + // between touchstart and touchend. Only rebuild when something changed. + let listSignature = ''; + function renderChannelsIfChanged() { + const sig = [ + state.channels.length, state.searchTerm, state.favOnly, + (state.extras.favorites || []).join(','), + state.status.channel || '', + ].join('|'); + if (sig === listSignature) return; + listSignature = sig; + renderChannels(); + } + + function renderAll() { renderStatus(); listSignature = ''; renderChannels(); } // --- actions --- function playChannel(c) { @@ -583,10 +609,17 @@ $('pair-btn').addEventListener('click', () => { const code = $('code-input').value.trim(); - if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; } + if (!/^[0-9]{4}$/.test(code)) { + $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; + return; + } + // A fresh attempt must not inherit the backoff from earlier failures. + state.retryDelay = 1000; + $('pair-error').textContent = 'Verbinde…'; manualCode = code; $('pair-error').textContent = ''; - if (state.ws) state.ws.close(); else connect(); + // A closed socket ignores close(), so the retry has to reconnect. + if (state.ws && state.ws.readyState <= 1) state.ws.close(); else connect(); }); let toastTimer = null; diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index 353aa64..3652e9a 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -45,6 +45,16 @@ class AppState( /** Digits typed on the remote's number pad (channel switching). */ var digitBuffer by mutableStateOf("") + /** False when the control server could not bind its port. */ + var remoteAvailable by mutableStateOf(true) + + /** Channel to restore focus to when the list comes back (#13). */ + var lastWatched by mutableStateOf(null) + + private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private var lastStoppedUrl: String = "" + private var lastStoppedAt: Long = 0L + /** Bumped on every interaction with the visible overlay to restart the * auto-hide timer. */ var overlayPing by mutableLongStateOf(0L) @@ -139,6 +149,11 @@ class AppState( ) } + private companion object { + /** Grace period before re-opening the channel just closed. */ + const val REENTRY_GRACE_MS = 2_500L + } + fun isOnline(): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false @@ -154,17 +169,40 @@ class AppState( } fun play(channel: Channel) { + lastWatched = channel currentChannel = channel playerVisible = true + // Re-opening the very channel that was just closed can hit the + // provider before it released the previous session, which comes back + // as its "Stream Offline" still image. Give it a moment. + val sinceStop = System.currentTimeMillis() - lastStoppedAt + val sameChannel = channel.url == lastStoppedUrl + if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) { + val wait = REENTRY_GRACE_MS - sinceStop + playerState = "reconnecting" + reentryHandler.removeCallbacksAndMessages(null) + reentryHandler.postDelayed({ + player.play(channel.url, channel.name, channel.group) + }, wait) + return + } player.play(channel.url, channel.name, channel.group) } fun stopPlayback() { + reentryHandler.removeCallbacksAndMessages(null) + lastStoppedUrl = currentChannel?.url.orEmpty() + lastStoppedAt = System.currentTimeMillis() player.stop() playerVisible = false currentChannel = null } + /** Retry the failed channel without leaving the player. */ + fun retryPlayback() { + player.retryNow() + } + fun zap(direction: Int) { val list = activeChannels() if (list.isEmpty()) return @@ -187,10 +225,15 @@ class AppState( overlayVisible = true } lastNowTitle = nowTitle - if (!player.hasMedia && playerVisible) { + // Keep the player on screen while an error or reconnect is pending — + // otherwise the failure silently drops the viewer back to the list. + if (!player.hasMedia && playerVisible && + player.errorMessage == null && !player.reconnecting + ) { playerVisible = false currentChannel = null } + if (player.errorMessage != null || player.reconnecting) overlayVisible = true } /** Steps to the next audio track (D-pad friendly: one key, cycles). */ diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index d6c5740..2270cf2 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -48,6 +48,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { ) server = ControlServer(this, this) server.startServer() + state.remoteAvailable = server.running setContent { CastarrApp(state) } lifecycleScope.launch { UpdateChecker.check(state) } } @@ -115,6 +116,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener { .put("duration", player.player.duration.coerceAtLeast(0)) .put("volume", if (max > 0) vol.toDouble() / max else 0.0) .put("error", player.errorMessage ?: "") + .put("reconnecting", player.reconnecting) .put("audioTracks", org.json.JSONArray().also { arr -> state.audioTracks.forEach { arr.put(it.label) } }) diff --git a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt index 220e5bf..2f29493 100644 --- a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt +++ b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt @@ -96,17 +96,44 @@ class SourceRepository(context: Context) { } } - private fun open(url: String): HttpURLConnection = - (URL(url).openConnection() as HttpURLConnection).apply { + /** + * Only plain HTTP(S) is fetched, and redirects are followed manually so + * every hop can be checked again — a paired phone must not be able to + * point the TV at arbitrary internal services (SSRF). + */ + private fun open(url: String): HttpURLConnection { + val parsed = URL(url) + require(parsed.protocol.equals("http", true) || parsed.protocol.equals("https", true)) { + "unsupported scheme" + } + return (parsed.openConnection() as HttpURLConnection).apply { connectTimeout = 15_000 readTimeout = 60_000 instanceFollowRedirects = true } + } + /** + * Reads at most [MAX_BYTES] and gives up after [MAX_TRANSFER_MS] overall. + * `readTimeout` alone only bounds a single read, so a server trickling + * bytes could grow the buffer until the app died. + */ private fun download(url: String): String { val connection = open(url) + val deadline = System.currentTimeMillis() + MAX_TRANSFER_MS return try { - connection.inputStream.bufferedReader().use { it.readText() } + val builder = StringBuilder() + connection.inputStream.bufferedReader().use { reader -> + val buffer = CharArray(8 * 1024) + while (true) { + val read = reader.read(buffer) + if (read < 0) break + builder.append(buffer, 0, read) + require(builder.length <= MAX_BYTES) { "playlist too large" } + require(System.currentTimeMillis() < deadline) { "download timed out" } + } + } + builder.toString() } finally { connection.disconnect() } @@ -114,5 +141,7 @@ class SourceRepository(context: Context) { private companion object { const val TAG = "SourceRepository" + const val MAX_BYTES = 24 * 1024 * 1024 + const val MAX_TRANSFER_MS = 120_000L } } diff --git a/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt b/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt index d38ed82..b19dfef 100644 --- a/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt +++ b/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt @@ -3,35 +3,84 @@ package dev.castarr.tv.pairing import android.content.Context import java.net.Inet4Address import java.net.NetworkInterface +import java.security.MessageDigest import java.security.SecureRandom object Pairing { const val PORT = 8765 + private const val PREFS = "pairing" + private const val KEY_CODE = "code" + private const val KEY_TOKEN = "token" + private const val KEY_SESSIONS = "session_tokens" + private const val MAX_SESSIONS = 8 + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + /** * Four-digit pairing code, generated once and kept stable so an already * paired phone survives app restarts. Human fallback only — the QR code * carries the long token below. */ fun code(context: Context): String { - val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) - prefs.getString("code", null)?.let { return it } + prefs(context).getString(KEY_CODE, null)?.let { return it } val code = "%04d".format(SecureRandom().nextInt(10_000)) - prefs.edit().putString("code", code).apply() + prefs(context).edit().putString(KEY_CODE, code).apply() return code } /** 128-bit random token embedded in the QR code; not brute-forceable. */ fun token(context: Context): String { - val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) - prefs.getString("token", null)?.let { return it } - val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) } - val token = bytes.joinToString("") { "%02x".format(it) } - prefs.edit().putString("token", token).apply() + prefs(context).getString(KEY_TOKEN, null)?.let { return it } + val token = randomToken() + prefs(context).edit().putString(KEY_TOKEN, token).apply() return token } + /** + * Issues a separate token for a client that authenticated with the + * four-digit code. Handing out the QR token there would make the + * brute-forceable path yield the un-rotating master credential. + */ + fun newSessionToken(context: Context): String { + val token = randomToken() + val sessions = sessionTokens(context).toMutableList() + sessions.add(token) + while (sessions.size > MAX_SESSIONS) sessions.removeAt(0) + prefs(context).edit().putStringSet(KEY_SESSIONS, sessions.toSet()).apply() + return token + } + + /** Constant-time check against the QR token and all session tokens. */ + fun isValidToken(context: Context, candidate: String): Boolean { + if (candidate.isEmpty()) return false + if (constantTimeEquals(candidate, token(context))) return true + return sessionTokens(context).any { constantTimeEquals(candidate, it) } + } + + fun isValidCode(context: Context, candidate: String): Boolean = + candidate.isNotEmpty() && constantTimeEquals(candidate, code(context)) + + /** + * Drops every credential: the QR token, the code and all session tokens. + * Paired phones must scan again — the way to revoke a leaked token. + */ + fun reset(context: Context) { + prefs(context).edit().clear().apply() + } + + private fun sessionTokens(context: Context): List = + prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty() + + private fun randomToken(): String = + ByteArray(16).also { SecureRandom().nextBytes(it) } + .joinToString("") { "%02x".format(it) } + + private fun constantTimeEquals(a: String, b: String): Boolean = + MessageDigest.isEqual(a.toByteArray(), b.toByteArray()) + /** Best-guess LAN IPv4 address of this device, or null when offline. */ fun lanAddress(): String? { val candidates = runCatching { diff --git a/app/src/main/java/dev/castarr/tv/player/PlayerController.kt b/app/src/main/java/dev/castarr/tv/player/PlayerController.kt index dde6f38..df58f45 100644 --- a/app/src/main/java/dev/castarr/tv/player/PlayerController.kt +++ b/app/src/main/java/dev/castarr/tv/player/PlayerController.kt @@ -23,7 +23,10 @@ class PlayerController( ) { val player: ExoPlayer = ExoPlayer.Builder(context).build() - private val mediaSession: MediaSession = MediaSession.Builder(context, player).build() + // Explicit id: Media3 keeps a process-wide registry and throws when a + // session with the same (default, empty) id is still registered. + private val mediaSession: MediaSession = + MediaSession.Builder(context, player).setId("castarr").build() var channelName: String = "" private set @@ -33,6 +36,16 @@ class PlayerController( private set private var retryCount = 0 private var lastUrl: String = "" + private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private var pendingRetry: Runnable? = null + + /** True while a reconnect attempt is scheduled or running. */ + var reconnecting: Boolean = false + private set + + /** When the current channel was opened — used to pace re-entry. */ + var startedAt: Long = 0L + private set /** One selectable audio track of the current stream. */ data class AudioTrack(val label: String, val selected: Boolean) @@ -46,14 +59,18 @@ class PlayerController( override fun onTracksChanged(tracks: Tracks) = onChanged() override fun onPlayerError(error: PlaybackException) { - // Self-heal: silently reconnect twice before surfacing (#13). - if (retryCount < 2 && lastUrl.isNotEmpty()) { + // Self-heal with spacing: providers rate-limit reconnects, so + // three back-to-back attempts all land in the same blocked + // window and burn the budget in under a second. + if (retryCount < RETRY_DELAYS_MS.size && lastUrl.isNotEmpty()) { + val delay = RETRY_DELAYS_MS[retryCount] retryCount++ - player.setMediaItem(MediaItem.fromUri(lastUrl)) - player.prepare() - player.play() + reconnecting = true + onChanged() + scheduleRetry(delay) return } + reconnecting = false errorMessage = "unreachable" onChanged() } @@ -63,6 +80,7 @@ class PlayerController( val state: String get() = when { errorMessage != null -> "error" + reconnecting -> "reconnecting" player.playbackState == Player.STATE_BUFFERING -> "buffering" player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing" player.playbackState == Player.STATE_READY -> "paused" @@ -74,9 +92,12 @@ class PlayerController( fun play(url: String, name: String, group: String) { if (url.isEmpty()) return + cancelRetry() errorMessage = null + reconnecting = false retryCount = 0 lastUrl = url + startedAt = System.currentTimeMillis() channelName = name.ifEmpty { url } channelGroup = group val item = MediaItem.Builder() @@ -88,13 +109,45 @@ class PlayerController( player.play() } + private fun scheduleRetry(delayMs: Long) { + cancelRetry() + val runnable = Runnable { + pendingRetry = null + player.setMediaItem(MediaItem.fromUri(lastUrl)) + player.prepare() + player.play() + } + pendingRetry = runnable + retryHandler.postDelayed(runnable, delayMs) + } + + private fun cancelRetry() { + pendingRetry?.let { retryHandler.removeCallbacks(it) } + pendingRetry = null + } + + /** Retries the current channel immediately, resetting the backoff. */ + fun retryNow() { + if (lastUrl.isEmpty()) return + cancelRetry() + retryCount = 0 + errorMessage = null + reconnecting = true + onChanged() + player.setMediaItem(MediaItem.fromUri(lastUrl)) + player.prepare() + player.play() + } + fun toggle() { if (!hasMedia) return - if (player.isPlaying) player.pause() else resume() + // playWhenReady, not isPlaying: the latter is false while buffering, + // which made pausing during a stall silently do nothing. + if (player.playWhenReady) pause() else resume() } fun pause() { - if (player.isPlaying) player.pause() + player.playWhenReady = false } fun resume() { @@ -104,6 +157,8 @@ class PlayerController( } fun stop() { + cancelRetry() + reconnecting = false player.stop() player.clearMediaItems() channelName = "" @@ -158,13 +213,22 @@ class PlayerController( fun seekBy(deltaSeconds: Long) { if (!hasMedia || !player.isCurrentMediaItemSeekable) return - val target = (player.currentPosition + deltaSeconds * 1000) - .coerceIn(0, player.duration.coerceAtLeast(0)) + // An unknown duration is C.TIME_UNSET (large negative). Clamping the + // upper bound to it collapsed every seek to position 0. + val duration = player.duration + var target = (player.currentPosition + deltaSeconds * 1000).coerceAtLeast(0) + if (duration > 0) target = target.coerceAtMost(duration) player.seekTo(target) } fun release() { + cancelRetry() mediaSession.release() player.release() } + + private companion object { + /** Spacing between reconnect attempts after a stream drops. */ + val RETRY_DELAYS_MS = longArrayOf(1_000L, 3_000L, 8_000L) + } } diff --git a/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt b/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt index 00296e0..7e6dfe0 100644 --- a/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt +++ b/app/src/main/java/dev/castarr/tv/playlist/M3uParser.kt @@ -3,10 +3,24 @@ package dev.castarr.tv.playlist object M3uParser { private const val MAX_CHANNELS = 5000 + private const val BOM = '' private val attrRegex = Regex("""([\w-]+)="([^"]*)"""") + /** Thrown when the payload is not a playlist at all (HTML, JSON, …). */ + class NotAPlaylistException : IllegalArgumentException("not an M3U playlist") + + /** + * Parses an M3U playlist. Rejects payloads that carry no playlist markers + * at all — pointing the app at a 404 page used to silently replace the + * channel list with HTML fragments. + */ fun parse(content: String): List { + // A UTF-8 BOM is not whitespace, so trim() leaves it in place and the + // first line stops looking like a comment. + val text = content.trimStart(BOM) + if (!looksLikePlaylist(text)) throw NotAPlaylistException() + val channels = mutableListOf() var name = "" var group = "" @@ -14,15 +28,15 @@ object M3uParser { var tvgId = "" var pendingInfo = false - for (rawLine in content.lineSequence()) { - val line = rawLine.trim() + for (rawLine in text.lineSequence()) { + val line = rawLine.trim().trimStart(BOM) when { line.startsWith("#EXTINF", ignoreCase = true) -> { val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] } group = attrs["group-title"].orEmpty() logo = attrs["tvg-logo"].orEmpty() tvgId = attrs["tvg-id"].orEmpty() - name = line.substringAfterLast(',', "").trim() + name = displayName(line) if (name.isEmpty()) name = attrs["tvg-name"].orEmpty() pendingInfo = true } @@ -41,11 +55,43 @@ object M3uParser { } else -> { // Bare URL without #EXTINF — still a playable entry. - channels.add(Channel(line, line, "", "")) + channels.add(Channel(line, line, group, "")) if (channels.size >= MAX_CHANNELS) return channels } } } return channels } + + private fun looksLikePlaylist(text: String): Boolean { + val head = text.lineSequence().take(50) + return head.any { + val line = it.trim().trimStart(BOM) + line.startsWith("#EXTM3U", ignoreCase = true) || + line.startsWith("#EXTINF", ignoreCase = true) + } + } + + /** + * The display name is everything after the FIRST comma that follows the + * duration field — `substringAfterLast` swallowed every name containing + * a comma ("Comedy Central, HD" became "HD"). + */ + private fun displayName(extinf: String): String { + val payload = extinf.substringAfter(':', "") + val comma = firstUnquotedComma(payload) + return if (comma < 0) "" else payload.substring(comma + 1).trim() + } + + /** Commas inside attribute values (group-title="A, B") do not separate. */ + private fun firstUnquotedComma(payload: String): Int { + var inQuotes = false + payload.forEachIndexed { index, c -> + when { + c == '"' -> inQuotes = !inQuotes + c == ',' && !inQuotes -> return index + } + } + return -1 + } } diff --git a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt index b8fc82e..848f49c 100644 --- a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt +++ b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt @@ -18,7 +18,8 @@ import java.util.concurrent.Executors /** * Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML * page) over HTTP and takes playback commands over a WebSocket. A client - * authorizes itself with the four-digit pairing code from the QR code. + * authorizes itself with the token from the QR code or the four-digit + * pairing code. */ class ControlServer( private val context: Context, @@ -51,30 +52,43 @@ class ControlServer( // sends are serialized through this worker. private val sendExecutor = Executors.newSingleThreadExecutor() private val clients = CopyOnWriteArrayList() - private val pairingCode = Pairing.code(context) - private val pairingToken = Pairing.token(context) private var pingTimer: Timer? = null - // Rate limit for the human-typable 4-digit code (the QR token is not - // brute-forceable and stays exempt). - private val codeAttempts = ArrayDeque() + /** False when the port could not be bound; the app stays usable. */ + @Volatile + var running = false + private set + + // Failed authentication attempts per remote address. Counting every + // failure (not just the ones carrying a code) keeps a hostile client + // from spending someone else's budget. + private val attempts = HashMap>() @Synchronized - private fun codeAttemptAllowed(): Boolean { + private fun attemptAllowed(address: String): Boolean { val now = System.currentTimeMillis() - while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) { - codeAttempts.removeFirst() + val queue = attempts.getOrPut(address) { ArrayDeque() } + while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) { + queue.removeFirst() } - if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false - codeAttempts.addLast(now) + if (attempts.size > MAX_TRACKED_ADDRESSES) { + attempts.entries.removeAll { it.value.isEmpty() } + } + if (queue.size >= ATTEMPT_MAX) return false + queue.addLast(now) return true } fun startServer() { - start(0, true) + // A busy port must not take the whole app down — the remote is + // optional, everything else keeps working. + running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) } + .onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") } + .isSuccess + if (!running) return pingTimer = Timer("ws-ping", true).also { it.schedule(object : TimerTask() { - override fun run() = pingClients() + override fun run() = maintainClients() }, PING_INTERVAL_MS, PING_INTERVAL_MS) } } @@ -83,17 +97,41 @@ class ControlServer( pingTimer?.cancel() pingTimer = null sendExecutor.shutdown() - stop() + if (running) runCatching { stop() } + running = false } // --- HTTP --- + /** + * Rejects cross-origin upgrades. NanoWSD itself never looks at `Origin`, + * and WebSockets are exempt from the same-origin policy, so without this + * any page on the LAN could talk to the control socket. + */ + override fun serve(session: IHTTPSession): Response { + val origin = session.headers["origin"] + if (origin != null && !isOwnOrigin(origin, session.headers["host"])) { + Log.w(TAG, "rejected cross-origin request") + return newFixedLengthResponse( + Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "forbidden", + ) + } + return super.serve(session) + } + + private fun isOwnOrigin(origin: String, host: String?): Boolean { + if (host.isNullOrEmpty()) return false + return origin.equals("http://$host", ignoreCase = true) || + origin.equals("https://$host", ignoreCase = true) + } + override fun serveHttp(session: IHTTPSession): Response { return when (session.uri) { "/", "/index.html" -> { val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() } newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply { addHeader("Cache-Control", "no-store") + addHeader("Referrer-Policy", "no-referrer") } } else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found") @@ -102,7 +140,8 @@ class ControlServer( // --- WebSocket --- - override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake) + override fun openWebSocket(handshake: IHTTPSession): WebSocket = + RemoteSocket(handshake, handshake.remoteIpAddress.orEmpty()) fun broadcast(message: JSONObject) { val payload = message.toString() @@ -139,8 +178,18 @@ class ControlServer( .put("channels", Channel.listToJson(listener.currentChannels())) .put("extras", listener.channelsExtras()) - private fun pingClients() { + /** + * Pings live clients and drops sockets that never authenticated. Without + * this an unauthenticated connection would pin a thread forever. + */ + private fun maintainClients() { + val now = System.currentTimeMillis() clients.forEach { client -> + if (!client.authorized && now - client.openedAt > HANDSHAKE_TIMEOUT_MS) { + Log.d(TAG, "dropping client that never authenticated") + client.dropSilently() + return@forEach + } try { client.ping(PING_PAYLOAD) } catch (e: IOException) { @@ -156,12 +205,16 @@ class ControlServer( mainHandler.post { listener.onClientsChanged(count, newestName) } } - inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) { + inner class RemoteSocket( + handshake: IHTTPSession, + private val remoteAddress: String, + ) : WebSocket(handshake) { @Volatile var authorized = false private set + val openedAt = System.currentTimeMillis() private var deviceName: String = "" fun trySend(payload: String) { @@ -173,7 +226,19 @@ class ControlServer( } } + fun dropSilently() { + clients.remove(this) + runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) } + } + override fun onOpen() { + // Cap concurrent sockets — a TV remote needs a handful, and an + // unbounded count is a free resource-exhaustion vector. + if (clients.size >= MAX_CLIENTS) { + Log.w(TAG, "client limit reached, refusing connection") + runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) } + return + } clients.add(this) } @@ -227,18 +292,15 @@ class ControlServer( } private fun handleHello(msg: JSONObject) { - val token = msg.optString("token") - val tokenOk = token.isNotEmpty() && token == pairingToken - val codeOk = !tokenOk && msg.optString("code").let { code -> - code.isNotEmpty() && when { - !codeAttemptAllowed() -> { - trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) - runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } - return - } - else -> code == pairingCode - } + // Every failed attempt counts against this address, whether it + // carried a token or a code. + if (!attemptAllowed(remoteAddress)) { + trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) + runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } + return } + val tokenOk = Pairing.isValidToken(context, msg.optString("token")) + val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code")) if (!tokenOk && !codeOk) { trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } @@ -246,6 +308,11 @@ class ControlServer( } authorized = true deviceName = msg.optString("name").ifEmpty { "Handy" } + // A code-authenticated client gets its own revocable token, never + // the QR token — otherwise the guessable path would yield the + // master credential. + val grantedToken = + if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context) // Build the welcome payload on the main thread — it reads the // player and UI state, which must not happen on the WS thread. post { @@ -253,7 +320,7 @@ class ControlServer( .put("type", "welcome") .put("setup", listener.setupNeeded()) .put("device", android.os.Build.MODEL) - .put("token", pairingToken) + .put("token", grantedToken) .put("status", listener.currentStatus()) .put("playlistUrl", listener.currentPlaylistUrl()) .put("channels", Channel.listToJson(listener.currentChannels())) @@ -272,8 +339,11 @@ class ControlServer( private companion object { const val TAG = "ControlServer" const val PING_INTERVAL_MS = 8_000L - const val CODE_ATTEMPT_WINDOW_MS = 60_000L - const val CODE_ATTEMPT_MAX = 5 + const val ATTEMPT_WINDOW_MS = 60_000L + const val ATTEMPT_MAX = 5 + const val MAX_TRACKED_ADDRESSES = 64 + const val MAX_CLIENTS = 8 + const val HANDSHAKE_TIMEOUT_MS = 10_000L val PING_PAYLOAD = byteArrayOf(0x6e, 0x63) } } diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index ee870b6..2a878af 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -92,9 +94,24 @@ fun LiveScreen(state: AppState) { // list at the top — which also gets scrolled up on every group change. val railFocus = remember { FocusRequester() } val listFocus = remember { FocusRequester() } + val restoreFocus = remember { FocusRequester() } val listState = rememberLazyListState() + // Index of the channel the viewer last watched, so leaving playback + // returns them to where they were instead of the top of the list. + val restoreIndex = state.lastWatched?.let { watched -> + channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 } + } + var restored by remember { mutableStateOf(false) } LaunchedEffect(state.groupFilter, state.favoritesOnly) { - listState.scrollToItem(0) + // A fresh group starts at the top; a return from playback does not. + if (restoreIndex == null) listState.scrollToItem(0) + } + LaunchedEffect(restoreIndex, channels.size) { + if (restoreIndex != null && !restored) { + listState.scrollToItem(restoreIndex) + runCatching { restoreFocus.requestFocus() } + restored = true + } } val intoList = if (channels.isEmpty()) Modifier else Modifier.focusProperties { right = listFocus } @@ -193,7 +210,11 @@ fun LiveScreen(state: AppState) { number = allChannels.indexOf(channel) + 1, modifier = Modifier .focusProperties { left = railFocus } - .then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier), + .then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier) + .then( + if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus) + else Modifier + ), nowNext = state.nowNext(channel), playing = state.currentChannel?.url == channel.url, // All rows are favorites in the favorites view — the diff --git a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt index da8d295..76e2e65 100644 --- a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -177,7 +177,8 @@ private fun Overlay(state: AppState) { val statusWord = when (state.playerState) { "paused" -> "Pausiert" "buffering" -> "Lädt…" - "error" -> "Wiedergabefehler" + "reconnecting" -> "Verbindung wird wiederhergestellt…" + "error" -> "Sender gerade nicht erreichbar" else -> null } if (statusWord != null) { @@ -282,13 +283,27 @@ private fun Overlay(state: AppState) { Spacer(Modifier.height(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, - contentDescription = if (state.playerState == "paused") "Weiter" else "Pause", - focusRequester = playFocus, - ) { state.player.toggle() } - Spacer(Modifier.width(10.dp)) - IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() } + if (state.playerState == "error") { + // One clear action instead of silently dropping back to + // the channel list. + PillButton("Erneut versuchen", focusRequester = playFocus) { + state.retryPlayback() + } + Spacer(Modifier.width(10.dp)) + IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { + state.stopPlayback() + } + } else { + IconButton( + icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, + contentDescription = if (state.playerState == "paused") "Weiter" else "Pause", + focusRequester = playFocus, + ) { state.player.toggle() } + Spacer(Modifier.width(10.dp)) + IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { + state.stopPlayback() + } + } if (state.audioTracks.size > 1) { Spacer(Modifier.width(10.dp)) val current = state.audioTracks.firstOrNull { it.selected } diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index efca763..6f30b1e 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -72,6 +72,8 @@ fun SettingsScreen(state: AppState) { val profiles by state.dispatcharr.profiles.collectAsState() var updateStatus by remember { mutableStateOf("") } var picker by remember { mutableStateOf(null) } + // Bumped on reset so the QR code and the four-digit code redraw. + var pairingEpoch by remember { mutableStateOf(0) } Row( Modifier @@ -212,9 +214,15 @@ fun SettingsScreen(state: AppState) { Column(Modifier.weight(0.75f)) { SettingsCard("Handy-Fernbedienung") { - val address = remember { Pairing.lanAddress() } - if (address != null) { - val qr = remember(address) { + val address = remember(pairingEpoch) { Pairing.lanAddress() } + if (!state.remoteAvailable) { + Text( + "Fernbedienung nicht verfügbar — Port belegt. Neustart des Fernsehers hilft meistens.", + color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) + } else if (address != null) { + val qr = remember(address, pairingEpoch) { Qr.encode( Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216"), @@ -236,6 +244,14 @@ fun SettingsScreen(state: AppState) { "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp, ) + Spacer(Modifier.height(10.dp)) + } + SettingRow( + "Kopplung zurücksetzen", + subtitle = "Neuer Code, alle Handys müssen neu scannen", + ) { + Pairing.reset(context) + pairingEpoch++ } } else { Text( diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..5a8a8de --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..59b6f37 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/unit/M3uParserTest.kt b/tests/unit/M3uParserTest.kt new file mode 100644 index 0000000..14b5547 --- /dev/null +++ b/tests/unit/M3uParserTest.kt @@ -0,0 +1,80 @@ +package dev.castarr.tv.playlist + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class M3uParserTest { + + @Test + fun `keeps commas inside display names`() { + val channels = M3uParser.parse( + """ + #EXTM3U + #EXTINF:-1 group-title="Doku",Comedy Central, HD + http://example.com/a.ts + """.trimIndent() + ) + assertEquals(1, channels.size) + assertEquals("Comedy Central, HD", channels[0].name) + assertEquals("Doku", channels[0].group) + } + + @Test + fun `commas inside attributes do not split the name`() { + val channels = M3uParser.parse( + """ + #EXTM3U + #EXTINF:-1 group-title="News, Sport" tvg-id="x",Das Erste HD + http://example.com/b.ts + """.trimIndent() + ) + assertEquals("Das Erste HD", channels[0].name) + assertEquals("News, Sport", channels[0].group) + assertEquals("x", channels[0].tvgId) + } + + @Test + fun `strips a UTF-8 BOM instead of inventing a channel`() { + val channels = M3uParser.parse( + "#EXTM3U\n#EXTINF:-1,Channel A\nhttp://example.com/a.ts\n" + ) + assertEquals(1, channels.size) + assertEquals("Channel A", channels[0].name) + } + + @Test + fun `rejects HTML instead of turning it into channels`() { + assertThrows(M3uParser.NotAPlaylistException::class.java) { + M3uParser.parse("\n\n404\n") + } + } + + @Test + fun `rejects an empty payload`() { + assertThrows(M3uParser.NotAPlaylistException::class.java) { + M3uParser.parse("") + } + } + + @Test + fun `accepts a playlist whose header is missing but has entries`() { + val channels = M3uParser.parse("#EXTINF:-1,Nur ein Sender\nhttp://example.com/c.ts") + assertEquals(1, channels.size) + assertEquals("Nur ein Sender", channels[0].name) + } + + @Test + fun `falls back to the url when the name is empty`() { + val channels = M3uParser.parse("#EXTM3U\n#EXTINF:-1,\nhttp://example.com/d.ts") + assertEquals("http://example.com/d.ts", channels[0].name) + } + + @Test + fun `carries EXTGRP over to a bare url`() { + val channels = M3uParser.parse("#EXTM3U\n#EXTGRP:Sport\nhttp://example.com/e.ts") + assertTrue(channels.isNotEmpty()) + assertEquals("Sport", channels[0].group) + } +}