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 + } +}