diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e3e0e57f4..b6068b94b 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 = 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" diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index b62f16540..e6458448e 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -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. diff --git a/strawApp/proguard-rules.pro b/strawApp/proguard-rules.pro index 2a81fe15c..97c631f42 100644 --- a/strawApp/proguard-rules.pro +++ b/strawApp/proguard-rules.pro @@ -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 { *; } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 58d75fa9f..c3226623a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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) } + } } } 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 7ccab0543..208de8be5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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 = _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 = _autoUpdateCheck.asStateFlow() + + private val _autoUpdateInterval = MutableStateFlow(loadAutoUpdateInterval()) + val autoUpdateInterval: StateFlow = _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 = _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 = _latestKnownVc.asStateFlow() + + private val _latestKnownVname = MutableStateFlow( + sp.getString(KEY_LATEST_KNOWN_VNAME, "") ?: "", + ) + val latestKnownVname: StateFlow = _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 { 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 { 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 c8a11962a..4fa6e7108 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 @@ -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", diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt new file mode 100644 index 000000000..4f0eacc6c --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/AppUpdateClient.kt @@ -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(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 = emptyMap()) + +@Serializable +private data class FdroidPackage(val versions: Map = 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, +) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt new file mode 100644 index 000000000..01af9933a --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateCheckWorker.kt @@ -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) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt new file mode 100644 index 000000000..b24ffe6e5 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/update/UpdateScheduler.kt @@ -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( + 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 + } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt index e021f7aeb..d1e9e1c21 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt @@ -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" + } +}