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

@@ -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()
}
}
}