vc=59 cont: wire bg subs refresh + R8 keep + Settings UI

Bundling the background-refresh worker (originally planned as
vc=60) into the same release as cache controls — they're both
storage-and-refresh user-facing knobs, ships cleaner together.

- StrawApp.onCreate calls FeedRefreshScheduler.applyFromSettings
- R8 keep rule for FeedRefreshWorker (same reason as
  UpdateCheckWorker — WorkManager instantiates via reflection)
- Settings UI: 'Auto-refresh subs' toggle (default off) +
  interval chip-row (30min / 1h / 6h) shown when enabled. Lives
  in the existing Local cache section since it's the same
  storage-and-refresh theme.

Worker calls uniffi.strawcore.subscriptionFeed which fans out 50
parallel RSS fetches in Rust — 50 subs refreshes in ~1-2s in the
background. Writes per-channel into FeedCacheStore so next cold
open of Subs paints instantly.
This commit is contained in:
Sulkta 2026-05-26 11:38:04 -07:00
parent d98d8cb0d4
commit dac381be8b
4 changed files with 108 additions and 0 deletions

View file

@ -86,4 +86,5 @@
# renames our UpdateCheckWorker the scheduler enqueues it but the # renames our UpdateCheckWorker the scheduler enqueues it but the
# instantiation fails silently and no checks ever run. # instantiation fails silently and no checks ever run.
-keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; } -keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; }
-keep class com.sulkta.straw.feature.feed.FeedRefreshWorker { *; }
-keep class * extends androidx.work.ListenableWorker { *; } -keep class * extends androidx.work.ListenableWorker { *; }

View file

@ -14,6 +14,7 @@ import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.dataimport.SettingsImport import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.feed.FeedRefreshScheduler
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.strawLogW import com.sulkta.straw.util.strawLogW
@ -93,5 +94,8 @@ class StrawApp : Application() {
if (Settings.get().autoUpdateCheck.value) { if (Settings.get().autoUpdateCheck.value) {
appScope.launch { runUpdateCheck(this@StrawApp) } appScope.launch { runUpdateCheck(this@StrawApp) }
} }
// Background subs feed refresh — opt-in periodic WorkManager
// job that pre-warms FeedCache so cold open paints fresh.
FeedRefreshScheduler.applyFromSettings(this)
} }
} }

View file

@ -104,6 +104,18 @@ enum class CacheTtl(val label: String, val days: Int) {
val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L
} }
/**
* How often the background subs-feed-refresh worker polls. Defaults to
* 1h tighter than that wastes battery without meaningful freshness
* gain (YouTube uploads aren't real-time). Background worker is OFF
* by default; opt-in via Settings.
*/
enum class BgFeedRefreshInterval(val label: String) {
M30("Every 30 minutes"),
H1("Every hour"),
H6("Every 6 hours"),
}
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"
@ -125,6 +137,8 @@ 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_RESUME_POSITIONS = "cache_resume_positions_v1"
private const val KEY_CACHE_SEARCH = "cache_search_v1" private const val KEY_CACHE_SEARCH = "cache_search_v1"
private const val KEY_CACHE_TTL = "cache_ttl_v1" private const val KEY_CACHE_TTL = "cache_ttl_v1"
private const val KEY_BG_FEED_REFRESH_ENABLED = "bg_feed_refresh_enabled_v1"
private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1"
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -270,6 +284,21 @@ class SettingsStore(context: Context) {
private val _cacheTtl = MutableStateFlow(loadCacheTtl()) private val _cacheTtl = MutableStateFlow(loadCacheTtl())
val cacheTtl: StateFlow<CacheTtl> = _cacheTtl.asStateFlow() val cacheTtl: StateFlow<CacheTtl> = _cacheTtl.asStateFlow()
/**
* Background subscription-feed refresh WorkManager periodic job
* that pre-warms FeedCache so the next cold open paints a fresh
* feed without pull-to-refresh. Off by default; cell-network
* battery cost is the explicit opt-in.
*/
private val _bgFeedRefreshEnabled = MutableStateFlow(
sp.getBoolean(KEY_BG_FEED_REFRESH_ENABLED, false),
)
val bgFeedRefreshEnabled: StateFlow<Boolean> = _bgFeedRefreshEnabled.asStateFlow()
private val _bgFeedRefreshInterval = MutableStateFlow(loadBgFeedInterval())
val bgFeedRefreshInterval: StateFlow<BgFeedRefreshInterval> =
_bgFeedRefreshInterval.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 ->
@ -404,6 +433,18 @@ class SettingsStore(context: Context) {
sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply() sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply()
} }
fun setBgFeedRefreshEnabled(enabled: Boolean) {
if (_bgFeedRefreshEnabled.value == enabled) return
_bgFeedRefreshEnabled.value = enabled
sp.edit().putBoolean(KEY_BG_FEED_REFRESH_ENABLED, enabled).apply()
}
fun setBgFeedRefreshInterval(interval: BgFeedRefreshInterval) {
if (_bgFeedRefreshInterval.value == interval) return
_bgFeedRefreshInterval.value = interval
sp.edit().putString(KEY_BG_FEED_REFRESH_INTERVAL, interval.name).apply()
}
private fun loadCap(key: String, default: Int): CacheCap = private fun loadCap(key: String, default: Int): CacheCap =
CacheCap.nearest(sp.getInt(key, default)) CacheCap.nearest(sp.getInt(key, default))
@ -412,6 +453,13 @@ class SettingsStore(context: Context) {
return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30 return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30
} }
private fun loadBgFeedInterval(): BgFeedRefreshInterval {
val name = sp.getString(KEY_BG_FEED_REFRESH_INTERVAL, null)
?: return BgFeedRefreshInterval.H1
return BgFeedRefreshInterval.entries.firstOrNull { it.name == name }
?: BgFeedRefreshInterval.H1
}
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,10 +47,12 @@ 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.BgFeedRefreshInterval
import com.sulkta.straw.data.CacheCap import com.sulkta.straw.data.CacheCap
import com.sulkta.straw.data.CacheTtl 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.data.Resume
import com.sulkta.straw.feature.feed.FeedRefreshScheduler
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
@ -532,6 +534,59 @@ fun SettingsScreen() {
) )
} }
Spacer(modifier = Modifier.height(16.dp))
Text(
"Background refresh",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Periodically pre-fetch the subs feed so the next time you " +
"open Straw the latest videos are already there. Off by " +
"default (battery cost on cell).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
val bgEnabled by store.bgFeedRefreshEnabled.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Auto-refresh subs",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Switch(
checked = bgEnabled,
onCheckedChange = { checked ->
store.setBgFeedRefreshEnabled(checked)
FeedRefreshScheduler.applyFromSettings(context)
},
)
}
if (bgEnabled) {
val bgInterval by store.bgFeedRefreshInterval.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BgFeedRefreshInterval.entries.forEach { opt ->
FilterChip(
selected = bgInterval == opt,
onClick = {
store.setBgFeedRefreshInterval(opt)
FeedRefreshScheduler.applyFromSettings(context)
},
label = { Text(opt.label) },
)
}
}
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Cache & history limits", "Cache & history limits",