vc=39: loop round 1/5 — 9 HIGH + 7 MED from 3 Opus round-4 audits
Three parallel Opus max-effort audits ran on vc=38. No new CRITs (the
LogDump + VM-error-scrub chain held), but real new HIGHs across VMs
that weren't touched in rounds 1-3 + the Rust runtime's brittle
one-shot init.
HIGH
R4-1 Rust runtime::ensure_initialized was one-shot via Once.
First-call failure (cold-boot in airplane mode, transient
DNS/SELinux denial on first TLS init) consumed the Once slot
and bricked the extractor for the rest of the process —
every subsequent search/streamInfo/channelInfo returned
DownloaderMissing forever. Replaced with AtomicBool + 5s
backoff retry; success closes the door, failure retries on
the next call.
R4-2 VideoDetailViewModel.load tracked no inFlight Job.
Activity-scoped VM is reused; tap video A → quickly tap a
related-video B → both loads race, slower-finisher wins.
A's resolved payload (different itags, different SB
segments, wrong title chip) could render on the B detail
page; recordWatch logged B while the player streamed A.
Now: inFlight?.cancel() at top, fenced terminal writes with
loadedUrl-stable guard. Same shape applied to
ChannelViewModel (had no in-flight tracking at all).
R4-3 `_ui.value = _ui.value.copy(...)` lost-write patterns
survived round-3's pass in SearchViewModel + VideoDetail +
Channel. Migrated all to `_ui.update {}` — same atomicity
regression class round 3 was supposed to close. Submit/load
terminal writes also now fence against late-arrivals.
R4-4 HistoryStore.recordAllWatches reported `size_after -
size_before` to SettingsImport — at a saturated store the
post-state size equals the pre-state size even when 20
fresh imports landed and 20 older entries got truncated.
User saw "0 watch history imported" when 30 actually
landed. Now: recordAllWatches/recordAllSearches return an
AtomicInteger-counted actual-fresh-added count from inside
the CAS lambda; SettingsImport plumbs through to the report.
R4-5 SubscriptionFeedViewModel.refresh() filtered to stale-only
— user-initiated tap of Refresh was a silent no-op when
every channel had been refreshed in the last 28min.
Split: refresh() forces fan-out across every sub;
refreshIfStale() keeps the TTL filter. Both share
refreshInternal(force: Bool).
R4-6 SettingsImport.importPlaylists called create() + addItem()
in a loop — both write SP, and addItem walks every playlist
linearly per insert. A NewPipe export with 100 playlists ×
100 items = ~10k SP commits + O(N²) work. New
PlaylistsStore.importPlaylist mints a single Playlist with
pre-attached items, one CAS, one SP write per playlist.
R4-7 VideoDetailViewModel auto-called channelInfo(uploaderUrl)
on every load — no allowlist gate. An extractor-emitted
non-YT uploaderUrl (poisoned related/moreFromChannel)
would have triggered an arbitrary-host network call.
R4-8 Similar shape: VideoDetailViewModel.recordWatch persisted
whatever URL was passed to load() — extractor-emitted non-YT
URLs would have survived in Recent Watches past process
death. Same import-time URL allowlist now gates both.
CVE-1 The reCAPTCHA error path embedded the full google.com/sorry/
URL into the user-visible banner. That URL carries
`continue=<full-signed-googlevideo-url>` — and LogDump's
scrub only matches googlevideo.com hosts. Now: strip the
`continue=` param in Rust before propagating; UI shows a
tappable challenge URL that still solves the rate-limit
when the user opens it.
MED
R4-9 SettingsStore.setMaxResolution/setThemeMode/setCacheEnabled
were not atomic vs toggle()'s updateAndGet pattern. Now
CAS-safe + idempotent (no SP write when the value is
already what's stored).
R4-10 SponsorBlockClient.fetch built the URL via string concat
with un-percent-encoded JSON-shaped categories list.
Switched to HttpUrl.Builder().addQueryParameter() — okhttp
does the right escaping. SB happens to accept the raw form
today; this guards future user-typed categories.
R4-11 strawHttpClient() synchronized on the interned
STRAW_USER_AGENT string literal — any unrelated code that
happened to lock the same literal could contend. Replaced
with lazy(SYNCHRONIZED) — same one-shot init, no shared
global lock.
R4-12 DownloadsScreen.queryDownloads ran on the main coroutine
every 1-5s. DownloadManager.query is a ContentResolver IPC
+ SQLite cursor walk; on devices with hundreds of historical
downloads it stuttered. withContext(Dispatchers.IO).
R4-13 Co-located the YT host allowlist (was inline in
SettingsImport) into util/YtUrl.kt — VideoDetailViewModel
now imports the same function. Future host changes are
one edit.
Deferred to round 2-5:
R4-MED — Nav.kt has no rememberSaveable / Parcelize on Screen
sealed types. Process-death loses entire back stack.
Needs Parcelize plugin add + listSaver — bigger refactor.
R4-HIGH — Release isMinifyEnabled = false / no R8. Needs
comprehensive keep-rules for UniFFI + kotlinx-serialization
before flipping safely. Holding for a dedicated round.
R4-MED — LazyColumn key= missing in 5 list sites; quick win
but cosmetic, won't slip into post-round-5 ship.
R4-MED — collectAsStateWithLifecycle bulk-replace.
R4-MED — SponsorBlock skip-loop should bind segments to
controller.currentMediaItem to avoid one-tick misapply on
track changes.
This commit is contained in:
parent
cbdba302ce
commit
b8325d1726
15 changed files with 443 additions and 167 deletions
|
|
@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 38
|
const val STRAW_VERSION_CODE = 39
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-AX"
|
const val STRAW_VERSION_NAME = "0.1.0-AY"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,47 @@ pub enum StrawcoreError {
|
||||||
RequiresLogin { detail: String },
|
RequiresLogin { detail: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the `continue=<signed-url>` param from a google.com/sorry/...
|
||||||
|
/// URL while leaving every other param intact. Used only for surfacing
|
||||||
|
/// recaptcha challenge URLs to the UI; keeps the URL tappable for the
|
||||||
|
/// user to solve the challenge while scrubbing the embedded
|
||||||
|
/// googlevideo signature.
|
||||||
|
fn strip_continue_param(url: &str) -> String {
|
||||||
|
let (base, query) = match url.split_once('?') {
|
||||||
|
Some(pair) => pair,
|
||||||
|
None => return url.to_owned(),
|
||||||
|
};
|
||||||
|
let filtered: Vec<&str> = query
|
||||||
|
.split('&')
|
||||||
|
.filter(|kv| {
|
||||||
|
let key = kv.split_once('=').map(|(k, _)| k).unwrap_or(*kv);
|
||||||
|
!key.eq_ignore_ascii_case("continue")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
base.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{}?{}", base, filtered.join("&"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<strawcore_core::exceptions::ExtractionError> for StrawcoreError {
|
impl From<strawcore_core::exceptions::ExtractionError> for StrawcoreError {
|
||||||
fn from(e: strawcore_core::exceptions::ExtractionError) -> Self {
|
fn from(e: strawcore_core::exceptions::ExtractionError) -> Self {
|
||||||
use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError};
|
use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError};
|
||||||
match e {
|
match e {
|
||||||
ExtractionError::Network(NetworkError::Recaptcha { url }) => {
|
ExtractionError::Network(NetworkError::Recaptcha { url }) => {
|
||||||
|
// Strip the `continue=` query param before propagating.
|
||||||
|
// google.com/sorry/index carries the full signed
|
||||||
|
// googlevideo URL in `continue=` so the user can be
|
||||||
|
// sent back to the stream after solving — but
|
||||||
|
// surfacing that to the UI is a credential leak via
|
||||||
|
// screenshot, and Kotlin's LogDump scrubber only
|
||||||
|
// catches googlevideo.com hosts. The challenge URL
|
||||||
|
// itself still solves without `continue=`, so the
|
||||||
|
// user can tap to unblock without leaking the
|
||||||
|
// signature/expire/pot token. Round-4 audit LOW-1.
|
||||||
StrawcoreError::RequiresLogin {
|
StrawcoreError::RequiresLogin {
|
||||||
detail: format!("reCAPTCHA at {url}"),
|
detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ExtractionError::Network(NetworkError::Transport(msg)) => {
|
ExtractionError::Network(NetworkError::Transport(msg)) => {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,83 @@
|
||||||
// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via
|
// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via
|
||||||
// init_logging(). Wires the strawcore-core Downloader + Localization
|
// init_logging(), and again before every strawcore call. Wires the
|
||||||
// singleton so the extractor calls have an HTTP client to use.
|
// strawcore-core Downloader + Localization singleton so the extractor
|
||||||
|
// has an HTTP client to use.
|
||||||
|
//
|
||||||
|
// Round-4 audit HIGH-1: the prior shape used `Once::call_once` and
|
||||||
|
// silently swallowed errors. If the FIRST call ran while the network
|
||||||
|
// stack wasn't ready (cold boot in airplane mode, SELinux denial on
|
||||||
|
// first TLS init, transient resolver failure), the Once slot was
|
||||||
|
// consumed, NewPipe::init_full never ran, and every subsequent
|
||||||
|
// search/streamInfo/channelInfo returned DownloaderMissing for the
|
||||||
|
// rest of the process lifetime.
|
||||||
|
//
|
||||||
|
// New shape: use an AtomicBool to track success. Only "success" closes
|
||||||
|
// the door. On failure we retry — rate-limited so a persistently-broken
|
||||||
|
// network doesn't hammer reqwest::Client::new() on every call.
|
||||||
|
|
||||||
use std::sync::{Arc, Once};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use strawcore_core::downloader::ReqwestDownloader;
|
use strawcore_core::downloader::ReqwestDownloader;
|
||||||
use strawcore_core::localization::{ContentCountry, Localization};
|
use strawcore_core::localization::{ContentCountry, Localization};
|
||||||
use strawcore_core::newpipe::NewPipe;
|
use strawcore_core::newpipe::NewPipe;
|
||||||
|
|
||||||
static INIT: Once = Once::new();
|
static INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
// Guards the actual init attempt so concurrent calls don't all try
|
||||||
|
// to build the downloader in parallel; serial retry is the goal.
|
||||||
|
static INIT_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Min ms between retries when init has failed. 5s — enough that a
|
||||||
|
/// hot loop of failed searches doesn't pin a CPU on reqwest setup,
|
||||||
|
/// short enough that a user who toggled airplane mode off recovers
|
||||||
|
/// within one tap.
|
||||||
|
const RETRY_BACKOFF_MS: u64 = 5_000;
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure_initialized() {
|
pub fn ensure_initialized() {
|
||||||
INIT.call_once(|| {
|
if INITIALIZED.load(Ordering::Acquire) {
|
||||||
match ReqwestDownloader::new() {
|
return;
|
||||||
Ok(dl) => {
|
}
|
||||||
NewPipe::init_full(
|
// Backoff check OUTSIDE the lock — avoids serializing every
|
||||||
Arc::new(dl),
|
// already-throttled caller on a single mutex.
|
||||||
Localization::default(),
|
let last = LAST_ATTEMPT_MS.load(Ordering::Acquire);
|
||||||
ContentCountry::default(),
|
let now = now_ms();
|
||||||
);
|
if last != 0 && now.saturating_sub(last) < RETRY_BACKOFF_MS {
|
||||||
log::info!("strawcore-core: downloader + localization initialized");
|
return;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
let _guard = match INIT_LOCK.lock() {
|
||||||
log::error!("strawcore-core: failed to build downloader: {e}");
|
Ok(g) => g,
|
||||||
}
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
// Re-check under the lock — another thread may have just
|
||||||
|
// succeeded while we were waiting.
|
||||||
|
if INITIALIZED.load(Ordering::Acquire) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LAST_ATTEMPT_MS.store(now_ms(), Ordering::Release);
|
||||||
|
match ReqwestDownloader::new() {
|
||||||
|
Ok(dl) => {
|
||||||
|
NewPipe::init_full(
|
||||||
|
Arc::new(dl),
|
||||||
|
Localization::default(),
|
||||||
|
ContentCountry::default(),
|
||||||
|
);
|
||||||
|
INITIALIZED.store(true, Ordering::Release);
|
||||||
|
log::info!("strawcore-core: downloader + localization initialized");
|
||||||
}
|
}
|
||||||
});
|
Err(e) => {
|
||||||
|
// Don't surface the underlying error string verbatim —
|
||||||
|
// it can embed URLs / hosts.
|
||||||
|
log::error!("strawcore-core: downloader init failed (will retry on next call)");
|
||||||
|
let _ = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,24 @@ class HistoryStore(context: Context) {
|
||||||
* reference equality after updateAndGet's no-op return) so a
|
* reference equality after updateAndGet's no-op return) so a
|
||||||
* spam-import on an already-up-to-date store doesn't thrash disk.
|
* spam-import on an already-up-to-date store doesn't thrash disk.
|
||||||
*/
|
*/
|
||||||
fun recordAllWatches(items: List<WatchHistoryItem>) {
|
/**
|
||||||
if (items.isEmpty()) return
|
* Returns the number of fresh items actually folded into the
|
||||||
|
* store on this call (counts new videoIds; duplicates of
|
||||||
|
* already-recorded entries don't count). Round-4 audit HIGH-7 —
|
||||||
|
* SettingsImport previously reported `size_after - size_before`
|
||||||
|
* which lies when the store was at MAX_WATCHES (post-state can
|
||||||
|
* be 50 = pre-state even when 20 imports landed and 20 older
|
||||||
|
* locals were truncated to make room).
|
||||||
|
*/
|
||||||
|
fun recordAllWatches(items: List<WatchHistoryItem>): Int {
|
||||||
|
if (items.isEmpty()) return 0
|
||||||
val before = _watches.value
|
val before = _watches.value
|
||||||
|
val counter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||||
val next = _watches.updateAndGet { current ->
|
val next = _watches.updateAndGet { current ->
|
||||||
|
// Reset the counter inside the CAS lambda so a retry
|
||||||
|
// doesn't accumulate across attempts — same shape as
|
||||||
|
// SubscriptionsStore.addAll's vc=37 round-3 fix.
|
||||||
|
counter.set(0)
|
||||||
val seen = HashSet<String>(current.size + items.size)
|
val seen = HashSet<String>(current.size + items.size)
|
||||||
current.forEach { seen.add(it.videoId) }
|
current.forEach { seen.add(it.videoId) }
|
||||||
// Build the import list newest-first. Capped at
|
// Build the import list newest-first. Capped at
|
||||||
|
|
@ -87,6 +101,7 @@ class HistoryStore(context: Context) {
|
||||||
if (item.videoId.isBlank()) continue
|
if (item.videoId.isBlank()) continue
|
||||||
if (!seen.add(item.videoId)) continue
|
if (!seen.add(item.videoId)) continue
|
||||||
fresh.add(item)
|
fresh.add(item)
|
||||||
|
counter.incrementAndGet()
|
||||||
}
|
}
|
||||||
if (fresh.isEmpty()) return@updateAndGet current
|
if (fresh.isEmpty()) return@updateAndGet current
|
||||||
// Combine + cap. take() truncates older `current` entries
|
// Combine + cap. take() truncates older `current` entries
|
||||||
|
|
@ -96,6 +111,7 @@ class HistoryStore(context: Context) {
|
||||||
if (next !== before) {
|
if (next !== before) {
|
||||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||||
}
|
}
|
||||||
|
return counter.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,10 +121,16 @@ class HistoryStore(context: Context) {
|
||||||
* calling recordSearch per row, producing N SP writes on a
|
* calling recordSearch per row, producing N SP writes on a
|
||||||
* potentially-100k-row import.
|
* potentially-100k-row import.
|
||||||
*/
|
*/
|
||||||
fun recordAllSearches(queries: List<String>) {
|
/**
|
||||||
if (queries.isEmpty()) return
|
* Returns the number of fresh queries actually folded into the
|
||||||
|
* store — same counter pattern as recordAllWatches.
|
||||||
|
*/
|
||||||
|
fun recordAllSearches(queries: List<String>): Int {
|
||||||
|
if (queries.isEmpty()) return 0
|
||||||
val before = _searches.value
|
val before = _searches.value
|
||||||
|
val counter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||||
val next = _searches.updateAndGet { current ->
|
val next = _searches.updateAndGet { current ->
|
||||||
|
counter.set(0)
|
||||||
val seen = HashSet<String>(current.size + queries.size)
|
val seen = HashSet<String>(current.size + queries.size)
|
||||||
current.forEach { seen.add(it.lowercase()) }
|
current.forEach { seen.add(it.lowercase()) }
|
||||||
val fresh = ArrayList<String>(MAX_SEARCHES)
|
val fresh = ArrayList<String>(MAX_SEARCHES)
|
||||||
|
|
@ -118,6 +140,7 @@ class HistoryStore(context: Context) {
|
||||||
if (q.isEmpty()) continue
|
if (q.isEmpty()) continue
|
||||||
if (!seen.add(q.lowercase())) continue
|
if (!seen.add(q.lowercase())) continue
|
||||||
fresh.add(q)
|
fresh.add(q)
|
||||||
|
counter.incrementAndGet()
|
||||||
}
|
}
|
||||||
if (fresh.isEmpty()) return@updateAndGet current
|
if (fresh.isEmpty()) return@updateAndGet current
|
||||||
(fresh + current).take(MAX_SEARCHES)
|
(fresh + current).take(MAX_SEARCHES)
|
||||||
|
|
@ -125,6 +148,7 @@ class HistoryStore(context: Context) {
|
||||||
if (next !== before) {
|
if (next !== before) {
|
||||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
||||||
}
|
}
|
||||||
|
return counter.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordSearch(query: String) {
|
fun recordSearch(query: String) {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,35 @@ class PlaylistsStore(context: Context) {
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import a playlist with all its items in a single CAS +
|
||||||
|
* single SP write. SettingsImport's old shape called create() +
|
||||||
|
* addItem() in a loop — both write SP, and addItem walks every
|
||||||
|
* playlist linearly per insert. A 100-playlist × 100-items
|
||||||
|
* NewPipe export was ~10,001 SP commits + ~10M comparisons.
|
||||||
|
* Round-4 audit HIGH-2.
|
||||||
|
*/
|
||||||
|
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
|
||||||
|
val stampNow = System.currentTimeMillis()
|
||||||
|
// Dedup within the import + stamp addedAt once.
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val deduped = ArrayList<PlaylistItem>(items.size)
|
||||||
|
for (it in items) {
|
||||||
|
if (it.streamUrl.isBlank()) continue
|
||||||
|
if (!seen.add(it.streamUrl)) continue
|
||||||
|
deduped.add(it.copy(addedAt = if (it.addedAt == 0L) stampNow else it.addedAt))
|
||||||
|
}
|
||||||
|
val pl = Playlist(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.trim().ifBlank { "Untitled" },
|
||||||
|
createdAt = stampNow,
|
||||||
|
items = deduped,
|
||||||
|
)
|
||||||
|
val next = _playlists.updateAndGet { it + pl }
|
||||||
|
persist(next)
|
||||||
|
return pl
|
||||||
|
}
|
||||||
|
|
||||||
fun delete(id: String) {
|
fun delete(id: String) {
|
||||||
val next = _playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
|
val next = _playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
|
||||||
persist(next)
|
persist(next)
|
||||||
|
|
|
||||||
|
|
@ -72,18 +72,28 @@ class SettingsStore(context: Context) {
|
||||||
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
|
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Atomic + idempotent. updateAndGet matches toggle()'s CAS shape;
|
||||||
|
// idempotency short-circuit means tap-spamming the radio rows
|
||||||
|
// (or replaying a settings import that doesn't actually change a
|
||||||
|
// value) doesn't repeatedly hit SP. Round-4 audit MED-2.
|
||||||
fun setMaxResolution(r: MaxResolution) {
|
fun setMaxResolution(r: MaxResolution) {
|
||||||
_maxResolution.value = r
|
val updated = _maxResolution.updateAndGet { r } == r
|
||||||
|
if (!updated) return
|
||||||
|
if (sp.getString(KEY_MAX_RES, null) == r.name) return
|
||||||
sp.edit().putString(KEY_MAX_RES, r.name).apply()
|
sp.edit().putString(KEY_MAX_RES, r.name).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setThemeMode(t: ThemeMode) {
|
fun setThemeMode(t: ThemeMode) {
|
||||||
_themeMode.value = t
|
val updated = _themeMode.updateAndGet { t } == t
|
||||||
|
if (!updated) return
|
||||||
|
if (sp.getString(KEY_THEME, null) == t.name) return
|
||||||
sp.edit().putString(KEY_THEME, t.name).apply()
|
sp.edit().putString(KEY_THEME, t.name).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCacheEnabled(enabled: Boolean) {
|
fun setCacheEnabled(enabled: Boolean) {
|
||||||
_cacheEnabled.value = enabled
|
val before = _cacheEnabled.value
|
||||||
|
_cacheEnabled.updateAndGet { enabled }
|
||||||
|
if (before == enabled) return
|
||||||
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
|
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ package com.sulkta.straw.feature.channel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class ChannelUiState(
|
data class ChannelUiState(
|
||||||
|
|
@ -31,9 +34,19 @@ class ChannelViewModel : ViewModel() {
|
||||||
private val _ui = MutableStateFlow(ChannelUiState())
|
private val _ui = MutableStateFlow(ChannelUiState())
|
||||||
val ui: StateFlow<ChannelUiState> = _ui.asStateFlow()
|
val ui: StateFlow<ChannelUiState> = _ui.asStateFlow()
|
||||||
|
|
||||||
|
// Track the active load coroutine — same shape as
|
||||||
|
// VideoDetailViewModel. Rapid channel switches no longer race;
|
||||||
|
// the late-arriving older fetch is cancelled. Round-4 audit
|
||||||
|
// HIGH-2 / MED-1.
|
||||||
|
private var inFlight: Job? = null
|
||||||
|
private var loadedUrl: String? = null
|
||||||
|
|
||||||
fun load(channelUrl: String) {
|
fun load(channelUrl: String) {
|
||||||
_ui.value = ChannelUiState(loading = true)
|
if (loadedUrl == channelUrl && _ui.value.videos.isNotEmpty()) return
|
||||||
viewModelScope.launch {
|
inFlight?.cancel()
|
||||||
|
loadedUrl = channelUrl
|
||||||
|
_ui.update { ChannelUiState(loading = true) }
|
||||||
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val ch = uniffi.strawcore.channelInfo(channelUrl)
|
val ch = uniffi.strawcore.channelInfo(channelUrl)
|
||||||
val videos = ch.videos.map { v ->
|
val videos = ch.videos.map { v ->
|
||||||
|
|
@ -48,25 +61,32 @@ class ChannelViewModel : ViewModel() {
|
||||||
uploadDateRelative = v.uploadDateRelative,
|
uploadDateRelative = v.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ui.value = ChannelUiState(
|
if (loadedUrl != channelUrl) return@launch
|
||||||
loading = false,
|
_ui.update {
|
||||||
name = ch.name,
|
ChannelUiState(
|
||||||
subscriberCount = ch.subscriberCount,
|
loading = false,
|
||||||
banner = ch.banner,
|
name = ch.name,
|
||||||
avatar = ch.avatar,
|
subscriberCount = ch.subscriberCount,
|
||||||
videos = videos,
|
banner = ch.banner,
|
||||||
)
|
avatar = ch.avatar,
|
||||||
|
videos = videos,
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_ui.value = ChannelUiState(
|
if (t is CancellationException) throw t
|
||||||
loading = false,
|
if (loadedUrl != channelUrl) return@launch
|
||||||
// Scrub before storing — UniFFI/Rust exceptions
|
_ui.update {
|
||||||
// can embed full signed googlevideo URLs in the
|
ChannelUiState(
|
||||||
// message (NetworkError::Recaptcha { url }). vc=37
|
loading = false,
|
||||||
// round-3 audit CVE-1.
|
// Scrub before storing — UniFFI/Rust exceptions
|
||||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
// can embed full signed googlevideo URLs in the
|
||||||
t.message ?: t.javaClass.simpleName,
|
// message (NetworkError::Recaptcha { url }). vc=37
|
||||||
),
|
// round-3 audit CVE-1.
|
||||||
)
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
|
t.message ?: t.javaClass.simpleName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,22 +102,11 @@ object SettingsImport {
|
||||||
// YouTube only — Straw doesn't extract from other services.
|
// YouTube only — Straw doesn't extract from other services.
|
||||||
private const val YT_SERVICE_ID = 0
|
private const val YT_SERVICE_ID = 0
|
||||||
|
|
||||||
// Mirror of StrawActivity.YT_HOSTS — kept inline rather than
|
// The allowlist itself lives in util.YtUrl now — VideoDetailViewModel
|
||||||
// imported because the activity holds the canonical copy and
|
// also gates auto-channelInfo + recordWatch through it. Round-4
|
||||||
// SettingsImport is the only other consumer.
|
// audit HIGH-4 / HIGH-5.
|
||||||
// vc=36 audit CVE MED-4 — validate imported URLs at import time
|
private fun isAllowedYtUrl(url: String): Boolean =
|
||||||
// so a hostile NewPipe export can't smuggle attacker-controlled
|
com.sulkta.straw.util.isAllowedYtUrl(url)
|
||||||
// URLs into PlaylistStore / HistoryStore.
|
|
||||||
private val IMPORT_ALLOWED_HOSTS = setOf(
|
|
||||||
"youtube.com", "www.youtube.com", "m.youtube.com",
|
|
||||||
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
|
|
||||||
"youtu.be",
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun isAllowedYtUrl(url: String): Boolean {
|
|
||||||
val host = runCatching { java.net.URI(url).host?.lowercase() }.getOrNull() ?: return false
|
|
||||||
return host in IMPORT_ALLOWED_HOSTS
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun run(context: Context, zipUri: Uri): Result<ImportResult> =
|
suspend fun run(context: Context, zipUri: Uri): Result<ImportResult> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
@ -347,11 +336,12 @@ object SettingsImport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (items.isEmpty()) continue
|
if (items.isEmpty()) continue
|
||||||
// Use the store's normal create + addItem rather than minting
|
// Bulk import: one CAS + one SP write per playlist
|
||||||
// a Playlist directly — keeps the atomic-update path
|
// instead of (1 create + N addItem) writes. Old shape
|
||||||
// consistent with user-driven creates.
|
// produced ~10k SP commits on a 100×100 export, plus
|
||||||
val created = store.create(name)
|
// O(N²) work in addItem's per-call linear scan over
|
||||||
for (it in items) store.addItem(created.id, it)
|
// every playlist. Round-4 audit HIGH-2.
|
||||||
|
store.importPlaylist(name, items)
|
||||||
playlistsAdded++
|
playlistsAdded++
|
||||||
itemsAdded += items.size
|
itemsAdded += items.size
|
||||||
}
|
}
|
||||||
|
|
@ -373,8 +363,8 @@ object SettingsImport {
|
||||||
var watchesAvailable = 0
|
var watchesAvailable = 0
|
||||||
var searchesSeen = 0
|
var searchesSeen = 0
|
||||||
var resumePositions = 0
|
var resumePositions = 0
|
||||||
val searchesBefore = historyStore.searches.value.size
|
var watchesAdded = 0
|
||||||
val watchesBefore = historyStore.watches.value.size
|
var searchesAdded = 0
|
||||||
openDb(dbFile).use { db ->
|
openDb(dbFile).use { db ->
|
||||||
// Search history — feed oldest first so the store ends up with
|
// Search history — feed oldest first so the store ends up with
|
||||||
// the most-recent on top after its own dedup + take(MAX).
|
// the most-recent on top after its own dedup + take(MAX).
|
||||||
|
|
@ -392,7 +382,7 @@ object SettingsImport {
|
||||||
searchesSeen++
|
searchesSeen++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
historyStore.recordAllSearches(stagedSearches)
|
searchesAdded = historyStore.recordAllSearches(stagedSearches)
|
||||||
|
|
||||||
// Watch history — newest first via stream_history.access_date,
|
// Watch history — newest first via stream_history.access_date,
|
||||||
// joined to streams for the metadata we need.
|
// joined to streams for the metadata we need.
|
||||||
|
|
@ -435,7 +425,7 @@ object SettingsImport {
|
||||||
watchesSeen++
|
watchesSeen++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
historyStore.recordAllWatches(staged)
|
watchesAdded = historyStore.recordAllWatches(staged)
|
||||||
|
|
||||||
// Resume positions — counted, not stored. Future task hooks into
|
// Resume positions — counted, not stored. Future task hooks into
|
||||||
// a ResumePositionsStore.
|
// a ResumePositionsStore.
|
||||||
|
|
@ -443,11 +433,16 @@ object SettingsImport {
|
||||||
if (c.moveToNext()) resumePositions = c.getInt(0)
|
if (c.moveToNext()) resumePositions = c.getInt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Report what actually landed in the store after its dedup + caps.
|
// recordAllWatches / recordAllSearches return the real
|
||||||
|
// added count (counts fresh videoIds / queries that landed,
|
||||||
|
// ignoring duplicates and pre-saturated-store truncation).
|
||||||
|
// Round-4 audit HIGH-7 / MED-2 — previous size_after -
|
||||||
|
// size_before reported 0 when the store was already at cap
|
||||||
|
// even when 20 fresh imports actually landed.
|
||||||
return HistResult(
|
return HistResult(
|
||||||
watchesAdded = historyStore.watches.value.size - watchesBefore,
|
watchesAdded = watchesAdded,
|
||||||
watchesAvailable = watchesAvailable.takeIf { it > 0 } ?: watchesSeen,
|
watchesAvailable = watchesAvailable.takeIf { it > 0 } ?: watchesSeen,
|
||||||
searches = historyStore.searches.value.size - searchesBefore,
|
searches = searchesAdded,
|
||||||
resumePositions = resumePositions,
|
resumePositions = resumePositions,
|
||||||
searchesAvailable = searchesSeen,
|
searchesAvailable = searchesSeen,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,14 @@ import com.sulkta.straw.net.RydVotes
|
||||||
import com.sulkta.straw.net.SbSegment
|
import com.sulkta.straw.net.SbSegment
|
||||||
import com.sulkta.straw.net.SponsorBlockClient
|
import com.sulkta.straw.net.SponsorBlockClient
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
|
import com.sulkta.straw.util.isAllowedYtUrl
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
|
@ -85,14 +89,21 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
|
|
||||||
private var loadedUrl: String? = null
|
private var loadedUrl: String? = null
|
||||||
|
|
||||||
|
// Track the active load coroutine so a rapid tap to a different video
|
||||||
|
// cancels the prior fetch; otherwise a slow-to-finish older load
|
||||||
|
// overwrites the newer state and the player ends up streaming A while
|
||||||
|
// the detail UI shows B. Round-4 audit HIGH-2.
|
||||||
|
private var inFlight: Job? = null
|
||||||
|
|
||||||
fun load(streamUrl: String) {
|
fun load(streamUrl: String) {
|
||||||
// viewModel() is activity-scoped, so the same VM is reused across
|
// viewModel() is activity-scoped, so the same VM is reused across
|
||||||
// navigations. Skip the refetch if the requested URL already has
|
// navigations. Skip the refetch if the requested URL already has
|
||||||
// a resolved state.
|
// a resolved state.
|
||||||
if (loadedUrl == streamUrl && _ui.value.detail != null) return
|
if (loadedUrl == streamUrl && _ui.value.detail != null) return
|
||||||
|
inFlight?.cancel()
|
||||||
loadedUrl = streamUrl
|
loadedUrl = streamUrl
|
||||||
_ui.value = VideoDetailUiState(loading = true)
|
_ui.update { VideoDetailUiState(loading = true) }
|
||||||
viewModelScope.launch {
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
|
// strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
|
||||||
val info = uniffi.strawcore.streamInfo(streamUrl)
|
val info = uniffi.strawcore.streamInfo(streamUrl)
|
||||||
|
|
@ -104,19 +115,25 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
// Move SP write off the main coroutine — recordWatch
|
// Move SP write off the main coroutine — recordWatch
|
||||||
// JSON-encodes the watch list (up to 50 entries) +
|
// JSON-encodes the watch list (up to 50 entries) +
|
||||||
// sp.edit().apply(). Small but synchronous; vc=36
|
// sp.edit().apply(). Small but synchronous; vc=36
|
||||||
// audit Q9.
|
// audit Q9. Only record when the resolved URL passes
|
||||||
withContext(Dispatchers.IO) {
|
// the YT allowlist — otherwise extractor-emitted
|
||||||
runCatching {
|
// non-YT URLs (poisoned related/moreFromChannel) end
|
||||||
History.get().recordWatch(
|
// up in Recent Watches and survive process death.
|
||||||
WatchHistoryItem(
|
// Round-4 audit HIGH-5.
|
||||||
url = streamUrl,
|
if (isAllowedYtUrl(streamUrl)) {
|
||||||
videoId = videoId,
|
withContext(Dispatchers.IO) {
|
||||||
title = title,
|
runCatching {
|
||||||
uploader = uploader,
|
History.get().recordWatch(
|
||||||
thumbnail = thumb,
|
WatchHistoryItem(
|
||||||
watchedAt = 0L,
|
url = streamUrl,
|
||||||
),
|
videoId = videoId,
|
||||||
)
|
title = title,
|
||||||
|
uploader = uploader,
|
||||||
|
thumbnail = thumb,
|
||||||
|
watchedAt = 0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,9 +161,13 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
|
|
||||||
// More from this channel via strawcore.channelInfo — one
|
// More from this channel via strawcore.channelInfo — one
|
||||||
// Rust round-trip returns the channel's Videos tab pre-mapped.
|
// Rust round-trip returns the channel's Videos tab pre-mapped.
|
||||||
|
// Gate the auto-fetch behind the same YT-host allowlist
|
||||||
|
// we apply to imports: a poisoned uploaderUrl from the
|
||||||
|
// extractor would otherwise trigger an arbitrary-host
|
||||||
|
// network call. Round-4 audit HIGH-4.
|
||||||
val uploaderUrl = info.uploaderUrl
|
val uploaderUrl = info.uploaderUrl
|
||||||
val moreFromChannel: List<StreamItem> =
|
val moreFromChannel: List<StreamItem> =
|
||||||
if (uploaderUrl.isNullOrBlank()) emptyList()
|
if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) emptyList()
|
||||||
else runCatching {
|
else runCatching {
|
||||||
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
|
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
|
||||||
ch.videos
|
ch.videos
|
||||||
|
|
@ -168,31 +189,43 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
|
|
||||||
val resolved = resolvePlayback(info, segments)
|
val resolved = resolvePlayback(info, segments)
|
||||||
|
|
||||||
_ui.value = VideoDetailUiState(
|
// Fence the terminal write against late-arriving older
|
||||||
loading = false,
|
// loads: if a subsequent load(B) cancelled this one but
|
||||||
detail = VideoDetail(
|
// we resolved past the suspension point, drop our
|
||||||
id = videoId,
|
// result rather than clobber B's state. Round-4 audit
|
||||||
title = title,
|
// HIGH-2.
|
||||||
uploader = uploader,
|
if (loadedUrl != streamUrl) return@launch
|
||||||
uploaderUrl = info.uploaderUrl,
|
_ui.update {
|
||||||
viewCount = info.viewCount,
|
VideoDetailUiState(
|
||||||
description = info.description,
|
loading = false,
|
||||||
thumbnail = thumb,
|
detail = VideoDetail(
|
||||||
ryd = ryd,
|
id = videoId,
|
||||||
sbSegmentCount = segments.size,
|
title = title,
|
||||||
related = related,
|
uploader = uploader,
|
||||||
moreFromChannel = moreFromChannel,
|
uploaderUrl = info.uploaderUrl,
|
||||||
),
|
viewCount = info.viewCount,
|
||||||
resolved = resolved,
|
description = info.description,
|
||||||
streamInfo = info,
|
thumbnail = thumb,
|
||||||
)
|
ryd = ryd,
|
||||||
|
sbSegmentCount = segments.size,
|
||||||
|
related = related,
|
||||||
|
moreFromChannel = moreFromChannel,
|
||||||
|
),
|
||||||
|
resolved = resolved,
|
||||||
|
streamInfo = info,
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_ui.value = VideoDetailUiState(
|
if (t is CancellationException) throw t
|
||||||
loading = false,
|
if (loadedUrl != streamUrl) return@launch
|
||||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
_ui.update {
|
||||||
t.message ?: t.javaClass.simpleName,
|
VideoDetailUiState(
|
||||||
),
|
loading = false,
|
||||||
)
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
|
t.message ?: t.javaClass.simpleName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
data class DownloadRow(
|
data class DownloadRow(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
|
|
@ -92,7 +94,11 @@ fun DownloadsScreen() {
|
||||||
// animations to update.
|
// animations to update.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
val fresh = queryDownloads(context)
|
// DownloadManager.query() is a ContentResolver IPC + a
|
||||||
|
// SQLite cursor walk — disk I/O on the main coroutine
|
||||||
|
// visibly stutters on devices with hundreds of historical
|
||||||
|
// downloads. Round-4 audit MED-2.
|
||||||
|
val fresh = withContext(Dispatchers.IO) { queryDownloads(context) }
|
||||||
rows = fresh
|
rows = fresh
|
||||||
val active = fresh.any {
|
val active = fresh.any {
|
||||||
it.status == DownloadManager.STATUS_RUNNING ||
|
it.status == DownloadManager.STATUS_RUNNING ||
|
||||||
|
|
|
||||||
|
|
@ -141,10 +141,12 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
val entry = channelCache[ch.url]
|
val entry = channelCache[ch.url]
|
||||||
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
||||||
}
|
}
|
||||||
if (anyStale || _ui.value.items.isEmpty()) refresh()
|
if (anyStale || _ui.value.items.isEmpty()) refreshInternal(force = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() = refreshInternal(force = true)
|
||||||
|
|
||||||
|
private fun refreshInternal(force: Boolean) {
|
||||||
// Cancel any in-flight refresh at the TOP — including before
|
// Cancel any in-flight refresh at the TOP — including before
|
||||||
// the empty-channels branch. Without this, a refresh that
|
// the empty-channels branch. Without this, a refresh that
|
||||||
// ran on a non-empty sub set could still be writing to
|
// ran on a non-empty sub set could still be writing to
|
||||||
|
|
@ -168,8 +170,15 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
val gate = Semaphore(parallelism)
|
val gate = Semaphore(parallelism)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
|
// force=true (user tapped Refresh): fan out across
|
||||||
|
// every subscribed channel. force=false (the auto
|
||||||
|
// refreshIfStale path): only the stale entries.
|
||||||
|
// Round-4 audit HIGH-8 — previously refresh() also
|
||||||
|
// filtered to stale-only, so a user-initiated tap
|
||||||
|
// 5min after the last refresh was a silent no-op.
|
||||||
channels
|
channels
|
||||||
.filter { ch ->
|
.filter { ch ->
|
||||||
|
if (force) return@filter true
|
||||||
val entry = channelCache[ch.url]
|
val entry = channelCache[ch.url]
|
||||||
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,13 @@ import com.sulkta.straw.data.FeedCache
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.SearchCache
|
import com.sulkta.straw.data.SearchCache
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
|
@ -89,25 +92,33 @@ class SearchViewModel : ViewModel() {
|
||||||
runCatching { FeedCache.get().load().values.forEach { addAll(it.items) } }
|
runCatching { FeedCache.get().load().values.forEach { addAll(it.items) } }
|
||||||
}.distinctBy { it.url }
|
}.distinctBy { it.url }
|
||||||
|
|
||||||
|
// Track the active submit so a fresh tap of Search cancels the
|
||||||
|
// previous network call rather than racing it. Round-4 audit
|
||||||
|
// HIGH-2: `_ui.value = _ui.value.copy()` patterns + concurrent
|
||||||
|
// submits were both lost-write hazards.
|
||||||
|
private var inFlight: Job? = null
|
||||||
|
|
||||||
fun onQueryChange(q: String) {
|
fun onQueryChange(q: String) {
|
||||||
// Clear any prior error state when the user resumes typing —
|
// Clear any prior error state when the user resumes typing —
|
||||||
// a failed submit's banner used to persist into the next
|
// a failed submit's banner used to persist into the next
|
||||||
// reactive preview, looking like the new query had failed.
|
// reactive preview, looking like the new query had failed.
|
||||||
// vc=36 audit Q3.
|
// vc=36 audit Q3.
|
||||||
_ui.value = _ui.value.copy(query = q, error = null)
|
_ui.update { it.copy(query = q, error = null) }
|
||||||
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
||||||
val matches = reactiveFilter(q.trim())
|
val matches = reactiveFilter(q.trim())
|
||||||
if (matches.isNotEmpty()) {
|
if (matches.isNotEmpty()) {
|
||||||
_ui.value = _ui.value.copy(
|
_ui.update {
|
||||||
results = matches,
|
it.copy(
|
||||||
fromCache = true,
|
results = matches,
|
||||||
loading = false,
|
fromCache = true,
|
||||||
)
|
loading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (_ui.value.fromCache) {
|
} else if (_ui.value.fromCache) {
|
||||||
_ui.value = _ui.value.copy(results = emptyList(), fromCache = false)
|
_ui.update { it.copy(results = emptyList(), fromCache = false) }
|
||||||
}
|
}
|
||||||
} else if (q.isBlank()) {
|
} else if (q.isBlank()) {
|
||||||
_ui.value = _ui.value.copy(results = emptyList(), fromCache = false)
|
_ui.update { it.copy(results = emptyList(), fromCache = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,22 +137,27 @@ class SearchViewModel : ViewModel() {
|
||||||
?.items
|
?.items
|
||||||
} else null
|
} else null
|
||||||
if (cached != null && cached.isNotEmpty()) {
|
if (cached != null && cached.isNotEmpty()) {
|
||||||
_ui.value = _ui.value.copy(
|
_ui.update {
|
||||||
loading = true,
|
it.copy(
|
||||||
error = null,
|
loading = true,
|
||||||
results = cached,
|
error = null,
|
||||||
fromCache = true,
|
results = cached,
|
||||||
)
|
fromCache = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_ui.value = _ui.value.copy(
|
_ui.update {
|
||||||
loading = true,
|
it.copy(
|
||||||
error = null,
|
loading = true,
|
||||||
results = emptyList(),
|
error = null,
|
||||||
fromCache = false,
|
results = emptyList(),
|
||||||
)
|
fromCache = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
inFlight?.cancel()
|
||||||
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// strawcore.search() is suspend on the tokio runtime baked
|
// strawcore.search() is suspend on the tokio runtime baked
|
||||||
// into libstrawcore.so — no Dispatchers.IO wrap needed.
|
// into libstrawcore.so — no Dispatchers.IO wrap needed.
|
||||||
|
|
@ -158,11 +174,17 @@ class SearchViewModel : ViewModel() {
|
||||||
uploadDateRelative = r.uploadDateRelative,
|
uploadDateRelative = r.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ui.value = _ui.value.copy(
|
// Fence terminal write against a fresher submit that
|
||||||
loading = false,
|
// cancelled this one. Drop our result if the query
|
||||||
results = items,
|
// already moved on.
|
||||||
fromCache = false,
|
if (_ui.value.query.trim() != q) return@launch
|
||||||
)
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
|
loading = false,
|
||||||
|
results = items,
|
||||||
|
fromCache = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
// Record AFTER the search succeeds so mistyped queries
|
// Record AFTER the search succeeds so mistyped queries
|
||||||
// that error out don't pollute the recent-searches list.
|
// that error out don't pollute the recent-searches list.
|
||||||
runCatching { History.get().recordSearch(q) }
|
runCatching { History.get().recordSearch(q) }
|
||||||
|
|
@ -176,14 +198,18 @@ class SearchViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
|
if (t is CancellationException) throw t
|
||||||
|
if (_ui.value.query.trim() != q) return@launch
|
||||||
// Keep the cached preview visible on network failure so
|
// Keep the cached preview visible on network failure so
|
||||||
// the user still has something to look at while offline.
|
// the user still has something to look at while offline.
|
||||||
_ui.value = _ui.value.copy(
|
_ui.update {
|
||||||
loading = false,
|
it.copy(
|
||||||
error = com.sulkta.straw.util.LogDump.scrubLine(
|
loading = false,
|
||||||
t.message ?: t.javaClass.simpleName,
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
),
|
t.message ?: t.javaClass.simpleName,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,19 +28,21 @@ import java.util.concurrent.TimeUnit
|
||||||
const val STRAW_USER_AGENT: String =
|
const val STRAW_USER_AGENT: String =
|
||||||
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
|
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
|
||||||
|
|
||||||
@Volatile
|
// OkHttpClient is internally thread-safe; lazy(SYNCHRONIZED) builds
|
||||||
private var sharedClient: OkHttpClient? = null
|
// exactly once across threads. Round-4 audit MED-6 — the prior
|
||||||
|
// synchronized(STRAW_USER_AGENT) locked an interned String literal
|
||||||
|
// shared with any other code in any library that happened to lock
|
||||||
|
// the same literal. Lazy-delegate avoids the global pool lock.
|
||||||
|
private val sharedClient: OkHttpClient by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(true)
|
||||||
|
.followSslRedirects(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
fun strawHttpClient(): OkHttpClient =
|
fun strawHttpClient(): OkHttpClient = sharedClient
|
||||||
sharedClient ?: synchronized(STRAW_USER_AGENT) {
|
|
||||||
sharedClient ?: OkHttpClient.Builder()
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.followRedirects(true)
|
|
||||||
.followSslRedirects(true)
|
|
||||||
.build()
|
|
||||||
.also { sharedClient = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ResponseBody.cappedString(maxBytes: Long): String {
|
fun ResponseBody.cappedString(maxBytes: Long): String {
|
||||||
val cl = contentLength()
|
val cl = contentLength()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.sulkta.straw.util.strawLogD
|
||||||
import com.sulkta.straw.util.strawLogW
|
import com.sulkta.straw.util.strawLogW
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
|
@ -41,11 +42,19 @@ object SponsorBlockClient {
|
||||||
categories: List<String> = listOf("sponsor"),
|
categories: List<String> = listOf("sponsor"),
|
||||||
): List<SbSegment> {
|
): List<SbSegment> {
|
||||||
val prefix = sha256Hex(videoId).substring(0, 4)
|
val prefix = sha256Hex(videoId).substring(0, 4)
|
||||||
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
|
// HttpUrl.Builder percent-encodes query values for us. Prior
|
||||||
"categories=" + buildJsonArray(categories)
|
// string-concat built `?categories=["sponsor","selfpromo"]`
|
||||||
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
|
// 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. Round-4
|
||||||
|
// audit MED-1 / LOW-2.
|
||||||
|
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()
|
val req = Request.Builder()
|
||||||
.url(urlStr)
|
.url(url)
|
||||||
.header("User-Agent", STRAW_USER_AGENT)
|
.header("User-Agent", STRAW_USER_AGENT)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
25
strawApp/src/main/kotlin/com/sulkta/straw/util/YtUrl.kt
Normal file
25
strawApp/src/main/kotlin/com/sulkta/straw/util/YtUrl.kt
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Shared YouTube-host allowlist. Originally lived inside
|
||||||
|
* SettingsImport for the import-time URL check; round-4 audit
|
||||||
|
* surfaced two more call sites — VideoDetailViewModel's auto
|
||||||
|
* channelInfo(uploaderUrl) and recordWatch persistence — that
|
||||||
|
* needed the same gate. Co-locating the set here so a future
|
||||||
|
* host (yewtu.be, hypothetical YT mirror) is one edit instead of
|
||||||
|
* three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.util
|
||||||
|
|
||||||
|
private val ALLOWED_YT_HOSTS: Set<String> = setOf(
|
||||||
|
"youtube.com", "www.youtube.com", "m.youtube.com",
|
||||||
|
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
|
||||||
|
"youtu.be",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isAllowedYtUrl(url: String): Boolean {
|
||||||
|
val host = runCatching { java.net.URI(url).host?.lowercase() }.getOrNull() ?: return false
|
||||||
|
return host in ALLOWED_YT_HOSTS
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue