Background autoplay keeps going with the screen off #32

Merged
kayos merged 9 commits from fix/autoplay-background into main 2026-10-22 20:19:23 -07:00
19 changed files with 1387 additions and 515 deletions

View file

@ -104,10 +104,6 @@ jobs:
STRAW_KEYSTORE_PASS: android
STRAW_KEY_ALIAS: androiddebugkey
STRAW_KEY_PASS: android
# Dogfood log-shipping: the app is compile-DISABLED unless this is set
# (empty ⇒ no crash handler, no network, Settings row hidden). Repo
# secret; absent on contributor forks so their builds don't phone home.
STRAW_LOGS_INGEST_TOKEN: ${{ secrets.STRAW_LOGS_INGEST_TOKEN }}
# Keep the 4-ABI cross-compile off the container rootfs.
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
# ionice idle class + low nice so the build yields disk/CPU to the

View file

@ -10,7 +10,6 @@
*/
import com.android.build.api.dsl.ApplicationExtension
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
@ -37,30 +36,6 @@ configure<ApplicationExtension> {
versionCode = STRAW_VERSION_CODE
versionName = STRAW_VERSION_NAME
resValue("string", "app_name", "Straw")
// Dogfood log-shipping token (feature/diag/LogShipper.kt). This repo
// is PUBLIC — the token must NEVER be committed, so it's injected at
// build time and defaults to "":
// 1. env var STRAW_LOGS_INGEST_TOKEN — CI: add a Forgejo Actions
// repo secret STRAW_LOGS_INGEST_TOKEN and pass it in the
// "Assemble debug APK" step's env block in
// .forgejo/workflows/build.yml.
// 2. `straw.logs.ingest.token=…` in local.properties (gitignored)
// for local dogfood builds.
// Empty token ⇒ LogShipper is fully disabled (no-op, zero network,
// no Settings row) — contributor builds neither break nor phone home.
val strawLogsToken: String = System.getenv("STRAW_LOGS_INGEST_TOKEN")
?: run {
val props = Properties()
val f = rootProject.file("local.properties")
if (f.exists()) f.inputStream().use { props.load(it) }
props.getProperty("straw.logs.ingest.token")
}
?: ""
// Escape so a pathological token can't break out of the generated
// String literal in BuildConfig.java.
val escapedToken = strawLogsToken.replace("\\", "\\\\").replace("\"", "\\\"")
buildConfigField("String", "STRAW_LOGS_TOKEN", "\"$escapedToken\"")
}
// Explicit signing so CI / release builds reuse ONE keystore instead of
@ -209,6 +184,8 @@ dependencies {
// Before this line that source set had no test framework, so it was
// never compiled or run (the note at the top of LogDumpScrubTest).
testImplementation(libs.junit)
// Virtual time and a stand-in for Dispatchers.Main in the autoplay tests.
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
}
// =============================================================================

View file

@ -21,7 +21,6 @@ import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.diag.LogShipper
import com.sulkta.straw.feature.feed.FeedRefreshScheduler
import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
@ -91,12 +90,6 @@ class StrawApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// Dogfood crash shipping — wrap the default uncaught-exception
// handler FIRST so even an init crash below gets shipped. The
// wrapper always chains to the previous handler (normal crash
// behavior is untouched) and is a no-op in builds without an
// injected STRAW_LOGS_TOKEN.
LogShipper.installCrashHandler(this)
// 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

View file

@ -332,9 +332,8 @@ class VideoDetailViewModel : ViewModel() {
if (t is CancellationException) throw t
if (_ui.value.loadedUrl != streamUrl) return@launch
// FULL scrub — this reason is rendered to the UI below (and so
// is screenshot-reachable). The always-log line seeds the
// dogfood ring with the user-visible extraction failure; the
// ring's own (lighter) scrub applies at send time.
// is screenshot-reachable). The always-log line puts the
// user-visible extraction failure in a log export.
val reason = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
)

View file

