vc=55: in-app auto-updater polling fdroid.sulkta.com

WorkManager periodic worker hits the repo's index-v2.json, parses
the highest versionCode for our package, compares with
BuildConfig.VERSION_CODE. When newer: posts a notification with
ACTION_VIEW on the APK URL — Android's DownloadManager picks it up
and the system installer takes over. No INSTALL_PACKAGES perm
needed.

Settings:
- Check for updates toggle (default on — closing NewPipe's silent
  staleness gap is the explicit motivation)
- Interval picker (1h / 6h / 24h, default 6h — WorkManager has a
  15-min periodic floor anyway)
- Last-checked timestamp + 'update available' tag when caught-up
  state is dirty
- Check now button — runs the same path as the worker so behaviors
  stay identical

Cold start fires one check too so users see pending updates without
waiting a full interval.

R8 keep-rule for UpdateCheckWorker added — WorkManager instantiates
workers by name via reflection.
This commit is contained in:
Kayos 2026-05-26 09:40:07 -07:00
parent fbccdce65a
commit ccd24c4ed3
10 changed files with 493 additions and 2 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
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 54
const val STRAW_VERSION_NAME = "0.1.0-BN"
const val STRAW_VERSION_CODE = 55
const val STRAW_VERSION_NAME = "0.1.0-BO"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -130,6 +130,11 @@ dependencies {
// Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
// WorkManager — periodic background poll of fdroid.sulkta.com index
// for self-update notifications. CoroutineWorker is built into the
// base work-runtime artifact as of 2.10.
implementation(libs.androidx.work.runtime)
// strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.

View file

@ -79,3 +79,11 @@
# AGP consumer rules cover this, but documenting the dependency here
# so a future bump doesn't surprise us.
-keep class androidx.compose.runtime.** { *; }
# -- WorkManager Worker classes ----------------------------------------
# WorkManager instantiates Worker subclasses by class name via
# reflection (`Class.forName(workerSpec.workerClassName)`). If R8
# 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 * extends androidx.work.ListenableWorker { *; }

View file

@ -14,6 +14,8 @@ 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.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@ -84,5 +86,12 @@ class StrawApp : Application() {
appScope.launch {
SettingsImport.sweepStale(this@StrawApp)
}
// Auto-update polling. Schedule the periodic worker if enabled,
// then kick a fresh check on cold start so users don't wait a
// full interval to find out about a pending update.
UpdateScheduler.applyFromSettings(this)
if (Settings.get().autoUpdateCheck.value) {
appScope.launch { runUpdateCheck(this@StrawApp) }
}
}
}

View file

