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 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 58 const val STRAW_VERSION_CODE = 59
const val STRAW_VERSION_NAME = "0.1.0-BR" const val STRAW_VERSION_NAME = "0.1.0-BS"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" 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 sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true } 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 { fun load(): Map<String, FeedCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyMap() 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()) }.getOrDefault(emptyMap())
/** Atomic write. Caller is responsible for diffing if needed. */ /** Atomic write. Caller is responsible for diffing if needed. */

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Recent watches + recent searches backed by SharedPreferences JSON * 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. * 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 PREFS = "straw_history"
private const val KEY_WATCHES = "watches_v1" private const val KEY_WATCHES = "watches_v1"
private const val KEY_SEARCHES = "searches_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) { 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 }
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()) private val _watches = MutableStateFlow(loadWatches())
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow() val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
@ -51,7 +68,7 @@ class HistoryStore(context: Context) {
// is exactly the bug updateAndGet avoids. // is exactly the bug updateAndGet avoids.
val next = _watches.updateAndGet { current -> val next = _watches.updateAndGet { current ->
val without = current.filterNot { it.videoId == item.videoId } 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() 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 * Walks input newest-first (input is fed oldest-first), filters
* blanks + already-seen videoIds, prepends to current, then takes * 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 * store is at the cap the vc=37 first cut silently discarded
* the whole import in that case (round-3 audit HIGH-1). * 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 * store on this call (counts new videoIds; duplicates of
* already-recorded entries don't count). Round-4 audit HIGH-7 * already-recorded entries don't count). Round-4 audit HIGH-7
* SettingsImport previously reported `size_after - size_before` * 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 * be 50 = pre-state even when 20 imports landed and 20 older
* locals were truncated to make room). * locals were truncated to make room).
*/ */
@ -92,11 +109,11 @@ class HistoryStore(context: Context) {
val seen = HashSet<String>(current.size + items.size) val seen = HashSet<String>(current.size + items.size)
current.forEach { seen.add(it.videoId) } current.forEach { seen.add(it.videoId) }
// Build the import list newest-first. Capped at // Build the import list newest-first. Capped at
// 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. // even on a 50k-row hostile export.
val fresh = ArrayList<WatchHistoryItem>(MAX_WATCHES) val fresh = ArrayList<WatchHistoryItem>(maxWatches())
val it = items.listIterator(items.size) val it = items.listIterator(items.size)
while (it.hasPrevious() && fresh.size < MAX_WATCHES) { while (it.hasPrevious() && fresh.size < maxWatches()) {
val item = it.previous() val item = it.previous()
if (item.videoId.isBlank()) continue if (item.videoId.isBlank()) continue
if (!seen.add(item.videoId)) continue if (!seen.add(item.videoId)) continue
@ -105,8 +122,8 @@ class HistoryStore(context: Context) {
} }
if (fresh.isEmpty()) return@updateAndGet current if (fresh.isEmpty()) return@updateAndGet current
// Combine + cap. take() truncates older `current` entries // Combine + cap. take() truncates older `current` entries
// when we'd exceed MAX_WATCHES, so imports always land. // when we'd exceed maxWatches(), so imports always land.
(fresh + current).take(MAX_WATCHES) (fresh + current).take(maxWatches())
} }
if (next !== before) { if (next !== before) {
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
@ -133,9 +150,9 @@ class HistoryStore(context: Context) {
counter.set(0) counter.set(0)
val seen = HashSet<String>(current.size + queries.size) val seen = HashSet<String>(current.size + queries.size)
current.forEach { seen.add(it.lowercase()) } current.forEach { seen.add(it.lowercase()) }
val fresh = ArrayList<String>(MAX_SEARCHES) val fresh = ArrayList<String>(maxSearches())
val it = queries.listIterator(queries.size) val it = queries.listIterator(queries.size)
while (it.hasPrevious() && fresh.size < MAX_SEARCHES) { while (it.hasPrevious() && fresh.size < maxSearches()) {
val q = it.previous().trim() val q = it.previous().trim()
if (q.isEmpty()) continue if (q.isEmpty()) continue
if (!seen.add(q.lowercase())) continue if (!seen.add(q.lowercase())) continue
@ -143,7 +160,7 @@ class HistoryStore(context: Context) {
counter.incrementAndGet() counter.incrementAndGet()
} }
if (fresh.isEmpty()) return@updateAndGet current if (fresh.isEmpty()) return@updateAndGet current
(fresh + current).take(MAX_SEARCHES) (fresh + current).take(maxSearches())
} }
if (next !== before) { if (next !== before) {
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
@ -156,7 +173,7 @@ class HistoryStore(context: Context) {
if (q.isEmpty()) return if (q.isEmpty()) return
val next = _searches.updateAndGet { current -> val next = _searches.updateAndGet { current ->
val without = current.filterNot { it.equals(q, ignoreCase = true) } 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() 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 * on player teardown, keyed by videoId so resume works across stream
* URL rotations (googlevideo URLs rotate per session). * 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 * oldest-eviction. Same shape as HistoryStore graduates to Room if a
* real query pattern shows up. * real query pattern shows up.
*/ */
@ -34,8 +34,14 @@ data class ResumePosition(
private const val PREFS = "straw_resume_positions" private const val PREFS = "straw_resume_positions"
private const val KEY_POSITIONS = "positions_v1" 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 * 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()) private val _positions = MutableStateFlow(load())
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow() 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 * Record (or update) the scrub-point for a video. Skipped silently
* when: * when:
@ -84,13 +95,13 @@ class ResumePositionsStore(context: Context) {
val before = _positions.value val before = _positions.value
val next = _positions.updateAndGet { current -> val next = _positions.updateAndGet { current ->
val withEntry = current + (videoId to entry) val withEntry = current + (videoId to entry)
if (withEntry.size > MAX_RESUMES) { if (withEntry.size > maxResumes()) {
// Drop oldest by lastWatchedAt — newcomers always land // Drop oldest by lastWatchedAt — newcomers always land
// because the entry we just added is by definition the // 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 withEntry.entries
.sortedByDescending { it.value.lastWatchedAt } .sortedByDescending { it.value.lastWatchedAt }
.take(MAX_RESUMES) .take(maxResumes())
.associate { it.key to it.value } .associate { it.key to it.value }
} else { } else {
withEntry withEntry

View file

@ -41,7 +41,7 @@ data class SearchCacheEntry(
private const val PREFS = "straw_search_cache" private const val PREFS = "straw_search_cache"
private const val KEY = "search_v1" 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 private const val MAX_ITEMS_PER_QUERY = 20
class SearchCacheStore(context: Context) { class SearchCacheStore(context: Context) {
@ -51,13 +51,29 @@ class SearchCacheStore(context: Context) {
private val _entries = MutableStateFlow(loadFromDisk()) private val _entries = MutableStateFlow(loadFromDisk())
val entries: StateFlow<List<SearchCacheEntry>> = _entries.asStateFlow() 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. */ /** 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 * Record a freshly-fetched query result. Idempotent: a re-run of
* the same query overwrites the prior entry rather than duplicating. * 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. * Atomic via updateAndGet concurrent records don't lose entries.
*/ */
@ -68,7 +84,7 @@ class SearchCacheStore(context: Context) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val next = _entries.updateAndGet { current -> val next = _entries.updateAndGet { current ->
val without = current.filterNot { it.query.equals(q, ignoreCase = true) } 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() sp.edit().putString(KEY, json.encodeToString(next)).apply()
} }

View file

@ -69,6 +69,41 @@ enum class AutoUpdateInterval(val label: String) {
H24("Every 24 hours"), 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 PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_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_VC = "latest_known_vc_v1"
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_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_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) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -198,6 +238,38 @@ class SettingsStore(context: Context) {
) )
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow() 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) { fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur -> val next = _sbCategories.updateAndGet { cur ->
@ -302,6 +374,44 @@ class SettingsStore(context: Context) {
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply() 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> { private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null) val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) { return if (raw == null) {

View file

@ -47,7 +47,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import com.sulkta.straw.BuildConfig import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.AutoUpdateInterval 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.FeedCache
import com.sulkta.straw.data.Resume
import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.formatRelativeSince 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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
"History", "History",
@ -544,6 +606,20 @@ fun SettingsScreen() {
Text("Clear searches") 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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
@ -665,3 +741,31 @@ private fun CategoryRow(
Switch(checked = enabled, onCheckedChange = { onToggle() }) 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) },
)
}
}
}
}