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
addd074f61
commit
055c9c6d4f
10 changed files with 487 additions and 141 deletions
|
|
@ -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"
|
||||
|
|
|
|||
74
rust/Cargo.lock
generated
74
rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -149,44 +149,11 @@ async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option<Vec<Sea
|
|||
.error_for_status()
|
||||
.ok()?;
|
||||
// Streaming body read with a hard byte cap — `.text()` reads
|
||||
// unbounded into a String.
|
||||
let body = read_capped_body(resp).await?;
|
||||
// unbounded into a String. Shared with the RYD/SB path (net.rs).
|
||||
let body = crate::net::read_capped_body(resp, RSS_MAX_BYTES).await?;
|
||||
parse_rss(&body, channel_id)
|
||||
}
|
||||
|
||||
/// Drain a reqwest Response into a String, bailing out (return None) if
|
||||
/// the body exceeds RSS_MAX_BYTES.
|
||||
async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
|
||||
use futures::StreamExt;
|
||||
let mut total = 0usize;
|
||||
let mut buf: Vec<u8> = 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<String> {
|
|||
/// cache the ID into Subscriptions.
|
||||
fn extract_channel_id(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
let trimmed_lower = trimmed.to_lowercase();
|
||||
// Match the "<scheme>://<host>/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<String> {
|
|||
"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);
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
275
rust/strawcore/src/net.rs
Normal file
275
rust/strawcore/src/net.rs
Normal file
|
|
@ -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<Client> = 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<String> {
|
||||
use futures::StreamExt;
|
||||
let mut total = 0usize;
|
||||
let mut buf: Vec<u8> = 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=<id>
|
||||
/// 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<RydVotes> {
|
||||
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<String>,
|
||||
pub action_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SbVideoWire {
|
||||
#[serde(rename = "videoID")]
|
||||
video_id: String,
|
||||
#[serde(default)]
|
||||
segments: Vec<SbSegmentWire>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SbSegmentWire {
|
||||
#[serde(rename = "UUID")]
|
||||
uuid: Option<String>,
|
||||
category: String,
|
||||
#[serde(default)]
|
||||
segment: Vec<f64>,
|
||||
#[serde(rename = "actionType")]
|
||||
action_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 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/<prefix4>?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<String>,
|
||||
) -> Vec<SponsorSegment> {
|
||||
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<Vec<SponsorSegment>> {
|
||||
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<SbVideoWire> = 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<SponsorSegment> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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-Coop
|
||||
* 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-Coop
|
||||
* 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