vc=88: deferred-hygiene sweep (audit #2 leftovers, no behavior change)

M-2: route every SharedPreferences write (Settings/History/Subs/Resume) through
one PrefsWriter — a per-store single-thread dispatcher — so the on-disk apply()
order matches the in-memory CAS order. Previously a Main-thread toggle and an
IO-thread import could land apply() out of order, and ResumePositions detached
ordering entirely via a fresh globalScope.launch per write; a stale value could
then win the next cold-start load. Each write reads the live StateFlow so disk
converges to the latest in-memory state regardless of enqueue order.

L-14: Settings storage-usage sampling (File.length() x4 + Coil diskCache.size)
moved off the composition/Main thread into a LaunchedEffect on Dispatchers.IO.

L-2 / L-4..L-8 / L-15 / L-16: dead code + stale comments from the vc=85 SB/RYD
to Rust migration. Http.kt trimmed to STRAW_USER_AGENT; reconciled the
network_security_config / feed.rs / SubscriptionFeedViewModel / net.rs / CI
comments with reality; recencyScore overflow-guarded; ci/Dockerfile now
pre-installs build-tools 36 (AGP 9.2.1's actual floor, was auto-fetched).

Verified: headless compileDebugKotlin green on the straw-build image.
This commit is contained in:
Sulkta 2026-06-21 20:03:45 -07:00
parent 378634078e
commit ce7174cb5e
12 changed files with 242 additions and 179 deletions

View file

@ -9,6 +9,25 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw // Sulkta fork — Straw
// //
// vc=88 / 0.1.0-CV — deferred-hygiene sweep (audit #2 leftovers, no behavior change):
// * SharedPreferences writes across every store (Settings, History, Subs,
// ResumePositions) now route through one PrefsWriter — a single-thread
// dispatcher per store — so the on-disk apply() order matches the in-memory
// CAS order. Previously a Main-thread toggle and an IO-thread settings/history
// import could land their apply() out of order, and ResumePositions detached
// write-ordering entirely via a fresh globalScope.launch per write; a stale
// value could then win the next cold-start load. Each write reads the live
// StateFlow so disk always converges to the latest in-memory state. (M-2)
// * Settings storage-usage sampling (File.length() ×4 + Coil diskCache.size)
// moved off the composition/Main thread into a LaunchedEffect on
// Dispatchers.IO — was real synchronous FS I/O stalling the first Settings
// frame. (L-14)
// * Dead code + stale comments from the vc=85 SB/RYD→Rust migration: Http.kt
// trimmed to just STRAW_USER_AGENT (the OkHttp client + bounded-body reader
// went to Rust); reconciled the network_security_config / feed.rs /
// SubscriptionFeedViewModel / net.rs / CI comments with reality. recencyScore
// now overflow-guards a crafted relative-date. (L-2 / L-4..L-8 / L-15 / L-16)
//
// vc=87 / 0.1.0-CU — audit #2 fix sprint (closes two vc=86 regressions + more): // vc=87 / 0.1.0-CU — audit #2 fix sprint (closes two vc=86 regressions + more):
// * FIX a duplicate-key crash: the Subs feed, Search, and Channel lists key // * FIX a duplicate-key crash: the Subs feed, Search, and Channel lists key
// their LazyColumn by the video url, but the sources weren't fully deduped // their LazyColumn by the video url, but the sources weren't fully deduped
@ -247,6 +266,6 @@ const val STRAW_SDK_TARGET = 35
// 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-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 87 const val STRAW_VERSION_CODE = 88
const val STRAW_VERSION_NAME = "0.1.0-CU" const val STRAW_VERSION_NAME = "0.1.0-CV"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -105,12 +105,17 @@ pub async fn channel_feed_rss(
crate::runtime::ensure_initialized(); crate::runtime::ensure_initialized();
log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len()); log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len());
let client = rss_client()?; let client = rss_client()?;
// Propagate the real failure (network / HTTP / parse) instead of // Propagate the real failure (network / HTTP / parse) as Err rather than
// collapsing it to an empty Vec. The single-channel caller (the subs // collapsing it to an empty Vec. The current Android caller flattens
// feed) needs to tell "this channel posted nothing" apart from "this // Err -> emptyList() and only overwrites its per-channel cache on a
// fetch broke" — the prior `.unwrap_or_default()` made the `Result` // NON-empty result, so a transient fetch error leaves the prior cache
// a lie. (subscription_feed keeps its own per-channel unwrap_or_default // intact instead of blanking the channel — which is exactly why the old
// below — fan-out tolerance is correct there, not here.) // `.unwrap_or_default()` here was wrong (it turned a broken fetch into an
// authoritative "no videos"). Note an unsupported URL (`extract_channel_id`
// -> None) and a genuinely empty feed both still return Ok(vec![]); the
// Err vs Ok distinction only separates *broken* from *empty*.
// subscription_feed keeps its own per-channel unwrap_or_default below —
// fan-out tolerance is correct there, not here.
fetch_channel_rss(client, &channel_url).await fetch_channel_rss(client, &channel_url).await
} }

