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:
parent
378634078e
commit
ce7174cb5e
12 changed files with 242 additions and 179 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue