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