View file

@ -57,6 +57,11 @@ fn client() -> Option<&'static Client> {
/// exceeds `cap`. Shared with the RSS feed path (`feed.rs`). Per-chunk /// exceeds `cap`. Shared with the RSS feed path (`feed.rs`). Per-chunk
/// guard first (HTTP allows multi-GiB chunks; hyper may have already /// guard first (HTTP allows multi-GiB chunks; hyper may have already
/// allocated one before we see it), then the running total. /// 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> { pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Option<String> {
use futures::StreamExt; use futures::StreamExt;
let mut total = 0usize; let mut total = 0usize;

View file

@ -48,6 +48,10 @@ private const val MAX_SEARCHES_HARD = 100_000
class HistoryStore(context: Context) { class HistoryStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
// Writes serialized so a Main-thread recordWatch can't land its apply()
// out of order vs an IO-thread bulk import (audit #2 M-2). Each write
// reads the live StateFlow so disk converges to the latest list.
private val writer = PrefsWriter(sp)
private fun maxWatches(): Int { private fun maxWatches(): Int {
val cap = Settings.get().historyWatchesCap.value.value val cap = Settings.get().historyWatchesCap.value.value
@ -79,7 +83,7 @@ class HistoryStore(context: Context) {
val without = current.filterNot { it.videoId == item.videoId } val without = current.filterNot { it.videoId == item.videoId }
(listOf(merged) + without).take(maxWatches()) (listOf(merged) + without).take(maxWatches())
} }
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() writer.write { putString(KEY_WATCHES, json.encodeToString(_watches.value)) }
} }
/** /**
@ -135,7 +139,7 @@ class HistoryStore(context: Context) {
(fresh + current).take(maxWatches()) (fresh + current).take(maxWatches())
} }
if (next !== before) { if (next !== before) {
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() writer.write { putString(KEY_WATCHES, json.encodeToString(_watches.value)) }
} }
return counter.get() return counter.get()
} }
@ -171,7 +175,7 @@ class HistoryStore(context: Context) {
(fresh + current).take(maxSearches()) (fresh + current).take(maxSearches())
} }
if (next !== before) { if (next !== before) {
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() writer.write { putString(KEY_SEARCHES, json.encodeToString(_searches.value)) }
} }
return counter.get() return counter.get()
} }
@ -183,17 +187,17 @@ class HistoryStore(context: Context) {
val without = current.filterNot { it.equals(q, ignoreCase = true) } val without = current.filterNot { it.equals(q, ignoreCase = true) }
(listOf(q) + without).take(maxSearches()) (listOf(q) + without).take(maxSearches())
} }
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() writer.write { putString(KEY_SEARCHES, json.encodeToString(_searches.value)) }
} }
fun clearWatches() { fun clearWatches() {
_watches.updateAndGet { emptyList() } _watches.updateAndGet { emptyList() }
sp.edit().putString(KEY_WATCHES, json.encodeToString(emptyList<WatchHistoryItem>())).apply() writer.write { putString(KEY_WATCHES, json.encodeToString(_watches.value)) }
} }
fun clearSearches() { fun clearSearches() {
_searches.updateAndGet { emptyList() } _searches.updateAndGet { emptyList() }
sp.edit().putString(KEY_SEARCHES, json.encodeToString(emptyList<String>())).apply() writer.write { putString(KEY_SEARCHES, json.encodeToString(_searches.value)) }
} }
private fun loadWatches(): List<WatchHistoryItem> = runCatching { private fun loadWatches(): List<WatchHistoryItem> = runCatching {

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Serializes one SharedPreferences file's writes onto a single thread so
* the on-disk apply() order matches the in-memory CAS order (audit #2 M-2).
*
* Every store here updates its MutableStateFlow atomically (updateAndGet),
* but the follow-up SharedPreferences write used to be dispatched
* unsequenced: two concurrent writers (e.g. the IO-thread settings importer
* vs a Main-thread toggle, or a player seek vs the 5s resume-poll) could
* land their apply() calls out of order, leaving disk holding the OLDER
* value while the StateFlow correctly held the newer one so the stale
* value won on the next cold-start load().
*
* Each store owns one PrefsWriter. The write lambda runs on a single-thread
* dispatcher and MUST read the store's StateFlow `.value` (not a captured
* snapshot): whichever serialized write executes last then persists the
* latest in-memory state, so disk always converges to it regardless of
* which writer was enqueued first.
*/
package com.sulkta.straw.data
import android.content.SharedPreferences
import com.sulkta.straw.StrawApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class PrefsWriter(private val sp: SharedPreferences) {
// limitedParallelism(1): a single-worker view of the shared IO pool —
// tasks run one at a time, in dispatch order, so apply() order is stable.
private val dispatcher = Dispatchers.IO.limitedParallelism(1)
/**
* Enqueue a serialized SharedPreferences write. The block runs with the
* Editor as receiver; read the store's StateFlow `.value` inside it (not
* a value captured at call time) so the latest in-memory state wins.
*/
fun write(block: SharedPreferences.Editor.() -> Unit) {
StrawApp.globalScope.launch(dispatcher) {
val editor = sp.edit()
editor.block()
editor.apply()
}
}
}

