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
3271d05f51
commit
188c99fe9c
10 changed files with 493 additions and 2 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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
|
# 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 { *; }
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue