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

@@ -93,4 +93,18 @@ class ServerStoreTest {
val decoded = json.decodeFromString<ServerStoreState>("{}")
assertFalse(decoded.keepConnectedInBackground)
}
@Test
fun testCheckReleaseCandidateUpdatesDefaultsToStableOnly() {
val state = ServerStoreState()
assertFalse(state.checkReleaseCandidateUpdates)
assertTrue(state.copy(checkReleaseCandidateUpdates = true).checkReleaseCandidateUpdates)
}
@Test
fun testCheckReleaseCandidateUpdates_deserializationDefault() {
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
val decoded = json.decodeFromString<ServerStoreState>("{}")
assertFalse(decoded.checkReleaseCandidateUpdates)
}
}
@@ -0,0 +1,158 @@
package com.m57.hermescontrol.data.update
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
/**
* Endpoint contract for the opt-in release-candidate channel: the stable path
* must keep using `releases/latest` (GitHub excludes pre-releases there), and
* only the opt-in path may scan the releases list.
*/
class AppUpdateCheckerNetworkTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
private fun checker(): AppUpdateChecker =
AppUpdateChecker(
client = OkHttpClient(),
apiBaseUrl = server.url("/repos/Hy4ri/hermes-mobile").toString(),
)
private fun enqueue(
body: String,
code: Int = 200,
) {
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
}
@Test
fun fetchLatestRelease_defaultUsesStableLatestEndpoint() =
runTest {
enqueue(STABLE_BODY)
val info = checker().fetchLatestRelease()
assertEquals("v1.24.2", info?.tagName)
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
runTest {
enqueue(RELEASES_BODY)
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
assertEquals("v1.25.0-rc.3", info?.tagName)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=20", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() =
runTest {
enqueue(ALPHA_BETA_BODY)
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
}
@Test
fun fetchLatestRelease_nonSuccessYieldsNull() =
runTest {
// One queued response per request: stable path, then RC path.
enqueue("", code = 404)
enqueue("", code = 404)
assertNull(checker().fetchLatestRelease())
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
}
private companion object {
const val STABLE_BODY = """
{
"tag_name": "v1.24.2",
"prerelease": false,
"draft": false,
"assets": [
{
"name": "hermes-mobile-v1.24.2.apk",
"size": 12345,
"browser_download_url": "https://example.com/stable.apk"
}
]
}
"""
const val RELEASES_BODY = """
[
{
"tag_name": "v1.25.0-rc.3",
"prerelease": true,
"draft": false,
"assets": [
{
"name": "hermes-mobile-v1.25.0-rc.3.apk",
"size": 999,
"browser_download_url": "https://example.com/rc.apk"
}
]
},
{
"tag_name": "v1.24.2",
"prerelease": false,
"draft": false,
"assets": [
{
"name": "hermes-mobile-v1.24.2.apk",
"size": 12345,
"browser_download_url": "https://example.com/stable.apk"
}
]
}
]
"""
const val ALPHA_BETA_BODY = """
[
{
"tag_name": "v1.30.0-alpha.1",
"prerelease": true,
"assets": [
{
"name": "hermes-mobile-v1.30.0-alpha.1.apk",
"size": 1,
"browser_download_url": "https://example.com/alpha.apk"
}
]
},
{
"tag_name": "v1.29.0-beta.4",
"prerelease": true,
"assets": [
{
"name": "hermes-mobile-v1.29.0-beta.4.apk",
"size": 1,
"browser_download_url": "https://example.com/beta.apk"
}
]
}
]
"""
}
}
@@ -175,4 +175,159 @@ class AppUpdateCheckerTest {
assertNull(parseUpdateInfo("not json at all"))
assertNull(parseUpdateInfo(""))
}
// ── Release candidate channel (opt-in) ──────────────────────────────
private fun release(
tag: String,
draft: Boolean = false,
prerelease: Boolean = false,
withApk: Boolean = true,
): UpdateInfo =
UpdateInfo(
tagName = tag,
draft = draft,
prerelease = prerelease,
assets =
if (withApk) {
listOf(
UpdateInfo.Asset(
name = "hermes-mobile-$tag.apk",
size = 1L,
browserDownloadUrl = "https://example.com/$tag.apk",
),
)
} else {
listOf(
UpdateInfo.Asset(
name = "version.txt",
size = 1L,
browserDownloadUrl = "https://example.com/version.txt",
),
)
},
)
@Test
fun isReleaseCandidateVersion_acceptsRcTagForms() {
listOf("v1.25.0-rc.1", "1.25-rc.1", "1.25.rc.1", "v1.25.0-rc", "1.25-rc1", "v1.25.0-RC.2").forEach {
assertTrue(it, isReleaseCandidateVersion(it))
}
}
@Test
fun isReleaseCandidateVersion_rejectsStableAndNonRcPrereleases() {
listOf("1.25", "v1.25.0", "1.25-alpha", "1.25-alpha.1", "1.25-beta.2", "1.25-dev", "garbage", "").forEach {
assertFalse(it, isReleaseCandidateVersion(it))
}
}
@Test
fun selectLatestUpdate_skipsDraftsAndReleasesWithoutApk() {
val releases =
listOf(
release("v1.30.0", draft = true),
release("v1.29.0", withApk = false),
release("v1.28.0"),
)
assertEquals("v1.28.0", selectLatestUpdate(releases)?.tagName)
}
@Test
fun selectLatestUpdate_excludesRcUnlessOptedIn() {
val releases =
listOf(
release("v1.25.0-rc.3", prerelease = true),
release("v1.24.2"),
)
assertEquals("v1.24.2", selectLatestUpdate(releases)?.tagName)
assertEquals("v1.25.0-rc.3", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
}
@Test
fun selectLatestUpdate_neverOffersAlphaOrBetaEvenWhenOptedIn() {
val releases =
listOf(
release("v1.30.0-alpha.1", prerelease = true),
release("v1.29.0-beta.4", prerelease = true),
release("v1.28.0-rc.1", prerelease = true),
)
assertEquals("v1.28.0-rc.1", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
assertNull(selectLatestUpdate(releases))
}
@Test
fun selectLatestUpdate_prefersStableWhenItIsNewerThanTheRc() {
val releases =
listOf(
release("v1.26.0"),
release("v1.25.0-rc.3", prerelease = true),
)
// List order is publication order, not version order — the stable
// release must win on semantics, not on position.
assertEquals("v1.26.0", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
}
@Test
fun selectLatestUpdate_prefersNewerRcOverOlderStable() {
val releases =
listOf(
release("v1.24.2"),
release("v1.25.0-rc.3", prerelease = true),
)
assertEquals("v1.25.0-rc.3", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
}
@Test
fun selectLatestUpdate_nullWhenNothingInstallable() {
assertNull(selectLatestUpdate(emptyList()))
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false))))
}
@Test
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
val json =
"""
[
{
"tag_name": "v1.25.0-rc.3",
"prerelease": true,
"draft": false,
"assets": [
{
"name": "hermes-mobile-v1.25.0-rc.3.apk",
"size": 999,
"browser_download_url": "https://example.com/rc.apk"
}
]
},
{
"tag_name": "v1.24.2",
"assets": []
}
]
""".trimIndent()
val releases = parseReleaseList(json)
assertNotNull(releases)
assertEquals(2, releases!!.size)
assertEquals("v1.25.0-rc.3", releases[0].tagName)
assertTrue(releases[0].prerelease)
assertFalse(releases[0].draft)
assertNotNull(releases[0].apkAsset)
// Absent flags default to a plain stable release with no APK.
assertFalse(releases[1].prerelease)
assertNull(releases[1].apkAsset)
}
@Test
fun parseReleaseList_malformedJson_yieldsNull() {
assertNull(parseReleaseList("not json at all"))
assertNull(parseReleaseList("{}"))
}
}
@@ -62,6 +62,8 @@ class UpdateNoticeManagerTest {
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
every { AuthManager.getDismissedUpdateTag() } returns null
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
}
@After
@@ -259,4 +261,85 @@ class UpdateNoticeManagerTest {
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
// ── Release candidate channel (opt-in) ──────────────────────────────
@Test
fun checkOnLaunch_staysOnStableChannelByDefault() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(false) } returns updateInfo()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
coVerify(exactly = 0) { checker.fetchLatestRelease(true) }
}
@Test
fun checkOnLaunch_scansReleaseCandidatesWhenOptedIn() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
assertEquals(
"v1.25.0-rc.3",
(AppUpdateCache.state.value as AppUpdateState.UpdateAvailable).latestTag,
)
}
@Test
fun noticeTag_suppressesPersistedRcTagOnStableChannel() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun noticeTag_suppressesCachedRcOfferOnStableChannel() {
AppUpdateCache.update(
AppUpdateState.UpdateAvailable(
latestTag = "v1.25.0-rc.3",
apkUrl = "https://example.com/apk",
sizeBytes = 1L,
),
)
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun checkOnLaunch_discardsRcResultWhenChannelFlippedMidRequest() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease(true) } coAnswers {
// The user turns the RC channel off while the request is in flight.
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
updateInfo(tag = "v1.25.0-rc.3")
}
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
// The late RC result must not reach the cache or the persisted tag.
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
}
}
@@ -18,13 +18,17 @@ import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -75,6 +79,8 @@ class AppUpdateViewModelTest {
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
every { AuthManager.getDismissedUpdateTag() } returns null
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
app = mockk(relaxed = true)
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
@@ -348,4 +354,244 @@ class AppUpdateViewModelTest {
assertTrue(AppUpdateCache.dismissed)
assertFalse(AppUpdateCache.isDialogVisible)
}
// ── Release candidate channel (opt-in) ──────────────────────────────
@Test
fun init_checkUsesStableChannelByDefault() =
runTest {
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
coVerify(exactly = 0) { checker.fetchLatestRelease(true) }
assertFalse(vm.checkReleaseCandidateUpdates.value)
}
@Test
fun init_doesNotAdoptCachedRcOfferOnStableChannel() =
runTest {
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
)
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
assertEquals(
AppUpdateState.UpdateAvailable(
"v1.22.0",
"https://example.com/hermes-mobile-v1.22.0.apk",
12345678L,
),
vm.state.value,
)
}
@Test
fun init_adoptsCachedRcOfferWhenOptedIn() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
)
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease(any()) }
assertEquals(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
vm.state.value,
)
assertTrue(vm.checkReleaseCandidateUpdates.value)
}
@Test
fun setCheckReleaseCandidateUpdates_persistsAndRechecksOnRcChannel() =
runTest {
val vm = createViewModel()
advanceUntilIdle()
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
vm.setCheckReleaseCandidateUpdates(true)
advanceUntilIdle()
assertTrue(vm.checkReleaseCandidateUpdates.value)
verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) }
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
assertEquals(vm.state.value, AppUpdateCache.state.value)
}
@Test
fun setCheckReleaseCandidateUpdates_sameValueDoesNotRecheckOrPersist() =
runTest {
val vm = createViewModel()
advanceUntilIdle()
vm.setCheckReleaseCandidateUpdates(false)
advanceUntilIdle()
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
}
@Test
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
val vm = createViewModel()
advanceUntilIdle()
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
// Back on the stable channel the installed version is current.
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
vm.setCheckReleaseCandidateUpdates(false)
advanceUntilIdle()
assertFalse(vm.checkReleaseCandidateUpdates.value)
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value)
// The RC offer must be gone everywhere, so startUpdate() is a no-op.
vm.startUpdate()
advanceUntilIdle()
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
}
@Test
fun checkForUpdate_afterOptInUsesRcChannel() =
runTest {
val vm = createViewModel()
advanceUntilIdle()
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
vm.setCheckReleaseCandidateUpdates(true)
advanceUntilIdle()
vm.checkForUpdate()
advanceUntilIdle()
coVerify(exactly = 2) { checker.fetchLatestRelease(true) }
}
// ── Channel-switch races ────────────────────────────────────────────
//
// The real fetch bottoms out in a blocking OkHttp `execute()`, which cannot
// be interrupted by coroutine cancellation. `withContext(NonCancellable)`
// models that faithfully: the job is cancelled, the call still finishes, and
// its result must not be published.
@Test
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
val rcGate = CompletableDeferred<Unit>()
coEvery { checker.fetchLatestRelease(true) } coAnswers {
withContext(NonCancellable) { rcGate.await() }
updateInfo(tag = "v1.25.0-rc.3")
}
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
val vm = createViewModel()
advanceUntilIdle()
assertTrue("RC check must start in flight", vm.state.value is AppUpdateState.Checking)
// User opts out while the RC check is still mid-request.
vm.setCheckReleaseCandidateUpdates(false)
// Let the un-interruptible RC request finish AFTER the channel switch.
rcGate.complete(Unit)
advanceUntilIdle()
assertFalse(vm.checkReleaseCandidateUpdates.value)
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value)
verify(exactly = 1) { AuthManager.setLastKnownLatestTag("1.21.0") }
verify(exactly = 0) { AuthManager.setLastKnownLatestTag("v1.25.0-rc.3") }
// And the stale RC offer must not be installable.
vm.startUpdate()
advanceUntilIdle()
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
}
@Test
fun cancelledCheck_neverSurfacesAnErrorState() =
runTest {
val gate = CompletableDeferred<Unit>()
coEvery { checker.fetchLatestRelease(false) } coAnswers {
gate.await()
updateInfo()
}
coEvery { checker.fetchLatestRelease(true) } returns updateInfo()
val vm = createViewModel()
val seen = mutableListOf<AppUpdateState>()
backgroundScope.launch { vm.state.collect { seen += it } }
advanceUntilIdle()
assertTrue(vm.state.value is AppUpdateState.Checking)
// Switching channels cancels the in-flight stable check.
vm.setCheckReleaseCandidateUpdates(true)
advanceUntilIdle()
// A cancelled check must never be reported as a failed one — not even
// momentarily, because the next legitimate result would mask it.
assertTrue(
"cancellation must not emit an error state: $seen",
seen.none { it is AppUpdateState.Error },
)
assertEquals(
AppUpdateState.UpdateAvailable(
"v1.22.0",
"https://example.com/hermes-mobile-v1.22.0.apk",
12345678L,
),
vm.state.value,
)
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
}
@Test
fun startUpdate_refusesCachedRcOfferOnStableChannel() =
runTest {
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
val vm = createViewModel()
advanceUntilIdle()
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
// A late RC result lands in the shared cache behind this ViewModel's
// back while the user is on the stable channel.
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
)
vm.startUpdate()
advanceUntilIdle()
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
// Refusal clears the stale offer and re-checks instead of dead-ending.
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value)
}
@Test
fun resumeInstallAfterPermission_refusesStaleRcOffer() =
runTest {
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
val vm = createViewModel()
advanceUntilIdle()
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
)
vm.resumeInstallAfterPermission()
advanceUntilIdle()
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
verify(exactly = 0) { app.startActivity(any()) }
}
}