vc=85: image caching + SB/RYD clients -> Rust + crash/autoplay fixes

- Thumbnails + channel icons stay cached: pin an explicit 256MB Coil disk
  cache + sized memory cache via SingletonImageLoader.Factory. Coil's
  default disk cap is 2% of the device's free space, so on a storage-tight
  phone the subs feed (most image-heavy screen) thrashed it and
  re-downloaded thumbnails on every visit.
- SponsorBlock + Return-YouTube-Dislike clients moved Kotlin -> Rust
  (strawcore net.rs: fetchSponsorSegments / fetchRydVotes). SponsorBlock
  keeps its privacy-preserving SHA-256 hash-prefix lookup. Kotlin is now a
  thin shim mapping the FFI records onto the SbSegment/RydVotes domain
  types; behavior identical. Migration #2 of "all backend -> Rust".
- Fix crash: extract_channel_id sliced the channel URL by a length derived
  from a lowercased copy of itself; to_lowercase() can change byte length
  on non-ASCII, so a non-ASCII URL tail could panic across the FFI and
  abort the app on a feed refresh. Now matches the prefix case-insensitively
  against the original with length + char-boundary guards.
- Fix autoplay hijack: advancing to the next video resolves over ~500ms; if
  you manually start a different video meanwhile, autoplay would replace
  your choice with the stale next-up. Added a staleness fence.

Verified: cargo check/test/clippy on the wrapper, full Android
compileDebugKotlin green, adversarial FFI pre-push audit passed.
This commit is contained in:
Sulkta 2026-06-21 12:59:04 -07:00
parent 4affa1e739
commit a36e673627
10 changed files with 487 additions and 141 deletions

View file

