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

@@ -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()) }
}
}