From 055c9c6d4f71279fa92ac50cdb855a4b5eb5c821 Mon Sep 17 00:00:00 2001 From: Cobb Date: Sun, 21 Jun 2026 12:59:04 -0700 Subject: [PATCH] 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. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 28 +- rust/Cargo.lock | 74 +++++ rust/strawcore/Cargo.toml | 7 + rust/strawcore/src/feed.rs | 60 ++-- rust/strawcore/src/lib.rs | 2 + rust/strawcore/src/net.rs | 275 ++++++++++++++++++ .../main/kotlin/com/sulkta/straw/StrawApp.kt | 34 ++- .../straw/feature/player/PlaybackService.kt | 15 + .../kotlin/com/sulkta/straw/net/RydClient.kt | 49 ++-- .../sulkta/straw/net/SponsorBlockClient.kt | 84 ++---- 10 files changed, 487 insertions(+), 141 deletions(-) create mode 100644 rust/strawcore/src/net.rs diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e54bd5609..63f2118de 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -9,6 +9,30 @@ const val STRAW_SDK_TARGET = 35 // Sulkta fork — Straw // +// vc=85 / 0.1.0-CS — image caching + SB/RYD → Rust + crash/autoplay fixes: +// * Thumbnails + channel icons stay cached. Coil's default disk cache is +// only 2% of the phone's FREE space, so on a storage-tight device the +// subs feed (the most image-heavy screen) thrashed its cache and +// re-downloaded thumbnails on every visit — the "each load takes a +// second" lag. Pinned an explicit 256 MB image disk cache + a sized +// memory cache via a SingletonImageLoader.Factory. +// * SponsorBlock + Return-YouTube-Dislike clients moved out of Kotlin +// into Rust (strawcore fetchSponsorSegments / fetchRydVotes) — network +// + JSON belong behind the FFI, not in the UI layer. SponsorBlock keeps +// its privacy-preserving SHA-256 hash-prefix lookup. Kotlin is now a +// thin shim mapping the FFI records onto the existing SbSegment/RydVotes +// domain types; behavior identical. (Migration #2 of "all backend → Rust".) +// * FIX a crash: extract_channel_id() sliced the channel URL using a +// length derived from a lowercased copy of itself — to_lowercase() can +// change byte length on non-ASCII, so a channel URL with any non-ASCII +// 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 started a different video in that window, autoplay +// would still fire and replace your choice with the stale next-up. Added +// a staleness fence (same class as the vc=83 minibar fix). +// // vc=84 / 0.1.0-CR — name + stream-picker → Rust: // * The app is now just "Straw" in the launcher, not "Straw debug" — // we're past the debug-branding phase. (The package id stays @@ -178,6 +202,6 @@ const val STRAW_SDK_TARGET = 35 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 84 -const val STRAW_VERSION_NAME = "0.1.0-CR" +const val STRAW_VERSION_CODE = 85 +const val STRAW_VERSION_NAME = "0.1.0-CS" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7948204d5..d86c0e89c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -224,6 +224,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -373,6 +382,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -382,6 +400,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -542,6 +580,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1425,6 +1473,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1499,6 +1558,9 @@ dependencies = [ "quick-xml", "reqwest", "rquickjs-sys", + "serde", + "serde_json", + "sha2", "strawcore-core", "thiserror 1.0.69", "tokio", @@ -1771,6 +1833,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" @@ -1950,6 +2018,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 6ccd53fd5..fde11e344 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -44,6 +44,13 @@ android_logger = { version = "0.14", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "stream"] } quick-xml = "0.36" futures = "0.3" +# RYD + SponsorBlock JSON clients (net.rs). serde/serde_json are already in +# the dependency tree via strawcore-core; declaring them here lets the +# wrapper parse the two small enrichment endpoints directly. sha2 powers +# SponsorBlock's privacy-preserving SHA-256 hash-prefix lookup. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" [build-dependencies] uniffi = { version = "0.28", features = ["build"] } diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 75f4edc98..bf4377636 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -149,44 +149,11 @@ async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option Option { - use futures::StreamExt; - let mut total = 0usize; - let mut buf: Vec = Vec::with_capacity(32 * 1024); - let mut stream = resp.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.ok()?; - // Defense-in-depth: a single hostile chunk can be arbitrarily - // large (HTTP allows multi-GiB chunks). Reject any one chunk - // bigger than the whole body cap before we even add it to the - // running total — protects against hyper having already - // allocated the chunk on our behalf. - if chunk.len() > RSS_MAX_BYTES { - log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len()); - return None; - } - total = total.saturating_add(chunk.len()); - if total > RSS_MAX_BYTES { - log::warn!("strawcore::rss body exceeded {RSS_MAX_BYTES} bytes; aborting"); - return None; - } - buf.extend_from_slice(&chunk); - } - // Lossy decode — A strict from_utf8 - // returns None on any invalid byte, so a single mojibake title - // would silently drop the entire channel from the feed. quick-xml - // tolerates U+FFFD replacement chars and the per-entry skip-on- - // empty handles broken entries downstream. - Some(String::from_utf8_lossy(&buf).into_owned()) -} - /// Extract the `UCxxx` channel ID from a channel URL. Accepts the /// shapes the Android app actually has in Subscriptions plus the ones /// users paste from share intents: @@ -203,7 +170,6 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { /// cache the ID into Subscriptions. fn extract_channel_id(input: &str) -> Option { let trimmed = input.trim(); - let trimmed_lower = trimmed.to_lowercase(); // Match the ":///channel/" prefix in a single sweep // so we accept http/https + www./m. variants without four-way // string-strip ladders. ANCHORED at the start of the string — @@ -220,11 +186,23 @@ fn extract_channel_id(input: &str) -> Option { "http://m.youtube.com/channel/", ]; for p in PREFIXES { - if let Some(rest) = trimmed_lower.strip_prefix(p) { - // Bytes match 1:1 with `trimmed` since the prefix is ASCII - // and case-folding ASCII doesn't change byte length. - let rest_in_original = &trimmed[p.len()..p.len() + rest.len()]; - let id = rest_in_original + // Case-insensitive prefix match WITHOUT lowercasing the whole + // string first. The prior version did `trimmed.to_lowercase()` + // then sliced the *original* by the lowercased copy's length — + // but `to_lowercase()` can change byte length on non-ASCII input + // (e.g. the part after the prefix), so `p.len() + rest.len()` + // could run past the end of `trimmed` or land mid-UTF-8-char and + // PANIC. Since a panic here crosses the UniFFI boundary it aborts + // the whole app on a feed refresh of a channel with any non-ASCII + // in the URL tail. The prefixes are pure ASCII, so compare the + // first p.len() bytes case-insensitively against the ORIGINAL + // (guarded by length + char-boundary) and slice the original + // directly — no lowercase round-trip, no length mismatch. + if trimmed.len() >= p.len() + && trimmed.is_char_boundary(p.len()) + && trimmed[..p.len()].eq_ignore_ascii_case(p) + { + let id = trimmed[p.len()..] .split(|c: char| c == '/' || c == '?' || c == '#') .next()?; return validate_channel_id(id); diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index ac9ade687..835a55a89 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -13,6 +13,7 @@ use std::sync::Once; mod channel; mod error; mod feed; +mod net; mod runtime; mod search; mod stream; @@ -20,6 +21,7 @@ mod stream; // Re-exports so UniFFI sees the types at the crate root for macro discovery. pub use channel::ChannelInfo; pub use error::StrawcoreError; +pub use net::{RydVotes, SponsorSegment}; pub use search::{Page, SearchItem}; pub use stream::{AudioStreamItem, ResolvedStreams, StreamInfo, VideoStreamItem}; diff --git a/rust/strawcore/src/net.rs b/rust/strawcore/src/net.rs new file mode 100644 index 000000000..1ceca7391 --- /dev/null +++ b/rust/strawcore/src/net.rs @@ -0,0 +1,275 @@ +// Small third-party HTTP/JSON clients that used to live in Kotlin +// (`net/RydClient.kt` + `net/SponsorBlockClient.kt`). Ported to Rust as +// part of the "all backend logic -> Rust" migration: these are network + +// parse, which belongs behind the FFI, not in the Android UI layer. Kotlin +// keeps thin shims that map these Records onto its existing domain types. +// +// Both endpoints are best-effort enrichment: any failure (transport, +// non-2xx, oversized body, malformed JSON) collapses to "no data" +// (None / empty Vec), exactly as the Kotlin originals did via runCatching. +// We never surface a StrawcoreError here — a dead RYD/SB host must not +// break video playback. + +use std::sync::OnceLock; +use std::time::Duration; + +use reqwest::Client; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// Matches the UA the Kotlin clients sent (some of these public APIs +/// rate-limit or shape responses by UA). Kept byte-identical to the old +/// `STRAW_USER_AGENT` in `net/Http.kt`. +const STRAW_USER_AGENT: &str = + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"; + +/// Body caps carried over 1:1 from `net/Http.kt` (RYD_MAX_BYTES / +/// SB_MAX_BYTES). A hostile or compromised host must not be able to +/// stream a GB-scale body into memory. +const RYD_MAX_BYTES: usize = 256 * 1024; +const SB_MAX_BYTES: usize = 1024 * 1024; + +const RYD_VOTES_URL: &str = "https://returnyoutubedislikeapi.com/votes"; +const SB_SKIP_BASE: &str = "https://sponsor.ajay.app/api/skipSegments/"; + +/// Shared reqwest client for the small enrichment endpoints. Mirrors the +/// old OkHttp config (`connectTimeout 15s`, `readTimeout 30s`). One pool +/// for RYD + SB — they're low-volume and the old code shared one OkHttp +/// client too. +static CLIENT: OnceLock = OnceLock::new(); + +fn client() -> Option<&'static Client> { + if let Some(c) = CLIENT.get() { + return Some(c); + } + let built = Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) + .user_agent(STRAW_USER_AGENT) + .redirect(reqwest::redirect::Policy::limited(3)) + .build() + .ok()?; + Some(CLIENT.get_or_init(|| built)) +} + +/// Drain a reqwest Response into a String, returning None if the body +/// exceeds `cap`. Shared with the RSS feed path (`feed.rs`). Per-chunk +/// guard first (HTTP allows multi-GiB chunks; hyper may have already +/// allocated one before we see it), then the running total. +pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Option { + use futures::StreamExt; + let mut total = 0usize; + let mut buf: Vec = Vec::with_capacity(32 * 1024); + let mut stream = resp.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.ok()?; + if chunk.len() > cap { + log::warn!("strawcore::net single chunk {} exceeds cap; aborting", chunk.len()); + return None; + } + total = total.saturating_add(chunk.len()); + if total > cap { + log::warn!("strawcore::net body exceeded {cap} bytes; aborting"); + return None; + } + buf.extend_from_slice(&chunk); + } + // Lossy decode: a strict from_utf8 would drop the whole response on a + // single mojibake byte; serde_json tolerates U+FFFD in string values. + Some(String::from_utf8_lossy(&buf).into_owned()) +} + +// --------------------------------------------------------------------------- +// Return YouTube Dislike +// --------------------------------------------------------------------------- + +/// Vote counts from the Return-YouTube-Dislike API. Kotlin maps this onto +/// its own `net.RydVotes` data class (the detail-screen overlay model). +#[derive(Debug, Clone, uniffi::Record)] +pub struct RydVotes { + pub id: String, + pub likes: i64, + pub dislikes: i64, + pub rating: f64, + pub view_count: i64, +} + +#[derive(Deserialize)] +struct RydVotesWire { + // `id` is required (no default) to match the Kotlin original, whose + // non-nullable `id: String` made a response missing `id` fail to parse + // and return null. likes/dislikes/rating/viewCount keep defaults — the + // Kotlin data class defaulted those. + id: String, + #[serde(default)] + likes: i64, + #[serde(default)] + dislikes: i64, + #[serde(default)] + rating: f64, + #[serde(default)] + #[serde(rename = "viewCount")] + view_count: i64, +} + +/// GET https://returnyoutubedislikeapi.com/votes?videoId= +/// Returns None on any failure (transport / non-2xx / oversize / bad JSON), +/// matching the Kotlin client's runCatching-to-null contract. +#[uniffi::export(async_runtime = "tokio")] +pub async fn fetch_ryd_votes(video_id: String) -> Option { + log::info!("strawcore::ryd fetch id_len={}", video_id.len()); + let client = client()?; + let resp = client + .get(RYD_VOTES_URL) + .query(&[("videoId", video_id.as_str())]) + .header("Accept", "application/json") + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let body = read_capped_body(resp, RYD_MAX_BYTES).await?; + let wire: RydVotesWire = serde_json::from_str(&body) + .map_err(|e| log::warn!("strawcore::ryd json decode failed: {e}")) + .ok()?; + Some(RydVotes { + id: wire.id, + likes: wire.likes, + dislikes: wire.dislikes, + rating: wire.rating, + view_count: wire.view_count, + }) +} + +// --------------------------------------------------------------------------- +// SponsorBlock +// --------------------------------------------------------------------------- + +/// One SponsorBlock segment. Kotlin maps this onto its `net.SbSegment` +/// (reconstructing the `[start, end]` list its serializer expects). +#[derive(Debug, Clone, uniffi::Record)] +pub struct SponsorSegment { + pub category: String, + pub start_sec: f64, + pub end_sec: f64, + pub uuid: Option, + pub action_type: Option, +} + +#[derive(Deserialize)] +struct SbVideoWire { + #[serde(rename = "videoID")] + video_id: String, + #[serde(default)] + segments: Vec, +} + +#[derive(Deserialize)] +struct SbSegmentWire { + #[serde(rename = "UUID")] + uuid: Option, + category: String, + #[serde(default)] + segment: Vec, + #[serde(rename = "actionType")] + action_type: Option, +} + +/// SponsorBlock skip-segment lookup via the privacy-preserving SHA-256 +/// hash-prefix endpoint (k-anonymity): we send only the first 4 hex chars +/// of sha256(videoId), the server returns segments for every video whose +/// hash shares that prefix, and we filter to the exact match locally — so +/// the server never learns which video the user is watching. +/// +/// GET https://sponsor.ajay.app/api/skipSegments/?categories=[...] +/// Returns an empty Vec on any failure, matching the Kotlin contract. +#[uniffi::export(async_runtime = "tokio")] +pub async fn fetch_sponsor_segments( + video_id: String, + categories: Vec, +) -> Vec { + log::info!( + "strawcore::sb fetch id_len={} categories={}", + video_id.len(), + categories.len() + ); + match fetch_sponsor_segments_inner(&video_id, &categories).await { + Some(v) => v, + None => Vec::new(), + } +} + +async fn fetch_sponsor_segments_inner( + video_id: &str, + categories: &[String], +) -> Option> { + let client = client()?; + let prefix = sha256_prefix4(video_id); + let url = format!("{SB_SKIP_BASE}{prefix}"); + // Encode the category list as a JSON array, the form the SB API + // expects (`?categories=["sponsor","selfpromo"]`). reqwest's `.query` + // percent-encodes the value for us. + let categories_json = serde_json::to_string(categories).ok()?; + let resp = client + .get(&url) + .query(&[("categories", categories_json.as_str())]) + .header("Accept", "application/json") + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let body = read_capped_body(resp, SB_MAX_BYTES).await?; + let videos: Vec = serde_json::from_str(&body) + .map_err(|e| log::warn!("strawcore::sb json decode failed: {e}")) + .ok()?; + // The prefix lookup returns many videos; keep only ours. + let mine = videos.into_iter().find(|v| v.video_id == video_id)?; + let out: Vec = mine + .segments + .into_iter() + .map(|s| SponsorSegment { + category: s.category, + // Kotlin read segment[0]/segment[1] with a 0.0 fallback; match + // that so a malformed 1-element segment doesn't drop the row. + start_sec: s.segment.first().copied().unwrap_or(0.0), + end_sec: s.segment.get(1).copied().unwrap_or(0.0), + uuid: s.uuid, + action_type: s.action_type, + }) + .collect(); + Some(out) +} + +/// First 4 lowercase-hex chars of sha256(input) — i.e. the first two +/// bytes of the digest. Matches Kotlin's +/// `sha256Hex(videoId).substring(0, 4)`. +fn sha256_prefix4(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + format!("{:02x}{:02x}", digest[0], digest[1]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefix_matches_known_sha256() { + // sha256("dQw4w9WgXcQ") starts 5f6b — this is the 4-char prefix + // SponsorBlock would receive for that video id. + assert_eq!(sha256_prefix4("dQw4w9WgXcQ"), "5f6b"); + // Empty string -> the well-known empty-sha256 digest starts e3b0. + assert_eq!(sha256_prefix4(""), "e3b0"); + } + + #[test] + fn segment_fallback_on_short_array() { + // Mirror the unwrap_or(0.0) guard for a malformed 1-element segment. + let seg = vec![12.5f64]; + assert_eq!(seg.first().copied().unwrap_or(0.0), 12.5); + assert_eq!(seg.get(1).copied().unwrap_or(0.0), 0.0); + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 585fefc00..dcf736621 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index bd50ab546..1d9cbe545 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 6a5bb17d8..0af9811be 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -2,17 +2,15 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * Return YouTube Dislike client. - * API: GET https://returnyoutubedislike.com/votes?videoId= + * 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(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, + ) + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index d337224a6..9ddd77da5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -2,25 +2,17 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later * - * SponsorBlock client — SHA-256 prefix lookup. - * API: GET https://sponsor.ajay.app/api/skipSegments/?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 = 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 = listOf("sponsor"), - ): List { - 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>(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 = - 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 = + uniffi.strawcore.fetchSponsorSegments(videoId, categories).map { s -> + SbSegment( + UUID = s.uuid, + category = s.category, + segment = listOf(s.startSec, s.endSec), + actionType = s.actionType, + ) + } }