@ -6,6 +6,12 @@
package com.sulkta.straw
import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.FeedEnrichment
import com.sulkta.straw.data.History
@ -25,7 +31,33 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class StrawApp : Application() {
class StrawApp : Application(), SingletonImageLoader.Factory {
/**
* Explicit Coil image loader. The default singleton already caches
* to disk (Coil 3 ignores Cache-Control and always writes), BUT its
* default disk cap is `maxSizePercent(0.02)` 2% of the device's
* *free* space. On a storage-tight phone that's a few tens of MB, so
* the subs feed (hundreds of thumbnails + channel avatars, by far the
* most image-heavy screen) thrashes the cache and re-downloads on
* every visit the "each load takes a second" lag. Pin a generous
* fixed disk cap + an explicit memory cache so thumbnails and channel
* icons stay resident instead of being evicted by the stingy default.
*/
override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizeBytes(256L * 1024 * 1024)
.build()
}
.build()
/**
* App-scoped coroutine scope for one-time startup work that
* shouldn't tie up Application.onCreate. SupervisorJob so a failure

View file

@ -267,6 +267,14 @@ class PlaybackService : MediaSessionService() {
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
// window, NowPlaying moves off this url and firing setPlayingFrom
// would hijack their choice with the stale next-up. Fenced against
// it right before the swap. Same staleness-fence class as the
// minibar resolve→play bug (vc=83).
val triggeredFrom = current.streamUrl
// 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.
@ -287,6 +295,13 @@ class PlaybackService : MediaSessionService() {
}
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
}
// 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

View file

@ -2,17 +2,15 @@
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Return YouTube Dislike client.
* API: GET https://returnyoutubedislike.com/votes?videoId=<id>
* Return YouTube Dislike client. The HTTP fetch + JSON parse now live in
* Rust (strawcore `fetchRydVotes`) per the "all backend logic -> Rust"
* migration; this is a thin shim that maps the FFI Record onto the app's
* `RydVotes` domain type (the detail-screen overlay model).
*/
package com.sulkta.straw.net
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Request
@Serializable
data class RydVotes(
@ -24,30 +22,17 @@ data class RydVotes(
)
object RydClient {
private const val TAG = "StrawRyd"
private val json = Json { ignoreUnknownKeys = true }
/** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? {
val url = "https://returnyoutubedislikeapi.com/votes?videoId=$videoId"
strawLogD(TAG) { "fetch start: $videoId$url" }
val req = Request.Builder()
.url(url)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read to defend against OOM.
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""
strawLogD(TAG) { "response: code=$code, body[0..120]=${bodyStr.take(120)}" }
if (!r.isSuccessful) return@use null
runCatching { json.decodeFromString<RydVotes>(bodyStr) }
.onFailure { strawLogW(TAG) { "json decode failed: ${it.message}" } }
.getOrNull()
}
}.onFailure { strawLogW(TAG) { "fetch failed: ${it.javaClass.simpleName}: ${it.message}" } }
.getOrNull()
}
/** Suspends on the Rust async runtime; call from a coroutine. Returns
* null on any failure (transport / non-2xx / bad JSON), same contract
* the old blocking client had. */
suspend fun fetch(videoId: String): RydVotes? =
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
RydVotes(
id = v.id,
likes = v.likes,
dislikes = v.dislikes,
rating = v.rating,
viewCount = v.viewCount,
)
}
}

View file

@ -2,25 +2,17 @@
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SponsorBlock client SHA-256 prefix lookup.
* API: GET https://sponsor.ajay.app/api/skipSegments/<prefix4>?categories=[...]
* SponsorBlock client. The privacy-preserving SHA-256 hash-prefix lookup +
* HTTP fetch + JSON parse now live in Rust (strawcore
* `fetchSponsorSegments`) per the "all backend logic -> Rust" migration.
* This is a thin shim that maps the FFI Records onto the app's `SbSegment`
* domain type, rebuilding the `[start, end]` list its @Serializable form
* (and the player's auto-skip loop) expect.
*/
package com.sulkta.straw.net
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.security.MessageDigest
@Serializable
data class SbVideoSegments(
val videoID: String,
val segments: List<SbSegment> = emptyList(),
)
@Serializable
data class SbSegment(
@ -34,57 +26,19 @@ data class SbSegment(
}
object SponsorBlockClient {
private const val TAG = "StrawSb"
private val json = Json { ignoreUnknownKeys = true }
fun fetch(
/** Suspends on the Rust async runtime; call from a coroutine. Returns
* an empty list on any failure, same contract the old blocking client
* had. `categories` are the SB category keys to request. */
suspend fun fetch(
videoId: String,
categories: List<String> = listOf("sponsor"),
): List<SbSegment> {
val prefix = sha256Hex(videoId).substring(0, 4)
// HttpUrl.Builder percent-encodes query values for us. Prior
// string-concat built `?categories=["sponsor","selfpromo"]`
// with literal brackets/quotes — SB happens to accept it
// today, but the next time someone interpolates a non-enum
// string in there it becomes a URL-construction bug.
val url = "https://sponsor.ajay.app/api/skipSegments/$prefix".toHttpUrl()
.newBuilder()
.addQueryParameter("categories", buildJsonArray(categories))
.build()
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix" }
val req = Request.Builder()
.url(url)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read.
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""
strawLogD(TAG) { "response: code=$code body_len=${bodyStr.length}" }
if (!r.isSuccessful) return@use emptyList()
val all = runCatching {
json.decodeFromString<List<SbVideoSegments>>(bodyStr)
}.onFailure { strawLogW(TAG) { "json decode failed: ${it.message}" } }
.getOrDefault(emptyList())
val mine = all.firstOrNull { it.videoID == videoId }?.segments.orEmpty()
strawLogD(TAG) { "armed ${mine.size} segments for $videoId (response had ${all.size} matching-prefix videos)" }
mine
}
}.onFailure { strawLogW(TAG) { "fetch failed: ${it.javaClass.simpleName}: ${it.message}" } }
.getOrDefault(emptyList())
}
/**
* AUD-MED: encode via kotlinx-serialization rather than string concat;
* defends against future user-typed category names breaking the URL.
*/
private fun buildJsonArray(items: List<String>): String =
json.encodeToString(items)
private fun sha256Hex(s: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
): List<SbSegment> =
uniffi.strawcore.fetchSponsorSegments(videoId, categories).map { s ->
SbSegment(
UUID = s.uuid,
category = s.category,
segment = listOf(s.startSec, s.endSec),
actionType = s.actionType,
)
}
}