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:
@@ -32,4 +32,8 @@ data class ServerStoreState(
|
|||||||
// a dismissed chat banner can return on a later launch without re-pinging
|
// a dismissed chat banner can return on a later launch without re-pinging
|
||||||
// GitHub (the once-per-version guard skips the check by then).
|
// GitHub (the once-per-version guard skips the check by then).
|
||||||
val lastKnownLatestTag: String? = null,
|
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) {
|
fun setLastKnownLatestTag(tag: String) {
|
||||||
serverStore.update { it.copy(lastKnownLatestTag = tag) }
|
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
|
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
|
||||||
* screen recomposes when it flips; dies with the process, so a dismissed
|
* screen recomposes when it flips; dies with the process.
|
||||||
* banner returns on the next launch (the persisted latest tag drives it).
|
|
||||||
*/
|
*/
|
||||||
var dismissed by mutableStateOf(false)
|
var dismissed by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Whether the full-screen / in-place update dialog should be visible. */
|
||||||
|
var isDialogVisible by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
fun update(state: AppUpdateState) {
|
fun update(state: AppUpdateState) {
|
||||||
_state.value = state
|
_state.value = state
|
||||||
}
|
}
|
||||||
@@ -33,9 +36,18 @@ object AppUpdateCache {
|
|||||||
dismissed = true
|
dismissed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showDialog() {
|
||||||
|
isDialogVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideDialog() {
|
||||||
|
isDialogVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
/** Test hook: clear state and dismissal between tests. */
|
/** Test hook: clear state and dismissal between tests. */
|
||||||
internal fun reset() {
|
internal fun reset() {
|
||||||
dismissed = false
|
dismissed = false
|
||||||
|
isDialogVisible = false
|
||||||
_state.value = AppUpdateState.Idle
|
_state.value = AppUpdateState.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import java.io.IOException
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class UpdateInfo(
|
data class UpdateInfo(
|
||||||
val tagName: String = "",
|
val tagName: String = "",
|
||||||
|
val body: String = "",
|
||||||
val assets: List<Asset> = emptyList(),
|
val assets: List<Asset> = emptyList(),
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ sealed interface AppUpdateState {
|
|||||||
val latestTag: String,
|
val latestTag: String,
|
||||||
val apkUrl: String,
|
val apkUrl: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
|
val releaseNotes: String = "",
|
||||||
) : AppUpdateState
|
) : AppUpdateState
|
||||||
|
|
||||||
/** APK download in progress; [progress] is 0..1. */
|
/** APK download in progress; [progress] is 0..1. */
|
||||||
|
|||||||
@@ -31,16 +31,24 @@ object UpdateNoticeManager {
|
|||||||
var enabled: Boolean = !BuildConfig.DEBUG
|
var enabled: Boolean = !BuildConfig.DEBUG
|
||||||
internal set
|
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(
|
fun checkOnLaunch(
|
||||||
checker: AppUpdateChecker = AppUpdateChecker(),
|
checker: AppUpdateChecker = AppUpdateChecker(),
|
||||||
currentVersion: String = BuildConfig.VERSION_NAME,
|
currentVersion: String = BuildConfig.VERSION_NAME,
|
||||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
now: Long = System.currentTimeMillis(),
|
||||||
) {
|
) {
|
||||||
if (!enabled) return
|
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) {
|
scope.launch(ioDispatcher) {
|
||||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
|
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
checker.fetchLatestRelease()
|
checker.fetchLatestRelease()
|
||||||
@@ -57,6 +65,7 @@ object UpdateNoticeManager {
|
|||||||
latestTag = info.tagName,
|
latestTag = info.tagName,
|
||||||
apkUrl = apk.browserDownloadUrl,
|
apkUrl = apk.browserDownloadUrl,
|
||||||
sizeBytes = apk.size,
|
sizeBytes = apk.size,
|
||||||
|
releaseNotes = info.body,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
@@ -68,15 +77,17 @@ object UpdateNoticeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The tag the chat banner should advertise, or null when nothing newer is
|
* 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
|
* known or if the user explicitly dismissed this tag.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
||||||
if (!enabled) return null
|
if (!enabled) return null
|
||||||
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.let { return it.latestTag }
|
val dismissed = AuthManager.getDismissedUpdateTag()
|
||||||
val persisted = AuthManager.getLastKnownLatestTag() ?: return null
|
val candidateTag =
|
||||||
return persisted.takeIf { isNewerVersion(it, currentVersion) }
|
(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.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -77,7 +78,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.m57.hermescontrol.HistoryScreen
|
import com.m57.hermescontrol.HistoryScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.SettingsAbout
|
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateCache
|
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.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
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.AutoScrollingTitleText
|
||||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
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.model.components.ModelPickerDialog
|
||||||
|
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -540,24 +541,58 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Issue #890: launch update check — non-blocking banner when a
|
// Issue #890: launch update check — non-blocking banner when a
|
||||||
// newer release exists. "Update" jumps to the About-tab install
|
// newer release exists. Tapping "Update" opens the in-place dialog.
|
||||||
// flow; "Later" dismisses for the session (it returns next launch
|
|
||||||
// via the persisted latest tag). Release-only builds
|
|
||||||
// (UpdateNoticeManager.enabled = !BuildConfig.DEBUG).
|
|
||||||
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
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) {
|
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
||||||
val noticeTag =
|
val noticeTag =
|
||||||
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||||
?: UpdateNoticeManager.noticeTag()
|
?: UpdateNoticeManager.noticeTag()
|
||||||
if (noticeTag != null) {
|
if (noticeTag != null) {
|
||||||
UpdateNoticeBanner(
|
com.m57.hermescontrol.ui.common.UpdateNoticeBanner(
|
||||||
latestTag = noticeTag,
|
latestTag = noticeTag,
|
||||||
onUpdate = { NavigationController.navigateTo(SettingsAbout) },
|
onUpdate = { AppUpdateCache.showDialog() },
|
||||||
onDismiss = { AppUpdateCache.dismiss() },
|
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(
|
AnimatedVisibility(
|
||||||
visible = searchState.isActive,
|
visible = searchState.isActive,
|
||||||
enter = expandVertically() + fadeIn(),
|
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)
|
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
||||||
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var downloadJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Silent first-launch check: once per installed version. Marked done
|
val cached = AppUpdateCache.state.value
|
||||||
// after the attempt completes (success or failure) so a failed check
|
if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) {
|
||||||
// can retry on the next open, but a dead network can't spam the API
|
_state.value = cached
|
||||||
// on every visit.
|
|
||||||
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
|
|
||||||
checkForUpdate()
|
|
||||||
} else {
|
} else {
|
||||||
// Issue #890: the launch check (UpdateNoticeManager) already ran
|
checkForUpdate()
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Manual check from the About row. */
|
/** Manual check from the About row or dialog. */
|
||||||
fun checkForUpdate() {
|
fun checkForUpdate() {
|
||||||
if (_state.value is AppUpdateState.Checking) return
|
if (_state.value is AppUpdateState.Checking) return
|
||||||
_state.value = AppUpdateState.Checking
|
_state.value = AppUpdateState.Checking
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
|
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
checker.fetchLatestRelease()
|
checker.fetchLatestRelease()
|
||||||
@@ -92,6 +87,7 @@ class AppUpdateViewModel(
|
|||||||
latestTag = info.tagName,
|
latestTag = info.tagName,
|
||||||
apkUrl = apk.browserDownloadUrl,
|
apkUrl = apk.browserDownloadUrl,
|
||||||
sizeBytes = apk.size,
|
sizeBytes = apk.size,
|
||||||
|
releaseNotes = info.body,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
@@ -111,17 +107,59 @@ class AppUpdateViewModel(
|
|||||||
}
|
}
|
||||||
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
||||||
_state.value = AppUpdateState.Downloading(0f)
|
_state.value = AppUpdateState.Downloading(0f)
|
||||||
viewModelScope.launch(ioDispatcher) {
|
downloadJob?.cancel()
|
||||||
val downloaded =
|
downloadJob =
|
||||||
checker.downloadApk(available.apkUrl, dest) { progress ->
|
viewModelScope.launch(ioDispatcher) {
|
||||||
_state.value = AppUpdateState.Downloading(progress)
|
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.Installing(available.latestTag)
|
||||||
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
launchInstaller(dest)
|
||||||
return@launch
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
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(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.settings_sec_about)) },
|
title = { Text(stringResource(R.string.settings_sec_about)) },
|
||||||
navigationIcon = NavIcon.Back(onBack),
|
navigationIcon = NavIcon.Back(onBack),
|
||||||
|
|||||||
@@ -1003,6 +1003,11 @@
|
|||||||
<!-- Update notice banner (issue #890) -->
|
<!-- Update notice banner (issue #890) -->
|
||||||
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string>
|
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string>
|
||||||
<string name="update_notice_banner_action">Update</string>
|
<string name="update_notice_banner_action">Update</string>
|
||||||
|
<string name="update_dialog_title">Update to %1$s</string>
|
||||||
|
<string name="update_dialog_changelog_header">What\'s New</string>
|
||||||
|
<string name="update_dialog_action_install">Download & Install</string>
|
||||||
|
<string name="update_dialog_action_later">Remind Me Later</string>
|
||||||
|
<string name="update_dialog_action_skip">Skip this version</string>
|
||||||
|
|
||||||
<!-- Processes Screen (issue #532) -->
|
<!-- Processes Screen (issue #532) -->
|
||||||
<string name="processes_empty_title">No running processes</string>
|
<string name="processes_empty_title">No running processes</string>
|
||||||
|
|||||||
@@ -271,11 +271,11 @@ class PersistentCookieJarTest {
|
|||||||
jar.useStore("cold-server")
|
jar.useStore("cold-server")
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(10)
|
// Wait for useStore to complete hydration
|
||||||
|
job.join()
|
||||||
|
|
||||||
val loaded = jar.loadForRequest("http://dash.local/api/status".toHttpUrl())
|
val loaded = jar.loadForRequest("http://dash.local/api/status".toHttpUrl())
|
||||||
assertEquals(1, loaded.size)
|
assertEquals(1, loaded.size)
|
||||||
assertEquals("cold-cookie-xyz", loaded[0].value)
|
assertEquals("cold-cookie-xyz", loaded[0].value)
|
||||||
job.join()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ class UpdateNoticeManagerTest {
|
|||||||
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
||||||
every { AuthManager.getLastKnownLatestTag() } returns null
|
every { AuthManager.getLastKnownLatestTag() } returns null
|
||||||
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
|
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
|
||||||
|
every { AuthManager.getLastUpdateCheckTimestamp() } returns 0L
|
||||||
|
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||||
|
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||||
|
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -86,13 +90,15 @@ class UpdateNoticeManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun checkOnLaunch_skipsWhenAlreadyCheckedForThisVersion() =
|
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() =
|
||||||
runTest {
|
runTest {
|
||||||
|
val now = 1000000000000L
|
||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
|
every { AuthManager.getLastUpdateCheckTimestamp() } returns now - 1000L // 1 sec ago
|
||||||
val checker = mockk<AppUpdateChecker>()
|
val checker = mockk<AppUpdateChecker>()
|
||||||
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
|
|
||||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||||
@@ -100,6 +106,31 @@ class UpdateNoticeManagerTest {
|
|||||||
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_runsWhenCheckedMoreThan24HoursAgo() =
|
||||||
|
runTest {
|
||||||
|
val now = 1000000000000L
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
|
val older = now - (UpdateNoticeManager.CHECK_INTERVAL_MS + 1000L)
|
||||||
|
every { AuthManager.getLastUpdateCheckTimestamp() } returns older
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { checker.fetchLatestRelease() }
|
||||||
|
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_returnsNullWhenTagDismissedByUser() {
|
||||||
|
every { AuthManager.getDismissedUpdateTag() } returns "v1.22.0"
|
||||||
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
|
||||||
|
|
||||||
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun checkOnLaunch_upToDateWhenSameVersion() =
|
fun checkOnLaunch_upToDateWhenSameVersion() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -70,6 +71,10 @@ class AppUpdateViewModelTest {
|
|||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
||||||
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
||||||
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
|
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
|
||||||
|
every { AuthManager.getLastUpdateCheckTimestamp() } returns 0L
|
||||||
|
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||||
|
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||||
|
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||||
@@ -108,15 +113,20 @@ class AppUpdateViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun init_skipsCheckWhenAlreadyCheckedForThisVersion() =
|
fun init_adoptsCachedResultWhenPresent() =
|
||||||
runTest {
|
runTest {
|
||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/apk", 123L),
|
||||||
|
)
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||||
assertEquals(AppUpdateState.Idle, vm.state.value)
|
assertEquals(
|
||||||
|
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/apk", 123L),
|
||||||
|
vm.state.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -285,17 +295,42 @@ class AppUpdateViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun startUpdate_ignoredWhenStateIsNotUpdateAvailable() =
|
fun cancelDownload_cancelsJobAndRestoresAvailableState() =
|
||||||
runTest {
|
runTest {
|
||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(AppUpdateState.Idle, vm.state.value)
|
|
||||||
|
// In-flight download
|
||||||
|
coEvery { checker.downloadApk(any(), any(), any()) } coAnswers {
|
||||||
|
kotlinx.coroutines.delay(10000)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
vm.startUpdate()
|
vm.startUpdate()
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(100)
|
||||||
|
|
||||||
|
assertTrue(vm.state.value is AppUpdateState.Downloading)
|
||||||
|
|
||||||
|
vm.cancelDownload()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
assertTrue(vm.state.value is AppUpdateState.UpdateAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dismissCurrentUpdate_persistsDismissedTagAndHidesDialog() =
|
||||||
|
runTest {
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
AppUpdateCache.showDialog()
|
||||||
|
assertTrue(AppUpdateCache.isDialogVisible)
|
||||||
|
|
||||||
|
vm.dismissCurrentUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
|
||||||
|
assertTrue(AppUpdateCache.dismissed)
|
||||||
|
assertFalse(AppUpdateCache.isDialogVisible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user