View file

@ -17,13 +17,10 @@ package com.sulkta.straw.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.sulkta.straw.StrawApp
import kotlinx.coroutines.Dispatchers
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.updateAndGet import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -63,6 +60,7 @@ private const val END_THRESHOLD_MS = 5_000L
class ResumePositionsStore(context: Context) { class ResumePositionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val writer = PrefsWriter(sp)
private val _positions = MutableStateFlow(load()) private val _positions = MutableStateFlow(load())
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow() val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
@ -131,12 +129,12 @@ class ResumePositionsStore(context: Context) {
} }
} }
if (next !== before) { if (next !== before) {
// JSON encode + SP write off Main — encoding 100k entries // JSON encode + SP write off Main (encoding 100k entries is
// would be ~50-100 ms on a low-end device, and the 5s // ~50-100 ms on a low-end device, and the 5s captureResumePosition
// captureResumePosition poll runs on Main. // poll runs on Main), serialized so a quick-succession seek + poll
StrawApp.globalScope.launch(Dispatchers.IO) { // tick can't land their apply() out of order (audit #2 M-2). The
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() // write reads `_positions.value`, so it persists the latest map.
} writer.write { putString(KEY_POSITIONS, json.encodeToString(_positions.value)) }
} }
} }
@ -153,9 +151,7 @@ class ResumePositionsStore(context: Context) {
if (videoId !in current) current else current - videoId if (videoId !in current) current else current - videoId
} }
if (next !== before) { if (next !== before) {
StrawApp.globalScope.launch(Dispatchers.IO) { writer.write { putString(KEY_POSITIONS, json.encodeToString(_positions.value)) }
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
}
} }
} }
@ -163,9 +159,7 @@ class ResumePositionsStore(context: Context) {
val before = _positions.value val before = _positions.value
_positions.updateAndGet { emptyMap() } _positions.updateAndGet { emptyMap() }
if (before.isNotEmpty()) { if (before.isNotEmpty()) {
StrawApp.globalScope.launch(Dispatchers.IO) { writer.write { putString(KEY_POSITIONS, json.encodeToString(_positions.value)) }
sp.edit().putString(KEY_POSITIONS, json.encodeToString(emptyMap<String, ResumePosition>())).apply()
}
} }
} }

View file

