feat: opt-in release candidate update channel (#1199)

This commit is contained in:
M57 authored and GitHub committed 2026-09-19 22:25:49 +03:00
1 parent c96a5ff4da
commit 03ce822119
17 files changed
+1071 -60

No files matched your search

@@ -30,6 +30,9 @@ data class ServerStoreState(
val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false,
val keepConnectedInBackground: Boolean = false,
// Opt-in update channel: when true the in-app update check also considers
// pre-release release-candidate GitHub releases, not just stable ones.
val checkReleaseCandidateUpdates: Boolean = false,
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
@@ -873,6 +873,16 @@ object AuthManager {
// ── In-app update check (issue #867) ─────────────────────────────────
/**
* Whether the user opted into release-candidate updates. Stable-only by
* default; when true the update check also considers pre-release RC tags.
*/
fun isCheckingReleaseCandidateUpdates(): Boolean = serverStore.getLatestState().checkReleaseCandidateUpdates
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
serverStore.update { it.copy(checkReleaseCandidateUpdates = enabled) }
}
/** App version the silent update check last completed for (null = never). */
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
@@ -18,6 +18,9 @@ import java.io.IOException
data class UpdateInfo(
val tagName: String = "",
val body: String = "",
/** GitHub release flags: a draft is unpublished, a prerelease is pre-stable. */
val draft: Boolean = false,
val prerelease: Boolean = false,
val assets: List<Asset> = emptyList(),
) {
@Serializable
@@ -61,6 +64,32 @@ fun isNewerVersion(
return a.prerelease.size > b.prerelease.size
}
/**
* Pick the newest installable release out of a GitHub `releases` list payload.
*
* Drafts and releases without an APK asset are always ignored. Pre-releases are
* only eligible when [includeReleaseCandidates] is set *and* the tag is a
* release candidate — alpha/beta/dev pre-releases stay out of the RC channel.
*
* Selection uses [isNewerVersion] rather than list order because the API orders
* releases by publication date, which is not a version oracle.
*/
fun selectLatestUpdate(
releases: List<UpdateInfo>,
includeReleaseCandidates: Boolean = false,
): UpdateInfo? =
releases
.filter { !it.draft && it.apkAsset != null }
.filter {
if (!it.prerelease) {
true
} else {
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
}
}.reduceOrNull { best, candidate ->
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
}
private data class ParsedVersion(
val core: List<Int>,
val prerelease: List<String>,
@@ -96,6 +125,19 @@ private fun comparePrereleaseIdentifier(
return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y)
}
/** `rc`, `rc1`, `RC2` — but never alpha/beta/dev. */
private val RC_IDENTIFIER = Regex("^rc[0-9]*$", RegexOption.IGNORE_CASE)
/**
* True when [tag] is a release-candidate version such as "v1.25.0-rc.1",
* "v1.25-rc1", or the legacy dot form "v1.25.rc.1".
*
* Only the first pre-release identifier is inspected, so alpha/beta builds are
* never treated as release candidates.
*/
fun isReleaseCandidateVersion(tag: String): Boolean =
parseVersion(tag)?.prerelease?.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
/**
* Talks to the GitHub releases API and downloads the release APK (issue
* #867). Network methods are `open` so tests can fake them; the pure logic
@@ -104,27 +146,44 @@ private fun comparePrereleaseIdentifier(
*/
open class AppUpdateChecker(
private val client: OkHttpClient = OkHttpProvider.base,
private val apiBaseUrl: String = "https://api.github.com/repos/Hy4ri/hermes-mobile",
) {
/**
* Fetch the latest release metadata. Returns null when there is no
* release yet (404) or the body can't be parsed. Throws [IOException]
* on network failure so the caller can surface a friendly error.
* Fetch the newest installable release metadata.
*
* Stable-only by default: the `releases/latest` endpoint deliberately
* excludes pre-releases. With [includeReleaseCandidates] the releases list
* is scanned instead so a newer release-candidate tag becomes eligible.
*
* Returns null when there is no usable release (404) or the body can't be
* parsed. Throws [IOException] on network failure so the caller can
* surface a friendly error.
*/
open suspend fun fetchLatestRelease(): UpdateInfo? =
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body.string().orEmpty()
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)
}
}
/** GET [url] as JSON text, or null on any non-2xx response. */
private fun get(url: String): String? {
val request =
Request
.Builder()
.url(url)
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body.string().orEmpty()
}
}
/**
* Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted)
@@ -167,6 +226,9 @@ open class AppUpdateChecker(
private companion object {
const val DEFAULT_BUFFER_SIZE = 8192
/** How many recent releases the opt-in RC scan inspects. */
const val RELEASE_LIST_PAGE_SIZE = 20
}
}
@@ -175,3 +237,9 @@ fun parseUpdateInfo(json: String): UpdateInfo? =
runCatching {
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
}.getOrNull()
/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */
fun parseReleaseList(json: String): List<UpdateInfo>? =
runCatching {
OkHttpProvider.json.decodeFromString<List<UpdateInfo>>(json)
}.getOrNull()
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
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()
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
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
/**
* The tag the chat banner should advertise, or null when nothing newer is
* known or if the user explicitly dismissed this tag.
* known, if the user explicitly dismissed this tag, or if it is a
* release-candidate tag while the user is on the stable channel.
*/
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
?: return null
if (candidateTag == dismissed) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null
}
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
}
}