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:
parent
7fff36c5e3
commit
2e75938f4e
7 changed files with 296 additions and 29 deletions
|
|
@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 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"
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue