feat(update): redesign in-app updater with smart cooldown, release notes, and in-place dialog (#971)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-26 07:42:41 +03:00
1 parent 460472f631
commit 1f1ebf3597
14 files changed
+528 -54

No files matched your search

@@ -42,30 +42,25 @@ class AppUpdateViewModel(
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
private var downloadJob: kotlinx.coroutines.Job? = null
init {
// Silent first-launch check: once per installed version. Marked done
// after the attempt completes (success or failure) so a failed check
// can retry on the next open, but a dead network can't spam the API
// on every visit.
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
checkForUpdate()
val cached = AppUpdateCache.state.value
if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) {
_state.value = cached
} else {
// Issue #890: the launch check (UpdateNoticeManager) already ran
// for this version — adopt its result instead of pinging GitHub
// again, so the About tab agrees with the chat banner.
val cached = AppUpdateCache.state.value
if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) {
_state.value = cached
}
checkForUpdate()
}
}
/** Manual check from the About row. */
/** Manual check from the About row or dialog. */
fun checkForUpdate() {
if (_state.value is AppUpdateState.Checking) return
_state.value = AppUpdateState.Checking
viewModelScope.launch(ioDispatcher) {
val now = System.currentTimeMillis()
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease()
@@ -92,6 +87,7 @@ class AppUpdateViewModel(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
@@ -111,17 +107,59 @@ class AppUpdateViewModel(
}
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
_state.value = AppUpdateState.Downloading(0f)
viewModelScope.launch(ioDispatcher) {
val downloaded =
checker.downloadApk(available.apkUrl, dest) { progress ->
_state.value = AppUpdateState.Downloading(progress)
downloadJob?.cancel()
downloadJob =
viewModelScope.launch(ioDispatcher) {
val downloaded =
checker.downloadApk(available.apkUrl, dest) { progress ->
_state.value = AppUpdateState.Downloading(progress)
}
if (!downloaded) {
if (downloadJob?.isCancelled != true) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
}
return@launch
}
if (!downloaded) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
return@launch
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
}
}
/** Cancel in-flight download and return to UpdateAvailable. */
fun cancelDownload() {
downloadJob?.cancel()
downloadJob = null
val available = AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable
if (available != null) {
_state.value = available
} else {
_state.value = AppUpdateState.Idle
}
}
/** Dismiss the current update tag so it stops nagging. */
fun dismissCurrentUpdate() {
val tag = _state.value.releaseTag()
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 =
_state.value as? AppUpdateState.UpdateAvailable
?: AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable
if (dest.exists() && dest.length() > 0 && available != null) {
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} else {
startUpdate()
}
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
}
}
@@ -241,6 +241,22 @@ internal fun SettingsAboutPage(
) {
val updateState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
if (updateState is com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission) {
viewModel.resumeInstallAfterPermission()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
HermesScaffold(
title = { Text(stringResource(R.string.settings_sec_about)) },
navigationIcon = NavIcon.Back(onBack),