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