@ -58,6 +58,17 @@ enum class AutoplayMode(val label: String, val help: String) {
YtRelated("YouTube related", "Pull from YT's related suggestions. (not yet wired — extractor returns empty)"),
}
/**
* How often the auto-update worker polls fdroid.sulkta.com. WorkManager
* has a 15-minute floor on periodic work, so 1h is the tightest cadence
* we expose.
*/
enum class AutoUpdateInterval(val label: String) {
H1("Every hour"),
H6("Every 6 hours"),
H24("Every 24 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"
@ -68,6 +79,11 @@ private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1"
private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1"
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1"
private const val KEY_AUTO_RESUME = "auto_resume_v1"
private const val KEY_AUTO_UPDATE_CHECK = "auto_update_check_v1"
private const val KEY_AUTO_UPDATE_INTERVAL = "auto_update_interval_v1"
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"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -129,6 +145,40 @@ class SettingsStore(context: Context) {
)
val autoResume: StateFlow<Boolean> = _autoResume.asStateFlow()
/**
* Periodic self-update check against fdroid.sulkta.com. Default on
* NewPipe's "user forgets to update for 6 months" failure mode
* is the explicit thing we're closing.
*/
private val _autoUpdateCheck = MutableStateFlow(
sp.getBoolean(KEY_AUTO_UPDATE_CHECK, true),
)
val autoUpdateCheck: StateFlow<Boolean> = _autoUpdateCheck.asStateFlow()
private val _autoUpdateInterval = MutableStateFlow(loadAutoUpdateInterval())
val autoUpdateInterval: StateFlow<AutoUpdateInterval> = _autoUpdateInterval.asStateFlow()
/** Last successful poll wall-clock ms; 0 if never. */
private val _lastUpdateCheckMs = MutableStateFlow(
sp.getLong(KEY_LAST_UPDATE_CHECK_MS, 0L),
)
val lastUpdateCheckMs: StateFlow<Long> = _lastUpdateCheckMs.asStateFlow()
/**
* Cached "latest version seen on fdroid" 0 / "" while none known
* or while caught-up. Lets SettingsScreen show "vc=55 available"
* without re-polling.
*/
private val _latestKnownVc = MutableStateFlow(
sp.getLong(KEY_LATEST_KNOWN_VC, 0L),
)
val latestKnownVc: StateFlow<Long> = _latestKnownVc.asStateFlow()
private val _latestKnownVname = MutableStateFlow(
sp.getString(KEY_LATEST_KNOWN_VNAME, "") ?: "",
)
val latestKnownVname: StateFlow<String> = _latestKnownVname.asStateFlow()
fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@ -198,6 +248,34 @@ class SettingsStore(context: Context) {
sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply()
}
fun setAutoUpdateCheck(enabled: Boolean) {
val before = _autoUpdateCheck.value
if (before == enabled) return
_autoUpdateCheck.value = enabled
sp.edit().putBoolean(KEY_AUTO_UPDATE_CHECK, enabled).apply()
}
fun setAutoUpdateInterval(interval: AutoUpdateInterval) {
val before = _autoUpdateInterval.value
if (before == interval) return
_autoUpdateInterval.value = interval
sp.edit().putString(KEY_AUTO_UPDATE_INTERVAL, interval.name).apply()
}
fun setLastUpdateCheck(ms: Long) {
_lastUpdateCheckMs.value = ms
sp.edit().putLong(KEY_LAST_UPDATE_CHECK_MS, ms).apply()
}
fun setLatestKnownVersion(vc: Long, vname: String) {
_latestKnownVc.value = vc
_latestKnownVname.value = vname
sp.edit()
.putLong(KEY_LATEST_KNOWN_VC, vc)
.putString(KEY_LATEST_KNOWN_VNAME, vname)
.apply()
}
private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {
@ -225,6 +303,13 @@ class SettingsStore(context: Context) {
val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel
return AutoplayMode.entries.firstOrNull { it.name == name } ?: AutoplayMode.SameChannel
}
private fun loadAutoUpdateInterval(): AutoUpdateInterval {
val name = sp.getString(KEY_AUTO_UPDATE_INTERVAL, null)
?: return AutoUpdateInterval.H6
return AutoUpdateInterval.entries.firstOrNull { it.name == name }
?: AutoUpdateInterval.H6
}
}
object Settings {

View file

@ -44,7 +44,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import android.widget.Toast
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.FeedCache
import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.formatRelativeSince
import com.sulkta.straw.data.History
import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.MaxResolution
@ -331,6 +337,111 @@ fun SettingsScreen() {
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"App updates",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Polls fdroid.sulkta.com for newer Straw builds. When one's " +
"available, a notification taps through to the system " +
"installer. NewPipe's silent-staleness problem, solved.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
val autoUpdateCheck by store.autoUpdateCheck.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Check for updates",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"Background poll. Tap the notification to install.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = autoUpdateCheck,
onCheckedChange = { checked ->
store.setAutoUpdateCheck(checked)
UpdateScheduler.applyFromSettings(context)
},
)
}
if (autoUpdateCheck) {
val interval by store.autoUpdateInterval.collectAsState()
Text(
"Interval",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AutoUpdateInterval.entries.forEach { opt ->
FilterChip(
selected = interval == opt,
onClick = {
store.setAutoUpdateInterval(opt)
UpdateScheduler.applyFromSettings(context)
},
label = { Text(opt.label) },
)
}
}
}
val lastCheckMs by store.lastUpdateCheckMs.collectAsState()
val latestVc by store.latestKnownVc.collectAsState()
val latestVname by store.latestKnownVname.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
val lastText = if (lastCheckMs <= 0L) {
"Never checked."
} else {
"Last checked: ${formatRelativeSince(lastCheckMs)}."
}
Text(
lastText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (latestVc > 0L && latestVc > BuildConfig.VERSION_CODE) {
val label = latestVname.ifBlank { "vc=$latestVc" }
Text(
"Update available: $label.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
}
TextButton(
onClick = {
scope.launch {
withContext(Dispatchers.IO) { runUpdateCheck(context) }
}
},
) { Text("Check now") }
}
Spacer(modifier = Modifier.height(32.dp))
Text(
"Local cache",

View file

@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Poll Sulkta's F-Droid repo for a newer Straw build. Returns the
* highest versionCode + the APK download URL so the worker can post
* a notification.
*
* F-Droid's index-v2.json is the canonical machine-readable shape; we
* parse just the subset we care about (versions.* manifest.versionCode
* + file.name). `ignoreUnknownKeys` keeps us forward-compat with new
* fields fdroidserver may add later.
*/
package com.sulkta.straw.feature.update
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
private const val INDEX_URL = "https://fdroid.sulkta.com/fdroid/repo/index-v2.json"
private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
data class UpdateInfo(
val versionCode: Long,
val versionName: String,
val apkUrl: String,
)
object AppUpdateClient {
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private val json = Json { ignoreUnknownKeys = true }
/**
* Fetch + parse the repo index, return the highest-versionCode entry
* for THIS app's package. Returns null on network/parse failure (the
* caller treats null as "no update available, try again later").
*/
suspend fun fetchLatest(): UpdateInfo? = withContext(Dispatchers.IO) {
runCatchingCancellable {
val req = Request.Builder().url(INDEX_URL).build()
val raw = http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@runCatchingCancellable null
resp.body.string()
}
val index = json.decodeFromString<FdroidIndex>(raw)
val pkg = index.packages[BuildConfig.APPLICATION_ID]
?: return@runCatchingCancellable null
val best = pkg.versions.values
.maxByOrNull { it.manifest.versionCode }
?: return@runCatchingCancellable null
UpdateInfo(
versionCode = best.manifest.versionCode,
versionName = best.manifest.versionName.orEmpty(),
apkUrl = "$REPO_BASE${best.file.name}",
)
}.getOrNull()
}
}
@Serializable
private data class FdroidIndex(val packages: Map<String, FdroidPackage> = emptyMap())
@Serializable
private data class FdroidPackage(val versions: Map<String, FdroidVersion> = emptyMap())
@Serializable
private data class FdroidVersion(val file: FdroidFile, val manifest: FdroidManifest)
@Serializable
private data class FdroidFile(val name: String)
@Serializable
private data class FdroidManifest(
val versionCode: Long,
val versionName: String? = null,
)

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Periodic + on-demand self-update check. WorkManager fires this on the
* cadence the user picked in Settings; on cold start StrawApp also
* kicks one off so the user sees pending updates without waiting a full
* interval. The runner is small + bounded fetch index, compare, post
* notification, done.
*
* NewPipe's biggest UX gap is silent staleness: users sit on
* months-old builds because nothing tells them to update. This worker
* + the SettingsScreen toggle close that gap without trying to be a
* full updater (Android won't let a non-system app silent-install
* APKs anyway). Tap the notification ACTION_VIEW the APK URL the
* system handles download + install confirm.
*/
package com.sulkta.straw.feature.update
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.Settings
import com.sulkta.straw.util.strawLogI
/**
* Single source of truth for "did we find a newer version?" logic.
* Touched by both the scheduled worker AND the "Check now" Settings
* button so behavior stays identical regardless of trigger.
*/
suspend fun runUpdateCheck(context: Context): UpdateInfo? {
val info = AppUpdateClient.fetchLatest()
Settings.get().setLastUpdateCheck(System.currentTimeMillis())
if (info == null) {
strawLogI("update", "check: network/parse failure, will retry")
return null
}
if (info.versionCode <= BuildConfig.VERSION_CODE) {
strawLogI("update", "check: up to date (latest=${info.versionCode})")
Settings.get().setLatestKnownVersion(0L, "")
return null
}
strawLogI("update", "check: ${BuildConfig.VERSION_CODE}${info.versionCode} available")
Settings.get().setLatestKnownVersion(info.versionCode, info.versionName)
postUpdateNotification(context, info)
return info
}
class UpdateCheckWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
if (!Settings.get().autoUpdateCheck.value) return Result.success()
runUpdateCheck(applicationContext)
// Always succeed — a failed check just retries on the next
// scheduled tick. Retry-with-backoff would burn battery for no
// gain (the index is sticky and fdroid.sulkta.com is on Cobb's
// own infra).
return Result.success()
}
}
private const val NOTIF_CHANNEL_ID = "straw-update"
private const val NOTIF_ID = 23
private fun postUpdateNotification(context: Context, info: UpdateInfo) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
NOTIF_CHANNEL_ID,
"Straw updates",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifies when a newer Straw build is on fdroid.sulkta.com."
}
nm.createNotificationChannel(channel)
// ACTION_VIEW on the APK URL — Chrome / system browser fetches it
// via DownloadManager and the user taps it to install. No
// INSTALL_PACKAGES permission needed; the system installer handles
// the confirm dialog.
val viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.apkUrl))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pending = PendingIntent.getActivity(
context,
0,
viewIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val name = info.versionName.ifBlank { "vc=${info.versionCode}" }
val notif = NotificationCompat.Builder(context, NOTIF_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("Straw $name available")
.setContentText("Tap to download from fdroid.sulkta.com.")
.setAutoCancel(true)
.setContentIntent(pending)
.build()
nm.notify(NOTIF_ID, notif)
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Wires the user's auto-update preferences into WorkManager. Called
* from StrawApp.onCreate (initial enqueue) and from SettingsScreen
* (re-apply when the toggle / interval flips).
*
* Uses unique-name + REPLACE so flipping the interval mid-flight just
* swaps the schedule instead of stacking workers.
*/
package com.sulkta.straw.feature.update
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.sulkta.straw.data.AutoUpdateInterval
import com.sulkta.straw.data.Settings
import java.util.concurrent.TimeUnit
private const val WORK_NAME = "straw-update-check"
object UpdateScheduler {
fun applyFromSettings(context: Context) {
val s = Settings.get()
val enabled = s.autoUpdateCheck.value
val interval = s.autoUpdateInterval.value
val wm = WorkManager.getInstance(context.applicationContext)
if (!enabled) {
wm.cancelUniqueWork(WORK_NAME)
return
}
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
interval.minutes,
TimeUnit.MINUTES,
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
).build()
wm.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
}
/**
* Map the user-facing AutoUpdateInterval enum to minutes for
* WorkManager. WM enforces a 15-minute floor on periodic work; any
* value below that would silently be clamped.
*/
private val AutoUpdateInterval.minutes: Long
get() = when (this) {
AutoUpdateInterval.H1 -> 60
AutoUpdateInterval.H6 -> 6 * 60
AutoUpdateInterval.H24 -> 24 * 60
}

View file

@ -25,3 +25,19 @@ fun formatDuration(sec: Long): String {
val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
/**
* Quick "12s ago" / "3m ago" / "5h ago" / "2d ago" for the auto-update
* "Last checked" timestamp. Future timestamps (clock skew) return the
* just-now bucket.
*/
fun formatRelativeSince(ms: Long, nowMs: Long = System.currentTimeMillis()): String {
val delta = (nowMs - ms).coerceAtLeast(0L)
val sec = delta / 1000
return when {
sec < 60 -> "${sec}s ago"
sec < 3600 -> "${sec / 60}m ago"
sec < 86_400 -> "${sec / 3600}h ago"
else -> "${sec / 86_400}d ago"
}
}