@ -1,236 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Dogfood log shipping — POST a scrubbed logcat window to Kayos's
* ingest service (logs.sulkta.com). Two triggers:
*
* * crash — a default-UncaughtExceptionHandler wrapper captures the
* log ring + the throwable's stacktrace, attempts one
* hard-capped best-effort send, then chains to the
* previously-installed handler so the process still dies
* (and Android still shows its crash dialog) normally.
* * manual — the Settings → Diagnostics "Send logs to Kayos" button.
*
* PRIVACY: every line — including the crash stacktrace — goes through
* LogDump.scrubLineDogfood before leaving the device (the lighter
* profile for our own logs.sulkta.com: it still redacts every
* credential/URL/token, but keeps bare video/channel/playlist ids as
* outage-triage signal); the server re-scrubs, but the phone is the
* primary gate. device_id is a per-install random
* UUID (no ANDROID_ID / IMEI / hardware fingerprint). And the whole
* feature only exists in builds where BuildConfig.STRAW_LOGS_TOKEN was
* injected (see strawApp/build.gradle.kts) — for everyone else
* `enabled` is a compile-time false, so R8 strips the network path and
* a contributor build cannot phone home.
*
* DELIVERY: strictly best-effort. Any failure (offline, 401/413/429,
* timeout) is swallowed; nothing here may ever crash or block the app.
*/
package com.sulkta.straw.feature.diag
import android.content.Context
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.util.LogDump
import com.sulkta.straw.util.runCatchingCancellable
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
object LogShipper {
/** Not a secret — only the bearer token is. */
private const val INGEST_URL = "https://logs.sulkta.com/ingest"
/** Server caps: 10k lines / 1 MB body. Stay well under both. */
private const val MAX_LINES = 2_000
private const val MAX_BODY_BYTES = 700_000
/** Hard cap on how long the crash path may delay process death. */
private const val CRASH_JOIN_MS = 4_000L
private const val PREFS = "straw_diag"
private const val KEY_DEVICE_ID = "device_id"
/**
* Compile-time gate: builds without an injected token have no
* shipping at all — no network, no crash handler, no Settings row.
*/
val enabled: Boolean
get() = BuildConfig.STRAW_LOGS_TOKEN.isNotEmpty()
// Short timeouts — this is telemetry, not payload. callTimeout is
// the absolute ceiling on the whole request so nothing here can
// hold a WorkManager-style wakelock pattern open.
private val http: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build()
}
@Serializable
private data class Envelope(
@SerialName("device_id") val deviceId: String,
@SerialName("app_version") val appVersion: String,
@SerialName("sent_at") val sentAt: String,
val reason: String,
val lines: List<String>,
)
/**
* Stable per-install id: `straw-<random uuid>`, minted once and
* persisted. Deliberately NOT ANDROID_ID/IMEI — it identifies an
* install for log correlation, never the hardware or the person.
* commit() (not apply()) because both call paths are already on a
* background thread and the crash path dies right after — an
* async write would lose the id and mint a new one per crash.
*/
private fun deviceId(context: Context): String {
val sp = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
sp.getString(KEY_DEVICE_ID, null)?.let { return it }
val id = "straw-${UUID.randomUUID()}"
sp.edit().putString(KEY_DEVICE_ID, id).commit()
return id
}
private fun isoNowUtc(): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date())
/**
* Manual trigger (Settings). Captures + scrubs the log ring and
* POSTs it. Returns true iff the server 2xx'd — the UI shows a
* toast either way; failures never throw.
*/
suspend fun send(context: Context, reason: String): Boolean =
withContext(Dispatchers.IO) {
if (!enabled) return@withContext false
runCatchingCancellable {
postLines(
context = context,
reason = reason,
lines = LogDump.captureScrubbedLinesBlocking(MAX_LINES),
)
}.getOrDefault(false)
}
/**
* Wrap the process-default uncaught-exception handler. Ship-then-
* chain: whatever happens in our path (including a throw), the
* previous handler ALWAYS runs, so the app still crashes/reports
* exactly as before. No-op without a token.
*/
fun installCrashHandler(context: Context) {
if (!enabled) return
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
shipCrash(appContext, throwable)
} catch (_: Throwable) {
// Never let diagnostics mask the real crash.
}
previous?.uncaughtException(thread, throwable)
}
}
@Volatile
private var crashShipStarted = false
/**
* Crash-path send. The crashing thread may be main (network there
* throws NetworkOnMainThreadException) and is about to die either
* way, so the capture+POST runs on a dedicated worker thread and
* we join with a hard cap — the death is delayed at most
* [CRASH_JOIN_MS], never hung.
*/
private fun shipCrash(context: Context, throwable: Throwable) {
// One attempt per process — also breaks recursion if the ship
// path itself crashes on another thread mid-flight.
if (crashShipStarted) return
crashShipStarted = true
val worker = Thread {
try {
val trace = StringWriter().also {
throwable.printStackTrace(PrintWriter(it))
}.toString()
.split('\n')
// Dogfood profile: this whole shipper targets our own
// logs.sulkta.com, so keep diagnostic ids while still
// scrubbing every credential/URL/token the trace may carry.
.take(400)
.map(LogDump::scrubLineDogfood)
val ring = LogDump.captureScrubbedLinesBlocking(MAX_LINES)
postLines(
context = context,
reason = "crash",
lines = ring + "--- uncaught exception ---" + trace,
)
} catch (_: Throwable) {
// Best-effort only.
}
}
worker.name = "straw-crash-ship"
worker.isDaemon = true
worker.start()
try {
worker.join(CRASH_JOIN_MS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
/**
* Build the envelope and POST. `lines` MUST already be scrubbed
* (both call paths run everything through LogDump.scrubLine).
* Trims oldest-first to stay under the server's 1 MB cap — one
* bounded request, no chunking needed at our line cap.
*/
private fun postLines(context: Context, reason: String, lines: List<String>): Boolean {
var budget = MAX_BODY_BYTES
val kept = ArrayList<String>(lines.size)
for (i in lines.indices.reversed()) {
// +8 ≈ JSON quotes/comma + escaping slack per line.
val cost = lines[i].toByteArray(Charsets.UTF_8).size + 8
if (cost > budget) break
budget -= cost
kept.add(lines[i])
}
kept.reverse()
val envelope = Envelope(
deviceId = deviceId(context),
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
sentAt = isoNowUtc(),
reason = reason,
lines = kept,
)
val body = Json.encodeToString(envelope)
.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url(INGEST_URL)
.header("Authorization", "Bearer ${BuildConfig.STRAW_LOGS_TOKEN}")
.post(body)
.build()
return http.newCall(request).execute().use { it.isSuccessful }
}
}

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* One autoplay attempt at a time, from the end of a video to the moment the next one is handed to
* the player, and whether the playback service has to stay in the foreground meanwhile.
*
* Why the hold exists. Autoplay starts when the last item ends, and finding the next video is a
* network round trip (channel list, then stream info). Media3 1.4.1 decides on its own that a
* service whose player has ended no longer needs the foreground: its rule is "play when ready, and
* READY or BUFFERING" (MediaNotificationManager.shouldRunInForeground), and ENDED is neither, so it
* calls stopForeground. By the time the next video is handed over, the service is out of the
* foreground and Media3 has to put it back. Android 12 and later do not let an app that is in the
* background start a foreground service (the platform's documented restriction; not reproduced on a
* device for this change). On the path where Media3 asks the service about its notification
* (MediaSessionService.onUpdateNotificationInternal), Media3 catches that refusal and only logs
* "Failed to start foreground", so the next video goes on playing from a service that is not in the
* foreground. The path that applies a notification once its artwork has loaded
* (MediaNotificationManager.onNotificationUpdated) starts the foreground without that catch. Both
* read in the Media3 1.4.1 source. That a device then stops that video a few seconds in is what was
* reported; the exact way the system stops it was not measured.
*
* Holding the foreground across the round trip means the service never leaves it, so the next
* video starts in a service that is already in the foreground. Media3 still repeats its start
* request on each update. Read in Android's ActiveServices (android-16.0.0_r1; an Android 14 copy
* has the same recompute rule), not measured on a device: a start request is refused when the
* service's foreground-start allowance is denied; the allowance is worked out again only while it
* is denied; and ActiveServices clears it when the service leaves the foreground or is torn down,
* on a first start long after a plain start, and on a repeated start of a short or time-limited
* service type, which mediaPlayback is not. By that reading a service that has not left the
* foreground keeps the allowance it started with, and the repeated request is not refused. The
* hold ends when the next video has been handed over (the player is
* then BUFFERING with play-when-ready set, which Media3's own rule answers yes to once its
* notification controller has seen it), or when the attempt ends without one, in which case
* [onReleasedWithoutHandOver] asks Media3 to look again so the service leaves the foreground
* straight away instead of at its next unrelated update.
*
* In production all four members are used from the main thread: [handOverContext] is the main
* dispatcher, and the main thread is where Media3 asks the service about its notification.
*/
package com.sulkta.straw.feature.player
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
class AutoplayHandoff(
private val scope: CoroutineScope,
private val handOverContext: CoroutineContext,
/** Upper bound on one attempt. A chosen bound, not a measured resolve time. */
private val timeoutMs: Long,
private val onReleasedWithoutHandOver: () -> Unit,
) {
/** True from [start] until that attempt hands a video over, gives up, or is cancelled. */
var inFlight: Boolean = false
private set
// Bumped by every start and cancel, so an older attempt that finishes late cannot release the
// hold a newer one has taken.
private var attempt = 0
private var job: Job? = null
/**
* What the service tells Media3 about the foreground. [media3Says] is Media3's own answer;
* an attempt in flight keeps the foreground even when Media3 would drop it.
*/
fun foregroundRequired(media3Says: Boolean): Boolean = media3Says || inFlight
/**
* Start an attempt, replacing any attempt still in flight. [resolve] finds the next video
* (null when there is none); [handOver] gives it to the player and returns whether it did.
* The hold is taken before this returns.
*/
fun <T : Any> start(resolve: suspend () -> T?, handOver: (T) -> Boolean) {
val mine = ++attempt
job?.cancel()
inFlight = true
job = scope.launch(handOverContext) {
var handedOver = false
try {
val next = withTimeoutOrNull(timeoutMs) { resolve() }
if (next != null) handedOver = handOver(next)
} catch (c: CancellationException) {
throw c
} catch (_: Throwable) {
// A failed lookup or hand-over ends this attempt like an empty one.
} finally {
if (mine == attempt) {
inFlight = false
job = null
if (!handedOver) onReleasedWithoutHandOver()
}
}
}
}
/** Drop any attempt in flight without asking Media3 to look again (the service is going away). */
fun cancel() {
attempt++
job?.cancel()
job = null
inFlight = false
}
}

View file

@ -486,8 +486,8 @@ private fun InlinePlayerSurface(
// FULL scrub — this is rendered in the on-screen error banner.
val scrubbed = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
playbackError = scrubbed
// Always-log so the dogfood ring keeps the user-visible
// playback failure (the ring's own scrub applies at send time).
// Always-log so a log export carries the user-visible
// playback failure.
strawLogI("StrawPlayer", "playback error: $scrubbed")
NowPlaying.clear()
}

View file

@ -50,6 +50,7 @@ import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Resume
import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.ResolvedPlayback
import com.sulkta.straw.feature.detail.resolveStreamPlayback
import com.sulkta.straw.net.IosSafeHttpDataSource
import com.sulkta.straw.net.STRAW_USER_AGENT
@ -71,6 +72,15 @@ class PlaybackService : MediaSessionService() {
private var settingsWatcherJob: Job? = null
private var resumePollJob: Job? = null
// One autoplay attempt at a time. It keeps the service in the foreground from the end of a
// video until the next one is handed over; see AutoplayHandoff for why. When an attempt ends
// without a hand-over it asks Media3 again through onUpdateNotification below.
private val autoplay = serviceAutoplayHandoff(
scope = StrawApp.globalScope,
session = { mediaSession },
updateNotification = ::onUpdateNotification,
)
override fun onCreate() {
super.onCreate()
@ -153,14 +163,6 @@ class PlaybackService : MediaSessionService() {
// press), kick off a background fetch and re-claim with the
// freshened segments. NowPlaying.claim handles the
// "same-streamUrl with fresher metadata" case via its CAS.
//
// Autoplay at end-of-queue: when STATE_ENDED fires and there's
// no next item in the queue, consult Settings.autoplayMode and
// pick a candidate. SameChannel → call channelInfo on the
// current uploader, take the first un-watched (gated on
// autoplaySkipWatched). YtRelated → would re-call streamInfo
// and pick info.related[0] but strawcore returns empty for
// related today, so it's a no-op until that lands.
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
if (item == null) return
@ -198,22 +200,20 @@ class PlaybackService : MediaSessionService() {
// covers the play-through case.
if (!isPlaying) captureResumePosition(player)
}
override fun onPlaybackStateChanged(state: Int) {
if (state != Player.STATE_ENDED) return
val mode = Settings.get().autoplayMode.value
if (mode == AutoplayMode.Off) return
// Media3 auto-advances inside the queue; we only kick
// in when the queue has truly run out. mediaItemCount
// hits 0 after the engine reports STATE_ENDED in some
// edge cases — handle both.
val atEnd = player.mediaItemCount <= 1 ||
player.currentMediaItemIndex >= player.mediaItemCount - 1
if (!atEnd) return
tryAutoplay(mode)
}
})
// Autoplay at end-of-queue: when STATE_ENDED fires and there's
// no next item in the queue, consult Settings.autoplayMode and
// pick a candidate. SameChannel: call channelInfo on the
// current uploader, take the first un-watched (gated on
// autoplaySkipWatched). YtRelated: would re-call streamInfo
// and pick info.related[0] but strawcore returns empty for
// related today, so it's a no-op until that lands.
player.startAutoplayWhenItEnds(
mode = { Settings.get().autoplayMode.value },
start = ::tryAutoplay,
)
// Periodic scrub-point write. Stays on Main so player reads are
// thread-safe; the SP write inside record() is async (apply()).
// 5s cadence is the sweet spot — finer is wasted disk churn,
@ -269,9 +269,17 @@ class PlaybackService : MediaSessionService() {
}
}
/**
* Media3 calls this on every notification update with its own answer to "must the service be
* in the foreground". An autoplay attempt in flight overrides a no, so the service does not
* leave the foreground in the gap between one video ending and the next being handed over.
*/
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
super.onUpdateNotification(session, autoplay.foregroundRequired(startInForegroundRequired))
}
private fun tryAutoplay(mode: AutoplayMode) {
val current = NowPlaying.current.value ?: return
val uploaderUrl = current.uploaderUrl
// The video this autoplay is advancing FROM. Candidate resolution
// below is a ~500ms+ network round-trip; if the user manually
// starts a different video (or another autoplay fires) in that
@ -283,30 +291,15 @@ class PlaybackService : MediaSessionService() {
// We need the channel URL for the SameChannel path; YtRelated
// re-resolves the current video's info. If we don't have what
// we need, silently bail — better than a half-baked surprise.
val controller = (mediaSession?.player as? Player) ?: return
StrawApp.globalScope.launch {
runCatchingCancellable {
val candidateUrl = withContext(Dispatchers.IO) {
pickAutoplayCandidate(mode, current.streamUrl, uploaderUrl)
} ?: return@runCatchingCancellable
// Final allowlist gate before we hit strawcore with a
// URL whose origin was the extractor. Same defense as
// VideoDetailViewModel.load. /
// HIGH-3 family — every uniffi.strawcore.* site that
// takes a user-influenced URL needs this gate.
if (!isAllowedYtUrl(candidateUrl)) return@runCatchingCancellable
val info = withContext(Dispatchers.IO) {
uniffi.strawcore.streamInfo(candidateUrl)
}
val resolved = resolveStreamPlayback(info)
withContext(Dispatchers.Main) {
// Staleness fence (see triggeredFrom above): only
// advance if the video that ended is still the current
// one. If the user started something else mid-resolve,
// bail rather than hijack it.
if (NowPlaying.current.value?.streamUrl != triggeredFrom) {
return@withContext
}
val controller = mediaSession?.player ?: return
autoplay.start(
resolve = { resolveAutoplayNext(mode, current) },
handOver = { next ->
// Staleness fence (see triggeredFrom above): only advance if the video that
// ended is still the current one and the player is still ended and set to play.
// If the user started something else, replayed it or paused mid-resolve, bail
// rather than hijack it.
handOverIfUnchanged(controller, from = triggeredFrom, nextUrl = next.streamUrl) {
// setPlayingFrom, NOT enqueueLast — at STATE_ENDED the
// just-finished video is still loaded (mediaItemCount==1),
// so enqueueLast would only append at index 1 and the
@ -315,16 +308,39 @@ class PlaybackService : MediaSessionService() {
// assumed the queue was empty). setPlayingFrom replaces +
// prepare()s + plays, which is exactly "play the next one."
controller.setPlayingFrom(
streamUrl = candidateUrl,
title = info.title,
uploader = info.uploader,
thumbnail = info.thumbnail,
resolved = resolved,
uploaderUrl = info.uploaderUrl,
streamUrl = next.streamUrl,
title = next.info.title,
uploader = next.info.uploader,
thumbnail = next.info.thumbnail,
resolved = next.resolved,
uploaderUrl = next.info.uploaderUrl,
)
}
}
}
},
)
}
/** The next video for autoplay, resolved and ready to hand to the player. */
private class AutoplayNext(
val streamUrl: String,
val info: uniffi.strawcore.StreamInfo,
val resolved: ResolvedPlayback,
)
private suspend fun resolveAutoplayNext(
mode: AutoplayMode,
current: NowPlayingItem,
): AutoplayNext? = withContext(Dispatchers.IO) {
// resolveAllowedCandidate is the final allowlist gate before strawcore sees a URL
// whose origin was the extractor (HIGH-3 family: a uniffi.strawcore.* site that takes
// a user-influenced URL needs this gate).
resolveAllowedCandidate(
pick = { pickAutoplayCandidate(mode, current.streamUrl, current.uploaderUrl) },
resolve = { candidateUrl ->
val info = uniffi.strawcore.streamInfo(candidateUrl)
AutoplayNext(candidateUrl, info, resolveStreamPlayback(info))
},
)
}
private suspend fun pickAutoplayCandidate(
@ -405,6 +421,7 @@ class PlaybackService : MediaSessionService() {
// away-without-pause case. Read before cancelling the poll
// job (the job's last tick may not have landed yet).
mediaSession?.player?.let { captureResumePosition(it) }
autoplay.cancel()
resumePollJob?.cancel()
resumePollJob = null
settingsWatcherJob?.cancel()

View file

@ -73,7 +73,6 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.feature.detail.VideoDetailViewModel
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.LogDump
import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.delay
@ -135,8 +134,8 @@ fun PlayerScreen(
// FULL scrub — this is rendered in the on-screen error banner.
val scrubbed = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
playbackError = scrubbed
// Always-log so the dogfood ring keeps the user-visible
// playback failure (the ring's own scrub applies at send time).
// Always-log so a log export carries the user-visible
// playback failure.
strawLogI("StrawPlayer", "playback error: $scrubbed")
// Also clear NowPlaying so the minibar doesn't keep
// claiming a dead session is loaded.
@ -398,7 +397,8 @@ fun SponsorBlockSkipLoop() {
continue
}
val posSec = controller.currentPosition / 1000.0
val s = pickActiveSegment(segments, posSec, skipped) ?: continue
// Skips only while NowPlaying still names cur's video: see segmentToSkip.
val s = segmentToSkip(cur.streamUrl, segments, posSec, skipped) ?: continue
strawLogI(
"StrawSb",
"skip: ${s.category} ${s.startSec}s..${s.endSec}s (pos=$posSec)",
@ -419,16 +419,3 @@ fun SponsorBlockSkipLoop() {
}
}
}
private fun pickActiveSegment(
segments: List<SbSegment>,
posSec: Double,
skipped: Set<String>,
): SbSegment? = segments.firstOrNull { s ->
val uuidNotSkipped = s.UUID == null || s.UUID !in skipped
val interval = s.endSec - s.startSec > 0.1
// Drop the prior -0.05 exclusion — combined with the loop's
// 150ms polling cadence, short SB segments could fall in the
// gap and silently skip the skip.
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec
}

View file

@ -0,0 +1,133 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The playback service's side of autoplay, kept out of PlaybackService so that a JVM test can run
* it: how the service builds its AutoplayHandoff, when a player state starts an attempt, the
* allowlist gate on the next video, and when the hand-over may go ahead. PlaybackService only
* connects these to Media3; AutoplayWiringTest checks those connections in the compiled service.
*/
package com.sulkta.straw.feature.player
import androidx.media3.common.Player
import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.util.isAllowedYtUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
/**
* How long one autoplay attempt may hold the foreground before it gives up.
*
* An attempt makes at most two strawcore extraction calls (the channel or the current video, then
* the next video), and strawcore abandons each extraction at 60 seconds (EXTRACT_TIMEOUT in
* rust/strawcore/src/runtime.rs). This bound sits past the 120 seconds two extractions can take,
* so it does not cut short a lookup strawcore is still willing to finish, except by the time the
* attempt spends outside the extractions, which is not measured. The extra 5 seconds is a chosen
* margin for that time, not a measured one.
*/
internal const val AUTOPLAY_TIMEOUT_MS = 125_000L
/**
* The handoff PlaybackService uses. Hand-overs run on the main thread, the thread the player was
* built on (ExoPlayer throws when it is used from another one), and an attempt is bounded by
* [AUTOPLAY_TIMEOUT_MS].
*
* When an attempt ends without a hand-over, [updateNotification] is called with the service's
* current [session] and false, so that Media3 decides again whether the service stays in the
* foreground, now rather than at its next unrelated update. With false, Media3 1.4.1 updates the
* notification and leaves the foreground only if no session should run in it, instead of starting
* the foreground.
*/
internal fun <S : Any> serviceAutoplayHandoff(
scope: CoroutineScope,
session: () -> S?,
updateNotification: (S, Boolean) -> Unit,
): AutoplayHandoff = AutoplayHandoff(
scope = scope,
handOverContext = Dispatchers.Main,
timeoutMs = AUTOPLAY_TIMEOUT_MS,
onReleasedWithoutHandOver = { session()?.let { updateNotification(it, false) } },
)
/**
* The mode to start an autoplay attempt with after the player reports [playbackState], or null
* for none. Only an ended player at the last item of its queue starts one (inside the queue Media3
* advances on its own), and only with autoplay on. [mode] is read only when the player has ended.
*/
internal fun autoplayModeAfter(
playbackState: Int,
mediaItemCount: Int,
currentMediaItemIndex: Int,
mode: () -> AutoplayMode,
): AutoplayMode? {
if (playbackState != Player.STATE_ENDED) return null
val m = mode()
if (m == AutoplayMode.Off) return null
// mediaItemCount can be 0 after the player reports STATE_ENDED in some edge cases; handle both.
val atEnd = mediaItemCount <= 1 || currentMediaItemIndex >= mediaItemCount - 1
return if (atEnd) m else null
}
/**
* Calls [start] with the autoplay mode each time this player ends at the last item of its queue
* with autoplay on (see [autoplayModeAfter]).
*/
internal fun Player.startAutoplayWhenItEnds(mode: () -> AutoplayMode, start: (AutoplayMode) -> Unit) {
val player = this
addListener(
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
autoplayModeAfter(
playbackState,
player.mediaItemCount,
player.currentMediaItemIndex,
mode,
)?.let(start)
}
},
)
}
/**
* The next video for autoplay: [pick] chooses its URL (null when there is none) and [resolve]
* looks it up. The URL came from the extractor, so one outside the YouTube allowlist is dropped
* before it reaches [resolve], the same gate VideoDetailViewModel.load applies before its own
* lookup.
*/
internal suspend fun <R : Any> resolveAllowedCandidate(
pick: suspend () -> String?,
resolve: suspend (String) -> R,
): R? {
val url = pick() ?: return null
if (!isAllowedYtUrl(url)) return null
return resolve(url)
}
/**
* Hands the next video to [player] through [play] if nothing has changed since the video [from]
* ended, and returns whether the player took it.
*
* Nothing has changed means NowPlaying still names [from] (nobody started another video or closed
* the player while the next one was looked up), and [player] is still ended with play-when-ready
* set. A play request from a controller (the notification, the minibar) replays an ended video:
* Media3 1.4.1 handles it with Util.handlePlayButtonAction, which seeks to the start and plays.
* Pause or stop clears one of the two. ExoPlayer leaves play-when-ready set when a video ends unless
* it was told to pause at the end of each item, which straw does not do. When something changed,
* that stands and nothing is handed over.
*
* The player took the video when NowPlaying names [nextUrl] afterwards: [play] (setPlayingFrom)
* returns early, leaving NowPlaying as it was, when the resolved playback has no stream it can
* build an item from.
*/
internal fun handOverIfUnchanged(
player: Player,
from: String,
nextUrl: String,
play: () -> Unit,
): Boolean {
if (NowPlaying.current.value?.streamUrl != from) return false
if (player.playbackState != Player.STATE_ENDED || !player.playWhenReady) return false
play()
return NowPlaying.current.value?.streamUrl == nextUrl
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The decision inside the SponsorBlock skip loop (SponsorBlockSkipLoop in PlayerScreen.kt), kept
* out of the composable so that a JVM test can run it.
*/
package com.sulkta.straw.feature.player
import com.sulkta.straw.net.SbSegment
/**
* The segment to skip at [posSec], or null.
*
* [segments] belong to the video [segmentsFor], and they are applied only while NowPlaying names
* that video, read here at the moment of the decision. The skip loop gets its item through
* collectAsStateWithLifecycle, which stops following NowPlaying while the activity is stopped, so
* with the app in the background the loop can still hold one video's segments after the player
* has moved to another one (autoplay, the queue). NowPlaying itself moves with the player: the
* service claims it for the next video before handing that video to the player, and for a queue
* item when the player moves to it.
*/
internal fun segmentToSkip(
segmentsFor: String,
segments: List<SbSegment>,
posSec: Double,
skipped: Set<String>,
): SbSegment? {
if (NowPlaying.current.value?.streamUrl != segmentsFor) return null
return segments.firstOrNull { s ->
val uuidNotSkipped = s.UUID == null || s.UUID !in skipped
val interval = s.endSec - s.startSec > 0.1
// Drop the prior -0.05 exclusion: combined with the loop's
// 150ms polling cadence, short SB segments could fall in the
// gap and silently skip the skip.
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec
}
}

View file

@ -66,7 +66,6 @@ import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.dataimport.ImportResult
import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.diag.LogShipper
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.SearchViewModel
import com.sulkta.straw.util.LogDump
@ -773,39 +772,6 @@ fun SettingsScreen() {
Text(if (logDumping) "Exporting…" else "Export logs…")
}
// Dogfood log shipping — only rendered in builds with an
// injected ingest token (LogShipper.enabled is compile-time
// false otherwise, so contributor builds show no phantom row).
if (LogShipper.enabled) {
Spacer(modifier = Modifier.height(16.dp))
Text(
"Or send the same scrubbed log dump straight to Kayos " +
"(logs.sulkta.com) — no share sheet. URLs, video ids, " +
"and anything token-shaped are redacted on-device first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
var logSending by remember { mutableStateOf(false) }
OutlinedButton(
enabled = !logSending,
onClick = {
logSending = true
scope.launch {
val ok = LogShipper.send(context, reason = "manual")
logSending = false
Toast.makeText(
context,
if (ok) "Logs sent — thanks!" else "Couldn't reach the log server",
Toast.LENGTH_LONG,
).show()
}
},
) {
Text(if (logSending) "Sending…" else "Send logs to Kayos")
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Import from NewPipe / Tubular",

View file

@ -95,38 +95,6 @@ object LogDump {
}
}
/**
* Pull recent logcat, scrub every line, keep only the newest
* [maxLines]. BLOCKING — callers must already be off the main
* thread (LogShipper runs on Dispatchers.IO; the crash handler
* runs this on its own short-lived worker thread, where a suspend
* fun can't be awaited without an event loop).
*
* Best-effort by design: a logcat exec failure returns whatever
* was read (possibly empty) instead of throwing, so the crash
* shipper can still deliver the stacktrace.
*/
fun captureScrubbedLinesBlocking(maxLines: Int): List<String> {
val ring = ArrayDeque<String>()
runCatching {
val pid = Process.myPid()
val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid")
val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start()
proc.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (ring.size == maxLines) ring.removeFirst()
ring.addLast(line)
}
}
proc.waitFor()
}
// Scrub only the retained window — cheaper than scrubbing lines
// that get dropped, and nothing raw ever leaves this function.
// This is the dogfood ingest path (LogShipper → logs.sulkta.com, our
// own infra), so use the lighter profile that keeps diagnostic ids.
return ring.map(::scrubLineDogfood)
}
/**
* Pre-redact known credential- and identity-shaped substrings
* before they hit disk or the wire. Cheap line-level pass —
@ -134,13 +102,11 @@ object LogDump {
* approach catches every documented leak vector at zero
* allocation cost.
*
* BIAS: over-redaction. This FULL profile backs the share-sheet
* export and every on-screen error string, which can be handed to a
* chooser app or screenshotted, so a false positive costs a little
* debug signal while a false negative leaks what someone watched.
* Anything URL-, token-, id-, or address-shaped goes. (The dogfood
* ingest path to our own logs.sulkta.com uses the lighter
* [scrubLineDogfood] instead — it keeps bare identifiers.)
* BIAS: over-redaction. This backs the share-sheet export and every
* on-screen error string, which can be handed to a chooser app or
* screenshotted, so a false positive costs a little debug signal
* while a false negative leaks what someone watched. Anything URL-,
* token-, id-, or address-shaped goes.
*
* Public so error-handler call sites (PlayerScreen / VideoDetail
* `playbackError`) can scrub Media3's `PlaybackException.message`
@ -148,30 +114,7 @@ object LogDump {
* request URI for HttpDataSource exceptions, which would otherwise
* be a leak via screenshot.
*/
fun scrubLine(line: String): String = scrub(line, keepIdentifiers = false)
/**
* DOGFOOD profile — for the logs.sulkta.com ingest path ONLY (our own
* infra, driven by [LogShipper]). Scrubs every real credential / PII
* vector exactly as [scrubLine] does (googlevideo URLs, bearer tokens,
* cred headers/params, signed params, URL tails, emails, high-entropy
* tokens, IPs), but KEEPS the bare YouTube identifiers — 11-char video
* ids, channel/playlist ids, schemeless youtu.be links — because those
* are the diagnostic signal we triage an outage on, and they only ever
* reach a server we run. NEVER use this for the share-sheet export or a
* string rendered to the UI; those keep the FULL [scrubLine] profile.
*/
fun scrubLineDogfood(line: String): String = scrub(line, keepIdentifiers = true)
/**
* Shared scrub core. [keepIdentifiers] = true selects the lighter
* DOGFOOD profile: it SKIPS the four identifier replacements (schemeless
* youtu.be, channel id, playlist id, bare video id) so those survive,
* while every credential / high-entropy / IP vector is still redacted in
* BOTH profiles. Step ORDER is unchanged from the original full pass, so
* `keepIdentifiers = false` is byte-for-byte the old behavior.
*/
private fun scrub(line: String, keepIdentifiers: Boolean): String {
fun scrubLine(line: String): String {
var s = line
// Pre-signed googlevideo URLs: keep the host label visible, drop
// host prefix + path + query (session-bound streaming creds).
@ -190,45 +133,24 @@ object LogDump {
// /vi/<id>/ thumbnails, search terms, etc. live.
s = URL_TAIL_RE.replace(s, "$1/<scrubbed>")
// Schemeless YT links pasted into log messages ("youtu.be/xyz").
// KEPT on the dogfood profile — the id is diagnostic signal.
if (!keepIdentifiers) {
s = SCHEMELESS_YT_RE.replace(s, "$1/<scrubbed>")
}
s = SCHEMELESS_YT_RE.replace(s, "$1/<scrubbed>")
// Emails.
s = EMAIL_RE.replace(s, "<email>")
// YouTube channel / playlist / bare video ids (watch behavior).
// KEPT on the dogfood profile — the signal we triage outages on.
if (!keepIdentifiers) {
s = CHANNEL_ID_RE.replace(s, "<channelId>")
s = PLAYLIST_ID_RE.replace(s, "<playlistId>")
// Bare 11-char video-id-shaped tokens ("dQw4w9WgXcQ") logged
// outside any URL (rustypipe/strawcore do this). Requiring a
// digit/_/- inside the run spares ordinary 11-letter words;
// real ids virtually always contain one. Pure-alpha ids are
// still caught earlier when keyed (v=…) or inside a URL.
s = VIDEO_ID_CANDIDATE_RE.replace(s) { m ->
if (m.value.any { it.isDigit() || it == '_' || it == '-' }) "<videoId>" else m.value
}
s = CHANNEL_ID_RE.replace(s, "<channelId>")
s = PLAYLIST_ID_RE.replace(s, "<playlistId>")
// Bare 11-char video-id-shaped tokens ("dQw4w9WgXcQ") logged
// outside any URL (rustypipe/strawcore do this). Requiring a
// digit/_/- inside the run spares ordinary 11-letter words;
// real ids virtually always contain one. Pure-alpha ids are
// still caught earlier when keyed (v=…) or inside a URL.
s = VIDEO_ID_CANDIDATE_RE.replace(s) { m ->
if (m.value.any { it.isDigit() || it == '_' || it == '-' }) "<videoId>" else m.value
}
// Long high-entropy runs (hashes, visitor data, unlabeled
// tokens): 20+ [A-Za-z0-9_-] chars containing a digit. Scrubbed in
// BOTH profiles — visitorData & friends are creds, not signal. On the
// dogfood profile a token that is exactly a channel/playlist id
// (UC…, PL…/UU…/LL…/RD…/OLAK5uy_…) is KEPT — those ride past the
// skipped id passes above but LONG_TOKEN would otherwise redact them,
// and they're the same triage signal as the video id we keep. Every
// other high-entropy run (visitorData, hashes) still redacts.
s = if (keepIdentifiers) {
LONG_TOKEN_RE.replace(s) { m ->
if (CHANNEL_ID_RE.matches(m.value) || PLAYLIST_ID_RE.matches(m.value)) {
m.value
} else {
"<token>"
}
}
} else {
LONG_TOKEN_RE.replace(s, "<token>")
}
// tokens): 20+ [A-Za-z0-9_-] chars containing a digit. visitorData
// and friends are creds, not signal.
s = LONG_TOKEN_RE.replace(s, "<token>")
// IP addresses (v4 + v6 — v6 patterns are shaped so threadtime
// HH:MM:SS timestamps can never match).
s = IPV4_RE.replace(s, "<ip>")

View file

@ -0,0 +1,254 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The autoplay hand-over keeps the playback service in the foreground from the end of a video until
* the next one is handed to the player, and lets go on every way an attempt can end.
*
* "Media3 says no" below stands for the player having ended: Media3 1.4.1 answers false for
* STATE_ENDED, which is what the service is told while autoplay looks up the next video.
*
* The harness dispatches the way the main thread does in production: a launched attempt runs when
* the test lets the scheduler run, never inside start(), and its timeout runs on virtual time.
*/
package com.sulkta.straw.feature.player
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AutoplayHandoffTest {
private class Harness(timeoutMs: Long = 60_000L) {
val scheduler = TestCoroutineScheduler()
val escaped = AtomicReference<Throwable?>(null)
val released = AtomicInteger(0)
val handedOver = mutableListOf<String>()
private val main = StandardTestDispatcher(scheduler)
val handoff = AutoplayHandoff(
scope = CoroutineScope(
SupervisorJob() + main + CoroutineExceptionHandler { _, t -> escaped.set(t) },
),
handOverContext = main,
timeoutMs = timeoutMs,
onReleasedWithoutHandOver = { released.incrementAndGet() },
)
fun accept(next: String): Boolean {
handedOver += next
return true
}
fun run() = scheduler.runCurrent()
}
@Test
fun theServiceStaysInTheForegroundUntilTheNextVideoIsHandedOver() {
val h = Harness()
val lookup = CompletableDeferred<String?>()
assertFalse("no attempt yet: Media3's no stands", h.handoff.foregroundRequired(false))
h.handoff.start(resolve = { lookup.await() }, handOver = h::accept)
assertTrue("the hold is taken before start returns, before the attempt runs", h.handoff.inFlight)
h.run()
assertTrue(
"while the next video is looked up the service must stay in the foreground",
h.handoff.foregroundRequired(false),
)
assertTrue(h.handedOver.isEmpty())
lookup.complete("next")
h.run()
assertEquals(listOf("next"), h.handedOver)
assertFalse("handed over: the hold is released", h.handoff.inFlight)
assertFalse(h.handoff.foregroundRequired(false))
assertEquals("a hand-over leaves the foreground to Media3, it does not ask again", 0, h.released.get())
}
@Test
fun media3SayingYesIsNeverOverridden() {
val h = Harness()
assertTrue(h.handoff.foregroundRequired(true))
val lookup = CompletableDeferred<String?>()
h.handoff.start(resolve = { lookup.await() }, handOver = h::accept)
h.run()
assertTrue(h.handoff.foregroundRequired(true))
lookup.complete("next")
h.run()
assertTrue(h.handoff.foregroundRequired(true))
}
@Test
fun anAttemptThatFindsNothingLetsTheServiceLeaveTheForeground() {
val h = Harness()
val lookup = CompletableDeferred<String?>()
h.handoff.start(resolve = { lookup.await() }, handOver = h::accept)
h.run()
lookup.complete(null)
h.run()
assertTrue(h.handedOver.isEmpty())
assertFalse(h.handoff.inFlight)
assertFalse(h.handoff.foregroundRequired(false))
assertEquals("Media3 is asked to look again once", 1, h.released.get())
}
@Test
fun theReleaseAsksMedia3AfterTheHoldIsDropped() {
// In the service the release calls onUpdateNotification, which asks foregroundRequired
// what to tell Media3. Asked while the hold was still up, it would keep the service in the
// foreground over an ended player.
val answer = AtomicReference<Boolean?>(null)
val scheduler = TestCoroutineScheduler()
val main = StandardTestDispatcher(scheduler)
lateinit var handoff: AutoplayHandoff
handoff = AutoplayHandoff(
scope = CoroutineScope(SupervisorJob() + main),
handOverContext = main,
timeoutMs = 60_000L,
onReleasedWithoutHandOver = { answer.set(handoff.foregroundRequired(false)) },
)
handoff.start(resolve = { null as String? }, handOver = { true })
scheduler.runCurrent()
assertEquals("the release must tell Media3 no", false, answer.get())
}
@Test
fun aFailedLookupLetsTheServiceLeaveTheForeground() {
val h = Harness()
val lookup = CompletableDeferred<String?>()
h.handoff.start(resolve = { lookup.await() }, handOver = h::accept)
h.run()
lookup.completeExceptionally(IllegalStateException("extractor failed"))
h.run()
assertTrue(h.handedOver.isEmpty())
assertFalse(h.handoff.inFlight)
assertEquals(1, h.released.get())
assertNull("a failed lookup ends the attempt inside the handoff", h.escaped.get())
}
@Test
fun aHandOverThatDidNotHappenLetsTheServiceLeaveTheForeground() {
// The player refuses when the user started another video meanwhile, or when the
// resolved playback had no stream to build an item from.
val h = Harness()
val lookup = CompletableDeferred<String?>()
h.handoff.start(resolve = { lookup.await() }, handOver = { false })
h.run()
lookup.complete("next")
h.run()
assertFalse(h.handoff.inFlight)
assertEquals(1, h.released.get())
}
@Test
fun aHandOverThatThrowsLetsTheServiceLeaveTheForeground() {
val h = Harness()
val lookup = CompletableDeferred<String?>()
h.handoff.start(resolve = { lookup.await() }, handOver = { throw IllegalStateException("player gone") })
h.run()
lookup.complete("next")
h.run()
assertFalse(h.handoff.inFlight)
assertEquals(1, h.released.get())
assertNull("a failed hand-over ends the attempt inside the handoff", h.escaped.get())
}
@Test
fun anAttemptGivesUpAtItsBoundAndNotBefore() {
val h = Harness(timeoutMs = 30_000L)
h.handoff.start(resolve = { awaitCancellation() }, handOver = h::accept)
h.run()
h.scheduler.advanceTimeBy(29_999L)
h.run()
assertTrue("one millisecond before the bound the attempt still holds", h.handoff.inFlight)
assertEquals(0, h.released.get())
h.scheduler.advanceTimeBy(1L)
h.run()
assertFalse("at the bound the attempt gives up", h.handoff.inFlight)
assertTrue(h.handedOver.isEmpty())
assertEquals(1, h.released.get())
}
@Test
fun aNewerAttemptKeepsTheHoldWhenTheOlderOneIsReplaced() {
val h = Harness()
val older = CompletableDeferred<String?>()
val newer = CompletableDeferred<String?>()
h.handoff.start(resolve = { older.await() }, handOver = h::accept)
h.run()
h.handoff.start(resolve = { newer.await() }, handOver = h::accept)
h.run()
assertTrue("replacing the older attempt must not release the newer one's hold", h.handoff.inFlight)
assertEquals(0, h.released.get())
older.complete("stale")
h.run()
assertTrue("the replaced attempt hands nothing over", h.handedOver.isEmpty())
assertTrue(h.handoff.inFlight)
newer.complete("next")
h.run()
assertEquals(listOf("next"), h.handedOver)
assertFalse(h.handoff.inFlight)
assertEquals(0, h.released.get())
}
@Test
fun aReplacedAttemptCannotReleaseTheNewerHoldOnADispatcherThatResumesAtOnce() {
// Production passes Dispatchers.Main, not Dispatchers.Main.immediate. A dispatcher that
// resumes at once (Unconfined here) runs the cancelled attempt's cleanup inside start's
// job.cancel(), so start must claim the newer attempt before it cancels the older one.
val released = AtomicInteger(0)
val handoff = AutoplayHandoff(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
handOverContext = Dispatchers.Unconfined,
timeoutMs = 60_000L,
onReleasedWithoutHandOver = { released.incrementAndGet() },
)
val older = CompletableDeferred<String?>()
handoff.start(resolve = { older.await() }, handOver = { true })
handoff.start(resolve = { CompletableDeferred<String?>().await() }, handOver = { true })
assertTrue(handoff.inFlight)
assertEquals(0, released.get())
}
@Test
fun cancelDropsTheHoldWithoutAskingMedia3Again() {
val h = Harness()
val lookup = CompletableDeferred<String?>()
h.handoff.start(resolve = { lookup.await() }, handOver = h::accept)
h.run()
h.handoff.cancel()
assertFalse(h.handoff.inFlight)
assertFalse(h.handoff.foregroundRequired(false))
lookup.complete("next")
h.run()
assertTrue(h.handedOver.isEmpty())
assertEquals("the service is going away; nothing to ask Media3", 0, h.released.get())
}
}

View file

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* PlaybackService is a framework service and cannot run in a JVM test, so everything it decides
* about autoplay lives in ServiceAutoplay.kt and AutoplayHandoff.kt, where ServiceAutoplayTest and
* AutoplayHandoffTest run it. What stays in the service is the connection to Media3: these checks
* read the compiled service (CompiledReach) for those connections. They see calls, not values: a
* call whose result is computed and then not used would still pass. The one exception is the
* onUpdateNotification override, where the test also follows Media3's answer through the compiled
* instructions into foregroundRequired and on to Media3.
*/
package com.sulkta.straw.feature.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AutoplayWiringTest {
private val reach = CompiledReach(AutoplayWiringTest::class.java.classLoader!!)
private val service = "com/sulkta/straw/feature/player/PlaybackService"
private val handoff = "com/sulkta/straw/feature/player/AutoplayHandoff"
private val serviceAutoplay = "com/sulkta/straw/feature/player/ServiceAutoplayKt"
private val updateNotification = "onUpdateNotification"
private val updateNotificationDesc = "(Landroidx/media3/session/MediaSession;Z)V"
private fun Set<CompiledReach.Member>.calls(owner: String, name: String, desc: String? = null) =
any { it.owner == owner && it.name == name && (desc == null || it.desc == desc) }
@Test
fun media3sForegroundDecisionGoesThroughTheHandoff() {
val r = reach.from(service, updateNotification, updateNotificationDesc)
assertTrue(
"onUpdateNotification must ask autoplay.foregroundRequired",
r.calls(handoff, "foregroundRequired"),
)
assertTrue(
"and hand the answer to Media3's own onUpdateNotification",
r.calls("androidx/media3/session/MediaSessionService", updateNotification, updateNotificationDesc),
)
// Values, not only calls: Media3's answer (the Boolean in local slot 2, read by iload_2)
// is read once and goes straight into foregroundRequired, and what Media3's own
// onUpdateNotification gets is that call's result (directly, or through a local), not a
// constant.
val foregroundRequired = CompiledReach.Member(handoff, "foregroundRequired", "(Z)Z")
val code = reach.instructions(service, updateNotification, updateNotificationDesc)
val reads = code.indices.filter { code[it].first == 0x1c }
assertEquals("Media3's answer is read once", 1, reads.size)
assertEquals(
"and handed straight to autoplay.foregroundRequired",
foregroundRequired,
code.getOrNull(reads.single() + 1)?.second,
)
val superCall = code.indexOfFirst {
it.first == 0xb7 && it.second?.owner == "androidx/media3/session/MediaSessionService"
}
val handed = code.getOrNull(superCall - 1)
assertTrue(
"Media3's onUpdateNotification gets the handoff's answer: $handed",
handed?.second == foregroundRequired || handed?.first in setOf(0x15, 0x1a, 0x1b, 0x1c, 0x1d),
)
}
@Test
fun theServiceBuildsItsHandoffWithServiceAutoplayHandoff() {
val built = reach.from(service, "<init>", "()V")
assertTrue("the service's handoff comes from serviceAutoplayHandoff", built.calls(serviceAutoplay, "serviceAutoplayHandoff"))
assertTrue(
"its release asks Media3 again through the service's own onUpdateNotification",
built.calls(service, updateNotification, updateNotificationDesc),
)
assertTrue("with the service's current session", built.calls(service, "mediaSession"))
assertFalse(
"no method of the service builds an AutoplayHandoff another way",
reach.fromAnyMethodOf(service).calls(handoff, "<init>"),
)
}
@Test
fun theEndOfAVideoStartsAnAttemptThroughTheHandoff() {
val created = reach.from(service, "onCreate", "()V")
assertTrue("onCreate listens for the end of a video", created.calls(serviceAutoplay, "startAutoplayWhenItEnds"))
assertTrue("and starts tryAutoplay with the autoplay setting", created.calls(service, "tryAutoplay"))
assertTrue(created.calls("com/sulkta/straw/data/SettingsStore", "getAutoplayMode"))
val tried = reach.from(service, "tryAutoplay", "(Lcom/sulkta/straw/data/AutoplayMode;)V")
assertTrue("tryAutoplay runs its attempt in the handoff", tried.calls(handoff, "start"))
assertTrue("the next video passes the allowlist gate", tried.calls(serviceAutoplay, "resolveAllowedCandidate"))
assertTrue("the hand-over goes through the fence", tried.calls(serviceAutoplay, "handOverIfUnchanged"))
assertTrue(
"and hands the video to the player with setPlayingFrom",
// A call with default arguments compiles to setPlayingFrom$default.
tried.any { it.owner == "com/sulkta/straw/feature/player/StrawMediaControllerKt" && it.name.startsWith("setPlayingFrom") },
)
}
@Test
fun theServiceDropsAnAttemptWhenItIsDestroyed() {
val destroyed = reach.from(service, "onDestroy", "()V")
assertTrue("onDestroy must cancel the attempt", destroyed.calls(handoff, "cancel"))
// Controls: the walk reads onDestroy (it reaches Media3's onDestroy) and is not
// everything the class touches (it does not reach start).
assertTrue(destroyed.calls("androidx/media3/session/MediaSessionService", "onDestroy", "()V"))
assertFalse(destroyed.calls(handoff, "start"))
}
}

View file

@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Reads compiled classes to say what a method reaches: the methods it calls and the fields it
* reads, plus everything reached through the methods of its own class (private helpers and
* synthetic accessors), the bodies of the lambdas it creates (invokedynamic), and the classes
* nested in its class that it instantiates (anonymous objects, function references, suspend
* lambdas). It parses only as much of the class-file format (the JVM specification, chapter 4) as
* that needs.
*
* Used where code cannot run in a JVM test because it is a framework callback. Unlike a check on
* the source text, a comment or a renamed local does not change what the compiled method calls.
*/
package com.sulkta.straw.feature.player
import java.io.ByteArrayInputStream
import java.io.DataInputStream
import java.nio.ByteBuffer
internal class CompiledReach(private val loader: ClassLoader) {
/** A method or field, by its JVM owner (slash-separated), name and descriptor. */
data class Member(val owner: String, val name: String, val desc: String)
private class ClassRef(val nameIndex: Int)
private class MemberRef(val classIndex: Int, val nameAndTypeIndex: Int)
private class NameAndType(val nameIndex: Int, val descIndex: Int)
private class MethodHandle(val referenceIndex: Int)
private class InvokeDynamic(val bootstrapIndex: Int)
private class Method(val name: String, val desc: String, val code: ByteArray?)
private class ClassFile(
val pool: Array<Any?>,
val methods: List<Method>,
val bootstrapArguments: List<List<Int>>,
)
private val classes = mutableMapOf<String, ClassFile>()
/** Every member reached from [owner].[name] with descriptor [desc]. */
fun from(owner: String, name: String, desc: String): Set<Member> {
require(load(owner).methods.any { it.name == name && it.desc == desc }) {
"$owner has no method $name$desc"
}
return walk(owner, listOf(Member(owner, name, desc)))
}
/**
* The instructions of [owner].[name] with descriptor [desc], in order: each opcode, and the
* field or method it names (getstatic to invokeinterface), else null.
*/
fun instructions(owner: String, name: String, desc: String): List<Pair<Int, Member?>> {
val cf = load(owner)
val code = cf.methods.firstOrNull { it.name == name && it.desc == desc }?.code
?: error("$owner has no method body $name$desc")
val out = mutableListOf<Pair<Int, Member?>>()
forEachInstruction(code) { op, at ->
out += op to (if (op in 0xb2..0xb9) member(cf, u2(code, at + 1)) else null)
}
return out
}
/** Every member reached from any method declared in [owner]. */
fun fromAnyMethodOf(owner: String): Set<Member> =
walk(owner, load(owner).methods.map { Member(owner, it.name, it.desc) })
private fun walk(root: String, start: List<Member>): Set<Member> {
val reached = mutableSetOf<Member>()
val seen = mutableSetOf<Member>()
val work = ArrayDeque(start)
fun follow(m: Member) {
reached += m
if (m.owner == root || m.owner.startsWith("$root$")) work += m
}
while (work.isNotEmpty()) {
val m = work.removeFirst()
if (!seen.add(m)) continue
val cf = load(m.owner)
// An inherited method has no body in this class; what it calls is not ours.
val code = cf.methods.firstOrNull { it.name == m.name && it.desc == m.desc }?.code ?: continue
forEachInstruction(code) { op, at ->
when (op) {
// getstatic, getfield, invokevirtual, invokespecial, invokestatic, invokeinterface
0xb2, 0xb4, 0xb6, 0xb7, 0xb8, 0xb9 -> follow(member(cf, u2(code, at + 1)))
// invokedynamic: the method handles among its bootstrap arguments are the
// lambda bodies and method references it creates.
0xba -> {
val indy = cf.pool[u2(code, at + 1)] as InvokeDynamic
for (arg in cf.bootstrapArguments[indy.bootstrapIndex]) {
val handle = cf.pool[arg] as? MethodHandle ?: continue
follow(member(cf, handle.referenceIndex))
}
}
// new: a class nested in the root (an anonymous object, a function reference,
// a suspend lambda) is walked whole.
0xbb -> {
val cls = className(cf, u2(code, at + 1))
if (cls.startsWith("$root$")) {
for (method in load(cls).methods) work += Member(cls, method.name, method.desc)
}
}
}
}
}
return reached
}
private fun load(name: String): ClassFile = classes.getOrPut(name) {
val bytes = loader.getResourceAsStream("$name.class")?.use { it.readBytes() }
?: error("compiled class not found on the test classpath: $name")
parse(bytes)
}
private fun className(cf: ClassFile, index: Int): String =
cf.pool[(cf.pool[index] as ClassRef).nameIndex] as String
private fun member(cf: ClassFile, index: Int): Member {
val ref = cf.pool[index] as MemberRef
val nat = cf.pool[ref.nameAndTypeIndex] as NameAndType
return Member(className(cf, ref.classIndex), cf.pool[nat.nameIndex] as String, cf.pool[nat.descIndex] as String)
}
private fun parse(bytes: ByteArray): ClassFile {
val d = DataInputStream(ByteArrayInputStream(bytes))
check(d.readInt() == 0xCAFEBABE.toInt()) { "not a class file" }
d.readUnsignedShort() // minor version
d.readUnsignedShort() // major version
val count = d.readUnsignedShort()
val pool = arrayOfNulls<Any>(count)
var i = 1
while (i < count) {
when (val tag = d.readUnsignedByte()) {
1 -> pool[i] = d.readUTF()
3, 4 -> d.readInt()
5, 6 -> {
d.readLong()
i++ // a long or double takes two entries
}
7 -> pool[i] = ClassRef(d.readUnsignedShort())
8, 16, 19, 20 -> d.readUnsignedShort()
9, 10, 11 -> pool[i] = MemberRef(d.readUnsignedShort(), d.readUnsignedShort())
12 -> pool[i] = NameAndType(d.readUnsignedShort(), d.readUnsignedShort())
15 -> {
d.readUnsignedByte() // reference kind
pool[i] = MethodHandle(d.readUnsignedShort())
}
17, 18 -> {
pool[i] = InvokeDynamic(d.readUnsignedShort())
d.readUnsignedShort() // name and type
}
else -> error("unknown constant pool tag $tag")
}
i++
}
d.readUnsignedShort() // access flags
d.readUnsignedShort() // this class
d.readUnsignedShort() // super class
repeat(d.readUnsignedShort()) { d.readUnsignedShort() } // interfaces
repeat(d.readUnsignedShort()) { // fields
repeat(3) { d.readUnsignedShort() }
repeat(d.readUnsignedShort()) { skipAttribute(d) }
}
val methods = List(d.readUnsignedShort()) {
d.readUnsignedShort() // access flags
val name = pool[d.readUnsignedShort()] as String
val desc = pool[d.readUnsignedShort()] as String
var code: ByteArray? = null
repeat(d.readUnsignedShort()) {
val attr = pool[d.readUnsignedShort()] as String
val body = ByteArray(d.readInt()).also { d.readFully(it) }
if (attr == "Code") {
// max_stack u2, max_locals u2, code_length u4, code
val length = ByteBuffer.wrap(body, 4, 4).int
code = body.copyOfRange(8, 8 + length)
}
}
Method(name, desc, code)
}
var bootstrap = emptyList<List<Int>>()
repeat(d.readUnsignedShort()) {
val attr = pool[d.readUnsignedShort()] as String
val body = ByteArray(d.readInt()).also { d.readFully(it) }
if (attr == "BootstrapMethods") {
val b = DataInputStream(ByteArrayInputStream(body))
bootstrap = List(b.readUnsignedShort()) {
b.readUnsignedShort() // the bootstrap method itself
List(b.readUnsignedShort()) { b.readUnsignedShort() }
}
}
}
return ClassFile(pool, methods, bootstrap)
}
private fun skipAttribute(d: DataInputStream) {
d.readUnsignedShort()
d.skipBytes(d.readInt())
}
private fun u2(code: ByteArray, at: Int): Int =
((code[at].toInt() and 0xff) shl 8) or (code[at + 1].toInt() and 0xff)
private fun s4(code: ByteArray, at: Int): Int = ByteBuffer.wrap(code, at, 4).int
/** Calls [visit] with each instruction's opcode and offset, stepping over operands. */
private fun forEachInstruction(code: ByteArray, visit: (Int, Int) -> Unit) {
var pc = 0
while (pc < code.size) {
val op = code[pc].toInt() and 0xff
visit(op, pc)
pc += when (op) {
0x10, 0x12, in 0x15..0x19, in 0x36..0x3a, 0xa9, 0xbc -> 2
0x11, 0x13, 0x14, 0x84, in 0x99..0xa8, in 0xb2..0xb8, 0xbb, 0xbd, 0xc0, 0xc1, 0xc6, 0xc7 -> 3
0xc5 -> 4
0xb9, 0xba, 0xc8, 0xc9 -> 5
0xc4 -> if ((code[pc + 1].toInt() and 0xff) == 0x84) 6 else 4 // wide
0xaa -> { // tableswitch: padding to a multiple of 4, default, low, high, offsets
val p = (pc + 4) and 3.inv()
val low = s4(code, p + 4)
val high = s4(code, p + 8)
p + 12 + (high - low + 1) * 4 - pc
}
0xab -> { // lookupswitch: padding, default, npairs, pairs
val p = (pc + 4) and 3.inv()
p + 8 + s4(code, p + 4) * 8 - pc
}
else -> 1
}
}
}
}

View file

@ -0,0 +1,327 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The playback service's side of autoplay (ServiceAutoplay.kt): the handoff it builds, the player
* state that starts an attempt, the allowlist gate on the next video, and the fence on the
* hand-over. Dispatchers.Main is replaced by a test dispatcher, so "runs on the main thread" here
* means "runs when the test lets that dispatcher run, on the test's own thread".
*/
package com.sulkta.straw.feature.player
import androidx.media3.common.Player
import com.sulkta.straw.data.AutoplayMode
import java.lang.reflect.Proxy
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ServiceAutoplayTest {
private val scheduler = TestCoroutineScheduler()
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher(scheduler))
NowPlaying.clear()
}
@After
fun tearDown() {
Dispatchers.resetMain()
NowPlaying.clear()
}
/** Stands for the service's MediaSession. */
private class Session
private class Built(session: Session?) {
val updates = mutableListOf<Pair<Session, Boolean>>()
val handoff = serviceAutoplayHandoff(
scope = CoroutineScope(SupervisorJob()),
session = { session },
updateNotification = { s, startInForegroundRequired -> updates += s to startInForegroundRequired },
)
}
// --- the handoff the service builds -------------------------------------------------------
@Test
fun theServicesHandOverRunsOnTheMainThread() {
val testThread = Thread.currentThread()
val ranOn = AtomicReference<Thread?>(null)
val ran = CountDownLatch(1)
val h = Built(Session()).handoff
h.start(
resolve = { "next" },
handOver = { _: String ->
ranOn.set(Thread.currentThread())
ran.countDown()
true
},
)
assertNull("nothing runs inside start: the main thread runs it later", ranOn.get())
scheduler.runCurrent()
assertTrue(ran.await(5, TimeUnit.SECONDS))
assertSame("the hand-over must run on the main thread, where the player lives", testThread, ranOn.get())
}
@Test
fun theServicesAttemptGivesUpAt125SecondsAndNotBefore() {
val session = Session()
val b = Built(session)
b.handoff.start(resolve = { awaitCancellation() }, handOver = { _: String -> true })
scheduler.runCurrent()
scheduler.advanceTimeBy(124_999L)
scheduler.runCurrent()
assertTrue(b.handoff.inFlight)
assertTrue(b.updates.isEmpty())
scheduler.advanceTimeBy(1L)
scheduler.runCurrent()
assertFalse(b.handoff.inFlight)
assertEquals(listOf(session to false), b.updates)
}
@Test
fun anAttemptThatEndsWithoutAHandOverAsksMedia3AgainWithFalse() {
val session = Session()
val b = Built(session)
b.handoff.start(resolve = { null as String? }, handOver = { true })
scheduler.runCurrent()
assertFalse(b.handoff.inFlight)
assertEquals(listOf(session to false), b.updates)
}
@Test
fun theServicesReleaseAsksMedia3AfterTheHoldIsDropped() {
// The service passes its own onUpdateNotification, which asks foregroundRequired.
val answer = AtomicReference<Boolean?>(null)
lateinit var h: AutoplayHandoff
h = serviceAutoplayHandoff(
scope = CoroutineScope(SupervisorJob()),
session = { Session() },
updateNotification = { _, startInForegroundRequired ->
answer.set(h.foregroundRequired(startInForegroundRequired))
},
)
h.start(resolve = { null as String? }, handOver = { true })
scheduler.runCurrent()
assertEquals(false, answer.get())
}
@Test
fun aHandOverLeavesMedia3Alone() {
val b = Built(Session())
b.handoff.start(resolve = { "next" }, handOver = { true })
scheduler.runCurrent()
assertFalse(b.handoff.inFlight)
assertTrue(b.updates.isEmpty())
}
@Test
fun aReleaseAfterTheSessionIsGoneAsksNothing() {
val b = Built(null)
b.handoff.start(resolve = { null as String? }, handOver = { true })
scheduler.runCurrent()
assertFalse(b.handoff.inFlight)
assertTrue(b.updates.isEmpty())
}
// --- which player state starts an attempt -------------------------------------------------
private class FakePlayer(
var playbackState: Int = Player.STATE_ENDED,
var playWhenReady: Boolean = true,
var mediaItemCount: Int = 1,
var currentMediaItemIndex: Int = 0,
) {
val listeners = mutableListOf<Player.Listener>()
val player: Player = Proxy.newProxyInstance(
Player::class.java.classLoader,
arrayOf(Player::class.java),
) { proxy, method, args ->
when (method.name) {
"getPlaybackState" -> playbackState
"getPlayWhenReady" -> playWhenReady
"getMediaItemCount" -> mediaItemCount
"getCurrentMediaItemIndex" -> currentMediaItemIndex
"addListener" -> {
listeners += args!![0] as Player.Listener
null
}
"hashCode" -> System.identityHashCode(proxy)
"equals" -> proxy === args!![0]
"toString" -> "FakePlayer"
else -> throw UnsupportedOperationException(method.name)
}
} as Player
}
@Test
fun anEndedPlayerAtTheLastItemStartsAnAttemptWithTheMode() {
assertEquals(AutoplayMode.SameChannel, autoplayModeAfter(Player.STATE_ENDED, 1, 0) { AutoplayMode.SameChannel })
assertEquals(AutoplayMode.YtRelated, autoplayModeAfter(Player.STATE_ENDED, 3, 2) { AutoplayMode.YtRelated })
assertEquals(
"the player can report no items once it has ended",
AutoplayMode.SameChannel,
autoplayModeAfter(Player.STATE_ENDED, 0, 0) { AutoplayMode.SameChannel },
)
}
@Test
fun nothingElseStartsAnAttempt() {
val reads = AtomicInteger(0)
val mode = {
reads.incrementAndGet()
AutoplayMode.SameChannel
}
for (state in listOf(Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY)) {
assertNull("state $state", autoplayModeAfter(state, 1, 0, mode))
}
assertEquals("the setting is read only when the player has ended", 0, reads.get())
assertNull("inside the queue Media3 advances itself", autoplayModeAfter(Player.STATE_ENDED, 3, 1, mode))
assertNull("autoplay off", autoplayModeAfter(Player.STATE_ENDED, 1, 0) { AutoplayMode.Off })
}
@Test
fun aPlayerThatEndsAtTheLastItemStartsAutoplay() {
val fake = FakePlayer()
val started = mutableListOf<AutoplayMode>()
fake.player.startAutoplayWhenItEnds(mode = { AutoplayMode.SameChannel }, start = { started += it })
assertEquals("one listener on the player", 1, fake.listeners.size)
fake.listeners.single().onPlaybackStateChanged(Player.STATE_READY)
assertTrue(started.isEmpty())
fake.listeners.single().onPlaybackStateChanged(Player.STATE_ENDED)
assertEquals(listOf(AutoplayMode.SameChannel), started)
fake.mediaItemCount = 2
fake.listeners.single().onPlaybackStateChanged(Player.STATE_ENDED)
assertEquals("the player's own queue position is used", listOf(AutoplayMode.SameChannel), started)
}
// --- the allowlist gate ---------------------------------------------------------------------
@Test
fun anAllowedCandidateIsLookedUp() = runBlocking {
val looked = mutableListOf<String>()
val url = "https://www.youtube.com/watch?v=abcdefghijk"
val r = resolveAllowedCandidate(pick = { url }, resolve = { u -> looked += u; "info:$u" })
assertEquals("info:$url", r)
assertEquals(listOf(url), looked)
}
@Test
fun aCandidateOutsideTheAllowlistNeverReachesTheLookup() = runBlocking {
val looked = mutableListOf<String>()
for (url in listOf("https://example.com/watch?v=abcdefghijk", "file:///sdcard/x", "not a url")) {
assertNull(url, resolveAllowedCandidate(pick = { url }, resolve = { u -> looked += u; "info" }))
}
assertTrue("nothing outside the allowlist reached the extractor: $looked", looked.isEmpty())
assertNull(resolveAllowedCandidate(pick = { null }, resolve = { u -> looked += u; "info" }))
assertTrue(looked.isEmpty())
}
// --- the fence on the hand-over -------------------------------------------------------------
private val a = "https://www.youtube.com/watch?v=aaaaaaaaaaa"
private val b = "https://www.youtube.com/watch?v=bbbbbbbbbbb"
private val c = "https://www.youtube.com/watch?v=ccccccccccc"
private fun nowPlaying(url: String) {
NowPlaying.claim(NowPlayingItem(streamUrl = url, title = "", uploader = "", thumbnail = null))
}
/** What setPlayingFrom does to NowPlaying when it can build an item. */
private fun playing(url: String): () -> Unit = { nowPlaying(url) }
@Test
fun theNextVideoIsHandedOverWhenNothingChanged() {
nowPlaying(a)
val played = AtomicInteger(0)
val took = handOverIfUnchanged(FakePlayer().player, from = a, nextUrl = b) {
played.incrementAndGet()
nowPlaying(b)
}
assertTrue(took)
assertEquals(1, played.get())
assertEquals(b, NowPlaying.current.value?.streamUrl)
}
@Test
fun anotherVideoStartedMeanwhileIsNotReplaced() {
nowPlaying(a)
nowPlaying(c) // the user started C while the next video was looked up
val played = AtomicInteger(0)
val took = handOverIfUnchanged(FakePlayer().player, from = a, nextUrl = b) {
played.incrementAndGet()
nowPlaying(b)
}
assertFalse("a stale attempt reports that nothing was handed over", took)
assertEquals("the user's video is not replaced", 0, played.get())
assertEquals(c, NowPlaying.current.value?.streamUrl)
}
@Test
fun aPlayerThatCouldNotBuildTheItemDidNotTakeIt() {
nowPlaying(a)
// setPlayingFrom returns early, before claiming NowPlaying, when it cannot build an item.
assertFalse(handOverIfUnchanged(FakePlayer().player, from = a, nextUrl = b) {})
assertTrue(handOverIfUnchanged(FakePlayer().player, from = a, nextUrl = b, play = playing(b)))
}
@Test
fun aVideoTheUserReplayedPausedOrStoppedIsNotReplaced() {
val cases = listOf(
// a controller's play on an ended player: Media3 seeks to the start and plays
"replayed" to FakePlayer(playbackState = Player.STATE_READY),
"replaying, still buffering" to FakePlayer(playbackState = Player.STATE_BUFFERING),
"replayed, then paused" to FakePlayer(playbackState = Player.STATE_READY, playWhenReady = false),
// a pause request while the player is ended
"paused at the end" to FakePlayer(playWhenReady = false),
"stopped" to FakePlayer(playbackState = Player.STATE_IDLE),
)
for ((what, fake) in cases) {
NowPlaying.clear()
nowPlaying(a)
val played = AtomicInteger(0)
val took = handOverIfUnchanged(fake.player, from = a, nextUrl = b) {
played.incrementAndGet()
nowPlaying(b)
}
assertFalse(what, took)
assertEquals("$what: the next video is not handed over", 0, played.get())
assertEquals(a, NowPlaying.current.value?.streamUrl)
}
}
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The SponsorBlock skip loop applies a video's segments only to that video. With the app in the
* background the loop can still hold the first video's segments after autoplay or the queue has
* moved the player on; skipping by them would seek the new video to the ends of the old one's
* segments, and a segment ending within half a second of the new video's end would end it.
*/
package com.sulkta.straw.feature.player
import com.sulkta.straw.net.SbSegment
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class SponsorBlockSkipTest {
private val a = "https://www.youtube.com/watch?v=aaaaaaaaaaa"
private val b = "https://www.youtube.com/watch?v=bbbbbbbbbbb"
private val sponsor = SbSegment(UUID = "u1", category = "sponsor", segment = listOf(10.0, 40.0))
private fun nowPlaying(url: String) {
NowPlaying.claim(NowPlayingItem(streamUrl = url, title = "", uploader = "", thumbnail = null))
}
@Before
fun setUp() = NowPlaying.clear()
@After
fun tearDown() = NowPlaying.clear()
@Test
fun aVideosSegmentsAreSkippedOnThatVideo() {
nowPlaying(a)
assertEquals(sponsor, segmentToSkip(a, listOf(sponsor), posSec = 12.0, skipped = emptySet()))
}
@Test
fun aVideosSegmentsAreNotSkippedOnTheNextOne() {
nowPlaying(a)
nowPlaying(b) // autoplay or the queue moved on while the loop still holds A
assertNull(
"the player has moved to B; A's segments must not seek it",
segmentToSkip(a, listOf(sponsor), posSec = 12.0, skipped = emptySet()),
)
NowPlaying.clear()
assertNull("nothing is playing", segmentToSkip(a, listOf(sponsor), posSec = 12.0, skipped = emptySet()))
}
@Test
fun theSegmentRulesAreUnchanged() {
nowPlaying(a)
assertNull("before the segment", segmentToSkip(a, listOf(sponsor), posSec = 9.9, skipped = emptySet()))
assertNull("at its end", segmentToSkip(a, listOf(sponsor), posSec = 40.0, skipped = emptySet()))
assertNull("already skipped once", segmentToSkip(a, listOf(sponsor), posSec = 12.0, skipped = setOf("u1")))
val tiny = SbSegment(UUID = "u2", category = "sponsor", segment = listOf(10.0, 10.05))
assertNull("too short to act on", segmentToSkip(a, listOf(tiny), posSec = 10.01, skipped = emptySet()))
}
}

View file

@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Unit tests for LogDump's two scrub profiles.
* Unit tests for LogDump's scrub profile.
*
* These are pure-JVM tests over LogDump.scrubLine / scrubLineDogfood — the
* These are pure-JVM tests over LogDump.scrubLine. The
* regexes have no Android dependency, so they run under the standard
* `:strawApp:testDebugUnitTest` task with junit on the test classpath.
*
@ -29,9 +29,8 @@ class LogDumpScrubTest {
fun rustPathWithDoubleColonSurvives() {
// The bug: `strawcore::stream` was scrubbed to `strawcor<ip>stream`
// because `e::` read as a compressed IPv6. It must now pass through
// untouched in BOTH profiles.
// untouched.
assertEquals("strawcore::stream", LogDump.scrubLine("strawcore::stream"))
assertEquals("strawcore::stream", LogDump.scrubLineDogfood("strawcore::stream"))
// A three-segment path — `::foo` must not clip `::f` (foo starts with
// the hex letter `f`).
assertEquals("strawcore::stream::foo", LogDump.scrubLine("strawcore::stream::foo"))
@ -53,69 +52,54 @@ class LogDumpScrubTest {
assertFalse(LogDump.scrubLine("bound peer fe80::1 up").contains("fe80"))
}
// ---- B2: dogfood profile KEEPS diagnostic identifiers ----------------
// ---- B2: identifiers and credentials are redacted -------------------
@Test
fun dogfoodKeepsBareVideoIdFullScrubs() {
val id = "dQw4w9WgXcQ"
assertEquals(id, LogDump.scrubLineDogfood(id))
assertEquals("<videoId>", LogDump.scrubLine(id))
fun bareVideoIdScrubs() {
assertEquals("<videoId>", LogDump.scrubLine("dQw4w9WgXcQ"))
}
@Test
fun dogfoodKeepsYoutubeShortLinkFullScrubs() {
fun schemelessYoutubeLinkScrubs() {
val line = "open youtu.be/dQw4w9WgXcQ now"
// Dogfood: the youtu.be/<id> stays — the id is the diagnostic signal.
assertEquals(line, LogDump.scrubLineDogfood(line))
// Full: the schemeless YT link is redacted.
assertTrue(LogDump.scrubLine(line).contains("<scrubbed>"))
assertFalse(LogDump.scrubLine(line).contains("dQw4w9WgXcQ"))
}
@Test
fun dogfoodKeepsChannelAndPlaylistIdFullScrubs() {
// These are ≥20 chars so LONG_TOKEN would otherwise redact them even
// on the dogfood profile; the channel/playlist exemption keeps them.
fun channelAndPlaylistIdsScrubToTheirPlaceholders() {
val channelId = "UCuAXFkgsw1L7xaCfnd5JJOw" // UC + 22, has digits
val playlistId = "PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb"
assertEquals(channelId, LogDump.scrubLineDogfood(channelId))
assertEquals(playlistId, LogDump.scrubLineDogfood(playlistId))
// Full profile redacts them to their labelled placeholders.
assertEquals("<channelId>", LogDump.scrubLine(channelId))
assertEquals("<playlistId>", LogDump.scrubLine(playlistId))
}
@Test
fun dogfoodStillScrubsGenericHighEntropyToken() {
// visitorData-shaped: ≥20 chars with a digit, NOT a channel/playlist
// prefix → still `<token>` on the dogfood profile (it's a cred).
val visitorData = "CgtVQzEyMzQ1Njc4OTBhYg"
assertEquals("<token>", LogDump.scrubLineDogfood(visitorData))
assertEquals("<token>", LogDump.scrubLine(visitorData))
fun highEntropyTokenScrubs() {
// visitorData-shaped: 20+ chars with a digit, not a channel/playlist prefix.
assertEquals("<token>", LogDump.scrubLine("CgtVQzEyMzQ1Njc4OTBhYg"))
}
// ---- B2: dogfood profile STILL scrubs real credentials / PII ---------
@Test
fun dogfoodStillScrubsSignedGooglevideoUrl() {
fun signedGooglevideoUrlScrubs() {
val line =
"url=https://r5---sn-abc.googlevideo.com/videoplayback?expire=99&sig=DEADBEEFSIG&pot=SECRETPOT"
val out = LogDump.scrubLineDogfood(line)
val out = LogDump.scrubLine(line)
assertTrue(out.contains("<scrubbed>"))
assertFalse(out.contains("DEADBEEFSIG"))
assertFalse(out.contains("SECRETPOT"))
}
@Test
fun dogfoodStillScrubsBearerToken() {
val out = LogDump.scrubLineDogfood("hdr Authorization: Bearer ya29.A0ARLongTokenValue123")
fun bearerTokenScrubs() {
val out = LogDump.scrubLine("hdr Authorization: Bearer ya29.A0ARLongTokenValue123")
assertTrue(out.contains("<scrubbed>"))
assertFalse(out.contains("ya29"))
}
@Test
fun dogfoodStillScrubsSignedParams() {
val out = LogDump.scrubLineDogfood("req q&sig=DEADBEEF123&pot=POTVAL456 done")
fun signedParamsScrub() {
val out = LogDump.scrubLine("req q&sig=DEADBEEF123&pot=POTVAL456 done")
assertTrue(out.contains("sig=<scrubbed>"))
assertTrue(out.contains("pot=<scrubbed>"))
assertFalse(out.contains("DEADBEEF123"))
@ -123,8 +107,8 @@ class LogDumpScrubTest {
}
@Test
fun dogfoodStillScrubsIpAddresses() {
val out = LogDump.scrubLineDogfood("peer 203.0.113.7 and fe80::1 up")
fun ipAddressesScrub() {
val out = LogDump.scrubLine("peer 203.0.113.7 and fe80::1 up")
assertTrue(out.contains("<ip>"))
assertFalse(out.contains("203.0.113.7"))
assertFalse(out.contains("fe80"))