vc=59: per-store cache caps + TTL + Clear all caches

User-facing cache controls Cobb specifically asked for. Each
SharedPreferences-backed store now reads its cap from Settings
instead of a hardcoded constant:

- History watches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 50)
- History searches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 20)
- Resume positions: same options (was fixed 500)
- Search results cache: same options (was fixed 30 queries)

Each store also enforces a hard ceiling (100k for History +
Resume, 5k for SearchCache) so Unlimited doesn't OOM SP on a
hostile import.

New global Cache TTL: 1 day / 7 days / 30 days / 1 year /
Forever. Drops subs feed + search cache entries older than the
cutoff on every read. Defaults to 30 days.

Settings UI — new 'Cache & history limits' section inside the
existing Local cache block with one chip-row per cap + the TTL
chip-row + a 'Clear all caches' button that nukes FeedCache,
SearchCache, ResumePositions, History.watches, History.searches
on one tap.
This commit is contained in:
Kayos 2026-05-26 11:33:53 -07:00
parent 7fff36c5e3
commit 2e75938f4e
7 changed files with 296 additions and 29 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 58
const val STRAW_VERSION_NAME = "0.1.0-BR"
const val STRAW_VERSION_CODE = 59
const val STRAW_VERSION_NAME = "0.1.0-BS"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -38,10 +38,19 @@ class FeedCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
/** Snapshot of the disk cache. Returns empty map if nothing saved. */
/**
* Snapshot of the disk cache, filtered by the user-configured TTL.
* Returns empty map if nothing saved or everything expired. vc=59
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
* drop entries whose fetchedAt is older than (now - ttl).
*/
fun load(): Map<String, FeedCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyMap()
json.decodeFromString<Map<String, FeedCacheEntry>>(s)
val raw = json.decodeFromString<Map<String, FeedCacheEntry>>(s)
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return raw
val cutoff = System.currentTimeMillis() - ttl.ms
raw.filterValues { it.fetchedAt >= cutoff }
}.getOrDefault(emptyMap())
/** Atomic write. Caller is responsible for diffing if needed. */

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Recent watches + recent searches backed by SharedPreferences JSON
* blobs. Capped to MAX_WATCHES / MAX_SEARCHES. Graduates to Room when
* blobs. Capped to maxWatches() / maxSearches(). Graduates to Room when
* a real query pattern (date ranges, full-text search) shows up.
*/
@ -31,13 +31,30 @@ data class WatchHistoryItem(
private const val PREFS = "straw_history"
private const val KEY_WATCHES = "watches_v1"
private const val KEY_SEARCHES = "searches_v1"
private const val MAX_WATCHES = 50
private const val MAX_SEARCHES = 20
/**
* Pre-vc=59 hard limits. Still used as the absolute upper bound when
* Settings.historyWatchesCap is CacheCap.Unlimited we don't want to
* allow truly-uncapped growth that could OOM SP on a hostile import.
* Any user-picked cap above this is silently floored to MAX_*_HARD.
*/
private const val maxWatches()_HARD = 100_000
private const val maxSearches()_HARD = 100_000
class HistoryStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
private fun maxWatches(): Int {
val cap = Settings.get().historyWatchesCap.value.value
return cap.coerceAtMost(maxWatches()_HARD)
}
private fun maxSearches(): Int {
val cap = Settings.get().historySearchesCap.value.value
return cap.coerceAtMost(maxSearches()_HARD)
}
private val _watches = MutableStateFlow(loadWatches())
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
@ -51,7 +68,7 @@ class HistoryStore(context: Context) {
// is exactly the bug updateAndGet avoids.
val next = _watches.updateAndGet { current ->
val without = current.filterNot { it.videoId == item.videoId }
(listOf(now) + without).take(MAX_WATCHES)
(listOf(now) + without).take(maxWatches())
}
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}
@ -63,7 +80,7 @@ class HistoryStore(context: Context) {
*
* Walks input newest-first (input is fed oldest-first), filters
* blanks + already-seen videoIds, prepends to current, then takes
* MAX_WATCHES. Imports WIN over older current entries when the
* maxWatches(). Imports WIN over older current entries when the
* store is at the cap the vc=37 first cut silently discarded
* the whole import in that case (round-3 audit HIGH-1).
*
@ -76,7 +93,7 @@ class HistoryStore(context: Context) {
* store on this call (counts new videoIds; duplicates of
* already-recorded entries don't count). Round-4 audit HIGH-7
* SettingsImport previously reported `size_after - size_before`
* which lies when the store was at MAX_WATCHES (post-state can
* which lies when the store was at maxWatches() (post-state can
* be 50 = pre-state even when 20 imports landed and 20 older
* locals were truncated to make room).
*/
@ -92,11 +109,11 @@ class HistoryStore(context: Context) {
val seen = HashSet<String>(current.size + items.size)
current.forEach { seen.add(it.videoId) }
// Build the import list newest-first. Capped at
// MAX_WATCHES on its own so we don't over-allocate
// maxWatches() on its own so we don't over-allocate
// even on a 50k-row hostile export.
val fresh = ArrayList<WatchHistoryItem>(MAX_WATCHES)
val fresh = ArrayList<WatchHistoryItem>(maxWatches())
val it = items.listIterator(items.size)
while (it.hasPrevious() && fresh.size < MAX_WATCHES) {
while (it.hasPrevious() && fresh.size < maxWatches()) {
val item = it.previous()
if (item.videoId.isBlank()) continue
if (!seen.add(item.videoId)) continue
@ -105,8 +122,8 @@ class HistoryStore(context: Context) {
}
if (fresh.isEmpty()) return@updateAndGet current
// Combine + cap. take() truncates older `current` entries
// when we'd exceed MAX_WATCHES, so imports always land.
(fresh + current).take(MAX_WATCHES)
// when we'd exceed maxWatches(), so imports always land.
(fresh + current).take(maxWatches())
}
if (next !== before) {
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
@ -133,9 +150,9 @@ class HistoryStore(context: Context) {
counter.set(0)
val seen = HashSet<String>(current.size + queries.size)
current.forEach { seen.add(it.lowercase()) }
val fresh = ArrayList<String>(MAX_SEARCHES)
val fresh = ArrayList<String>(maxSearches())
val it = queries.listIterator(queries.size)
while (it.hasPrevious() && fresh.size < MAX_SEARCHES) {
while (it.hasPrevious() && fresh.size < maxSearches()) {
val q = it.previous().trim()
if (q.isEmpty()) continue
if (!seen.add(q.lowercase())) continue
@ -143,7 +160,7 @@ class HistoryStore(context: Context) {
counter.incrementAndGet()
}
if (fresh.isEmpty()) return@updateAndGet current
(fresh + current).take(MAX_SEARCHES)
(fresh + current).take(maxSearches())
}
if (next !== before) {
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
@ -156,7 +173,7 @@ class HistoryStore(context: Context) {
if (q.isEmpty()) return
val next = _searches.updateAndGet { current ->
val without = current.filterNot { it.equals(q, ignoreCase = true) }
(listOf(q) + without).take(MAX_SEARCHES)
(listOf(q) + without).take(maxSearches())
}
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
}

View file

@ -8,7 +8,7 @@
* on player teardown, keyed by videoId so resume works across stream
* URL rotations (googlevideo URLs rotate per session).
*
* SharedPreferences-lite, single JSON blob, capped at MAX_RESUMES with
* SharedPreferences-lite, single JSON blob, capped at maxResumes() with
* oldest-eviction. Same shape as HistoryStore graduates to Room if a
* real query pattern shows up.
*/
@ -34,8 +34,14 @@ data class ResumePosition(
private const val PREFS = "straw_resume_positions"
private const val KEY_POSITIONS = "positions_v1"
/** Cap on retained per-video resume entries — prune oldest on overflow. */
private const val MAX_RESUMES = 500
/**
* Pre-vc=59 hard cap. Now a ceiling rather than a fixed value: the
* user-picked cap from Settings.resumePositionsCap is silently floored
* to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here
* than HistoryStore because resume entries are tiny (~50 bytes each)
* vs WatchHistoryItem's ~250 bytes.
*/
private const val maxResumes()_HARD = 100_000
/**
* Skip writes for trivial positions auto-resuming from 0:03 is more
@ -58,6 +64,11 @@ class ResumePositionsStore(context: Context) {
private val _positions = MutableStateFlow(load())
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
private fun maxResumes(): Int {
val cap = Settings.get().resumePositionsCap.value.value
return cap.coerceAtMost(maxResumes()_HARD)
}
/**
* Record (or update) the scrub-point for a video. Skipped silently
* when:
@ -84,13 +95,13 @@ class ResumePositionsStore(context: Context) {
val before = _positions.value
val next = _positions.updateAndGet { current ->
val withEntry = current + (videoId to entry)
if (withEntry.size > MAX_RESUMES) {
if (withEntry.size > maxResumes()) {
// Drop oldest by lastWatchedAt — newcomers always land
// because the entry we just added is by definition the
// freshest. take(MAX_RESUMES) of the sorted-desc list.
// freshest. take(maxResumes()) of the sorted-desc list.
withEntry.entries
.sortedByDescending { it.value.lastWatchedAt }
.take(MAX_RESUMES)
.take(maxResumes())
.associate { it.key to it.value }
} else {
withEntry

View file

@ -41,7 +41,7 @@ data class SearchCacheEntry(
private const val PREFS = "straw_search_cache"
private const val KEY = "search_v1"
private const val MAX_QUERIES = 30
private const val maxQueries()_HARD = 5000
private const val MAX_ITEMS_PER_QUERY = 20
class SearchCacheStore(context: Context) {
@ -51,13 +51,29 @@ class SearchCacheStore(context: Context) {
private val _entries = MutableStateFlow(loadFromDisk())
val entries: StateFlow<List<SearchCacheEntry>> = _entries.asStateFlow()
private fun maxQueries(): Int =
Settings.get().searchCacheCap.value.value.coerceAtMost(maxQueries()_HARD)
/**
* Filter out entries older than the configured TTL. Called on every
* read path so stale data never surfaces. Forever (ttl.isForever)
* is a no-op. Returns a fresh list caller decides whether to
* persist the trim.
*/
private fun filterByTtl(items: List<SearchCacheEntry>): List<SearchCacheEntry> {
val ttl = Settings.get().cacheTtl.value
if (ttl.isForever) return items
val cutoff = System.currentTimeMillis() - ttl.ms
return items.filter { it.fetchedAt >= cutoff }
}
/** Snapshot of the cache. Used by the reactive search filter. */
fun load(): List<SearchCacheEntry> = _entries.value
fun load(): List<SearchCacheEntry> = filterByTtl(_entries.value)
/**
* Record a freshly-fetched query result. Idempotent: a re-run of
* the same query overwrites the prior entry rather than duplicating.
* Oldest entries fall off when MAX_QUERIES is exceeded.
* Oldest entries fall off when maxQueries() is exceeded.
*
* Atomic via updateAndGet concurrent records don't lose entries.
*/
@ -68,7 +84,7 @@ class SearchCacheStore(context: Context) {
val now = System.currentTimeMillis()
val next = _entries.updateAndGet { current ->
val without = current.filterNot { it.query.equals(q, ignoreCase = true) }
(listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES)
(listOf(SearchCacheEntry(q, now, capped)) + without).take(maxQueries())
}
sp.edit().putString(KEY, json.encodeToString(next)).apply()
}

View file

@ -69,6 +69,41 @@ enum class AutoUpdateInterval(val label: String) {
H24("Every 24 hours"),
}
/**
* User-facing cache caps. Each store's hard limit is the cap's value;
* `Int.MAX_VALUE` means "unlimited" (the store grows without trimming).
* Defaults match the pre-vc=59 hardcoded constants so existing data
* keeps the same shape until the user picks something different.
*/
enum class CacheCap(val label: String, val value: Int) {
Tiny("50", 50),
Small("200", 200),
Medium("1000", 1000),
Large("10000", 10000),
Unlimited("Unlimited", Int.MAX_VALUE);
companion object {
fun nearest(target: Int): CacheCap =
entries.firstOrNull { it.value == target } ?: Unlimited
}
}
/**
* TTL knob for time-decayed caches (subs feed + search results). 0
* means "forever" entries never time out and only fall off via
* size cap. Shorter TTLs reclaim disk on devices with tight storage.
*/
enum class CacheTtl(val label: String, val days: Int) {
D1("1 day", 1),
D7("7 days", 7),
D30("30 days", 30),
D365("1 year", 365),
Forever("Forever", 0);
val isForever: Boolean get() = days == 0
val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L
}
private const val PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_v1"
@ -85,6 +120,11 @@ private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1"
private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1"
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
private const val KEY_HIDE_SHORTS = "hide_shorts_v1"
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1"
private const val KEY_CACHE_RESUME_POSITIONS = "cache_resume_positions_v1"
private const val KEY_CACHE_SEARCH = "cache_search_v1"
private const val KEY_CACHE_TTL = "cache_ttl_v1"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -198,6 +238,38 @@ class SettingsStore(context: Context) {
)
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
/**
* Per-store cache caps. Each store reads its cap from the matching
* StateFlow on every prune cycle so flipping the toggle in Settings
* takes effect immediately (next write trims to the new cap; reads
* are unbounded since they're already in memory).
*
* Defaults match the pre-vc=59 hardcoded constants so first-launch
* behavior is unchanged from prior versions.
*/
private val _historyWatchesCap = MutableStateFlow(
CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, 50)),
)
val historyWatchesCap: StateFlow<CacheCap> = _historyWatchesCap.asStateFlow()
private val _historySearchesCap = MutableStateFlow(
loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20),
)
val historySearchesCap: StateFlow<CacheCap> = _historySearchesCap.asStateFlow()
private val _resumePositionsCap = MutableStateFlow(
loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500),
)
val resumePositionsCap: StateFlow<CacheCap> = _resumePositionsCap.asStateFlow()
private val _searchCacheCap = MutableStateFlow(
loadCap(KEY_CACHE_SEARCH, default = 30),
)
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()
private val _cacheTtl = MutableStateFlow(loadCacheTtl())
val cacheTtl: StateFlow<CacheTtl> = _cacheTtl.asStateFlow()
fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@ -302,6 +374,44 @@ class SettingsStore(context: Context) {
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply()
}
fun setHistoryWatchesCap(cap: CacheCap) {
if (_historyWatchesCap.value == cap) return
_historyWatchesCap.value = cap
sp.edit().putInt(KEY_CACHE_HISTORY_WATCHES, cap.value).apply()
}
fun setHistorySearchesCap(cap: CacheCap) {
if (_historySearchesCap.value == cap) return
_historySearchesCap.value = cap
sp.edit().putInt(KEY_CACHE_HISTORY_SEARCHES, cap.value).apply()
}
fun setResumePositionsCap(cap: CacheCap) {
if (_resumePositionsCap.value == cap) return
_resumePositionsCap.value = cap
sp.edit().putInt(KEY_CACHE_RESUME_POSITIONS, cap.value).apply()
}
fun setSearchCacheCap(cap: CacheCap) {
if (_searchCacheCap.value == cap) return
_searchCacheCap.value = cap
sp.edit().putInt(KEY_CACHE_SEARCH, cap.value).apply()
}
fun setCacheTtl(ttl: CacheTtl) {
if (_cacheTtl.value == ttl) return
_cacheTtl.value = ttl
sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply()
}
private fun loadCap(key: String, default: Int): CacheCap =
CacheCap.nearest(sp.getInt(key, default))
private fun loadCacheTtl(): CacheTtl {
val name = sp.getString(KEY_CACHE_TTL, null) ?: return CacheTtl.D30
return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30
}
private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {

View file

@ -47,7 +47,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.material3.FilterChip
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.AutoUpdateInterval
import com.sulkta.straw.data.CacheCap
import com.sulkta.straw.data.CacheTtl
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.Resume
import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.formatRelativeSince
@ -529,6 +532,65 @@ fun SettingsScreen() {
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
"Cache & history limits",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Pick how much to keep. Unlimited = no auto-pruning. Old " +
"entries beyond a TTL are dropped on read.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
CacheCapRow(
label = "Watch history",
selected = store.historyWatchesCap.collectAsState().value,
onPick = { store.setHistoryWatchesCap(it) },
)
CacheCapRow(
label = "Search history",
selected = store.historySearchesCap.collectAsState().value,
onPick = { store.setHistorySearchesCap(it) },
)
CacheCapRow(
label = "Resume positions",
selected = store.resumePositionsCap.collectAsState().value,
onPick = { store.setResumePositionsCap(it) },
)
CacheCapRow(
label = "Search results cache",
selected = store.searchCacheCap.collectAsState().value,
onPick = { store.setSearchCacheCap(it) },
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Cache TTL",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
"Drop subs feed + search cache entries older than this.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val ttl by store.cacheTtl.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CacheTtl.entries.forEach { opt ->
FilterChip(
selected = ttl == opt,
onClick = { store.setCacheTtl(opt) },
label = { Text(opt.label) },
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"History",
@ -544,6 +606,20 @@ fun SettingsScreen() {
Text("Clear searches")
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
scope.launch {
withContext(Dispatchers.IO) {
runCatching { FeedCache.get().clear() }
runCatching { SearchCache.get().clear() }
runCatching { Resume.get().clearAll() }
runCatching { History.get().clearWatches() }
runCatching { History.get().clearSearches() }
}
}
},
) { Text("Clear all caches") }
Spacer(modifier = Modifier.height(32.dp))
Text(
@ -665,3 +741,31 @@ private fun CategoryRow(
Switch(checked = enabled, onCheckedChange = { onToggle() })
}
}
/**
* Compact chip-group row for picking a CacheCap. Label on the left,
* 5 chips on the right. Used four times in the Cache section so the
* shape is consolidated here.
*/
@Composable
private fun CacheCapRow(
label: String,
selected: CacheCap,
onPick: (CacheCap) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
CacheCap.entries.forEach { opt ->
FilterChip(
selected = selected == opt,
onClick = { onPick(opt) },
label = { Text(opt.label) },
)
}
}
}
}