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:
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),
|
||||
|
||||
Reference in new issue
Block a user