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

@@ -32,4 +32,8 @@ data class ServerStoreState(
// a dismissed chat banner can return on a later launch without re-pinging
// GitHub (the once-per-version guard skips the check by then).
val lastKnownLatestTag: String? = null,
// Timestamp (epoch millis) of the last successful background release check.
val lastUpdateCheckTimestamp: Long = 0L,
// Tag the user explicitly dismissed from the update banner / dialog (e.g. "v1.24.2").
val dismissedUpdateTag: String? = null,
)
@@ -655,4 +655,18 @@ object AuthManager {
fun setLastKnownLatestTag(tag: String) {
serverStore.update { it.copy(lastKnownLatestTag = tag) }
}
/** Timestamp of the last background update check. */
fun getLastUpdateCheckTimestamp(): Long = serverStore.getLatestState().lastUpdateCheckTimestamp
fun setLastUpdateCheckTimestamp(timestamp: Long) {
serverStore.update { it.copy(lastUpdateCheckTimestamp = timestamp) }
}
/** Release tag dismissed by the user. */
fun getDismissedUpdateTag(): String? = serverStore.getLatestState().dismissedUpdateTag
fun setDismissedUpdateTag(tag: String?) {
serverStore.update { it.copy(dismissedUpdateTag = tag) }
}
}
@@ -19,12 +19,15 @@ object AppUpdateCache {
/**
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
* screen recomposes when it flips; dies with the process, so a dismissed
* banner returns on the next launch (the persisted latest tag drives it).
* screen recomposes when it flips; dies with the process.
*/
var dismissed by mutableStateOf(false)
private set
/** Whether the full-screen / in-place update dialog should be visible. */
var isDialogVisible by mutableStateOf(false)
private set
fun update(state: AppUpdateState) {
_state.value = state
}
@@ -33,9 +36,18 @@ object AppUpdateCache {
dismissed = true
}
fun showDialog() {
isDialogVisible = true
}
fun hideDialog() {
isDialogVisible = false
}
/** Test hook: clear state and dismissal between tests. */
internal fun reset() {
dismissed = false
isDialogVisible = false
_state.value = AppUpdateState.Idle
}
}
@@ -17,6 +17,7 @@ import java.io.IOException
@Serializable
data class UpdateInfo(
val tagName: String = "",
val body: String = "",
val assets: List<Asset> = emptyList(),
) {
@Serializable
@@ -20,6 +20,7 @@ sealed interface AppUpdateState {
val latestTag: String,
val apkUrl: String,
val sizeBytes: Long,
val releaseNotes: String = "",
) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */
@@ -31,16 +31,24 @@ object UpdateNoticeManager {
var enabled: Boolean = !BuildConfig.DEBUG
internal set
/** Run from Application.onCreate, after AuthManager.init. No-op once per version. */
/** Release check interval (24 hours). */
const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L
/** Run from Application.onCreate, after AuthManager.init. Checks at most once every 24h. */
fun checkOnLaunch(
checker: AppUpdateChecker = AppUpdateChecker(),
currentVersion: String = BuildConfig.VERSION_NAME,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
now: Long = System.currentTimeMillis(),
) {
if (!enabled) return
if (AuthManager.getUpdateCheckDoneForVersion() == currentVersion) return
val lastCheck = AuthManager.getLastUpdateCheckTimestamp()
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease()
@@ -57,6 +65,7 @@ object UpdateNoticeManager {
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
@@ -68,15 +77,17 @@ object UpdateNoticeManager {
/**
* The tag the chat banner should advertise, or null when nothing newer is
* known. Prefers the in-process check result; falls back to the persisted
* latest tag (issue #890) so a banner dismissed on a previous launch — or
* a launch whose check was skipped by the once-per-version guard — still
* comes back.
* known or if the user explicitly dismissed this tag.
*/
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.let { return it.latestTag }
val persisted = AuthManager.getLastKnownLatestTag() ?: return null
return persisted.takeIf { isNewerVersion(it, currentVersion) }
val dismissed = AuthManager.getDismissedUpdateTag()
val candidateTag =
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag
?: AuthManager.getLastKnownLatestTag()
?: return null
if (candidateTag == dismissed) return null
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
}
}
@@ -70,6 +70,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
@@ -77,7 +78,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.SettingsAbout
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.update.AppUpdateCache
@@ -104,12 +104,13 @@ import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
import com.m57.hermescontrol.ui.common.ActionProgressDialog
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -540,24 +541,58 @@ fun ChatScreen(
}
// Issue #890: launch update check — non-blocking banner when a
// newer release exists. "Update" jumps to the About-tab install
// flow; "Later" dismisses for the session (it returns next launch
// via the persisted latest tag). Release-only builds
// (UpdateNoticeManager.enabled = !BuildConfig.DEBUG).
// newer release exists. Tapping "Update" opens the in-place dialog.
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
val appUpdateViewModel: AppUpdateViewModel =
viewModel {
val app =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
?: error("Application not available")
AppUpdateViewModel(app)
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
UpdateNoticeBanner(
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { NavigationController.navigateTo(SettingsAbout) },
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
)
}
}
if (AppUpdateCache.isDialogVisible) {
val context = androidx.compose.ui.platform.LocalContext.current
AppUpdateDialog(
state = updateNotice,
onDismiss = { AppUpdateCache.hideDialog() },
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
val intent =
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
context.startActivity(
android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
)
}
AnimatedVisibility(
visible = searchState.isActive,
enter = expandVertically() + fadeIn(),
@@ -0,0 +1,271 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.releaseTag
@Composable
fun AppUpdateDialog(
state: AppUpdateState,
onDismiss: () -> Unit,
onStartUpdate: () -> Unit,
onCancelDownload: () -> Unit,
onNeverAskAgain: () -> Unit,
onOpenSettings: () -> Unit,
) {
val tag = state.releaseTag().orEmpty()
val available = state as? AppUpdateState.UpdateAvailable
val sizeMb =
available?.let {
val mb = it.sizeBytes / (1024 * 1024.0)
"%.1f MB".format(mb)
}
val releaseNotes = available?.releaseNotes?.trim().orEmpty()
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.SystemUpdate,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.update_dialog_title, tag),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
if (sizeMb != null) {
Text(
text = sizeMb,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_dismiss),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Release notes / content preview
if (releaseNotes.isNotBlank()) {
Text(
text = stringResource(R.string.update_dialog_changelog_header),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 180.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(12.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = releaseNotes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// Progress / Action States
when (state) {
is AppUpdateState.Downloading -> {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
val progressPercent = (state.progress * 100).toInt()
Text(
text =
stringResource(
R.string.settings_about_update_downloading,
progressPercent,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = { state.progress },
modifier =
Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onCancelDownload,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.action_cancel))
}
}
}
is AppUpdateState.Installing -> {
Text(
text = stringResource(R.string.settings_about_update_installing, tag),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
is AppUpdateState.NeedsUnknownSourcesPermission -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.settings_about_update_allow_sources),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_about_update_open_settings))
}
}
}
is AppUpdateState.Error -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = state.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onStartUpdate,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_about_update_retry))
}
}
}
else -> {
// UpdateAvailable or Idle
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onStartUpdate,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.update_dialog_action_install))
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.update_dialog_action_later))
}
TextButton(onClick = onNeverAskAgain) {
Text(
text = stringResource(R.string.update_dialog_action_skip),
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
}
}
}
}
}
@@ -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),