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:
Sulkta 2026-05-26 09:40:07 -07:00
parent 3271d05f51
commit 188c99fe9c
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 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 54 const val STRAW_VERSION_CODE = 55
const val STRAW_VERSION_NAME = "0.1.0-BN" const val STRAW_VERSION_NAME = "0.1.0-BO"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -130,6 +130,11 @@ dependencies {
// Guava ListenableFuture support for awaiting MediaController connect. // Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") 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 // strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search / // cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor. // streamInfo / channelInfo to replace NewPipeExtractor.

View file

@ -79,3 +79,11 @@
# AGP consumer rules cover this, but documenting the dependency here # AGP consumer rules cover this, but documenting the dependency here
# so a future bump doesn't surprise us. # so a future bump doesn't surprise us.
-keep class androidx.compose.runtime.** { *; } -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.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.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck
import com.sulkta.straw.util.strawLogW import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -84,5 +86,12 @@ class StrawApp : Application() {
appScope.launch { appScope.launch {
SettingsImport.sweepStale(this@StrawApp) 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)"), 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 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"
@ -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_AUTOSTART_PLAYBACK = "autostart_playback_v1"
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_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_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) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -129,6 +145,40 @@ class SettingsStore(context: Context) {
) )
val autoResume: StateFlow<Boolean> = _autoResume.asStateFlow() 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) { 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 ->
@ -198,6 +248,34 @@ class SettingsStore(context: Context) {
sp.edit().putBoolean(KEY_AUTO_RESUME, enabled).apply() 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> { 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) {
@ -225,6 +303,13 @@ class SettingsStore(context: Context) {
val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel val name = sp.getString(KEY_AUTOPLAY_MODE, null) ?: return AutoplayMode.SameChannel
return AutoplayMode.entries.firstOrNull { it.name == name } ?: 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 { object Settings {

View file

@ -44,7 +44,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.viewmodel.compose.viewModel 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.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.History
import com.sulkta.straw.data.AutoplayMode import com.sulkta.straw.data.AutoplayMode
import com.sulkta.straw.data.MaxResolution 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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
"Local cache", "Local cache",

View file

@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* 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
* 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 a tester'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
* 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 val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) 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"
}
}