Resume + Enrichment stores no longer JSON-decode on the main thread in Application.onCreate (Resume is the heaviest cold-start cost: ~50-100ms decode at its 100k-entry cap). Both seed an empty StateFlow and hydrate off-thread on their PrefsWriter single-thread dispatcher. Mutations that arrive before the hydrate finishes defer onto that FIFO dispatcher (a hydrated gate) so they re-run on fully-loaded state -- correct for adds AND clears; a naive loaded+live merge would resurrect a clear that landed on the empty seed. Subscriptions/Playlists/History/Settings stay eager (tiny + rendered directly -> would flash empty). (audit 2.1) Video page: details/related rows no longer leak into the status-bar strip above the player when scrolled. topPadding is now a layout inset on the detail LazyColumn, so the scroll viewport begins+clips at the player's bottom edge instead of letting rows scroll up over the clock/signal. RYD: dropped the dead rating + view_count fields from the strawcore RydVotes FFI Record + Kotlin shim -- only likes/dislikes are rendered; RYD's rating restates the like/dislike ratio and its viewCount is a stale aggregate conflicting with the real YouTube count already shown. (audit #2 L-9) Adversarially reviewed (Opus): clear-resurrection closed, no defer-loop, FFI slim has no readers. Headless compileDebugKotlin green.
279 lines
10 KiB
Rust
279 lines
10 KiB
Rust
// 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.
|
|
///
|
|
/// The cap bounds gzip-DECOMPRESSED bytes (reqwest is built with `gzip`),
|
|
/// not wire bytes — same as the old OkHttp `cappedString`, and fine for the
|
|
/// few-hundred-byte-to-low-KB RYD / SponsorBlock / RSS payloads. A hostile
|
|
/// host could force up-to-`cap` decompression, but never enough to OOM.
|
|
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).
|
|
///
|
|
/// We carry only the fields the UI actually renders (likes/dislikes). RYD's
|
|
/// own `rating` (a 0-5 restatement of the like/dislike ratio) and `viewCount`
|
|
/// (RYD's stale aggregate — the real YouTube count from `videoDetails` is what
|
|
/// the detail screen already shows) are intentionally NOT surfaced: rendering
|
|
/// either would be redundant or actively misleading, so they don't cross the
|
|
/// FFI (audit #2 L-9 — slimmed the dead carry-through).
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct RydVotes {
|
|
pub id: String,
|
|
pub likes: i64,
|
|
pub dislikes: 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 keep defaults — the Kotlin data class
|
|
// defaulted those. The JSON's `rating`/`viewCount` are simply not read
|
|
// (serde ignores unknown fields here) — see RydVotes above.
|
|
id: String,
|
|
#[serde(default)]
|
|
likes: i64,
|
|
#[serde(default)]
|
|
dislikes: 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,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
}
|
|
}
|