fix(update): make app update check, discovery, and installer flow reliable

Automate background update checks on launch/foreground and share results across chat and settings. Disregard release candidates by default unless opted in, and handle installer cancellation and download failures cleanly.
This commit is contained in:
M57 committed 2026-09-23 20:43:41 +03:00
1 parent 68a17d5499
commit 19eacf260f
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.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -41,9 +40,6 @@ class HermesControlApp :
// so the tab renders instantly when opened (the usage endpoint is slow on a // so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup. // cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this@HermesControlApp) 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.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.local.AuthManager 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.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver 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.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper import com.m57.hermescontrol.util.LocaleContextWrapper
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -131,6 +133,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
lifecycleScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
UpdateNoticeManager.checkOnLaunch()
}
NotificationHelper.setAppForeground(this, true) NotificationHelper.setAppForeground(this, true)
ExternalActivityLifecycleGuard.onHostResumed() ExternalActivityLifecycleGuard.onHostResumed()
NotificationHelper.stop(this) NotificationHelper.stop(this)
@@ -21,8 +21,9 @@ 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. * screen recomposes when it flips; dies with the process.
*/ */
var dismissed by mutableStateOf(false) private var dismissedTags by mutableStateOf<Set<String>>(emptySet())
private set
fun isDismissed(tag: String): Boolean = tag in dismissedTags
/** Whether the full-screen / in-place update dialog should be visible. */ /** Whether the full-screen / in-place update dialog should be visible. */
var isDialogVisible by mutableStateOf(false) var isDialogVisible by mutableStateOf(false)
@@ -32,8 +33,8 @@ object AppUpdateCache {
_state.value = state _state.value = state
} }
fun dismiss() { fun dismiss(tag: String) {
dismissed = true dismissedTags = dismissedTags + tag
} }
fun showDialog() { fun showDialog() {
@@ -46,7 +47,7 @@ object AppUpdateCache {
/** Test hook: clear state and dismissal between tests. */ /** Test hook: clear state and dismissal between tests. */
internal fun reset() { internal fun reset() {
dismissed = false dismissedTags = emptySet()
isDialogVisible = false isDialogVisible = false
_state.value = AppUpdateState.Idle _state.value = AppUpdateState.Idle
} }
@@ -2,10 +2,15 @@ package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -79,17 +84,28 @@ fun selectLatestUpdate(
includeReleaseCandidates: Boolean = false, includeReleaseCandidates: Boolean = false,
): UpdateInfo? = ): UpdateInfo? =
releases releases
.filter { !it.draft && it.apkAsset != null } .filter(::hasUsableApk)
.filter { .filter { isEligibleRelease(it, includeReleaseCandidates) }
if (!it.prerelease) { .reduceOrNull { best, candidate ->
true
} else {
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
}
}.reduceOrNull { best, candidate ->
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best 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( private data class ParsedVersion(
val core: List<Int>, val core: List<Int>,
val prerelease: List<String>, val prerelease: List<String>,
@@ -161,17 +177,16 @@ open class AppUpdateChecker(
*/ */
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? = open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (includeReleaseCandidates) { val latest = get("$apiBaseUrl/releases/latest")?.let(::parseRelease)
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null if (!includeReleaseCandidates && latest != null && selectLatestUpdate(listOf(latest)) != null) {
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true) return@withContext latest
} else {
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
parseUpdateInfo(body)
} }
val releases = fetchReleasePages() ?: return@withContext null
selectLatestUpdate(releases + listOfNotNull(latest), includeReleaseCandidates)
} }
/** GET [url] as JSON text, or null on any non-2xx response. */ /** GET a GitHub URL. 404 means absent; other HTTP/network failures stay visible. */
private fun get(url: String): String? { private fun get(url: String): ReleasePage? {
val request = val request =
Request Request
.Builder() .Builder()
@@ -179,11 +194,45 @@ open class AppUpdateChecker(
.header("Accept", "application/vnd.github+json") .header("Accept", "application/vnd.github+json")
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null if (response.code == HTTP_NOT_FOUND) return null
return response.body.string().orEmpty() 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 * Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted) * [onProgress]. True on success; false (with the partial file deleted)
@@ -194,41 +243,103 @@ open class AppUpdateChecker(
dest: File, dest: File,
onProgress: (Float) -> Unit, onProgress: (Float) -> Unit,
): Boolean = ): Boolean =
withContext(Dispatchers.IO) { suspendCancellableCoroutine { continuation ->
val request = Request.Builder().url(url).build() val request =
try { try {
client.newCall(request).execute().use { response -> val httpUrl =
if (!response.isSuccessful) return@withContext false url.toHttpUrlOrNull()?.takeIf { it.scheme == "http" || it.scheme == "https" }
val body = response.body ?: run {
val total = body.contentLength() failDownload(continuation, dest)
dest.outputStream().use { out -> return@suspendCancellableCoroutine
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) }
var read = 0L Request.Builder().url(httpUrl).build()
body.byteStream().use { input -> } catch (_: IllegalArgumentException) {
while (true) { failDownload(continuation, dest)
val n = input.read(buffer) return@suspendCancellableCoroutine
if (n == -1) break }
out.write(buffer, 0, n) val call =
read += n try {
if (total > 0) { client.newCall(request)
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f)) } 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) { try {
dest.delete() call.enqueue(callback)
false } 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 { private companion object {
const val DEFAULT_BUFFER_SIZE = 8192 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 = 100
const val RELEASE_LIST_PAGE_SIZE = 20 val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
} }
} }
@@ -40,6 +40,7 @@ sealed interface AppUpdateState {
data class Error( data class Error(
val message: String, val message: String,
val isCheckError: Boolean = false,
) : AppUpdateState ) : AppUpdateState
} }
@@ -6,23 +6,31 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
/** /**
* Startup update check (issue #890): one silent GitHub ping per installed * Shared release check (issue #890): checks after launch and on foreground
* version, fired right after launch, so the chat screen can show an update * return, with a short process-local debounce so the chat screen can show an
* banner without the user ever opening the About tab. * update banner without the user ever opening the About tab.
* *
* The result lands in [AppUpdateCache] (consumed by the chat banner and * The result lands in [AppUpdateCache] (consumed by the chat banner and
* adopted by the About tab) and the latest tag is persisted via * adopted by the About tab) and the latest tag is persisted via
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a * [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
* later launch. Failures leave the cache Idle — the About tab still has its * later launch. Failed checks remain visible and can be retried from the About
* own manual retry path. * tab or dialog without persisting a false successful-check marker.
*/ */
object UpdateNoticeManager { object UpdateNoticeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 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 * Release-only feature (issue #890): the launch check and chat banner are
@@ -32,57 +40,153 @@ object UpdateNoticeManager {
var enabled: Boolean = !BuildConfig.DEBUG var enabled: Boolean = !BuildConfig.DEBUG
internal set internal set
/** Release check interval (24 hours). */ /** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L 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( 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(), now: Long = System.currentTimeMillis(),
force: Boolean = false,
) { ) {
if (!enabled) return if (!enabled && !force) return
val lastCheck = AuthManager.getLastUpdateCheckTimestamp() synchronized(checkLock) {
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion if (activeCheck?.isActive == true) return
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return if (!force && lastSuccessfulCheckAt?.let { now - it < CHECK_INTERVAL_MS } == true) return
if (!force && lastFailedCheckAt?.let { now - it < FAILURE_BACKOFF_MS } == true) return
// Captured once so the check and the notice path agree on the channel. val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates() val generation = channelGeneration
val previousState = AppUpdateCache.state.value
scope.launch(ioDispatcher) { if (force ||
AuthManager.setUpdateCheckDoneForVersion(currentVersion) previousState !is AppUpdateState.UpdateAvailable
AuthManager.setLastUpdateCheckTimestamp(now) ) {
val result = AppUpdateCache.update(AppUpdateState.Checking)
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
} }
val apk = info.apkAsset ?: return@launch activeCheck =
val state = scope.launch(ioDispatcher) {
if (isNewerVersion(info.tagName, currentVersion)) { val thisJob = currentCoroutineContext()[Job]
AppUpdateState.UpdateAvailable( var successful = false
latestTag = info.tagName, var failed = false
apkUrl = apk.browserDownloadUrl, try {
sizeBytes = apk.size, val info = checker.fetchLatestRelease(includeReleaseCandidates)
releaseNotes = info.body, val apk = info?.apkAsset
) val state =
} else { if (info == null) {
AppUpdateState.UpToDate(latestTag = info.tagName) 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? { fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null if (!enabled) return null
val dismissed = AuthManager.getDismissedUpdateTag() val dismissed = AuthManager.getDismissedUpdateTag()
val candidateTag = val candidateTag = (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag ?: return null
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag
?: AuthManager.getLastKnownLatestTag()
?: return null
if (candidateTag == dismissed) return null if (candidateTag == dismissed || AppUpdateCache.isDismissed(candidateTag)) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) { if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null return null
} }
@@ -7,6 +7,7 @@ import android.provider.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver 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.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager 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.AppUpdateDialog
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.settings.AppUpdateViewModel import com.m57.hermescontrol.ui.settings.AppUpdateViewModel
@@ -32,14 +34,17 @@ fun ChatAppUpdateSection() {
AppUpdateViewModel(app) AppUpdateViewModel(app)
} }
val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle() val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle()
val currentAppUpdateState by rememberUpdatedState(appUpdateState)
val updateLifecycleOwner = LocalLifecycleOwner.current val updateLifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(updateLifecycleOwner) { DisposableEffect(updateLifecycleOwner) {
val observer = val observer =
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) { if (currentAppUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) {
appUpdateViewModel.resumeInstallAfterPermission() appUpdateViewModel.resumeInstallAfterPermission()
} else {
appUpdateViewModel.reconcileInstallerReturn()
} }
} }
} }
@@ -49,15 +54,13 @@ fun ChatAppUpdateSection() {
} }
} }
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { if (UpdateNoticeManager.enabled) {
val noticeTag = val noticeTag = UpdateNoticeManager.noticeTag()
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) { if (noticeTag != null) {
UpdateNoticeBanner( UpdateNoticeBanner(
latestTag = noticeTag, latestTag = noticeTag,
onUpdate = { AppUpdateCache.showDialog() }, onUpdate = { AppUpdateCache.showDialog() },
onDismiss = { AppUpdateCache.dismiss() }, onDismiss = { AppUpdateCache.dismiss(noticeTag) },
) )
} }
} }
@@ -73,12 +76,17 @@ fun ChatAppUpdateSection() {
AppUpdateDialog( AppUpdateDialog(
state = dialogState, state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() }, onDismiss = { AppUpdateCache.hideDialog() },
onLater = {
dialogState.releaseTag()?.let(AppUpdateCache::dismiss)
AppUpdateCache.hideDialog()
},
onStartUpdate = { appUpdateViewModel.startUpdate() }, onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() }, onCancelDownload = { appUpdateViewModel.cancelDownload() },
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = { onOpenSettings = {
launchUnknownAppSourcesSettings(context) launchUnknownAppSourcesSettings(context)
}, },
onCheckUpdate = { appUpdateViewModel.checkForUpdate() },
) )
} }
} }
@@ -48,10 +48,12 @@ import com.m57.hermescontrol.data.update.releaseTag
fun AppUpdateDialog( fun AppUpdateDialog(
state: AppUpdateState, state: AppUpdateState,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onLater: () -> Unit = onDismiss,
onStartUpdate: () -> Unit, onStartUpdate: () -> Unit,
onCancelDownload: () -> Unit, onCancelDownload: () -> Unit,
onNeverAskAgain: () -> Unit, onNeverAskAgain: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCheckUpdate: () -> Unit = {},
) { ) {
val available = state as? AppUpdateState.UpdateAvailable val available = state as? AppUpdateState.UpdateAvailable
var cachedAvailable by remember { mutableStateOf(available) } var cachedAvailable by remember { mutableStateOf(available) }
@@ -160,6 +162,26 @@ fun AppUpdateDialog(
// Progress / Action States // Progress / Action States
when (state) { 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 -> { is AppUpdateState.Downloading -> {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Row( Row(
@@ -260,7 +282,7 @@ fun AppUpdateDialog(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( Button(
onClick = onStartUpdate, onClick = if (state.isCheckError) onCheckUpdate else onStartUpdate,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(stringResource(R.string.settings_about_update_retry)) Text(stringResource(R.string.settings_about_update_retry))
@@ -268,8 +290,7 @@ fun AppUpdateDialog(
} }
} }
else -> { is AppUpdateState.UpdateAvailable -> {
// UpdateAvailable or Idle
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Button( Button(
onClick = onStartUpdate, onClick = onStartUpdate,
@@ -291,7 +312,7 @@ fun AppUpdateDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
TextButton(onClick = onDismiss) { TextButton(onClick = onLater) {
Text(stringResource(R.string.update_dialog_action_later)) Text(stringResource(R.string.update_dialog_action_later))
} }
TextButton(onClick = onNeverAskAgain) { 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.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateChecker import com.m57.hermescontrol.data.update.AppUpdateChecker
import com.m57.hermescontrol.data.update.AppUpdateState 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.isReleaseCandidateVersion
import com.m57.hermescontrol.data.update.releaseTag import com.m57.hermescontrol.data.update.releaseTag
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.IOException import java.util.UUID
/** /**
* Drives the in-app self-update flow (issue #867): check the GitHub * 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 * installer. The installer launch uses the application context
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required. * (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
* *
* A silent check runs once per installed version (guarded by the * The ViewModel adopts the process-shared launch/foreground check result, while
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update * manual checks, downloads, and system-installer handoff stay screen-owned.
* available" badge on the About row without the user tapping anything.
*/ */
class AppUpdateViewModel( class AppUpdateViewModel(
application: Application, application: Application,
@@ -51,16 +50,11 @@ class AppUpdateViewModel(
/** Whether the RC update channel is enabled (issue: RC update toggle). */ /** Whether the RC update channel is enabled (issue: RC update toggle). */
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow() val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow()
private var checkJob: kotlinx.coroutines.Job? = null
private var downloadJob: kotlinx.coroutines.Job? = null private var downloadJob: kotlinx.coroutines.Job? = null
private var lastAvailable: AppUpdateState.UpdateAvailable? = null private var lastAvailable: AppUpdateState.UpdateAvailable? = null
private var downloadGeneration = 0
/** private var downloadPartialFile: File? = null
* Bumped by every check. The blocking OkHttp call underneath cannot be private var installerReturnPending = false
* 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
init { init {
val cached = AppUpdateCache.state.value val cached = AppUpdateCache.state.value
@@ -70,13 +64,25 @@ class AppUpdateViewModel(
!_checkReleaseCandidateUpdates.value && !_checkReleaseCandidateUpdates.value &&
cached.releaseTag()?.let(::isReleaseCandidateVersion) == true cached.releaseTag()?.let(::isReleaseCandidateVersion) == true
if (blocked) AppUpdateCache.reset() 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) { if (cached is AppUpdateState.UpdateAvailable && !blocked) {
lastAvailable = cached lastAvailable = cached
_state.value = cached _state.value = cached
} else if (cached is AppUpdateState.UpToDate && !blocked) { } else if (cached is AppUpdateState.UpToDate && !blocked) {
_state.value = cached _state.value = cached
} else { } else {
checkForUpdate() _state.value = cached
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, ioDispatcher)
} }
} }
@@ -94,92 +100,20 @@ class AppUpdateViewModel(
*/ */
fun setCheckReleaseCandidateUpdates(enabled: Boolean) { fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
if (_checkReleaseCandidateUpdates.value == enabled) return if (_checkReleaseCandidateUpdates.value == enabled) return
AuthManager.setCheckReleaseCandidateUpdates(enabled) UpdateNoticeManager.channelChanged(enabled)
_checkReleaseCandidateUpdates.value = enabled _checkReleaseCandidateUpdates.value = enabled
if (!enabled) { if (!enabled) {
if (lastAvailable?.latestTag?.let(::isReleaseCandidateVersion) == true) cancelDownload()
lastAvailable = null lastAvailable = null
AppUpdateCache.reset() 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() runCheck()
} }
private fun runCheck() { private fun runCheck() {
val generation = ++checkGeneration UpdateNoticeManager.checkNow(checker, currentVersion, ioDispatcher)
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) }
}
} }
/**
* 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. */ /** A release-candidate tag may only be used while the RC channel is on. */
private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean = private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean =
tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value
@@ -208,36 +142,70 @@ class AppUpdateViewModel(
/** Download the release APK and launch the system installer. */ /** Download the release APK and launch the system installer. */
fun startUpdate() { fun startUpdate() {
if (_state.value is AppUpdateState.Downloading || _state.value is AppUpdateState.Installing) return
val available = updateAvailableForInstall() ?: return val available = updateAvailableForInstall() ?: return
lastAvailable = available lastAvailable = available
if (!canRequestInstalls()) { if (!canRequestInstalls()) {
_state.value = AppUpdateState.NeedsUnknownSourcesPermission _state.value = AppUpdateState.NeedsUnknownSourcesPermission
return return
} }
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME) val dest = updateApkFile(available.latestTag)
_state.value = AppUpdateState.Downloading(0f) val generation = ++downloadGeneration
downloadJob?.cancel() val partial = File(dest.parentFile, "${dest.name}.${UUID.randomUUID()}.$generation.part")
downloadJob = downloadPartialFile = partial
viewModelScope.launch(ioDispatcher) { try {
val downloaded = partial.delete()
checker.downloadApk(available.apkUrl, dest) { progress -> _state.value = AppUpdateState.Downloading(0f)
_state.value = AppUpdateState.Downloading(progress) 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) } catch (_: Exception) {
launchInstaller(dest) if (generation == downloadGeneration) {
partial.delete()
downloadPartialFile = null
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
} }
}
} }
/** Cancel in-flight download and return to UpdateAvailable. */ /** Cancel in-flight download and return to UpdateAvailable. */
fun cancelDownload() { fun cancelDownload() {
downloadGeneration++
downloadJob?.cancel() downloadJob?.cancel()
downloadJob = null downloadJob = null
downloadPartialFile?.delete()
downloadPartialFile = null
val available = val available =
lastAvailable lastAvailable
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
@@ -257,16 +225,15 @@ class AppUpdateViewModel(
if (tag != null) { if (tag != null) {
AuthManager.setDismissedUpdateTag(tag) AuthManager.setDismissedUpdateTag(tag)
} }
AppUpdateCache.dismiss()
AppUpdateCache.hideDialog() AppUpdateCache.hideDialog()
} }
/** Resume install when returning from Unknown Sources permission screen. */ /** Resume install when returning from Unknown Sources permission screen. */
fun resumeInstallAfterPermission() { fun resumeInstallAfterPermission() {
if (canRequestInstalls()) { if (canRequestInstalls()) {
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
val available = updateAvailableForInstall() 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) _state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest) launchInstaller(dest)
} else { } 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 = private fun canRequestInstalls(): Boolean =
try { try {
getApplication<Application>().packageManager.canRequestPackageInstalls() getApplication<Application>().packageManager.canRequestPackageInstalls()
@@ -292,18 +271,14 @@ class AppUpdateViewModel(
apkFile, apkFile,
) )
context.startActivity(installIntentFactory(uri)) context.startActivity(installIntentFactory(uri))
installerReturnPending = true
} catch (e: Exception) { } catch (e: Exception) {
installerReturnPending = false
_state.value = AppUpdateState.Error(INSTALLER_ERROR) _state.value = AppUpdateState.Error(INSTALLER_ERROR)
} }
} }
private companion object { 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 DOWNLOAD_ERROR = "Download failed — tap to retry"
val INSTALLER_ERROR = "Couldn't open the installer" val INSTALLER_ERROR = "Couldn't open the installer"
} }
@@ -262,6 +262,7 @@ internal fun SettingsAboutPage(
}, },
) { ) {
val updateState by viewModel.state.collectAsStateWithLifecycle() val updateState by viewModel.state.collectAsStateWithLifecycle()
val currentUpdateState by androidx.compose.runtime.rememberUpdatedState(updateState)
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle() val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
@@ -270,8 +271,12 @@ internal fun SettingsAboutPage(
val observer = val observer =
androidx.lifecycle.LifecycleEventObserver { _, event -> androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { 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() viewModel.resumeInstallAfterPermission()
} else {
viewModel.reconcileInstallerReturn()
} }
} }
} }
@@ -1,14 +1,21 @@
package com.m57.hermescontrol.data.update package com.m57.hermescontrol.data.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
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.assertNull import org.junit.Assert.assertNull
import org.junit.Before import org.junit.Before
import org.junit.Test 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 * Endpoint contract for the opt-in release-candidate channel: the stable path
@@ -38,8 +45,11 @@ class AppUpdateCheckerNetworkTest {
private fun enqueue( private fun enqueue(
body: String, body: String,
code: Int = 200, 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 @Test
@@ -56,26 +66,62 @@ class AppUpdateCheckerNetworkTest {
@Test @Test
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() = fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
runTest { runTest {
enqueue(STABLE_BODY)
enqueue(RELEASES_BODY) enqueue(RELEASES_BODY)
val info = checker().fetchLatestRelease(includeReleaseCandidates = true) val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
assertEquals("v1.25.0-rc.3", info?.tagName) 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 @Test
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() = fun fetchLatestRelease_stableFallsBackToReleasesWhenLatestIsNotUsable() =
runTest { 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) enqueue(ALPHA_BETA_BODY)
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true)) assertEquals("v1.24.2", checker().fetchLatestRelease(includeReleaseCandidates = true)?.tagName)
} }
@Test @Test
fun fetchLatestRelease_nonSuccessYieldsNull() = fun fetchLatestRelease_nonSuccessYieldsNull() =
runTest { 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)
enqueue("", code = 404) enqueue("", code = 404)
@@ -83,6 +129,35 @@ class AppUpdateCheckerNetworkTest {
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true)) 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 { private companion object {
const val STABLE_BODY = """ const val STABLE_BODY = """
{ {
@@ -183,6 +183,7 @@ class AppUpdateCheckerTest {
draft: Boolean = false, draft: Boolean = false,
prerelease: Boolean = false, prerelease: Boolean = false,
withApk: Boolean = true, withApk: Boolean = true,
apkUrl: String = "https://example.com/$tag.apk",
): UpdateInfo = ): UpdateInfo =
UpdateInfo( UpdateInfo(
tagName = tag, tagName = tag,
@@ -194,7 +195,7 @@ class AppUpdateCheckerTest {
UpdateInfo.Asset( UpdateInfo.Asset(
name = "hermes-mobile-$tag.apk", name = "hermes-mobile-$tag.apk",
size = 1L, size = 1L,
browserDownloadUrl = "https://example.com/$tag.apk", browserDownloadUrl = apkUrl,
), ),
) )
} else { } else {
@@ -289,6 +290,55 @@ class AppUpdateCheckerTest {
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false)))) 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 @Test
fun parseReleaseList_readsDraftAndPrereleaseFlags() { fun parseReleaseList_readsDraftAndPrereleaseFlags() {
val json = val json =
@@ -49,6 +49,7 @@ class UpdateNoticeManagerTest {
@Before @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
UpdateNoticeManager.resetForTests()
AppUpdateCache.reset() AppUpdateCache.reset()
// Unit tests run against the debug variant where the feature is // Unit tests run against the debug variant where the feature is
// disabled by default — flip the knob on so the behavior is testable. // disabled by default — flip the knob on so the behavior is testable.
@@ -68,6 +69,7 @@ class UpdateNoticeManagerTest {
@After @After
fun tearDown() { fun tearDown() {
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = false UpdateNoticeManager.enabled = false
unmockkAll() unmockkAll()
Dispatchers.resetMain() Dispatchers.resetMain()
@@ -92,7 +94,7 @@ class UpdateNoticeManagerTest {
} }
@Test @Test
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() = fun checkOnLaunch_ignoresPersistedDailyCheckTimestampAfterColdStart() =
runTest { runTest {
val now = 1000000000000L val now = 1000000000000L
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
@@ -103,9 +105,24 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now) UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() } coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value) assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) } 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 @Test
@@ -148,9 +165,9 @@ class UpdateNoticeManagerTest {
} }
@Test @Test
fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() { fun noticeTag_doesNotUsePersistedTagForStableInstall() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25" 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")) assertNull(UpdateNoticeManager.noticeTag("1.25"))
} }
@@ -176,9 +193,11 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle() advanceUntilIdle()
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value) assertEquals(
// Still marked done so a dead network can't spam the API per launch. AppUpdateState.Error("Network error — check your connection", isCheckError = true),
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) } AppUpdateCache.state.value,
)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
} }
@Test @Test
@@ -190,8 +209,41 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle() 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) 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) ───────────────────── // ── noticeTag (dismissed/restart banner return) ─────────────────────
@@ -211,10 +263,10 @@ class UpdateNoticeManagerTest {
} }
@Test @Test
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() { fun noticeTag_doesNotAdvertisePersistedTagWithoutFreshApkMetadata() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0" every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion)) assertNull(UpdateNoticeManager.noticeTag(currentVersion))
} }
@Test @Test
@@ -303,11 +355,11 @@ class UpdateNoticeManagerTest {
} }
@Test @Test
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() { fun noticeTag_doesNotUsePersistedRcTagWithoutApkMetadata() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3" every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion)) assertNull(UpdateNoticeManager.noticeTag(currentVersion))
} }
@Test @Test
@@ -336,10 +388,50 @@ class UpdateNoticeManagerTest {
} }
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
UpdateNoticeManager.channelChanged(enabled = false)
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
advanceUntilIdle() 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) assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) } 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.AppUpdateChecker
import com.m57.hermescontrol.data.update.AppUpdateState import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateInfo import com.m57.hermescontrol.data.update.UpdateInfo
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@@ -48,6 +50,7 @@ class AppUpdateViewModelTest {
private lateinit var app: Application private lateinit var app: Application
private lateinit var packageManager: PackageManager private lateinit var packageManager: PackageManager
private lateinit var checker: AppUpdateChecker private lateinit var checker: AppUpdateChecker
private var rcUpdatesEnabled = false
private val currentVersion = "1.21.0" private val currentVersion = "1.21.0"
@@ -70,6 +73,8 @@ class AppUpdateViewModelTest {
@Before @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = true
AppUpdateCache.reset() AppUpdateCache.reset()
mockkObject(AuthManager) mockkObject(AuthManager)
every { AuthManager.getUpdateCheckDoneForVersion() } returns null every { AuthManager.getUpdateCheckDoneForVersion() } returns null
@@ -79,8 +84,12 @@ class AppUpdateViewModelTest {
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
every { AuthManager.getDismissedUpdateTag() } returns null every { AuthManager.getDismissedUpdateTag() } returns null
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false rcUpdatesEnabled = false
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit every { AuthManager.isCheckingReleaseCandidateUpdates() } answers { rcUpdatesEnabled }
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } answers {
rcUpdatesEnabled = firstArg()
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"))
@@ -91,11 +100,17 @@ class AppUpdateViewModelTest {
checker = mockk() checker = mockk()
coEvery { checker.fetchLatestRelease() } returns updateInfo() 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 @After
fun tearDown() { fun tearDown() {
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = false
unmockkAll() unmockkAll()
Dispatchers.resetMain() Dispatchers.resetMain()
} }
@@ -237,14 +252,14 @@ class AppUpdateViewModelTest {
} }
@Test @Test
fun checkForUpdate_marksDoneEvenWhenCheckFails() = fun checkForUpdate_marksDoneOnlyAfterSuccessfulDiscovery() =
runTest { runTest {
coEvery { checker.fetchLatestRelease() } throws IOException("boom") coEvery { checker.fetchLatestRelease() } throws IOException("boom")
val vm = createViewModel() val vm = createViewModel()
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) } coVerify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
} }
// ── Update flow ───────────────────────────────────────────────────── // ── Update flow ─────────────────────────────────────────────────────
@@ -351,7 +366,7 @@ class AppUpdateViewModelTest {
advanceUntilIdle() advanceUntilIdle()
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") } verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
assertTrue(AppUpdateCache.dismissed) assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
assertFalse(AppUpdateCache.isDialogVisible) assertFalse(AppUpdateCache.isDialogVisible)
} }
@@ -392,7 +407,7 @@ class AppUpdateViewModelTest {
@Test @Test
fun init_adoptsCachedRcOfferWhenOptedIn() = fun init_adoptsCachedRcOfferWhenOptedIn() =
runTest { runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true rcUpdatesEnabled = true
AppUpdateCache.update( AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L), AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
) )
@@ -419,6 +434,7 @@ class AppUpdateViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertTrue(vm.checkReleaseCandidateUpdates.value) assertTrue(vm.checkReleaseCandidateUpdates.value)
verify(exactly = 1) { UpdateNoticeManager.channelChanged(true) }
verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) } verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) }
coVerify(exactly = 1) { checker.fetchLatestRelease(true) } coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag) assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
@@ -435,13 +451,14 @@ class AppUpdateViewModelTest {
advanceUntilIdle() advanceUntilIdle()
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) } verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
verify(exactly = 0) { UpdateNoticeManager.channelChanged(any()) }
coVerify(exactly = 1) { checker.fetchLatestRelease(false) } coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
} }
@Test @Test
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() = fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
runTest { runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true rcUpdatesEnabled = true
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3") coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
val vm = createViewModel() val vm = createViewModel()
@@ -487,7 +504,7 @@ class AppUpdateViewModelTest {
@Test @Test
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() = fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
runTest { runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true rcUpdatesEnabled = true
val rcGate = CompletableDeferred<Unit>() val rcGate = CompletableDeferred<Unit>()
coEvery { checker.fetchLatestRelease(true) } coAnswers { coEvery { checker.fetchLatestRelease(true) } coAnswers {
withContext(NonCancellable) { rcGate.await() } withContext(NonCancellable) { rcGate.await() }