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:
parent
4affa1e739
commit
a36e673627
10 changed files with 487 additions and 141 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue