Merge pull request #1245 from Hy4ri/fix/reliable-app-update-flow

fix(update): make app update check, discovery, and installer flow reliable
This commit is contained in:
m57 authored and GitHub committed 2026-09-23 21:01:14 +03:00
commit 2785b14368
14 files changed
+723 -258

No files matched your search

@@ -13,7 +13,6 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import kotlinx.coroutines.CoroutineScope
@@ -41,9 +40,6 @@ class HermesControlApp :
// so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this@HermesControlApp)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
}
}
@@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@@ -131,6 +133,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
lifecycleScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
UpdateNoticeManager.checkOnLaunch()
}
NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this)
@@ -21,8 +21,9 @@ object AppUpdateCache {
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
* screen recomposes when it flips; dies with the process.
*/
var dismissed by mutableStateOf(false)
private set
private var dismissedTags by mutableStateOf<Set<String>>(emptySet())
fun isDismissed(tag: String): Boolean = tag in dismissedTags
/** Whether the full-screen / in-place update dialog should be visible. */
var isDialogVisible by mutableStateOf(false)
@@ -32,8 +33,8 @@ object AppUpdateCache {
_state.value = state
}
fun dismiss() {
dismissed = true
fun dismiss(tag: String) {
dismissedTags = dismissedTags + tag
}
fun showDialog() {
@@ -46,7 +47,7 @@ object AppUpdateCache {
/** Test hook: clear state and dismissal between tests. */
internal fun reset() {
dismissed = false
dismissedTags = emptySet()
isDialogVisible = false
_state.value = AppUpdateState.Idle
}
@@ -2,10 +2,15 @@ package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.IOException
@@ -79,17 +84,28 @@ fun selectLatestUpdate(
includeReleaseCandidates: Boolean = false,
): UpdateInfo? =
releases
.filter { !it.draft && it.apkAsset != null }
.filter {
if (!it.prerelease) {
true
} else {
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
}
}.reduceOrNull { best, candidate ->
.filter(::hasUsableApk)
.filter { isEligibleRelease(it, includeReleaseCandidates) }
.reduceOrNull { best, candidate ->
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
}
private fun hasUsableApk(release: UpdateInfo): Boolean =
!release.draft && release.apkAsset?.browserDownloadUrl?.toHttpUrlOrNull()?.let {
it.scheme == "http" || it.scheme == "https"
} == true
private fun isEligibleRelease(
release: UpdateInfo,
includeReleaseCandidates: Boolean,
): Boolean {
val version = parseVersion(release.tagName) ?: return false
val isRc = version.prerelease.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
val isStable = version.prerelease.isEmpty() && !release.prerelease
if (isStable) return true
return includeReleaseCandidates && isRc
}
private data class ParsedVersion(
val core: List<Int>,
val prerelease: List<String>,
@@ -161,17 +177,16 @@ open class AppUpdateChecker(
*/
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
withContext(Dispatchers.IO) {
if (includeReleaseCandidates) {
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
} else {
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
parseUpdateInfo(body)
val latest = get("$apiBaseUrl/releases/latest")?.let(::parseRelease)
if (!includeReleaseCandidates && latest != null && selectLatestUpdate(listOf(latest)) != null) {
return@withContext latest
}
val releases = fetchReleasePages() ?: return@withContext null
selectLatestUpdate(releases + listOfNotNull(latest), includeReleaseCandidates)
}
/** GET [url] as JSON text, or null on any non-2xx response. */
private fun get(url: String): String? {
/** GET a GitHub URL. 404 means absent; other HTTP/network failures stay visible. */
private fun get(url: String): ReleasePage? {
val request =
Request
.Builder()
@@ -179,11 +194,45 @@ open class AppUpdateChecker(
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body.string().orEmpty()
if (response.code == HTTP_NOT_FOUND) return null
if (!response.isSuccessful) throw IOException("GitHub releases request failed (${response.code})")
return ReleasePage(response.body.string().orEmpty(), response.nextPageUrl())
}
}
private fun fetchReleasePages(): List<UpdateInfo>? {
var nextUrl: String? = "$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE&page=1"
val releases = mutableListOf<UpdateInfo>()
while (nextUrl != null) {
val page =
get(nextUrl)
?: return if (releases.isEmpty()) null else throw IOException("Incomplete GitHub releases list")
val parsed = parseReleaseList(page.body) ?: throw IOException("GitHub returned malformed release metadata")
releases += parsed
nextUrl = page.nextUrl
}
return releases
}
private fun parseRelease(page: ReleasePage): UpdateInfo =
parseUpdateInfo(page.body) ?: throw IOException("GitHub returned malformed release metadata")
private fun Response.nextPageUrl(): String? {
val link = header("Link") ?: return null
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL")
if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") {
throw IOException("GitHub returned an untrusted pagination URL")
}
return next.toString()
}
private data class ReleasePage(
val body: String,
val nextUrl: String?,
)
/**
* Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted)
@@ -194,41 +243,103 @@ open class AppUpdateChecker(
dest: File,
onProgress: (Float) -> Unit,
): Boolean =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext false
val body = response.body
val total = body.contentLength()
dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = 0L
body.byteStream().use { input ->
while (true) {
val n = input.read(buffer)
if (n == -1) break
out.write(buffer, 0, n)
read += n
if (total > 0) {
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
suspendCancellableCoroutine { continuation ->
val request =
try {
val httpUrl =
url.toHttpUrlOrNull()?.takeIf { it.scheme == "http" || it.scheme == "https" }
?: run {
failDownload(continuation, dest)
return@suspendCancellableCoroutine
}
Request.Builder().url(httpUrl).build()
} catch (_: IllegalArgumentException) {
failDownload(continuation, dest)
return@suspendCancellableCoroutine
}
val call =
try {
client.newCall(request)
} catch (_: Exception) {
failDownload(continuation, dest)
return@suspendCancellableCoroutine
}
continuation.invokeOnCancellation {
call.cancel()
deletePartial(dest)
}
val callback =
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
failDownload(continuation, dest)
}
override fun onResponse(
call: Call,
response: Response,
) {
var succeeded = false
try {
response.use {
if (response.isSuccessful) {
val body = response.body
val total = body.contentLength()
dest.outputStream().use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read = 0L
body.byteStream().use { input ->
while (true) {
val n = input.read(buffer)
if (n == -1) break
out.write(buffer, 0, n)
read += n
if (total > 0) {
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
}
}
}
}
succeeded = true
}
}
} catch (_: Exception) {
// Includes response/body close failures; never report success before close.
}
if (succeeded) {
if (continuation.isActive) continuation.resume(true) { _, _, _ -> }
} else {
failDownload(continuation, dest)
}
}
true
}
} catch (e: Exception) {
dest.delete()
false
try {
call.enqueue(callback)
} catch (_: Exception) {
failDownload(continuation, dest)
}
}
private fun failDownload(
continuation: kotlinx.coroutines.CancellableContinuation<Boolean>,
dest: File,
) {
deletePartial(dest)
if (continuation.isActive) continuation.resume(false) { _, _, _ -> }
}
private fun deletePartial(dest: File) {
runCatching { dest.delete() }
}
private companion object {
const val DEFAULT_BUFFER_SIZE = 8192
const val HTTP_NOT_FOUND = 404
/** How many recent releases the opt-in RC scan inspects. */
const val RELEASE_LIST_PAGE_SIZE = 20
const val RELEASE_LIST_PAGE_SIZE = 100
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
}
}
@@ -40,6 +40,7 @@ sealed interface AppUpdateState {
data class Error(
val message: String,
val isCheckError: Boolean = false,
) : AppUpdateState
}
@@ -6,23 +6,31 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
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.
* Shared release check (issue #890): checks after launch and on foreground
* return, with a short process-local debounce 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.
* later launch. Failed checks remain visible and can be retried from the About
* tab or dialog without persisting a false successful-check marker.
*/
object UpdateNoticeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val checkLock = Any()
private var activeCheck: Job? = null
private var lastSuccessfulCheckAt: Long? = null
private var lastFailedCheckAt: Long? = null
private var channelGeneration = 0
/**
* Release-only feature (issue #890): the launch check and chat banner are
@@ -32,57 +40,153 @@ object UpdateNoticeManager {
var enabled: Boolean = !BuildConfig.DEBUG
internal set
/** Release check interval (24 hours). */
const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
const val CHECK_INTERVAL_MS: Long = 5 * 60 * 1000L
const val FAILURE_BACKOFF_MS: Long = 30 * 1000L
/** Run from Application.onCreate, after AuthManager.init. Checks at most once every 24h. */
/** Run after preferences are ready; failed checks never suppress the next foreground retry. */
fun checkOnLaunch(
checker: AppUpdateChecker = AppUpdateChecker(),
currentVersion: String = BuildConfig.VERSION_NAME,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
now: Long = System.currentTimeMillis(),
force: Boolean = false,
) {
if (!enabled) return
val lastCheck = AuthManager.getLastUpdateCheckTimestamp()
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
// Captured once so the check and the notice path agree on the channel.
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
return@launch
} catch (e: Exception) {
return@launch
}
val info = result ?: return@launch
// The user may have switched back to stable while this request was in
// flight — an RC result must never be published on the stable channel.
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return@launch
if (!enabled && !force) return
synchronized(checkLock) {
if (activeCheck?.isActive == true) return
if (!force && lastSuccessfulCheckAt?.let { now - it < CHECK_INTERVAL_MS } == true) return
if (!force && lastFailedCheckAt?.let { now - it < FAILURE_BACKOFF_MS } == true) return
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
val generation = channelGeneration
val previousState = AppUpdateCache.state.value
if (force ||
previousState !is AppUpdateState.UpdateAvailable
) {
AppUpdateCache.update(AppUpdateState.Checking)
}
val apk = info.apkAsset ?: return@launch
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
activeCheck =
scope.launch(ioDispatcher) {
val thisJob = currentCoroutineContext()[Job]
var successful = false
var failed = false
try {
val info = checker.fetchLatestRelease(includeReleaseCandidates)
val apk = info?.apkAsset
val state =
if (info == null) {
AppUpdateState.Error(NO_RELEASE_ERROR, isCheckError = true)
} else if (apk == null) {
AppUpdateState.Error(NO_APK_ERROR, isCheckError = true)
} else if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
var resultRejected = false
synchronized(checkLock) {
if (activeCheck !== thisJob || generation != channelGeneration) return@synchronized
val currentChannel = AuthManager.isCheckingReleaseCandidateUpdates()
if (includeReleaseCandidates != currentChannel ||
(info != null && isReleaseCandidateVersion(info.tagName) && !currentChannel)
) {
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(previousState)
}
resultRejected = true
return@synchronized
}
AppUpdateCache.update(state)
if (state is AppUpdateState.UpdateAvailable || state is AppUpdateState.UpToDate) {
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(System.currentTimeMillis())
successful = true
} else {
failed = true
}
}
if (resultRejected) return@launch
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
synchronized(checkLock) {
if (activeCheck === thisJob && generation == channelGeneration) {
failed = true
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(AppUpdateState.Error(NETWORK_ERROR, isCheckError = true))
}
}
}
} catch (e: Exception) {
synchronized(checkLock) {
if (activeCheck === thisJob && generation == channelGeneration) {
failed = true
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(
AppUpdateState.Error(GENERIC_CHECK_ERROR, isCheckError = true),
)
}
}
}
} finally {
synchronized(checkLock) {
if (activeCheck === thisJob) {
if (successful) lastSuccessfulCheckAt = now
if (failed) lastFailedCheckAt = now
activeCheck = null
}
}
}
}
AppUpdateCache.update(state)
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
}
}
private const val NETWORK_ERROR = "Network error — check your connection"
private const val GENERIC_CHECK_ERROR = "Couldn't check for updates"
private const val NO_RELEASE_ERROR = "No release found yet"
private const val NO_APK_ERROR = "Release has no APK asset"
/** Explicit check action; shares in-flight work but bypasses the local debounce. */
fun checkNow(
checker: AppUpdateChecker,
currentVersion: String,
ioDispatcher: CoroutineDispatcher,
) {
checkOnLaunch(checker, currentVersion, ioDispatcher, force = true)
}
/** Persist the channel choice and invalidate in-flight checks as one operation. */
fun channelChanged(enabled: Boolean) {
synchronized(checkLock) {
AuthManager.setCheckReleaseCandidateUpdates(enabled)
channelGeneration++
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(AppUpdateState.Idle)
}
activeCheck?.cancel()
activeCheck = null
lastSuccessfulCheckAt = null
lastFailedCheckAt = null
}
}
/** Test hook: reset process-local admission state between tests. */
internal fun resetForTests() {
synchronized(checkLock) {
activeCheck?.cancel()
activeCheck = null
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
AppUpdateCache.update(AppUpdateState.Idle)
}
lastSuccessfulCheckAt = null
lastFailedCheckAt = null
channelGeneration++
}
}
@@ -94,12 +198,9 @@ object UpdateNoticeManager {
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
val dismissed = AuthManager.getDismissedUpdateTag()
val candidateTag =
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag
?: AuthManager.getLastKnownLatestTag()
?: return null
val candidateTag = (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag ?: return null
if (candidateTag == dismissed) return null
if (candidateTag == dismissed || AppUpdateCache.isDismissed(candidateTag)) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null
}
@@ -7,6 +7,7 @@ import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -17,6 +18,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.update.releaseTag
import com.m57.hermescontrol.ui.common.AppUpdateDialog
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
@@ -32,14 +34,17 @@ fun ChatAppUpdateSection() {
AppUpdateViewModel(app)
}
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val currentAppUpdateState by rememberUpdatedState(appUpdateState)
val updateLifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(updateLifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
if (currentAppUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission()
} else {
appUpdateViewModel.reconcileInstallerReturn()
}
}
}
@@ -49,15 +54,13 @@ fun ChatAppUpdateSection() {
}
}
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (UpdateNoticeManager.enabled) {
val noticeTag = UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() },
onDismiss = { AppUpdateCache.dismiss(noticeTag) },
)
}
}
@@ -73,12 +76,17 @@ fun ChatAppUpdateSection() {
AppUpdateDialog(
state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() },
onLater = {
dialogState.releaseTag()?.let(AppUpdateCache::dismiss)
AppUpdateCache.hideDialog()
},
onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
launchUnknownAppSourcesSettings(context)
},
onCheckUpdate = { appUpdateViewModel.checkForUpdate() },
)
}
}
@@ -48,10 +48,12 @@ import com.m57.hermescontrol.data.update.releaseTag
fun AppUpdateDialog(
state: AppUpdateState,
onDismiss: () -> Unit,
onLater: () -> Unit = onDismiss,
onStartUpdate: () -> Unit,
onCancelDownload: () -> Unit,
onNeverAskAgain: () -> Unit,
onOpenSettings: () -> Unit,
onCheckUpdate: () -> Unit = {},
) {
val available = state as? AppUpdateState.UpdateAvailable
var cachedAvailable by remember { mutableStateOf(available) }
@@ -160,6 +162,26 @@ fun AppUpdateDialog(
// Progress / Action States
when (state) {
AppUpdateState.Checking -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.settings_about_update_checking),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
is AppUpdateState.UpToDate -> {
Text(
text = stringResource(R.string.settings_about_update_uptodate, state.latestTag),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
is AppUpdateState.Downloading -> {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -260,7 +282,7 @@ fun AppUpdateDialog(
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onStartUpdate,
onClick = if (state.isCheckError) onCheckUpdate else onStartUpdate,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_about_update_retry))
@@ -268,8 +290,7 @@ fun AppUpdateDialog(
}
}
else -> {
// UpdateAvailable or Idle
is AppUpdateState.UpdateAvailable -> {
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onStartUpdate,
@@ -291,7 +312,7 @@ fun AppUpdateDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = onDismiss) {
TextButton(onClick = onLater) {
Text(stringResource(R.string.update_dialog_action_later))
}
TextButton(onClick = onNeverAskAgain) {
@@ -303,6 +324,12 @@ fun AppUpdateDialog(
}
}
}
AppUpdateState.Idle -> {
Button(onClick = onCheckUpdate, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.settings_about_update_check))
}
}
}
}
}
@@ -11,19 +11,19 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateChecker
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.isNewerVersion
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.update.isReleaseCandidateVersion
import com.m57.hermescontrol.data.update.releaseTag
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import java.util.UUID
/**
* Drives the in-app self-update flow (issue #867): check the GitHub
@@ -31,9 +31,8 @@ import java.io.IOException
* installer. The installer launch uses the application context
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
*
* A silent check runs once per installed version (guarded by the
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update
* available" badge on the About row without the user tapping anything.
* The ViewModel adopts the process-shared launch/foreground check result, while
* manual checks, downloads, and system-installer handoff stay screen-owned.
*/
class AppUpdateViewModel(
application: Application,
@@ -51,16 +50,11 @@ class AppUpdateViewModel(
/** Whether the RC update channel is enabled (issue: RC update toggle). */
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow()
private var checkJob: kotlinx.coroutines.Job? = null
private var downloadJob: kotlinx.coroutines.Job? = null
private var lastAvailable: AppUpdateState.UpdateAvailable? = null
/**
* Bumped by every check. The blocking OkHttp call underneath cannot be
* interrupted, so a cancelled check may still be running; results are only
* published when this token is unchanged (and the channel still matches).
*/
private var checkGeneration = 0
private var downloadGeneration = 0
private var downloadPartialFile: File? = null
private var installerReturnPending = false
init {
val cached = AppUpdateCache.state.value
@@ -70,13 +64,25 @@ class AppUpdateViewModel(
!_checkReleaseCandidateUpdates.value &&
cached.releaseTag()?.let(::isReleaseCandidateVersion) == true
if (blocked) AppUpdateCache.reset()
viewModelScope.launch {
AppUpdateCache.state.collect { sharedState ->
if (_state.value !is AppUpdateState.Downloading &&
_state.value !is AppUpdateState.Installing &&
_state.value !is AppUpdateState.NeedsUnknownSourcesPermission
) {
_state.value = sharedState
if (sharedState is AppUpdateState.UpdateAvailable) lastAvailable = sharedState
}
}
}
if (cached is AppUpdateState.UpdateAvailable && !blocked) {
lastAvailable = cached
_state.value = cached
} else if (cached is AppUpdateState.UpToDate && !blocked) {
_state.value = cached
} else {
checkForUpdate()
_state.value = cached
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, ioDispatcher)
}
}
@@ -94,92 +100,20 @@ class AppUpdateViewModel(
*/
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
if (_checkReleaseCandidateUpdates.value == enabled) return
AuthManager.setCheckReleaseCandidateUpdates(enabled)
UpdateNoticeManager.channelChanged(enabled)
_checkReleaseCandidateUpdates.value = enabled
if (!enabled) {
if (lastAvailable?.latestTag?.let(::isReleaseCandidateVersion) == true) cancelDownload()
lastAvailable = null
AppUpdateCache.reset()
}
// A check already in flight captured the previous channel — cancel it and
// bump the generation so a late result can never be published.
checkJob?.cancel()
runCheck()
}
private fun runCheck() {
val generation = ++checkGeneration
val includeReleaseCandidates = _checkReleaseCandidateUpdates.value
_state.value = AppUpdateState.Checking
checkJob =
viewModelScope.launch(ioDispatcher) {
val now = System.currentTimeMillis()
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
// Cancellation is control flow — it must never be reported
// as a failed check.
throw e
} catch (e: IOException) {
if (isCurrentCheck(generation, includeReleaseCandidates)) {
_state.value = AppUpdateState.Error(NETWORK_ERROR)
}
return@launch
} catch (e: Exception) {
if (isCurrentCheck(generation, includeReleaseCandidates)) {
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
}
return@launch
}
// The OkHttp call underneath blocks and cannot be interrupted, so a
// newer check — or a channel switch — may have happened while it ran.
ensureActive()
if (!isCurrentCheck(generation, includeReleaseCandidates)) return@launch
val info =
result ?: run {
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
return@launch
}
val apk =
info.apkAsset ?: run {
_state.value = AppUpdateState.Error(NO_APK_ERROR)
return@launch
}
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
releaseNotes = info.body,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
// Fail closed: a release-candidate result is never published once
// the RC channel is off, however it reached this point.
if (!isAdmissibleOnCurrentChannel(state.releaseTag())) return@launch
if (state is AppUpdateState.UpdateAvailable) lastAvailable = state
_state.value = state
// Keep the launch notice (issue #890) in sync with manual checks.
AppUpdateCache.update(state)
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
}
UpdateNoticeManager.checkNow(checker, currentVersion, ioDispatcher)
}
/**
* True when [generation] is still the newest check *and* the channel that
* check was started for is still the selected one.
*/
private fun isCurrentCheck(
generation: Int,
includeReleaseCandidates: Boolean,
): Boolean = generation == checkGeneration && includeReleaseCandidates == _checkReleaseCandidateUpdates.value
/** A release-candidate tag may only be used while the RC channel is on. */
private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean =
tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value
@@ -208,36 +142,70 @@ class AppUpdateViewModel(
/** Download the release APK and launch the system installer. */
fun startUpdate() {
if (_state.value is AppUpdateState.Downloading || _state.value is AppUpdateState.Installing) return
val available = updateAvailableForInstall() ?: return
lastAvailable = available
if (!canRequestInstalls()) {
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
return
}
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
_state.value = AppUpdateState.Downloading(0f)
downloadJob?.cancel()
downloadJob =
viewModelScope.launch(ioDispatcher) {
val downloaded =
checker.downloadApk(available.apkUrl, dest) { progress ->
_state.value = AppUpdateState.Downloading(progress)
val dest = updateApkFile(available.latestTag)
val generation = ++downloadGeneration
val partial = File(dest.parentFile, "${dest.name}.${UUID.randomUUID()}.$generation.part")
downloadPartialFile = partial
try {
partial.delete()
_state.value = AppUpdateState.Downloading(0f)
downloadJob?.cancel()
downloadJob =
viewModelScope.launch(ioDispatcher) {
try {
val downloaded =
checker.downloadApk(available.apkUrl, partial) { progress ->
if (generation ==
downloadGeneration
) {
_state.value = AppUpdateState.Downloading(progress)
}
}
if (generation != downloadGeneration) return@launch
if (!downloaded || !partial.exists() || partial.length() == 0L) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
return@launch
}
if ((dest.exists() && !dest.delete()) || !partial.renameTo(dest)) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
return@launch
}
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
if (generation == downloadGeneration) _state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
} finally {
if (generation == downloadGeneration) {
partial.delete()
downloadPartialFile = null
}
}
if (!downloaded) {
if (downloadJob?.isCancelled != true) {
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
}
return@launch
}
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} catch (_: Exception) {
if (generation == downloadGeneration) {
partial.delete()
downloadPartialFile = null
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
}
}
}
/** Cancel in-flight download and return to UpdateAvailable. */
fun cancelDownload() {
downloadGeneration++
downloadJob?.cancel()
downloadJob = null
downloadPartialFile?.delete()
downloadPartialFile = null
val available =
lastAvailable
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
@@ -257,16 +225,15 @@ class AppUpdateViewModel(
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 = updateAvailableForInstall()
if (dest.exists() && dest.length() > 0 && available != null) {
val dest = available?.let { updateApkFile(it.latestTag) }
if (dest != null && dest.exists() && dest.length() > 0L) {
_state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest)
} else {
@@ -275,6 +242,18 @@ class AppUpdateViewModel(
}
}
/** Returning from the external installer is not proof that installation succeeded. */
fun reconcileInstallerReturn() {
if (_state.value !is AppUpdateState.Installing || !installerReturnPending) return
installerReturnPending = false
_state.value = lastAvailable ?: AppUpdateCache.state.value
}
private fun updateApkFile(tag: String): File {
val safeTag = tag.replace(Regex("[^A-Za-z0-9._-]"), "_")
return File(getApplication<Application>().cacheDir, "hermes-update-$safeTag.apk")
}
private fun canRequestInstalls(): Boolean =
try {
getApplication<Application>().packageManager.canRequestPackageInstalls()
@@ -292,18 +271,14 @@ class AppUpdateViewModel(
apkFile,
)
context.startActivity(installIntentFactory(uri))
installerReturnPending = true
} catch (e: Exception) {
installerReturnPending = false
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
}
}
private companion object {
const val APK_FILE_NAME = "hermes-update.apk"
val NETWORK_ERROR = "Network error — check your connection"
val GENERIC_CHECK_ERROR = "Couldn't check for updates"
val NO_RELEASE_ERROR = "No release found yet"
val NO_APK_ERROR = "Release has no APK asset"
val DOWNLOAD_ERROR = "Download failed — tap to retry"
val INSTALLER_ERROR = "Couldn't open the installer"
}
@@ -262,6 +262,7 @@ internal fun SettingsAboutPage(
},
) {
val updateState by viewModel.state.collectAsStateWithLifecycle()
val currentUpdateState by androidx.compose.runtime.rememberUpdatedState(updateState)
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
@@ -270,8 +271,12 @@ internal fun SettingsAboutPage(
val observer =
androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
if (updateState is com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission) {
if (currentUpdateState is
com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission
) {
viewModel.resumeInstallAfterPermission()
} else {
viewModel.reconcileInstallerReturn()
}
}
}
@@ -1,14 +1,21 @@
package com.m57.hermescontrol.data.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Endpoint contract for the opt-in release-candidate channel: the stable path
@@ -38,8 +45,11 @@ class AppUpdateCheckerNetworkTest {
private fun enqueue(
body: String,
code: Int = 200,
headers: Map<String, String> = emptyMap(),
) {
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
val response = MockResponse().setResponseCode(code).setBody(body)
headers.forEach(response::addHeader)
server.enqueue(response)
}
@Test
@@ -56,26 +66,62 @@ class AppUpdateCheckerNetworkTest {
@Test
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
runTest {
enqueue(STABLE_BODY)
enqueue(RELEASES_BODY)
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
assertEquals("v1.25.0-rc.3", info?.tagName)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=20", server.takeRequest().path)
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() =
fun fetchLatestRelease_stableFallsBackToReleasesWhenLatestIsNotUsable() =
runTest {
enqueue("{}")
enqueue(RELEASES_BODY)
val info = checker().fetchLatestRelease()
assertEquals("v1.24.2", info?.tagName)
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_rcScanFollowsGithubPagination() =
runTest {
enqueue(STABLE_BODY)
val nextPage = server.url("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2")
enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\""))
enqueue(
"""[{"tag_name":"v1.26.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""",
)
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
assertEquals("v1.26.0-rc.1", info?.tagName)
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() =
runTest {
enqueue(STABLE_BODY)
enqueue(ALPHA_BETA_BODY)
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
assertEquals("v1.24.2", checker().fetchLatestRelease(includeReleaseCandidates = true)?.tagName)
}
@Test
fun fetchLatestRelease_nonSuccessYieldsNull() =
runTest {
// One queued response per request: stable path, then RC path.
// Each channel checks the stable endpoint and falls back to the list on 404.
enqueue("", code = 404)
enqueue("", code = 404)
enqueue("", code = 404)
enqueue("", code = 404)
@@ -83,6 +129,35 @@ class AppUpdateCheckerNetworkTest {
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
}
@Test
fun downloadApk_cancellingCoroutineCancelsNetworkAndDeletesPartialFile() =
runTest {
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
val destination = File.createTempFile("hermes-update", ".apk")
destination.writeText("partial")
val download =
launch(Dispatchers.IO) {
checker().downloadApk(server.url("/slow.apk").toString(), destination) {}
}
assertEquals("/slow.apk", server.takeRequest(5, TimeUnit.SECONDS)?.path)
download.cancelAndJoin()
assertFalse(destination.exists())
}
@Test
fun downloadApk_invalidUrlReturnsFailureAndDeletesDestination() =
runTest {
val destination = File.createTempFile("hermes-update", ".apk")
destination.writeText("partial")
val downloaded = checker().downloadApk("file:///tmp/release.apk", destination) {}
assertFalse(downloaded)
assertFalse(destination.exists())
}
private companion object {
const val STABLE_BODY = """
{
@@ -183,6 +183,7 @@ class AppUpdateCheckerTest {
draft: Boolean = false,
prerelease: Boolean = false,
withApk: Boolean = true,
apkUrl: String = "https://example.com/$tag.apk",
): UpdateInfo =
UpdateInfo(
tagName = tag,
@@ -194,7 +195,7 @@ class AppUpdateCheckerTest {
UpdateInfo.Asset(
name = "hermes-mobile-$tag.apk",
size = 1L,
browserDownloadUrl = "https://example.com/$tag.apk",
browserDownloadUrl = apkUrl,
),
)
} else {
@@ -289,6 +290,55 @@ class AppUpdateCheckerTest {
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false))))
}
@Test
fun selectLatestUpdate_rejectsPrereleaseTagsMislabelledAsStable() {
val releases =
listOf(
release("v1.30.0-rc.1"),
release("v1.29.0-alpha.1"),
release("v1.28.0-beta.2"),
release("v1.27.0"),
)
assertEquals("v1.27.0", selectLatestUpdate(releases)?.tagName)
assertEquals("v1.30.0-rc.1", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
}
@Test
fun selectLatestUpdate_ignoresInvalidTagsAndUnusableApkUrls() {
val releases =
listOf(
release("not-a-version"),
release("v1.30.0", apkUrl = " "),
release("v1.29.0"),
)
assertEquals("v1.29.0", selectLatestUpdate(releases)?.tagName)
}
@Test
fun selectLatestUpdate_rejectsMalformedAndUnsupportedApkUrlsButAllowsHttp() {
val releases =
listOf(
release("v1.32.0", apkUrl = "https://bad host/release.apk"),
release("v1.31.0", apkUrl = "file:///tmp/release.apk"),
release("v1.30.0", apkUrl = "http://localhost/release.apk"),
)
assertEquals("v1.30.0", selectLatestUpdate(releases)?.tagName)
}
@Test
fun selectLatestUpdate_stableOutranksRcForSameVersion() {
val releases =
listOf(
release("v1.25.0-rc.10", prerelease = true),
release("v1.25.0"),
)
assertEquals("v1.25.0", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
}
@Test
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
val json =
@@ -49,6 +49,7 @@ class UpdateNoticeManagerTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
UpdateNoticeManager.resetForTests()
AppUpdateCache.reset()
// Unit tests run against the debug variant where the feature is
// disabled by default — flip the knob on so the behavior is testable.
@@ -68,6 +69,7 @@ class UpdateNoticeManagerTest {
@After
fun tearDown() {
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = false
unmockkAll()
Dispatchers.resetMain()
@@ -92,7 +94,7 @@ class UpdateNoticeManagerTest {
}
@Test
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() =
fun checkOnLaunch_ignoresPersistedDailyCheckTimestampAfterColdStart() =
runTest {
val now = 1000000000000L
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
@@ -103,9 +105,24 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
@Test
fun checkOnLaunch_debouncesRepeatedForegroundChecksAfterSuccess() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(false) } returns updateInfo()
val now = 1_000_000L
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now)
advanceUntilIdle()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now + 1_000L)
advanceUntilIdle()
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
}
@Test
@@ -148,9 +165,9 @@ class UpdateNoticeManagerTest {
}
@Test
fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() {
fun noticeTag_doesNotUsePersistedTagForStableInstall() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25"
assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.rc.1"))
assertNull(UpdateNoticeManager.noticeTag("1.25.rc.1"))
assertNull(UpdateNoticeManager.noticeTag("1.25"))
}
@@ -176,9 +193,11 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
// Still marked done so a dead network can't spam the API per launch.
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
assertEquals(
AppUpdateState.Error("Network error — check your connection", isCheckError = true),
AppUpdateCache.state.value,
)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
@Test
@@ -190,8 +209,41 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.Error("No release found yet", isCheckError = true), AppUpdateCache.state.value)
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
}
@Test
fun checkOnLaunch_noReleaseDoesNotPersistSuccessfulCheck() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(false) } returns null
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
verify(exactly = 0) { AuthManager.setLastUpdateCheckTimestamp(any()) }
}
@Test
fun checkOnLaunch_channelInvalidationDuringFetchPreservesNewChannelState() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(true) } coAnswers {
// A channel change invalidates the captured request while it is completing.
UpdateNoticeManager.channelChanged(enabled = false)
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
updateInfo(tag = "v1.25.0-rc.3")
}
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
assertFalse(AppUpdateCache.dismissed)
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
}
// ── noticeTag (dismissed/restart banner return) ─────────────────────
@@ -211,10 +263,10 @@ class UpdateNoticeManagerTest {
}
@Test
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() {
fun noticeTag_doesNotAdvertisePersistedTagWithoutFreshApkMetadata() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
@@ -303,11 +355,11 @@ class UpdateNoticeManagerTest {
}
@Test
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() {
fun noticeTag_doesNotUsePersistedRcTagWithoutApkMetadata() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion))
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
@@ -336,10 +388,50 @@ class UpdateNoticeManagerTest {
}
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
UpdateNoticeManager.channelChanged(enabled = false)
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
advanceUntilIdle()
// The late RC result must not reach the cache or the persisted tag.
// A genuine manager-mediated opt-out invalidates the in-flight RC result.
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
}
@Test
fun checkOnLaunch_retriesAfterFailureBackoffInsteadOfPersistingFailureAsDone() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(false) } throws IOException("offline") andThen updateInfo()
val now = 100_000L
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now)
advanceUntilIdle()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now + 1_000L)
advanceUntilIdle()
UpdateNoticeManager.checkOnLaunch(
checker,
currentVersion,
testDispatcher,
now + UpdateNoticeManager.FAILURE_BACKOFF_MS + 1L,
)
advanceUntilIdle()
coVerify(exactly = 2) { checker.fetchLatestRelease(false) }
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
}
@Test
fun noticeTag_dismissalAppliesOnlyToThatRelease() {
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/1.apk", 1L),
)
AppUpdateCache.dismiss("v1.22.0")
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.23.0", "https://example.com/2.apk", 1L),
)
assertEquals("v1.23.0", UpdateNoticeManager.noticeTag(currentVersion))
}
}
@@ -10,12 +10,14 @@ import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateChecker
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateInfo
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
@@ -48,6 +50,7 @@ class AppUpdateViewModelTest {
private lateinit var app: Application
private lateinit var packageManager: PackageManager
private lateinit var checker: AppUpdateChecker
private var rcUpdatesEnabled = false
private val currentVersion = "1.21.0"
@@ -70,6 +73,8 @@ class AppUpdateViewModelTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = true
AppUpdateCache.reset()
mockkObject(AuthManager)
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
@@ -79,8 +84,12 @@ class AppUpdateViewModelTest {
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
every { AuthManager.getDismissedUpdateTag() } returns null
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
rcUpdatesEnabled = false
every { AuthManager.isCheckingReleaseCandidateUpdates() } answers { rcUpdatesEnabled }
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } answers {
rcUpdatesEnabled = firstArg()
Unit
}
app = mockk(relaxed = true)
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
@@ -91,11 +100,17 @@ class AppUpdateViewModelTest {
checker = mockk()
coEvery { checker.fetchLatestRelease() } returns updateInfo()
coEvery { checker.downloadApk(any(), any(), any()) } returns true
val downloadDestination = slot<File>()
coEvery { checker.downloadApk(any(), capture(downloadDestination), any()) } coAnswers {
downloadDestination.captured.writeBytes(byteArrayOf(1))
true
}
}
@After
fun tearDown() {
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = false
unmockkAll()
Dispatchers.resetMain()
}
@@ -237,14 +252,14 @@ class AppUpdateViewModelTest {
}
@Test
fun checkForUpdate_marksDoneEvenWhenCheckFails() =
fun checkForUpdate_marksDoneOnlyAfterSuccessfulDiscovery() =
runTest {
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
coVerify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
// ── Update flow ─────────────────────────────────────────────────────
@@ -351,7 +366,7 @@ class AppUpdateViewModelTest {
advanceUntilIdle()
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
assertTrue(AppUpdateCache.dismissed)
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
assertFalse(AppUpdateCache.isDialogVisible)
}
@@ -392,7 +407,7 @@ class AppUpdateViewModelTest {
@Test
fun init_adoptsCachedRcOfferWhenOptedIn() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
rcUpdatesEnabled = true
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
)
@@ -419,6 +434,7 @@ class AppUpdateViewModelTest {
advanceUntilIdle()
assertTrue(vm.checkReleaseCandidateUpdates.value)
verify(exactly = 1) { UpdateNoticeManager.channelChanged(true) }
verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) }
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
@@ -435,13 +451,14 @@ class AppUpdateViewModelTest {
advanceUntilIdle()
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
verify(exactly = 0) { UpdateNoticeManager.channelChanged(any()) }
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
}
@Test
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
rcUpdatesEnabled = true
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
val vm = createViewModel()
@@ -487,7 +504,7 @@ class AppUpdateViewModelTest {
@Test
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
rcUpdatesEnabled = true
val rcGate = CompletableDeferred<Unit>()
coEvery { checker.fetchLatestRelease(true) } coAnswers {
withContext(NonCancellable) { rcGate.await() }