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

@@ -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),