@ -144,6 +144,14 @@ private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
// SP writes serialized off the calling thread (audit #2 M-2): the
// importer toggles SponsorBlock categories on Dispatchers.IO while the
// user can tap the same toggles on Main — an unsequenced apply() could
// otherwise revert a user toggle after restart. Each setter updates its
// StateFlow synchronously, then the serialized write reads `.value` so
// disk converges to the latest in-memory state.
private val writer = PrefsWriter(sp)
private val _sbCategories = MutableStateFlow(loadCategories()) private val _sbCategories = MutableStateFlow(loadCategories())
val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow() val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow()
@ -311,90 +319,92 @@ class SettingsStore(context: Context) {
_bgFeedRefreshInterval.asStateFlow() _bgFeedRefreshInterval.asStateFlow()
fun toggle(cat: SbCategory) { fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. // Atomic toggle via updateAndGet; the serialized write reads the live
val next = _sbCategories.updateAndGet { cur -> // set so an IO-thread import + a Main-thread tap converge (audit #2 M-2).
_sbCategories.updateAndGet { cur ->
if (cat in cur) cur - cat else cur + cat if (cat in cur) cur - cat else cur + cat
} }
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply() writer.write { putStringSet(KEY_SB_CATS, _sbCategories.value.map { it.key }.toSet()) }
} }
// Atomic + idempotent. Capture before-state, update in-memory, // Atomic + idempotent. Capture before-state, update in-memory, skip the SP
// skip the SP write when the value didn't actually change. The // write when the value didn't change. The serialized write reads the live
// prior shape used `updateAndGet { r } == r` which is unconditionally // StateFlow so disk converges to the latest value (audit #2 M-2). The prior
// true (the lambda ignores prior) — dead code that confused readers. // shape used `updateAndGet { r } == r` which is unconditionally true (the
// lambda ignores prior) — dead code that confused readers.
fun setMaxResolution(r: MaxResolution) { fun setMaxResolution(r: MaxResolution) {
val before = _maxResolution.value val before = _maxResolution.value
if (before == r) return if (before == r) return
_maxResolution.value = r _maxResolution.value = r
sp.edit().putString(KEY_MAX_RES, r.name).apply() writer.write { putString(KEY_MAX_RES, _maxResolution.value.name) }
} }
fun setThemeMode(t: ThemeMode) { fun setThemeMode(t: ThemeMode) {
val before = _themeMode.value val before = _themeMode.value
if (before == t) return if (before == t) return
_themeMode.value = t _themeMode.value = t
sp.edit().putString(KEY_THEME, t.name).apply() writer.write { putString(KEY_THEME, _themeMode.value.name) }
} }
fun setCacheEnabled(enabled: Boolean) { fun setCacheEnabled(enabled: Boolean) {
val before = _cacheEnabled.value val before = _cacheEnabled.value
if (before == enabled) return if (before == enabled) return
_cacheEnabled.value = enabled _cacheEnabled.value = enabled
sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply() writer.write { putBoolean(KEY_CACHE_ENABLED, _cacheEnabled.value) }
} }
fun setAutoplayMode(mode: AutoplayMode) { fun setAutoplayMode(mode: AutoplayMode) {
val before = _autoplayMode.value val before = _autoplayMode.value
if (before == mode) return if (before == mode) return
_autoplayMode.value = mode _autoplayMode.value = mode
sp.edit().putString(KEY_AUTOPLAY_MODE, mode.name).apply() writer.write { putString(KEY_AUTOPLAY_MODE, _autoplayMode.value.name) }
} }
fun setAutoplaySkipWatched(skip: Boolean) { fun setAutoplaySkipWatched(skip: Boolean) {
val before = _autoplaySkipWatched.value val before = _autoplaySkipWatched.value
if (before == skip) return if (before == skip) return
_autoplaySkipWatched.value = skip _autoplaySkipWatched.value = skip
sp.edit().putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, skip).apply() writer.write { putBoolean(KEY_AUTOPLAY_SKIP_WATCHED, _autoplaySkipWatched.value) }
} }
fun setAutoStartPlayback(autoStart: Boolean) { fun setAutoStartPlayback(autoStart: Boolean) {
val before = _autoStartPlayback.value val before = _autoStartPlayback.value
if (before == autoStart) return if (before == autoStart) return
_autoStartPlayback.value = autoStart _autoStartPlayback.value = autoStart
sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply() writer.write { putBoolean(KEY_AUTOSTART_PLAYBACK, _autoStartPlayback.value) }
} }
fun setPauseOnHeadphoneDisconnect(pause: Boolean) { fun setPauseOnHeadphoneDisconnect(pause: Boolean) {
val before = _pauseOnHeadphoneDisconnect.value val before = _pauseOnHeadphoneDisconnect.value
if (before == pause) return if (before == pause) return
_pauseOnHeadphoneDisconnect.value = pause _pauseOnHeadphoneDisconnect.value = pause
sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply() writer.write { putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, _pauseOnHeadphoneDisconnect.value) }
} }
fun setAutoResume(enabled: Boolean) { fun setAutoResume(enabled: Boolean) {
val before = _autoResume.value val before = _autoResume.value
if (before == enabled) return if (before == enabled) return
_autoResume.value = enabled _autoResume.value = enabled
sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply() writer.write { putBoolean(KEY_AUTO_RESUME, _autoResume.value) }
} }
fun setAutoUpdateCheck(enabled: Boolean) { fun setAutoUpdateCheck(enabled: Boolean) {
val before = _autoUpdateCheck.value val before = _autoUpdateCheck.value
if (before == enabled) return if (before == enabled) return
_autoUpdateCheck.value = enabled _autoUpdateCheck.value = enabled
sp.edit().putBoolean(KEY_AUTO_UPDATE_CHECK, enabled).apply() writer.write { putBoolean(KEY_AUTO_UPDATE_CHECK, _autoUpdateCheck.value) }
} }
fun setAutoUpdateInterval(interval: AutoUpdateInterval) { fun setAutoUpdateInterval(interval: AutoUpdateInterval) {
val before = _autoUpdateInterval.value val before = _autoUpdateInterval.value
if (before == interval) return if (before == interval) return
_autoUpdateInterval.value = interval _autoUpdateInterval.value = interval
sp.edit().putString(KEY_AUTO_UPDATE_INTERVAL, interval.name).apply() writer.write { putString(KEY_AUTO_UPDATE_INTERVAL, _autoUpdateInterval.value.name) }
} }
fun setLastUpdateCheck(ms: Long) { fun setLastUpdateCheck(ms: Long) {
_lastUpdateCheckMs.value = ms _lastUpdateCheckMs.value = ms
sp.edit().putLong(KEY_LAST_UPDATE_CHECK_MS, ms).apply() writer.write { putLong(KEY_LAST_UPDATE_CHECK_MS, _lastUpdateCheckMs.value) }
} }
fun setLatestKnownVersion(vc: Long, vname: String) { fun setLatestKnownVersion(vc: Long, vname: String) {
@ -404,65 +414,65 @@ class SettingsStore(context: Context) {
if (_latestKnownVc.value == vc && _latestKnownVname.value == vname) return if (_latestKnownVc.value == vc && _latestKnownVname.value == vname) return
_latestKnownVc.value = vc _latestKnownVc.value = vc
_latestKnownVname.value = vname _latestKnownVname.value = vname
sp.edit() writer.write {
.putLong(KEY_LATEST_KNOWN_VC, vc) putLong(KEY_LATEST_KNOWN_VC, _latestKnownVc.value)
.putString(KEY_LATEST_KNOWN_VNAME, vname) putString(KEY_LATEST_KNOWN_VNAME, _latestKnownVname.value)
.apply() }
} }
fun setHideShorts(hide: Boolean) { fun setHideShorts(hide: Boolean) {
val before = _hideShorts.value val before = _hideShorts.value
if (before == hide) return if (before == hide) return
_hideShorts.value = hide _hideShorts.value = hide
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply() writer.write { putBoolean(KEY_HIDE_SHORTS, _hideShorts.value) }
} }
fun setHideWatched(hide: Boolean) { fun setHideWatched(hide: Boolean) {
if (_hideWatched.value == hide) return if (_hideWatched.value == hide) return
_hideWatched.value = hide _hideWatched.value = hide
sp.edit().putBoolean(KEY_HIDE_WATCHED, hide).apply() writer.write { putBoolean(KEY_HIDE_WATCHED, _hideWatched.value) }
} }
fun setHistoryWatchesCap(cap: CacheCap) { fun setHistoryWatchesCap(cap: CacheCap) {
if (_historyWatchesCap.value == cap) return if (_historyWatchesCap.value == cap) return
_historyWatchesCap.value = cap _historyWatchesCap.value = cap
sp.edit().putInt(KEY_CACHE_HISTORY_WATCHES, cap.value).apply() writer.write { putInt(KEY_CACHE_HISTORY_WATCHES, _historyWatchesCap.value.value) }
} }
fun setHistorySearchesCap(cap: CacheCap) { fun setHistorySearchesCap(cap: CacheCap) {
if (_historySearchesCap.value == cap) return if (_historySearchesCap.value == cap) return
_historySearchesCap.value = cap _historySearchesCap.value = cap
sp.edit().putInt(KEY_CACHE_HISTORY_SEARCHES, cap.value).apply() writer.write { putInt(KEY_CACHE_HISTORY_SEARCHES, _historySearchesCap.value.value) }
} }
fun setResumePositionsCap(cap: CacheCap) { fun setResumePositionsCap(cap: CacheCap) {
if (_resumePositionsCap.value == cap) return if (_resumePositionsCap.value == cap) return
_resumePositionsCap.value = cap _resumePositionsCap.value = cap
sp.edit().putInt(KEY_CACHE_RESUME_POSITIONS, cap.value).apply() writer.write { putInt(KEY_CACHE_RESUME_POSITIONS, _resumePositionsCap.value.value) }
} }
fun setSearchCacheCap(cap: CacheCap) { fun setSearchCacheCap(cap: CacheCap) {
if (_searchCacheCap.value == cap) return if (_searchCacheCap.value == cap) return
_searchCacheCap.value = cap _searchCacheCap.value = cap
sp.edit().putInt(KEY_CACHE_SEARCH, cap.value).apply() writer.write { putInt(KEY_CACHE_SEARCH, _searchCacheCap.value.value) }
} }
fun setCacheTtl(ttl: CacheTtl) { fun setCacheTtl(ttl: CacheTtl) {
if (_cacheTtl.value == ttl) return if (_cacheTtl.value == ttl) return
_cacheTtl.value = ttl _cacheTtl.value = ttl
sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply() writer.write { putString(KEY_CACHE_TTL, _cacheTtl.value.name) }
} }
fun setBgFeedRefreshEnabled(enabled: Boolean) { fun setBgFeedRefreshEnabled(enabled: Boolean) {
if (_bgFeedRefreshEnabled.value == enabled) return if (_bgFeedRefreshEnabled.value == enabled) return
_bgFeedRefreshEnabled.value = enabled _bgFeedRefreshEnabled.value = enabled
sp.edit().putBoolean(KEY_BG_FEED_REFRESH_ENABLED, enabled).apply() writer.write { putBoolean(KEY_BG_FEED_REFRESH_ENABLED, _bgFeedRefreshEnabled.value) }
} }
fun setBgFeedRefreshInterval(interval: BgFeedRefreshInterval) { fun setBgFeedRefreshInterval(interval: BgFeedRefreshInterval) {
if (_bgFeedRefreshInterval.value == interval) return if (_bgFeedRefreshInterval.value == interval) return
_bgFeedRefreshInterval.value = interval _bgFeedRefreshInterval.value = interval
sp.edit().putString(KEY_BG_FEED_REFRESH_INTERVAL, interval.name).apply() writer.write { putString(KEY_BG_FEED_REFRESH_INTERVAL, _bgFeedRefreshInterval.value.name) }
} }
private fun loadCap(key: String, default: Int): CacheCap = private fun loadCap(key: String, default: Int): CacheCap =

View file

@ -30,6 +30,9 @@ private const val KEY = "subs_v1"
class SubscriptionsStore(context: Context) { class SubscriptionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
// Writes serialized so a Main-thread toggle and an IO-thread bulk import
// can't land their apply() out of order (audit #2 M-2).
private val writer = PrefsWriter(sp)
private val _subs = MutableStateFlow(load()) private val _subs = MutableStateFlow(load())
val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow() val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow()
@ -41,14 +44,14 @@ class SubscriptionsStore(context: Context) {
// updateAndGet makes the read-modify-write atomic vs. concurrent // updateAndGet makes the read-modify-write atomic vs. concurrent
// toggles (e.g. one channel subscribed from the feed while another // toggles (e.g. one channel subscribed from the feed while another
// is unsubscribed from VideoDetail). // is unsubscribed from VideoDetail).
val next = _subs.updateAndGet { cur -> _subs.updateAndGet { cur ->
if (cur.any { it.url == ref.url }) { if (cur.any { it.url == ref.url }) {
cur.filterNot { it.url == ref.url } cur.filterNot { it.url == ref.url }
} else { } else {
cur + ref cur + ref
} }
} }
persist(next) persist()
} }
/** /**
@ -58,10 +61,10 @@ class SubscriptionsStore(context: Context) {
* it at subscribe time). No-op for non-subscribed URLs. * it at subscribe time). No-op for non-subscribed URLs.
*/ */
fun updateAvatar(channelUrl: String, avatar: String) { fun updateAvatar(channelUrl: String, avatar: String) {
val next = _subs.updateAndGet { cur -> _subs.updateAndGet { cur ->
cur.map { if (it.url == channelUrl) it.copy(avatar = avatar) else it } cur.map { if (it.url == channelUrl) it.copy(avatar = avatar) else it }
} }
persist(next) persist()
} }
/** /**
@ -80,7 +83,7 @@ class SubscriptionsStore(context: Context) {
// ). The counter lives in an // ). The counter lives in an
// AtomicInteger so each lambda re-run resets it correctly. // AtomicInteger so each lambda re-run resets it correctly.
val counter = java.util.concurrent.atomic.AtomicInteger(0) val counter = java.util.concurrent.atomic.AtomicInteger(0)
val next = _subs.updateAndGet { state -> _subs.updateAndGet { state ->
counter.set(0) counter.set(0)
val byUrl = state.associateBy { it.url }.toMutableMap() val byUrl = state.associateBy { it.url }.toMutableMap()
for (r in refs) { for (r in refs) {
@ -92,7 +95,7 @@ class SubscriptionsStore(context: Context) {
} }
byUrl.values.toList() byUrl.values.toList()
} }
persist(next) persist()
return counter.get() return counter.get()
} }
@ -101,11 +104,13 @@ class SubscriptionsStore(context: Context) {
// toggle racing the clear and persisting [new-item] after the // toggle racing the clear and persisting [new-item] after the
// remove() call has fired. // remove() call has fired.
_subs.updateAndGet { emptyList() } _subs.updateAndGet { emptyList() }
persist(emptyList()) persist()
} }
private fun persist(list: List<ChannelRef>) { // Reads `_subs.value` (not a captured list) so the serialized write
sp.edit().putString(KEY, json.encodeToString(list)).apply() // always persists the latest in-memory state — see PrefsWriter.
private fun persist() {
writer.write { putString(KEY, json.encodeToString(_subs.value)) }
} }
private fun load(): List<ChannelRef> = runCatching { private fun load(): List<ChannelRef> = runCatching {

View file

@ -140,12 +140,12 @@ class SubscriptionFeedViewModel : ViewModel() {
private var inFlight: Job? = null private var inFlight: Job? = null
/** /**
* The background enrichment job runs on StrawApp.globalScope so it * The background enrichment job (enrichVisibleItems) runs on
* outlives the VM's viewModelScope but a refresh-cancel must * viewModelScope, so it dies with the VM a destroyed _ui must not get
* still kill the *previous* enrichment so we don't pile up * a stale emit and a torn-down channelCache must not be re-merged. A
* overlapping fan-outs (8-wide × N overlapping refreshes blows the * refresh-cancel still kills the *previous* enrichment so overlapping
* concurrency budget). Tracked here, cancelled in the same places * fan-outs don't pile up (8-wide × N overlapping refreshes would blow the
* `inFlight` is./3/8. * concurrency budget). Tracked here, cancelled wherever `inFlight` is.
*/ */
private var enrichJob: Job? = null private var enrichJob: Job? = null
@ -468,12 +468,10 @@ class SubscriptionFeedViewModel : ViewModel() {
*/ */
fun clearInMemoryCache() { fun clearInMemoryCache() {
// Cancel any in-flight refresh — without this, fetchChannelInto // Cancel any in-flight refresh — without this, fetchChannelInto
// coroutines mid-execution would re-populate the cache after // coroutines mid-execution would re-populate the cache after the
// the clear. Also cancel any // clear. Also cancel any enrichment fan-out (on viewModelScope) —
// enrichment fan-out (lives on globalScope, NOT viewModelScope) // otherwise a still-running enrichment would write to FeedEnrichment
// — otherwise a still-running enrichment would write to // and then push a merged emit reading the empty channelCache.
// FeedEnrichment + then push a merged emit reading the empty
// channelCache.
inFlight?.cancel() inFlight?.cancel()
enrichJob?.cancel() enrichJob?.cancel()
channelCache.clear() channelCache.clear()
@ -512,8 +510,15 @@ private fun StreamItem.recencyScore(): Long {
"year" -> 31_536_000 "year" -> 31_536_000
else -> return Long.MIN_VALUE else -> return Long.MIN_VALUE
} }
// Sign flip: smaller "seconds ago" → larger score (more recent). // Sign flip: smaller "seconds ago" → larger (less-negative) score, so
// Cap at a sane horizon so a "1 second ago" doesn't overwhelm the // newer sorts first. A crafted/garbage relative-date ("99999999 years
// viewCount tiebreaker on items that are functionally tied. // ago") could overflow the multiply — treat overflow as unparseable and
return -(n * unitSecs) // sink it to the bottom. (The RSS path can't reach this; Rust's
// iso_to_relative clamps. A non-RSS-sourced StreamItem theoretically could.)
val secondsAgo = try {
Math.multiplyExact(n, unitSecs)
} catch (e: ArithmeticException) {
return Long.MIN_VALUE
}
return -secondsAgo
} }

View file

@ -31,6 +31,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -601,23 +602,29 @@ fun SettingsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Sample on-disk usage once per Settings entry — File.length() is // Sample on-disk usage once per Settings entry, OFF the main thread
// cheap but we don't need it to recompose on every state change. // (audit #2 L-14): File.length() ×4 + Coil diskCache.size is real
// remember keeps the same snapshot for the entire session. // synchronous FS I/O, and doing it in the composition stalled the
val usage = remember { // first Settings frame. LaunchedEffect → Dispatchers.IO populates the
object { // snapshot; until it lands `usage` is null and the "Used:" labels
val history = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_history") // simply don't render (matching CacheCapRow's hide-on-zero).
val resume = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_resume_positions") var usage by remember { mutableStateOf<CacheUsage?>(null) }
val search = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_search_cache") LaunchedEffect(Unit) {
val feed = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_feed_cache") usage = withContext(Dispatchers.IO) {
val coil = com.sulkta.straw.util.StorageUsage.coilDiskCacheBytes(context) CacheUsage(
history = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_history"),
resume = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_resume_positions"),
search = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_search_cache"),
feed = com.sulkta.straw.util.StorageUsage.sharedPrefBytes(context, "straw_feed_cache"),
coil = com.sulkta.straw.util.StorageUsage.coilDiskCacheBytes(context),
)
} }
} }
CacheCapRow( CacheCapRow(
label = "Watch + search history", label = "Watch + search history",
selected = store.historyWatchesCap.collectAsState().value, selected = store.historyWatchesCap.collectAsState().value,
onPick = { store.setHistoryWatchesCap(it) }, onPick = { store.setHistoryWatchesCap(it) },
usageBytes = usage.history, usageBytes = usage?.history ?: 0L,
) )
CacheCapRow( CacheCapRow(
label = "Search history", label = "Search history",
@ -628,13 +635,13 @@ fun SettingsScreen() {
label = "Resume positions", label = "Resume positions",
selected = store.resumePositionsCap.collectAsState().value, selected = store.resumePositionsCap.collectAsState().value,
onPick = { store.setResumePositionsCap(it) }, onPick = { store.setResumePositionsCap(it) },
usageBytes = usage.resume, usageBytes = usage?.resume ?: 0L,
) )
CacheCapRow( CacheCapRow(
label = "Search results cache", label = "Search results cache",
selected = store.searchCacheCap.collectAsState().value, selected = store.searchCacheCap.collectAsState().value,
onPick = { store.setSearchCacheCap(it) }, onPick = { store.setSearchCacheCap(it) },
usageBytes = usage.search, usageBytes = usage?.search ?: 0L,
) )
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
@ -646,11 +653,13 @@ fun SettingsScreen() {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( usage?.let {
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.feed)}", Text(
style = MaterialTheme.typography.labelSmall, text = "Used: ${com.sulkta.straw.util.StorageUsage.format(it.feed)}",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
@ -662,11 +671,13 @@ fun SettingsScreen() {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( usage?.let {
text = "Used: ${com.sulkta.straw.util.StorageUsage.format(usage.coil)}", Text(
style = MaterialTheme.typography.labelSmall, text = "Used: ${com.sulkta.straw.util.StorageUsage.format(it.coil)}",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@ -844,6 +855,15 @@ private fun CategoryRow(
} }
} }
/** On-disk usage snapshot, sampled off the main thread (audit #2 L-14). */
private data class CacheUsage(
val history: Long,
val resume: Long,
val search: Long,
val feed: Long,
val coil: Long,
)
/** /**
* Compact chip-group row for picking a CacheCap. Label on the left, * Compact chip-group row for picking a CacheCap. Label on the left,
* 5 chips on the right, optional "Used: X KB" suffix to the right * 5 chips on the right, optional "Used: X KB" suffix to the right

View file

@ -2,73 +2,14 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Bounded body reader. AUD-HIGH flagged that body.string() reads the whole * Shared HTTP bits for the Android layer. The RYD + SponsorBlock JSON
* response into memory with no cap a hostile or compromised endpoint * clients moved to Rust (strawcore `net.rs`) in the vc=85 backend
* (RYD, SponsorBlock, even a poisoned YT response) can OOM the app. * migration, which took the OkHttp client + bounded-body reader with it
* * so all that survives here is the User-Agent string, still used by the
* Partial defense: if Content-Length is advertised and over the cap, we * ExoPlayer DataSource factory (PlaybackService).
* refuse the read. If Content-Length is absent (chunked transfer), we
* stream up to the cap and refuse if the body keeps coming.
*/ */
package com.sulkta.straw.net package com.sulkta.straw.net
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okio.Buffer
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Path C-6 / Phase U-5: USER_AGENT + shared OkHttpClient that previously
* lived on NewPipeDownloader. After ripping NewPipeExtractor, the RYD +
* SponsorBlock + ExoPlayer HTTP factories still need both. One shared
* client is fine.
*/
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"
// OkHttpClient is internally thread-safe; lazy(SYNCHRONIZED) builds
// exactly once across threads. — 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 = sharedClient
fun ResponseBody.cappedString(maxBytes: Long): String {
val cl = contentLength()
if (cl in 1..maxBytes) {
return string()
}
if (cl > maxBytes) {
close()
throw IOException("response too large: $cl bytes > $maxBytes cap")
}
// Chunked transfer or no content-length advertised — stream up to cap.
val src = source()
val buf = Buffer()
var read = 0L
while (read < maxBytes) {
val chunk = src.read(buf, maxBytes - read)
if (chunk == -1L) break
read += chunk
}
if (!src.exhausted()) {
close()
throw IOException("response exceeded cap of $maxBytes bytes")
}
return buf.readUtf8()
}
const val NEWPIPE_MAX_BYTES = 8L * 1024 * 1024
const val RYD_MAX_BYTES = 256L * 1024
const val SB_MAX_BYTES = 1L * 1024 * 1024

View file

@ -3,11 +3,18 @@
SPDX-FileCopyrightText: 2026 Sulkta SPDX-FileCopyrightText: 2026 Sulkta
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
Returnyoutubedislike.com serves a leaf cert without including the base-config: app-wide trust = system roots, cleartext OFF. This is live —
Sectigo "Public Server Authentication CA DV R36" intermediate. it governs every Android-layer request (ExoPlayer DataSource, Coil image
Android's system trust store has the USERTrust root but not the loads, the auto-updater).
intermediate, so chain reconstruction fails. We bundle the intermediate
as an additional trust anchor scoped to that domain. domain-config (RYD): historically RYD's host served a leaf without the
Sectigo "Public Server Authentication CA DV R36" intermediate, so we
bundled that intermediate as an extra trust anchor for the OkHttp RYD
client. As of vc=85 the RYD + SponsorBlock clients moved to Rust
(strawcore net.rs), which uses reqwest's OWN trust store (rustls +
webpki-roots) and does NOT consult this file — so this block + the bundled
@raw/sectigo_dv_r36 cert no longer affect RYD traffic. Kept as an inert,
harmless guard in case any Android-layer code ever hits those hosts again.
--> -->
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="false"> <base-config cleartextTrafficPermitted="false">
@ -15,8 +22,9 @@
<certificates src="system" /> <certificates src="system" />
</trust-anchors> </trust-anchors>
</base-config> </base-config>
<!-- domain-config does NOT inherit cleartextTrafficPermitted from base-config; set it <!-- Vestigial since vc=85 (RYD/SB are Rust/reqwest now — see above). Left in
explicitly here so RYD stays HTTPS-only even if the default ever flips. --> place as a no-cleartext guard for the RYD hosts should any Android-layer
request ever target them. -->
<domain-config cleartextTrafficPermitted="false"> <domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">returnyoutubedislike.com</domain> <domain includeSubdomains="true">returnyoutubedislike.com</domain>
<domain includeSubdomains="true">returnyoutubedislikeapi.com</domain> <domain includeSubdomains="true">returnyoutubedislikeapi.com</domain>