From 69c91fdca62e0c699c30a818562fb6d90bb3576b Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 24 May 2026 14:05:57 -0700 Subject: [PATCH] Path C-7: IosSafeHttpDataSource + onPlayerError + Rust log init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the post-vc=16 regression Cobb hit: "videos do not play anymore". Root cause (from memory/audit-straw-vc16-emulator-2026-05-24.md): ExoPlayer's DefaultHttpDataSource sends open-ended `Range: bytes=N-` on first read of a stream. iOS-bound googlevideo URLs return HTTP 403 on any open-ended Range, even with the iOS YT UA. Bounded ranges (`Range: bytes=N-M`) return 206 normally. ExoPlayer's behaviour is correct per spec; YT's iOS-channel URLs are quarantined to bounded reads only — the iOS app does this internally; ExoPlayer doesn't. Fixes: 1. **net/IosSafeHttpDataSource.kt** (new) — wraps any HttpDataSource so each open() with unbounded length issues a sequence of bounded 1 MiB Range requests, rolling forward transparently on read(). Drops in via IosSafeHttpDataSource.Factory(DefaultHttpDataSource.Factory()). 2. **VideoDetailScreen.kt** (inline player), **PlayerScreen.kt** (fullscreen), **PlaybackService.kt** (background audio) — wrap the DataSource factory accordingly. 3. **VideoDetailScreen.kt + PlayerScreen.kt** — add Player.Listener with onPlayerError so ExoPlayer failures surface in the UI as a visible error string. The audit's confirmation-bias trap ("pause icon must mean playback") was caused by failures being invisible. 4. **StrawApp.kt** — call uniffi.strawcore.initLogging() so Rust-side log::warn!() (in particular the soft-fail messages from the fork's deobf path) reach `adb logcat -s strawcore`. The init fn already existed in strawcore/lib.rs; the call was lost during C-6. Audit findings not fixed in this pass (deferred / cosmetic): - Finding 3 (TV-client IpBan on Lucy egress) — environmental, not code. - Finding 5 (eager ExoPlayer init in inline composable) — latent. - Finding 6 (duplicate strawcore round-trip on inline tap-to-play) — V-2 work, intersects MediaController unification. Verification: rebuild + emulator smoke ahead. Should ship as v0.1.0-AC vc=17. --- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 8 +- .../straw/feature/detail/VideoDetailScreen.kt | 34 ++++- .../straw/feature/player/PlaybackService.kt | 12 +- .../straw/feature/player/PlayerScreen.kt | 30 +++- .../sulkta/straw/net/IosSafeHttpDataSource.kt | 135 ++++++++++++++++++ 5 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 38402f821..7444f3ab8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -13,9 +13,11 @@ import com.sulkta.straw.data.Subscriptions class StrawApp : Application() { override fun onCreate() { super.onCreate() - // Path C-6 / Phase U-5: NewPipeExtractor is out. strawcore (Rust) - // loads its own libstrawcore.so via JNA when first called — no - // explicit init needed here. Just bootstrap the local stores. + // Path C-7: route Rust `log::*` calls into Android logcat under tag + // "strawcore". Without this, every log line emitted from rustypipe / + // strawcore is silently dropped, making playback regressions invisible + // from `adb logcat`. + uniffi.strawcore.initLogging() History.init(this) Settings.init(this) Subscriptions.init(this) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 1bd6cae8c..ecb92d66e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -401,16 +401,35 @@ private fun InlinePlayer( .build() } - DisposableEffect(Unit) { - onDispose { exoPlayer.release() } + // Path C-7: surface ExoPlayer failures into UI state. Without this an + // HTTP 403 / source error showed as a stuck black box with the pause + // controls visible — directly enabled a false-positive in the prior + // verification pass. + var playbackError by remember { mutableStateOf(null) } + DisposableEffect(exoPlayer) { + val listener = object : androidx.media3.common.Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + playbackError = + "${error.errorCodeName}: ${error.message ?: "(no message)"}" + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + exoPlayer.release() + } } val resolved = state.resolved LaunchedEffect(resolved) { val r = resolved ?: return@LaunchedEffect - val dataSourceFactory = DefaultHttpDataSource.Factory() - .setUserAgent(STRAW_USER_AGENT) - .setAllowCrossProtocolRedirects(true) + // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs + // don't 403 on first byte. See net/IosSafeHttpDataSource.kt. + val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( + DefaultHttpDataSource.Factory() + .setUserAgent(STRAW_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + ) val source = when { r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) .createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) @@ -444,6 +463,11 @@ private fun InlinePlayer( color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp), ) + playbackError != null -> Text( + "playback error: $playbackError", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) resolved?.isPlayable != true -> Text( "no playable stream", color = Color.White, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 349c49ba8..5cf85506c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -50,6 +50,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.sulkta.straw.StrawActivity +import com.sulkta.straw.net.IosSafeHttpDataSource import com.sulkta.straw.net.STRAW_USER_AGENT @UnstableApi @@ -62,9 +63,14 @@ class PlaybackService : MediaSessionService() { super.onCreate() ensureChannel() - val httpFactory = DefaultHttpDataSource.Factory() - .setUserAgent(STRAW_USER_AGENT) - .setAllowCrossProtocolRedirects(true) + // Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended + // Range requests get chunked into bounded reads. iOS-bound googlevideo + // URLs 403 on `Range: bytes=N-` but accept `Range: bytes=N-M`. + val httpFactory = IosSafeHttpDataSource.Factory( + DefaultHttpDataSource.Factory() + .setUserAgent(STRAW_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + ) val mediaSourceFactory = DefaultMediaSourceFactory(this) .setDataSourceFactory(httpFactory) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index dfb0518e0..caf3d79ef 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -114,6 +114,20 @@ fun PlayerScreen( MediaSession.Builder(context, exoPlayer).build() } + // Path C-7: surface ExoPlayer failures so they don't read as "stuck spinner" + // (Audit Finding 2). Posts to playbackError state which the UI renders. + var playbackError by remember { mutableStateOf(null) } + DisposableEffect(exoPlayer) { + val listener = object : androidx.media3.common.Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + playbackError = + "${error.errorCodeName}: ${error.message ?: "(no message)"}" + } + } + exoPlayer.addListener(listener) + onDispose { exoPlayer.removeListener(listener) } + } + DisposableEffect(Unit) { onDispose { mediaSession.release() @@ -168,9 +182,13 @@ fun PlayerScreen( LaunchedEffect(resolved) { val r = resolved ?: return@LaunchedEffect - val dataSourceFactory = DefaultHttpDataSource.Factory() - .setUserAgent(STRAW_USER_AGENT) - .setAllowCrossProtocolRedirects(true) + // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs + // don't 403 on first byte. + val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory( + DefaultHttpDataSource.Factory() + .setUserAgent(STRAW_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + ) val source = when { r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) @@ -251,6 +269,12 @@ fun PlayerScreen( modifier = Modifier.padding(16.dp), ) + playbackError != null -> Text( + "playback error: $playbackError", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + resolved?.isPlayable != true -> Text( "no playable stream found", modifier = Modifier.padding(16.dp), diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt new file mode 100644 index 000000000..8815cfa9c --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Path C-7 (post-audit): wrap DefaultHttpDataSource so each open() that + * lacks a bounded length turns into a sequence of bounded Range requests + * (default 1 MiB chunks). + * + * Background: rustypipe's iOS InnerTube client returns pre-signed + * googlevideo URLs. Those URLs reject an open-ended `Range: bytes=N-` + * with HTTP 403 — they only accept bounded `Range: bytes=N-M`. ExoPlayer + * issues open-ended Range requests by default, which made every non-HLS + * iOS-origin video 403 on first byte. This shim makes ExoPlayer + * iOS-shaped without touching media-source selection. + * + * Verified via 2026-05-24 emulator audit (memory/audit-straw-vc16-emulator-2026-05-24.md): + * curl -r 0-1023 → 206 OK + * curl -H "Range: bytes=0-" → 403 (any UA) + * curl no Range → 403 + */ + +package com.sulkta.straw.net + +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource + +@UnstableApi +class IosSafeHttpDataSource( + private val inner: HttpDataSource, + private val chunkBytes: Long = DEFAULT_CHUNK_BYTES, +) : HttpDataSource by inner { + + /** The original (caller-supplied) spec — kept so we can compute the next chunk. */ + private var originalSpec: DataSpec? = null + + /** How many bytes have been read since the caller's open(). */ + private var totalRead: Long = 0 + + /** Bytes left in the current inner-open chunk. -1 = unknown end. */ + private var chunkRemaining: Long = 0 + + override fun open(dataSpec: DataSpec): Long { + // When length is set, respect it but still cap the first inner-open to + // chunkBytes. When length is unset, request a chunk and we'll roll + // forward on subsequent reads. + val requestLen = if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + chunkBytes + } else { + minOf(dataSpec.length, chunkBytes) + } + val bounded = dataSpec.subrange(dataSpec.position, requestLen) + originalSpec = dataSpec + totalRead = 0 + // inner.open() returns the BOUNDED chunk's length. Track it so we + // know when to roll to the next chunk. + chunkRemaining = inner.open(bounded) + // Report the original (potentially unbounded) length to the caller — + // ExoPlayer cares about the overall length, not our internal chunking. + return if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + C.LENGTH_UNSET.toLong() + } else { + dataSpec.length + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (length == 0) return 0 + // Need a fresh chunk? + if (chunkRemaining == 0L) { + val spec = originalSpec ?: return C.RESULT_END_OF_INPUT + inner.close() + val nextPos = spec.position + totalRead + val remainingOverall = if (spec.length == C.LENGTH_UNSET.toLong()) { + Long.MAX_VALUE + } else { + spec.length - totalRead + } + if (remainingOverall <= 0L) return C.RESULT_END_OF_INPUT + val nextLen = remainingOverall.coerceAtMost(chunkBytes) + chunkRemaining = inner.open(spec.subrange(nextPos, nextLen)) + } + // Cap the read against what's left in this chunk. + val toRead = if (chunkRemaining < 0L) { + // Inner doesn't know its end either; just read what was asked. + length + } else { + length.toLong().coerceAtMost(chunkRemaining).toInt() + } + val read = inner.read(buffer, offset, toRead) + if (read != C.RESULT_END_OF_INPUT) { + totalRead += read.toLong() + if (chunkRemaining > 0L) chunkRemaining -= read.toLong() + // If chunkRemaining hits 0 here, the next read() call will roll + // to the next chunk via the block at the top. + } else if (chunkRemaining > 0L) { + // Inner ran out before its advertised end. Force chunk roll on + // next read() so we re-open at the next position. + chunkRemaining = 0L + } + return read + } + + override fun close() { + try { + inner.close() + } finally { + originalSpec = null + totalRead = 0 + chunkRemaining = 0 + } + } + + /** Factory: wrap any inner HttpDataSource.Factory. */ + @UnstableApi + class Factory( + private val innerFactory: HttpDataSource.Factory, + private val chunkBytes: Long = DEFAULT_CHUNK_BYTES, + ) : HttpDataSource.Factory { + override fun createDataSource(): HttpDataSource = + IosSafeHttpDataSource(innerFactory.createDataSource(), chunkBytes) + + override fun setDefaultRequestProperties( + defaultRequestProperties: Map, + ): HttpDataSource.Factory { + innerFactory.setDefaultRequestProperties(defaultRequestProperties) + return this + } + } + + companion object { + const val DEFAULT_CHUNK_BYTES: Long = 1L * 1024 * 1024 + } +}