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
@@ -21,8 +21,9 @@ object AppUpdateCache {
|
||||
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
|
||||
* screen recomposes when it flips; dies with the process.
|
||||
*/
|
||||
var dismissed by mutableStateOf(false)
|
||||
private set
|
||||
private var dismissedTags by mutableStateOf<Set<String>>(emptySet())
|
||||
|
||||
fun isDismissed(tag: String): Boolean = tag in dismissedTags
|
||||
|
||||
/** Whether the full-screen / in-place update dialog should be visible. */
|
||||
var isDialogVisible by mutableStateOf(false)
|
||||
@@ -32,8 +33,8 @@ object AppUpdateCache {
|
||||
_state.value = state
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
dismissed = true
|
||||
fun dismiss(tag: String) {
|
||||
dismissedTags = dismissedTags + tag
|
||||
}
|
||||
|
||||
fun showDialog() {
|
||||
@@ -46,7 +47,7 @@ object AppUpdateCache {
|
||||
|
||||
/** Test hook: clear state and dismissal between tests. */
|
||||
internal fun reset() {
|
||||
dismissed = false
|
||||
dismissedTags = emptySet()
|
||||
isDialogVisible = false
|
||||
_state.value = AppUpdateState.Idle
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ package com.m57.hermescontrol.data.update
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@@ -79,17 +84,28 @@ fun selectLatestUpdate(
|
||||
includeReleaseCandidates: Boolean = false,
|
||||
): UpdateInfo? =
|
||||
releases
|
||||
.filter { !it.draft && it.apkAsset != null }
|
||||
.filter {
|
||||
if (!it.prerelease) {
|
||||
true
|
||||
} else {
|
||||
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
|
||||
}
|
||||
}.reduceOrNull { best, candidate ->
|
||||
.filter(::hasUsableApk)
|
||||
.filter { isEligibleRelease(it, includeReleaseCandidates) }
|
||||
.reduceOrNull { best, candidate ->
|
||||
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
|
||||
}
|
||||
|
||||
private fun hasUsableApk(release: UpdateInfo): Boolean =
|
||||
!release.draft && release.apkAsset?.browserDownloadUrl?.toHttpUrlOrNull()?.let {
|
||||
it.scheme == "http" || it.scheme == "https"
|
||||
} == true
|
||||
|
||||
private fun isEligibleRelease(
|
||||
release: UpdateInfo,
|
||||
includeReleaseCandidates: Boolean,
|
||||
): Boolean {
|
||||
val version = parseVersion(release.tagName) ?: return false
|
||||
val isRc = version.prerelease.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
|
||||
val isStable = version.prerelease.isEmpty() && !release.prerelease
|
||||
if (isStable) return true
|
||||
return includeReleaseCandidates && isRc
|
||||
}
|
||||
|
||||
private data class ParsedVersion(
|
||||
val core: List<Int>,
|
||||
val prerelease: List<String>,
|
||||
@@ -161,17 +177,16 @@ open class AppUpdateChecker(
|
||||
*/
|
||||
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (includeReleaseCandidates) {
|
||||
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
|
||||
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
|
||||
} else {
|
||||
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
|
||||
parseUpdateInfo(body)
|
||||
val latest = get("$apiBaseUrl/releases/latest")?.let(::parseRelease)
|
||||
if (!includeReleaseCandidates && latest != null && selectLatestUpdate(listOf(latest)) != null) {
|
||||
return@withContext latest
|
||||
}
|
||||
val releases = fetchReleasePages() ?: return@withContext null
|
||||
selectLatestUpdate(releases + listOfNotNull(latest), includeReleaseCandidates)
|
||||
}
|
||||
|
||||
/** GET [url] as JSON text, or null on any non-2xx response. */
|
||||
private fun get(url: String): String? {
|
||||
/** GET a GitHub URL. 404 means absent; other HTTP/network failures stay visible. */
|
||||
private fun get(url: String): ReleasePage? {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
@@ -179,11 +194,45 @@ open class AppUpdateChecker(
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
return response.body.string().orEmpty()
|
||||
if (response.code == HTTP_NOT_FOUND) return null
|
||||
if (!response.isSuccessful) throw IOException("GitHub releases request failed (${response.code})")
|
||||
return ReleasePage(response.body.string().orEmpty(), response.nextPageUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchReleasePages(): List<UpdateInfo>? {
|
||||
var nextUrl: String? = "$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE&page=1"
|
||||
val releases = mutableListOf<UpdateInfo>()
|
||||
while (nextUrl != null) {
|
||||
val page =
|
||||
get(nextUrl)
|
||||
?: return if (releases.isEmpty()) null else throw IOException("Incomplete GitHub releases list")
|
||||
val parsed = parseReleaseList(page.body) ?: throw IOException("GitHub returned malformed release metadata")
|
||||
releases += parsed
|
||||
nextUrl = page.nextUrl
|
||||
}
|
||||
return releases
|
||||
}
|
||||
|
||||
private fun parseRelease(page: ReleasePage): UpdateInfo =
|
||||
parseUpdateInfo(page.body) ?: throw IOException("GitHub returned malformed release metadata")
|
||||
|
||||
private fun Response.nextPageUrl(): String? {
|
||||
val link = header("Link") ?: return null
|
||||
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
|
||||
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
|
||||
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL")
|
||||
if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") {
|
||||
throw IOException("GitHub returned an untrusted pagination URL")
|
||||
}
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
private data class ReleasePage(
|
||||
val body: String,
|
||||
val nextUrl: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
||||
* [onProgress]. True on success; false (with the partial file deleted)
|
||||
@@ -194,41 +243,103 @@ open class AppUpdateChecker(
|
||||
dest: File,
|
||||
onProgress: (Float) -> Unit,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext false
|
||||
val body = response.body
|
||||
val total = body.contentLength()
|
||||
dest.outputStream().use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var read = 0L
|
||||
body.byteStream().use { input ->
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n == -1) break
|
||||
out.write(buffer, 0, n)
|
||||
read += n
|
||||
if (total > 0) {
|
||||
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val request =
|
||||
try {
|
||||
val httpUrl =
|
||||
url.toHttpUrlOrNull()?.takeIf { it.scheme == "http" || it.scheme == "https" }
|
||||
?: run {
|
||||
failDownload(continuation, dest)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
Request.Builder().url(httpUrl).build()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
failDownload(continuation, dest)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val call =
|
||||
try {
|
||||
client.newCall(request)
|
||||
} catch (_: Exception) {
|
||||
failDownload(continuation, dest)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
continuation.invokeOnCancellation {
|
||||
call.cancel()
|
||||
deletePartial(dest)
|
||||
}
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
failDownload(continuation, dest)
|
||||
}
|
||||
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
var succeeded = false
|
||||
try {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body
|
||||
val total = body.contentLength()
|
||||
dest.outputStream().use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var read = 0L
|
||||
body.byteStream().use { input ->
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n == -1) break
|
||||
out.write(buffer, 0, n)
|
||||
read += n
|
||||
if (total > 0) {
|
||||
onProgress((read.toFloat() / total.toFloat()).coerceIn(0f, 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
succeeded = true
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Includes response/body close failures; never report success before close.
|
||||
}
|
||||
if (succeeded) {
|
||||
if (continuation.isActive) continuation.resume(true) { _, _, _ -> }
|
||||
} else {
|
||||
failDownload(continuation, dest)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dest.delete()
|
||||
false
|
||||
try {
|
||||
call.enqueue(callback)
|
||||
} catch (_: Exception) {
|
||||
failDownload(continuation, dest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failDownload(
|
||||
continuation: kotlinx.coroutines.CancellableContinuation<Boolean>,
|
||||
dest: File,
|
||||
) {
|
||||
deletePartial(dest)
|
||||
if (continuation.isActive) continuation.resume(false) { _, _, _ -> }
|
||||
}
|
||||
|
||||
private fun deletePartial(dest: File) {
|
||||
runCatching { dest.delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_BUFFER_SIZE = 8192
|
||||
const val HTTP_NOT_FOUND = 404
|
||||
|
||||
/** How many recent releases the opt-in RC scan inspects. */
|
||||
const val RELEASE_LIST_PAGE_SIZE = 20
|
||||
const val RELEASE_LIST_PAGE_SIZE = 100
|
||||
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ sealed interface AppUpdateState {
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
val isCheckError: Boolean = false,
|
||||
) : AppUpdateState
|
||||
}
|
||||
|
||||
|
||||
@@ -6,23 +6,31 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Startup update check (issue #890): one silent GitHub ping per installed
|
||||
* version, fired right after launch, so the chat screen can show an update
|
||||
* banner without the user ever opening the About tab.
|
||||
* Shared release check (issue #890): checks after launch and on foreground
|
||||
* return, with a short process-local debounce so the chat screen can show an
|
||||
* update banner without the user ever opening the About tab.
|
||||
*
|
||||
* The result lands in [AppUpdateCache] (consumed by the chat banner and
|
||||
* adopted by the About tab) and the latest tag is persisted via
|
||||
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
|
||||
* later launch. Failures leave the cache Idle — the About tab still has its
|
||||
* own manual retry path.
|
||||
* later launch. Failed checks remain visible and can be retried from the About
|
||||
* tab or dialog without persisting a false successful-check marker.
|
||||
*/
|
||||
object UpdateNoticeManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val checkLock = Any()
|
||||
private var activeCheck: Job? = null
|
||||
private var lastSuccessfulCheckAt: Long? = null
|
||||
private var lastFailedCheckAt: Long? = null
|
||||
private var channelGeneration = 0
|
||||
|
||||
/**
|
||||
* Release-only feature (issue #890): the launch check and chat banner are
|
||||
@@ -32,57 +40,153 @@ object UpdateNoticeManager {
|
||||
var enabled: Boolean = !BuildConfig.DEBUG
|
||||
internal set
|
||||
|
||||
/** Release check interval (24 hours). */
|
||||
const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L
|
||||
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
|
||||
const val CHECK_INTERVAL_MS: Long = 5 * 60 * 1000L
|
||||
const val FAILURE_BACKOFF_MS: Long = 30 * 1000L
|
||||
|
||||
/** Run from Application.onCreate, after AuthManager.init. Checks at most once every 24h. */
|
||||
/** Run after preferences are ready; failed checks never suppress the next foreground retry. */
|
||||
fun checkOnLaunch(
|
||||
checker: AppUpdateChecker = AppUpdateChecker(),
|
||||
currentVersion: String = BuildConfig.VERSION_NAME,
|
||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
force: Boolean = false,
|
||||
) {
|
||||
if (!enabled) return
|
||||
val lastCheck = AuthManager.getLastUpdateCheckTimestamp()
|
||||
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
|
||||
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
|
||||
|
||||
// Captured once so the check and the notice path agree on the channel.
|
||||
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||
val result =
|
||||
try {
|
||||
checker.fetchLatestRelease(includeReleaseCandidates)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
return@launch
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
val info = result ?: return@launch
|
||||
// The user may have switched back to stable while this request was in
|
||||
// flight — an RC result must never be published on the stable channel.
|
||||
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||
return@launch
|
||||
if (!enabled && !force) return
|
||||
synchronized(checkLock) {
|
||||
if (activeCheck?.isActive == true) return
|
||||
if (!force && lastSuccessfulCheckAt?.let { now - it < CHECK_INTERVAL_MS } == true) return
|
||||
if (!force && lastFailedCheckAt?.let { now - it < FAILURE_BACKOFF_MS } == true) return
|
||||
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||
val generation = channelGeneration
|
||||
val previousState = AppUpdateCache.state.value
|
||||
if (force ||
|
||||
previousState !is AppUpdateState.UpdateAvailable
|
||||
) {
|
||||
AppUpdateCache.update(AppUpdateState.Checking)
|
||||
}
|
||||
val apk = info.apkAsset ?: return@launch
|
||||
val state =
|
||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||
AppUpdateState.UpdateAvailable(
|
||||
latestTag = info.tagName,
|
||||
apkUrl = apk.browserDownloadUrl,
|
||||
sizeBytes = apk.size,
|
||||
releaseNotes = info.body,
|
||||
)
|
||||
} else {
|
||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||
activeCheck =
|
||||
scope.launch(ioDispatcher) {
|
||||
val thisJob = currentCoroutineContext()[Job]
|
||||
var successful = false
|
||||
var failed = false
|
||||
try {
|
||||
val info = checker.fetchLatestRelease(includeReleaseCandidates)
|
||||
val apk = info?.apkAsset
|
||||
val state =
|
||||
if (info == null) {
|
||||
AppUpdateState.Error(NO_RELEASE_ERROR, isCheckError = true)
|
||||
} else if (apk == null) {
|
||||
AppUpdateState.Error(NO_APK_ERROR, isCheckError = true)
|
||||
} else if (isNewerVersion(info.tagName, currentVersion)) {
|
||||
AppUpdateState.UpdateAvailable(
|
||||
latestTag = info.tagName,
|
||||
apkUrl = apk.browserDownloadUrl,
|
||||
sizeBytes = apk.size,
|
||||
releaseNotes = info.body,
|
||||
)
|
||||
} else {
|
||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||
}
|
||||
var resultRejected = false
|
||||
synchronized(checkLock) {
|
||||
if (activeCheck !== thisJob || generation != channelGeneration) return@synchronized
|
||||
val currentChannel = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||
if (includeReleaseCandidates != currentChannel ||
|
||||
(info != null && isReleaseCandidateVersion(info.tagName) && !currentChannel)
|
||||
) {
|
||||
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||
AppUpdateCache.update(previousState)
|
||||
}
|
||||
resultRejected = true
|
||||
return@synchronized
|
||||
}
|
||||
AppUpdateCache.update(state)
|
||||
if (state is AppUpdateState.UpdateAvailable || state is AppUpdateState.UpToDate) {
|
||||
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
AuthManager.setLastUpdateCheckTimestamp(System.currentTimeMillis())
|
||||
successful = true
|
||||
} else {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
if (resultRejected) return@launch
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
synchronized(checkLock) {
|
||||
if (activeCheck === thisJob && generation == channelGeneration) {
|
||||
failed = true
|
||||
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||
AppUpdateCache.update(AppUpdateState.Error(NETWORK_ERROR, isCheckError = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
synchronized(checkLock) {
|
||||
if (activeCheck === thisJob && generation == channelGeneration) {
|
||||
failed = true
|
||||
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.Error(GENERIC_CHECK_ERROR, isCheckError = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized(checkLock) {
|
||||
if (activeCheck === thisJob) {
|
||||
if (successful) lastSuccessfulCheckAt = now
|
||||
if (failed) lastFailedCheckAt = now
|
||||
activeCheck = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppUpdateCache.update(state)
|
||||
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private const val NETWORK_ERROR = "Network error — check your connection"
|
||||
private const val GENERIC_CHECK_ERROR = "Couldn't check for updates"
|
||||
private const val NO_RELEASE_ERROR = "No release found yet"
|
||||
private const val NO_APK_ERROR = "Release has no APK asset"
|
||||
|
||||
/** Explicit check action; shares in-flight work but bypasses the local debounce. */
|
||||
fun checkNow(
|
||||
checker: AppUpdateChecker,
|
||||
currentVersion: String,
|
||||
ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
checkOnLaunch(checker, currentVersion, ioDispatcher, force = true)
|
||||
}
|
||||
|
||||
/** Persist the channel choice and invalidate in-flight checks as one operation. */
|
||||
fun channelChanged(enabled: Boolean) {
|
||||
synchronized(checkLock) {
|
||||
AuthManager.setCheckReleaseCandidateUpdates(enabled)
|
||||
channelGeneration++
|
||||
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||
AppUpdateCache.update(AppUpdateState.Idle)
|
||||
}
|
||||
activeCheck?.cancel()
|
||||
activeCheck = null
|
||||
lastSuccessfulCheckAt = null
|
||||
lastFailedCheckAt = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: reset process-local admission state between tests. */
|
||||
internal fun resetForTests() {
|
||||
synchronized(checkLock) {
|
||||
activeCheck?.cancel()
|
||||
activeCheck = null
|
||||
if (AppUpdateCache.state.value is AppUpdateState.Checking) {
|
||||
AppUpdateCache.update(AppUpdateState.Idle)
|
||||
}
|
||||
lastSuccessfulCheckAt = null
|
||||
lastFailedCheckAt = null
|
||||
channelGeneration++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +198,9 @@ object UpdateNoticeManager {
|
||||
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
||||
if (!enabled) return null
|
||||
val dismissed = AuthManager.getDismissedUpdateTag()
|
||||
val candidateTag =
|
||||
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||
?: AuthManager.getLastKnownLatestTag()
|
||||
?: return null
|
||||
val candidateTag = (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag ?: return null
|
||||
|
||||
if (candidateTag == dismissed) return null
|
||||
if (candidateTag == dismissed || AppUpdateCache.isDismissed(candidateTag)) return null
|
||||
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user