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:
parent
c5f029b60b
commit
69c91fdca6
5 changed files with 205 additions and 14 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue