diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index b65da1030..72b5ffa36 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt index deb46f2f6..af886ccf5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -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 = runCatching { val s = sp.getString(KEY, null) ?: return emptyMap() - json.decodeFromString>(s) + val raw = json.decodeFromString>(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. */ diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index a785c58c2..351e9b7b7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -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> = _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(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(MAX_WATCHES) + val fresh = ArrayList(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(current.size + queries.size) current.forEach { seen.add(it.lowercase()) } - val fresh = ArrayList(MAX_SEARCHES) + val fresh = ArrayList(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() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index 3b4fa5f57..db4621e98 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -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> = _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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index ff917b7e4..5fad63def 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -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> = _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): List { + 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 = _entries.value + fun load(): List = 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() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 60fc8f147..500c330ed 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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 = _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 = _historyWatchesCap.asStateFlow() + + private val _historySearchesCap = MutableStateFlow( + loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20), + ) + val historySearchesCap: StateFlow = _historySearchesCap.asStateFlow() + + private val _resumePositionsCap = MutableStateFlow( + loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500), + ) + val resumePositionsCap: StateFlow = _resumePositionsCap.asStateFlow() + + private val _searchCacheCap = MutableStateFlow( + loadCap(KEY_CACHE_SEARCH, default = 30), + ) + val searchCacheCap: StateFlow = _searchCacheCap.asStateFlow() + + private val _cacheTtl = MutableStateFlow(loadCacheTtl()) + val cacheTtl: StateFlow = _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 { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index ca1b7b6c2..0eeb56246 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -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) }, + ) + } + } + } +}