fix(update): make app update check, discovery, and installer flow reliable

Automate background update checks on launch/foreground and share results across chat and settings. Disregard release candidates by default unless opted in, and handle installer cancellation and download failures cleanly.
This commit is contained in:
M57 committed 2026-09-23 20:43:41 +03:00
1 parent 68a17d5499
commit 19eacf260f
14 files changed
+723 -258

No files matched your search

@@ -13,7 +13,6 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import kotlinx.coroutines.CoroutineScope
@@ -41,9 +40,6 @@ class HermesControlApp :
// so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this@HermesControlApp)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
}
}
@@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@@ -131,6 +133,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
lifecycleScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
UpdateNoticeManager.checkOnLaunch()
}
NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this)
@@ -21,8 +21,9 @@ object AppUpdateCache {
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
* screen recomposes when it flips; dies with the process.
*/
var dismissed by mutableStateOf(false)
private set
private var dismissedTags by mutableStateOf<Set<String>>(emptySet())
fun isDismissed(tag: String): Boolean = tag in dismissedTags
/** Whether the full-screen / in-place update dialog should be visible. */
var isDialogVisible by mutableStateOf(false)
@@ -32,8 +33,8 @@ object AppUpdateCache {
_state.value = state
}
fun dismiss() {
dismissed = true
fun dismiss(tag: String) {
dismissedTags = dismissedTags + tag
}
fun showDialog() {
@@ -46,7 +47,7 @@ object AppUpdateCache {
/** Test hook: clear state and dismissal between tests. */
internal fun reset() {
dismissed = false
dismissedTags = emptySet()
isDialogVisible = false
_state.value = AppUpdateState.Idle
}
@@ -2,10 +2,15 @@ package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.IOException
@@ -79,17 +84,28 @@ fun selectLatestUpdate(
includeReleaseCandidates: Boolean = false,
): UpdateInfo? =
releases
.filter { !it.draft && it.apkAsset != null }
.filter {
if (!it.prerelease) {
true
} else {
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
}
}.reduceOrNull { best, candidate ->
.filter(::hasUsableApk)
.filter { isEligibleRelease(it, includeReleaseCandidates) }
.reduceOrNull { best, candidate ->
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
}
private fun hasUsableApk(release: UpdateInfo): Boolean =
!release.draft && release.apkAsset?.browserDownloadUrl?.toHttpUrlOrNull()?.let {
it.scheme == "http" || it.scheme == "https"
} == true
private fun isEligibleRelease(
release: UpdateInfo,
includeReleaseCandidates: Boolean,
): Boolean {
val version = parseVersion(release.tagName) ?: return false
val isRc = version.prerelease.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
val isStable = version.prerelease.isEmpty() && !release.prerelease
if (isStable) return true
return includeReleaseCandidates && isRc
}
private data class ParsedVersion(
val core: List<Int>,
val prerelease: List<String>,
@@ -161,17 +177,16 @@ open class AppUpdateChecker(
*/
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
withContext(Dispatchers.IO) {
if (includeReleaseCandidates) {
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
} else {
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
parseUpdateInfo(body)
val latest = get("$apiBaseUrl/releases/latest")?.let(::parseRelease)
if (!includeReleaseCandidates && latest != null && selectLatestUpdate(listOf(latest)) != null) {
return@withContext latest
}
val releases = fetchReleasePages() ?: return@withContext null
selectLatestUpdate(releases + listOfNotNull(latest), includeReleaseCandidates)
}
/** GET [url] as JSON text, or null on any non-2xx response. */
private fun get(url: String): String? {
/** GET a GitHub URL. 404 means absent; other HTTP/network failures stay visible. */
private fun get(url: String): ReleasePage? {
val request =
Request
.Builder()
@@ -179,11 +194,45 @@ open class AppUpdateChecker(
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body.string().orEmpty()
if (response.code == HTTP_NOT_FOUND) return null
if (!response.isSuccessful) throw IOException("GitHub releases request failed (${response.code})")
return ReleasePage(response.body.string().orEmpty(), response.nextPageUrl())
}
}
private fun fetchReleasePages(): List<UpdateInfo>? {
var nextUrl: String? = "$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE&page=1"
val releases = mutableListOf<UpdateInfo>()
while (nextUrl != null) {
val page =
get(nextUrl)
?: return if (releases.isEmpty()) null else throw IOException("Incomplete GitHub releases list")
val parsed = parseReleaseList(page.body) ?: throw IOException("GitHub returned malformed release metadata")
releases += parsed
nextUrl = page.nextUrl
}
return releases
}
private fun parseRelease(page: ReleasePage): UpdateInfo =
parseUpdateInfo(page.body) ?: throw IOException("GitHub returned malformed release metadata")
private fun Response.nextPageUrl(): String? {
val link = header("Link") ?: return null
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL")
if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") {
throw IOException("GitHub returned an untrusted pagination URL")
}
return next.toString()
}
private data class ReleasePage(
val body: String,
val nextUrl: String?,
)
/**
* Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted)
@@ -194,41 +243,103 @@ open class AppUpdateChecker(
dest: File,
onProgress: (Float) -> Unit,
): Boolean =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext false
val body = response.body
val total = body.contentLength()
dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = 0L
body.byteStream().use { input ->
while (true) {
val n = input.read(buffer)
if (n == -1) break
out.write(buffer, 0, n)
read += n
if (total > 0) {
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
suspendCancellableCoroutine { continuation ->
val request =
try {
val httpUrl =
url.toHttpUrlOrNull()?.takeIf { it.scheme == "http" || it.scheme == "https" }
?: run {
failDownload(continuation, dest)
return@suspendCancellableCoroutine
}
Request.Builder().url(httpUrl).build()
} catch (_: IllegalArgumentException) {
failDownload(continuation, dest)
return@suspendCancellableCoroutine
}
val call =
try {
client.newCall(request)
} catch (_: Exception) {
failDownload(continuation, dest)
return@suspendCancellableCoroutine
}
continuation.invokeOnCancellation {
call.cancel()
deletePartial(dest)
}
val callback =
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
failDownload(continuation, dest)
}
override fun onResponse(
call: Call,
response: Response,
) {
var succeeded = false
try {
response.use {
if (response.isSuccessful) {
val body = response.body
val total = body.contentLength()
dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = 0L
body.byteStream().use { input ->
while (true) {
val n = input.read(buffer)
if (n == -1) break
out.write(buffer, 0, n)
read += n
if (total > 0) {
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
}
}
}
}
succeeded = true
}
}
} catch (_: Exception) {
// Includes response/body close failures; never report success before close.
}
if (succeeded) {
if (continuation.isActive) continuation.resume(true) { _, _, _ -> }
} else {
failDownload(continuation, dest)
}
}
true
}
} catch (e: Exception) {
dest.delete()
false
try {
call.enqueue(callback)
} catch (_: Exception) {
failDownload(continuation, dest)
}
}
private fun failDownload(
continuation: kotlinx.coroutines.CancellableContinuation<Boolean>,
dest: File,
) {
deletePartial(dest)
if (continuation.isActive) continuation.resume(false) { _, _, _ -> }
}
private fun deletePartial(dest: File) {
runCatching { dest.delete() }
}
private companion object {
const val DEFAULT_BUFFER_SIZE = 8192
const val HTTP_NOT_FOUND = 404
/** How many recent releases the opt-in RC scan inspects. */
const val RELEASE_LIST_PAGE_SIZE = 20
const val RELEASE_LIST_PAGE_SIZE = 100
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
}
}
@@ -40,6 +40,7 @@ sealed interface AppUpdateState {
data class Error(
val message: String,
val isCheckError: Boolean = false,
) : AppUpdateState
}
@@ -6,23 +6,31 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.IOException
/**
* Startup update check (issue #890): one silent GitHub ping per installed
* version, fired right after launch, so the chat screen can show an update
* banner without the user ever opening the About tab.
* Shared release check (issue #890): checks after launch and on foreground
* return, with a short process-local debounce so the chat screen can show an
* update banner without the user ever opening the About tab.
*
* The result lands in [AppUpdateCache] (consumed by the chat banner and
* adopted by the About tab) and the latest tag is persisted via
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
* later launch. Failures leave the cache Idle — the About tab still has its
* own manual retry path.
* later launch. Failed checks remain visible and can be retried from the About
* tab or dialog without persisting a false successful-check marker.
*/
object UpdateNoticeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val checkLock = Any()
private var activeCheck: Job? = null
private var lastSuccessfulCheckAt: Long? = null
private var lastFailedCheckAt: Long? = null
private var channelGeneration = 0
/**
* Release-only feature (issue #890): the launch check and chat banner are
@@ -32,57 +40,153 @@ object UpdateNoticeManager {
var enabled: Boolean = !BuildConfig.DEBUG
internal set
/** Release check interval (24 hours). */
const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
const val CHECK_INTERVAL_MS: Long = 5 * 60 * 1000L
const val FAILURE_BACKOFF_MS: Long = 30 * 1000L
/** Run from Application.onCreate, after AuthManager.init. Checks at most once every 24h. */
/** Run after preferences are ready; failed checks never suppress the next foreground retry. */
fun checkOnLaunch(
checker: AppUpdateChecker = AppUpdateChecker(),
currentVersion: String = BuildConfig.VERSION_NAME,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
now: Long = System.currentTimeMillis(),
force: Boolean = false,
) {
if (!enabled) return
val lastCheck = AuthManager.getLastUpdateCheckTimestamp()
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
// Captured once so the check and the notice path agree on the channel.
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
return@launch
} catch (e: Exception) {
return@launch
}
val info = result ?: return@launch
// The user may have switched back to stable while this request was in
// flight — an RC result must never be published on the stable channel.
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return@launch
if (!enabled && !force) return
synchronized(checkLock) {
if (activeCheck?.isActive == true) return
if (!force && lastSuccessfulCheckAt?.let { now - it < CHECK_INTERVAL_MS } == true) return
if (!force && lastFailedCheckAt?.let { now - it < FAILURE_BACKOFF_MS } == true) return
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
val generation = channelGeneration
val previousState = AppUpdateCache.state.value
if (force ||
previousState !is AppUpdateState.UpdateAvailable
) {
AppUpdateCache.update(AppUpdateState.Checking)
}
val apk = info.apkAsset ?: return@launch
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
activeCheck =
scope.launch(ioDispatcher) {
val thisJob = currentCoroutineContext()[Job]
var successful = false
var failed = false
try {
val info = checker.fetchLatestRelease(includeReleaseCandidates)
val apk = info?.apkAsset
val state =
if (info == null) {
AppUpdateState.Error(NO_RELEASE_ERROR, isCheckError = true)
} else if (apk == null) {
AppUpdateState.Error(NO_APK_ERROR, isCheckError = true)
} else if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
var resultRejected = false
synchronized(checkLock) {
if (activeCheck !== thisJob || generation != channelGeneration) return@synchronized
val currentChannel = AuthManager.isCheckingReleaseCandidateUpdates()
if (includeReleaseCandidates != currentChannel ||
(info != null && isReleaseCandidateVersion(info.tagName) && !currentChannel)
) {
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(previousState)
}
resultRejected = true
return@synchronized
}
AppUpdateCache.update(state)
if (state is AppUpdateState.UpdateAvailable || state is AppUpdateState.UpToDate) {
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(System.currentTimeMillis())
successful = true
} else {
failed = true
}
}
if (resultRejected) return@launch
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
synchronized(checkLock) {
if (activeCheck === thisJob && generation == channelGeneration) {
failed = true
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(AppUpdateState.Error(NETWORK_ERROR, isCheckError = true))
}
}
}
} catch (e: Exception) {
synchronized(checkLock) {
if (activeCheck === thisJob && generation == channelGeneration) {
failed = true
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(
AppUpdateState.Error(GENERIC_CHECK_ERROR, isCheckError = true),
)
}
}
}
} finally {
synchronized(checkLock) {
if (activeCheck === thisJob) {
if (successful) lastSuccessfulCheckAt = now
if (failed) lastFailedCheckAt = now
activeCheck = null
}
}
}
}
AppUpdateCache.update(state)
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
}
}
private const val NETWORK_ERROR = "Network error — check your connection"
private const val GENERIC_CHECK_ERROR = "Couldn't check for updates"
private const val NO_RELEASE_ERROR = "No release found yet"
private const val NO_APK_ERROR = "Release has no APK asset"
/** Explicit check action; shares in-flight work but bypasses the local debounce. */
fun checkNow(
checker: AppUpdateChecker,
currentVersion: String,
ioDispatcher: CoroutineDispatcher,
) {
checkOnLaunch(checker, currentVersion, ioDispatcher, force = true)
}
/** Persist the channel choice and invalidate in-flight checks as one operation. */
fun channelChanged(enabled: Boolean) {
synchronized(checkLock) {
AuthManager.setCheckReleaseCandidateUpdates(enabled)
channelGeneration++
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(AppUpdateState.Idle)
}
activeCheck?.cancel()
activeCheck = null
lastSuccessfulCheckAt = null
lastFailedCheckAt = null
}
}
/** Test hook: reset process-local admission state between tests. */
internal fun resetForTests() {
synchronized(checkLock) {
activeCheck?.cancel()
activeCheck = null
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(AppUpdateState.Idle)
}
lastSuccessfulCheckAt = null
lastFailedCheckAt = null
channelGeneration++
}
}
@@ -94,12 +198,9 @@ object UpdateNoticeManager {
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
val dismissed = AuthManager.getDismissedUpdateTag()
val candidateTag =
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag
?: AuthManager.getLastKnownLatestTag()
?: return null
val candidateTag = (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag ?: return null
if (candidateTag == dismissed) return null
if (candidateTag == dismissed || AppUpdateCache.isDismissed(candidateTag)) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null
}
@@ -7,6 +7,7 @@ import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -17,6 +18,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.update.releaseTag
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
@@ -32,14 +34,17 @@ fun ChatAppUpdateSection() {
AppUpdateViewModel(app)
}
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val currentAppUpdateState by rememberUpdatedState(appUpdateState)
val updateLifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(updateLifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
if (currentAppUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission()
} else {
appUpdateViewModel.reconcileInstallerReturn()
}
}
}
@@ -49,15 +54,13 @@ fun ChatAppUpdateSection() {
}
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (UpdateNoticeManager.enabled) {
val noticeTag = UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
onDismiss = { AppUpdateCache.dismiss(noticeTag) },
)
}
}
@@ -73,12 +76,17 @@ fun ChatAppUpdateSection() {
AppUpdateDialog(
state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() },
onLater = {
dialogState.releaseTag()?.let(AppUpdateCache::dismiss)
AppUpdateCache.hideDialog()
},
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
launchUnknownAppSourcesSettings(context)
},
onCheckUpdate = { appUpdateViewModel.checkForUpdate() },
)
}
}
@@ -48,10 +48,12 @@ import com.m57.hermescontrol.data.update.releaseTag
fun AppUpdateDialog(
state: AppUpdateState,
onDismiss: () -> Unit,
onLater: () -> Unit = onDismiss,
onStartUpdate: () -> Unit,
onCancelDownload: () -> Unit,
onNeverAskAgain: () -> Unit,
onOpenSettings: () -> Unit,
onCheckUpdate: () -> Unit = {},
) {
val available = state as? AppUpdateState.UpdateAvailable
var cachedAvailable by remember { mutableStateOf(available) }
@@ -160,6 +162,26 @@ fun AppUpdateDialog(
// Progress / Action States
when (state) {
AppUpdateState.Checking -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.settings_about_update_checking),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
is AppUpdateState.UpToDate -> {
Text(
text = stringResource(R.string.settings_about_update_uptodate, state.latestTag),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
is AppUpdateState.Downloading -> {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -260,7 +282,7 @@ fun AppUpdateDialog(
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onStartUpdate,
onClick = if (state.isCheckError) onCheckUpdate else onStartUpdate,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_about_update_retry))
@@ -268,8 +290,7 @@ fun AppUpdateDialog(
}
}
else -> {
// UpdateAvailable or Idle
is AppUpdateState.UpdateAvailable -> {
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onStartUpdate,
@@ -291,7 +312,7 @@ fun AppUpdateDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = onDismiss) {
TextButton(onClick = onLater) {
Text(stringResource(R.string.update_dialog_action_later))
}
TextButton(onClick = onNeverAskAgain) {
@@ -303,6 +324,12 @@ fun AppUpdateDialog(
}
}
}
AppUpdateState.Idle -> {
Button(onClick = onCheckUpdate, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.settings_about_update_check))
}
}
}
}
}
@@ -11,19 +11,19 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateChecker
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.isNewerVersion
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.update.isReleaseCandidateVersion
import com.m57.hermescontrol.data.update.releaseTag
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import java.util.UUID
/**
* Drives the in-app self-update flow (issue #867): check the GitHub
@@ -31,9 +31,8 @@ import java.io.IOException
* installer. The installer launch uses the application context
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
*
* A silent check runs once per installed version (guarded by the
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update
* available" badge on the About row without the user tapping anything.
* The ViewModel adopts the process-shared launch/foreground check result, while
* manual checks, downloads, and system-installer handoff stay screen-owned.
*/
class AppUpdateViewModel(
application: Application,
@@ -51,16 +50,11 @@ class AppUpdateViewModel(
/** Whether the RC update channel is enabled (issue: RC update toggle). */
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow()
private var checkJob: kotlinx.coroutines.Job? = null
private var downloadJob: kotlinx.coroutines.Job? = null
private var lastAvailable: AppUpdateState.UpdateAvailable? = null
/**
* Bumped by every check. The blocking OkHttp call underneath cannot be
* interrupted, so a cancelled check may still be running; results are only
* published when this token is unchanged (and the channel still matches).
*/
private var checkGeneration = 0
private var downloadGeneration = 0
private var downloadPartialFile: File? = null
private var installerReturnPending = false
init {
val cached = AppUpdateCache.state.value
@@ -70,13 +64,25 @@ class AppUpdateViewModel(
!_checkReleaseCandidateUpdates.value &&
cached.releaseTag()?.let(::isReleaseCandidateVersion) == true
if (blocked) AppUpdateCache.reset()
viewModelScope.launch {
AppUpdateCache.state.collect { sharedState ->
if (_state.value !is AppUpdateState.Downloading &&
_state.value !is AppUpdateState.Installing &&
_state.value !is AppUpdateState.NeedsUnknownSourcesPermission
) {
_state.value = sharedState
if (sharedState is AppUpdateState.UpdateAvailable) lastAvailable = sharedState
}
}
}
if (cached is AppUpdateState.UpdateAvailable && !blocked) {
lastAvailable = cached
_state.value = cached
} else if (cached is AppUpdateState.UpToDate && !blocked) {
_state.value = cached
} else {
checkForUpdate()
_state.value = cached
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, ioDispatcher)
}
}
@@ -94,92 +100,20 @@ class AppUpdateViewModel(
*/
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
if (_checkReleaseCandidateUpdates.value == enabled) return
AuthManager.setCheckReleaseCandidateUpdates(enabled)
UpdateNoticeManager.channelChanged(enabled)
_checkReleaseCandidateUpdates.value = enabled
if (!enabled) {
if (lastAvailable?.latestTag?.let(::isReleaseCandidateVersion) == true) cancelDownload()
lastAvailable = null
AppUpdateCache.reset()
}
// A check already in flight captured the previous channel — cancel it and
// bump the generation so a late result can never be published.
checkJob?.cancel()
runCheck()
}
private fun runCheck() {
val generation = ++checkGeneration
val includeReleaseCandidates = _checkReleaseCandidateUpdates.value
_state.value = AppUpdateState.Checking
checkJob =
viewModelScope.launch(ioDispatcher) {
val now = System.currentTimeMillis()
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
// Cancellation is control flow — it must never be reported
// as a failed check.
throw e
} catch (e: IOException) {
if (isCurrentCheck(generation, includeReleaseCandidates)) {
_state.value = AppUpdateState.Error(NETWORK_ERROR)
}
return@launch
} catch (e: Exception) {
if (isCurrentCheck(generation, includeReleaseCandidates)) {
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
}
return@launch
}
// The OkHttp call underneath blocks and cannot be interrupted, so a
// newer check — or a channel switch — may have happened while it ran.
ensureActive()
if (!isCurrentCheck(generation, includeReleaseCandidates)) return@launch
val info =
result ?: run {
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
return@launch
}
val apk =
info.apkAsset ?: run {
_state.value = AppUpdateState.Error(NO_APK_ERROR)
return@launch
}
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
// Fail closed: a release-candidate result is never published once
// the RC channel is off, however it reached this point.
if (!isAdmissibleOnCurrentChannel(state.releaseTag())) return@launch
if (state is AppUpdateState.UpdateAvailable) lastAvailable = state
_state.value = state
// Keep the launch notice (issue #890) in sync with manual checks.
AppUpdateCache.update(state)
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
}
UpdateNoticeManager.checkNow(checker, currentVersion, ioDispatcher)
}
/**
* True when [generation] is still the newest check *and* the channel that
* check was started for is still the selected one.
*/
private fun isCurrentCheck(
generation: Int,
includeReleaseCandidates: Boolean,
): Boolean = generation == checkGeneration && includeReleaseCandidates == _checkReleaseCandidateUpdates.value
/** A release-candidate tag may only be used while the RC channel is on. */
private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean =
tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value
@@ -208,36 +142,70 @@ class AppUpdateViewModel(
/** Download the release APK and launch the system installer. */
fun startUpdate() {
if (_state.value is AppUpdateState.Downloading || _state.value is AppUpdateState.Installing) return
val available = updateAvailableForInstall() ?: return
lastAvailable = available
if (!canRequestInstalls()) {
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
return
}
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
_state.value = AppUpdateState.Downloading(0f)
downloadJob?.cancel()
downloadJob =
viewModelScope.launch(ioDispatcher) {
val downloaded =
checker.downloadApk(available.apkUrl, dest) { progress ->
_state.value = AppUpdateState.Downloading(progress)
val dest = updateApkFile(available.latestTag)
val generation = ++downloadGeneration
val partial = File(dest.parentFile, "${dest.name}.${UUID.randomUUID()}.$generation.part")
downloadPartialFile = partial
try {
partial.delete()
_state.value = AppUpdateState.Downloading(0f)
downloadJob?.cancel()
downloadJob =
viewModelScope.launch(ioDispatcher) {
try {
val downloaded =
checker.downloadApk(available.apkUrl, partial) { progress ->
if (generation ==
downloadGeneration
) {
_state.value = AppUpdateState.Downloading(progress)
}
}
if (generation != downloadGeneration) return@launch
if (!downloaded || !partial.exists() || partial.length() == 0L) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
return@launch
}
if ((dest.exists() && !dest.delete()) || !partial.renameTo(dest)) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
return@launch
}
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
if (generation == downloadGeneration) _state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
} finally {
if (generation == downloadGeneration) {
partial.delete()
downloadPartialFile = null
}
}
if (!downloaded) {
if (downloadJob?.isCancelled != true) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
}
return@launch
}
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} catch (_: Exception) {
if (generation == downloadGeneration) {
partial.delete()
downloadPartialFile = null
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
}
}
}
/** Cancel in-flight download and return to UpdateAvailable. */
fun cancelDownload() {
downloadGeneration++
downloadJob?.cancel()
downloadJob = null
downloadPartialFile?.delete()
downloadPartialFile = null
val available =
lastAvailable
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
@@ -257,16 +225,15 @@ class AppUpdateViewModel(
if (tag != null) {
AuthManager.setDismissedUpdateTag(tag)
}
AppUpdateCache.dismiss()
AppUpdateCache.hideDialog()
}
/** Resume install when returning from Unknown Sources permission screen. */
fun resumeInstallAfterPermission() {
if (canRequestInstalls()) {
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
val available = updateAvailableForInstall()
if (dest.exists() && dest.length() > 0 && available != null) {
val dest = available?.let { updateApkFile(it.latestTag) }
if (dest != null && dest.exists() && dest.length() > 0L) {
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} else {
@@ -275,6 +242,18 @@ class AppUpdateViewModel(
}
}
/** Returning from the external installer is not proof that installation succeeded. */
fun reconcileInstallerReturn() {
if (_state.value !is AppUpdateState.Installing || !installerReturnPending) return
installerReturnPending = false
_state.value = lastAvailable ?: AppUpdateCache.state.value
}
private fun updateApkFile(tag: String): File {
val safeTag = tag.replace(Regex("[^A-Za-z0-9._-]"), "_")
return File(getApplication<Application>().cacheDir, "hermes-update-$safeTag.apk")
}
private fun canRequestInstalls(): Boolean =
try {
getApplication<Application>().packageManager.canRequestPackageInstalls()
@@ -292,18 +271,14 @@ class AppUpdateViewModel(
apkFile,
)
context.startActivity(installIntentFactory(uri))
installerReturnPending = true
} catch (e: Exception) {
installerReturnPending = false
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
}
}
private companion object {
const val APK_FILE_NAME = "hermes-update.apk"
val NETWORK_ERROR = "Network error — check your connection"
val GENERIC_CHECK_ERROR = "Couldn't check for updates"
val NO_RELEASE_ERROR = "No release found yet"
val NO_APK_ERROR = "Release has no APK asset"
val DOWNLOAD_ERROR = "Download failed — tap to retry"
val INSTALLER_ERROR = "Couldn't open the installer"
}
@@ -262,6 +262,7 @@ internal fun SettingsAboutPage(
},
) {
val updateState by viewModel.state.collectAsStateWithLifecycle()
val currentUpdateState by androidx.compose.runtime.rememberUpdatedState(updateState)
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
@@ -270,8 +271,12 @@ internal fun SettingsAboutPage(
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
if (updateState is com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission) {
if (currentUpdateState is
com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission
) {
viewModel.resumeInstallAfterPermission()
} else {
viewModel.reconcileInstallerReturn()
}
}
}