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:
Kayos 2026-05-26 11:38:04 -07:00
parent c4bf7446c9
commit aead95f1bc
4 changed files with 108 additions and 0 deletions

View file

@ -86,4 +86,5 @@
# renames our UpdateCheckWorker the scheduler enqueues it but the
# instantiation fails silently and no checks ever run.
-keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; }
-keep class com.sulkta.straw.feature.feed.FeedRefreshWorker { *; }
-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.Subscriptions
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.runUpdateCheck
import com.sulkta.straw.util.strawLogW
@ -93,5 +94,8 @@ class StrawApp : Application() {
if (Settings.get().autoUpdateCheck.value) {
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
}
/**
* 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 KEY_SB_CATS = "sb_categories_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_SEARCH = "cache_search_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) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -270,6 +284,21 @@ class SettingsStore(context: Context) {
private val _cacheTtl = MutableStateFlow(loadCacheTtl())
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) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@ -404,6 +433,18 @@ class SettingsStore(context: Context) {
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 =
CacheCap.nearest(sp.getInt(key, default))
@ -412,6 +453,13 @@ class SettingsStore(context: Context) {
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> {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {

View file

@ -47,10 +47,12 @@ 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.BgFeedRefreshInterval
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.feed.FeedRefreshScheduler
import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
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))
Text(
"Cache & history limits",