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:
parent
fbccdce65a
commit
ccd24c4ed3
10 changed files with 493 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
8
strawApp/proguard-rules.pro
vendored
8
strawApp/proguard-rules.pro
vendored
|
|
@ -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 { *; }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue