feat(update): notify about app updates on launch (chat banner) (#891)

Fires one silent update check per installed version at app start
(UpdateNoticeManager, release builds only) and shows a non-blocking
banner on the chat screen when a newer release exists. The About tab
adopts the launch result instead of re-pinging GitHub; dismissals
return on the next launch via the persisted latest tag.

Closes #890

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-13 01:08:42 +03:00
1 parent ff8370bb8d
commit daf33dcb4c
14 files changed
+535 -31

No files matched your search

@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.update
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide holder of the latest update-check result (issue #890). The
* launch check ([UpdateNoticeManager]) writes here so the chat banner can
* appear without a second network call, and the About tab adopts the same
* result so both surfaces agree on what the last check found.
*/
object AppUpdateCache {
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
/**
* 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).
*/
var dismissed by mutableStateOf(false)
private set
fun update(state: AppUpdateState) {
_state.value = state
}
fun dismiss() {
dismissed = true
}
/** Test hook: clear state and dismissal between tests. */
internal fun reset() {
dismissed = false
_state.value = AppUpdateState.Idle
}
}
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.data.update
/**
* State of the in-app updater (issue #867) — shared by the About tab's
* [com.m57.hermescontrol.ui.settings.AppUpdateViewModel] and the launch
* notice (issue #890) via [AppUpdateCache].
*/
sealed interface AppUpdateState {
/** Nothing shown yet (no check run in this session). */
data object Idle : AppUpdateState
/** A check is in flight. */
data object Checking : AppUpdateState
/** Latest release equals the installed version. */
data class UpToDate(val latestTag: String) : AppUpdateState
/** A newer release with an APK asset exists. */
data class UpdateAvailable(
val latestTag: String,
val apkUrl: String,
val sizeBytes: Long,
) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */
data class Downloading(val progress: Float) : AppUpdateState
/** Download finished and the system package installer was launched. */
data class Installing(val latestTag: String) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */
data object NeedsUnknownSourcesPermission : AppUpdateState
data class Error(val message: String) : AppUpdateState
}
/** The release tag a state carries, when it carries one. */
fun AppUpdateState.releaseTag(): String? =
when (this) {
is AppUpdateState.UpToDate -> latestTag
is AppUpdateState.UpdateAvailable -> latestTag
is AppUpdateState.Installing -> latestTag
else -> null
}
@@ -0,0 +1,82 @@
package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.IOException
/**
* Startup update check (issue #890): one silent GitHub ping per installed
* version, fired right after launch, so the chat screen can show an update
* banner without the user ever opening the About tab.
*
* The result lands in [AppUpdateCache] (consumed by the chat banner and
* adopted by the About tab) and the latest tag is persisted via
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
* later launch. Failures leave the cache Idle — the About tab still has its
* own manual retry path.
*/
object UpdateNoticeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* Release-only feature (issue #890): the launch check and chat banner are
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
* hit the GitHub API or nag about updates. Tests flip this to true.
*/
var enabled: Boolean = !BuildConfig.DEBUG
internal set
/** Run from Application.onCreate, after AuthManager.init. No-op once per version. */
fun checkOnLaunch(
checker: AppUpdateChecker = AppUpdateChecker(),
currentVersion: String = BuildConfig.VERSION_NAME,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
if (!enabled) return
if (AuthManager.getUpdateCheckDoneForVersion() == currentVersion) return
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
val result =
try {
checker.fetchLatestRelease()
} catch (e: IOException) {
return@launch
} catch (e: Exception) {
return@launch
}
val info = result ?: return@launch
val apk = info.apkAsset ?: return@launch
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
AppUpdateCache.update(state)
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
}
}
/**
* 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.
*/
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) }
}
}