Path C-7: IosSafeHttpDataSource + onPlayerError + Rust log init

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.
This commit is contained in:
Kayos 2026-05-24 14:05:57 -07:00
parent c5f029b60b
commit 69c91fdca6
5 changed files with 205 additions and 14 deletions

View file

@ -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)

View file

@ -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<String?>(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,

View file

@ -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)

View file

@ -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<String?>(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),

View file

@ -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<String, String>,
): HttpDataSource.Factory {
innerFactory.setDefaultRequestProperties(defaultRequestProperties)
return this
}
}
companion object {
const val DEFAULT_CHUNK_BYTES: Long = 1L * 1024 * 1024
}
}