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:
M57 committed 2026-09-23 20:43:41 +03:00
1 parent 68a17d5499
commit 19eacf260f
14 files changed
+723 -258

No files matched your search

@@ -1,14 +1,21 @@
package com.m57.hermescontrol.data.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
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
@@ -38,8 +45,11 @@ class AppUpdateCheckerNetworkTest {
private fun enqueue(
body: String,
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
@@ -56,26 +66,62 @@ class AppUpdateCheckerNetworkTest {
@Test
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
runTest {
enqueue(STABLE_BODY)
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)
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_releaseCandidatesStillIgnoreAlphaAndBeta() =
fun fetchLatestRelease_stableFallsBackToReleasesWhenLatestIsNotUsable() =
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)
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
assertEquals("v1.24.2", checker().fetchLatestRelease(includeReleaseCandidates = true)?.tagName)
}
@Test
fun fetchLatestRelease_nonSuccessYieldsNull() =
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)
@@ -83,6 +129,35 @@ class AppUpdateCheckerNetworkTest {
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 {
const val STABLE_BODY = """
{
@@ -183,6 +183,7 @@ class AppUpdateCheckerTest {
draft: Boolean = false,
prerelease: Boolean = false,
withApk: Boolean = true,
apkUrl: String = "https://example.com/$tag.apk",
): UpdateInfo =
UpdateInfo(
tagName = tag,
@@ -194,7 +195,7 @@ class AppUpdateCheckerTest {
UpdateInfo.Asset(
name = "hermes-mobile-$tag.apk",
size = 1L,
browserDownloadUrl = "https://example.com/$tag.apk",
browserDownloadUrl = apkUrl,
),
)
} else {
@@ -289,6 +290,55 @@ class AppUpdateCheckerTest {
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
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
val json =
@@ -49,6 +49,7 @@ class UpdateNoticeManagerTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
UpdateNoticeManager.resetForTests()
AppUpdateCache.reset()
// Unit tests run against the debug variant where the feature is
// disabled by default — flip the knob on so the behavior is testable.
@@ -68,6 +69,7 @@ class UpdateNoticeManagerTest {
@After
fun tearDown() {
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = false
unmockkAll()
Dispatchers.resetMain()
@@ -92,7 +94,7 @@ class UpdateNoticeManagerTest {
}
@Test
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() =
fun checkOnLaunch_ignoresPersistedDailyCheckTimestampAfterColdStart() =
runTest {
val now = 1000000000000L
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
@@ -103,9 +105,24 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
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
@@ -148,9 +165,9 @@ class UpdateNoticeManagerTest {
}
@Test
fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() {
fun noticeTag_doesNotUsePersistedTagForStableInstall() {
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"))
}
@@ -176,9 +193,11 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
// Still marked done so a dead network can't spam the API per launch.
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
assertEquals(
AppUpdateState.Error("Network error — check your connection", isCheckError = true),
AppUpdateCache.state.value,
)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
@Test
@@ -190,8 +209,41 @@ class UpdateNoticeManagerTest {
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
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)
assertFalse(AppUpdateCache.dismissed)
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
}
// ── noticeTag (dismissed/restart banner return) ─────────────────────
@@ -211,10 +263,10 @@ class UpdateNoticeManagerTest {
}
@Test
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() {
fun noticeTag_doesNotAdvertisePersistedTagWithoutFreshApkMetadata() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
@@ -303,11 +355,11 @@ class UpdateNoticeManagerTest {
}
@Test
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() {
fun noticeTag_doesNotUsePersistedRcTagWithoutApkMetadata() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion))
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
@@ -336,10 +388,50 @@ class UpdateNoticeManagerTest {
}
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
UpdateNoticeManager.channelChanged(enabled = false)
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
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)
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.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateInfo
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
@@ -48,6 +50,7 @@ class AppUpdateViewModelTest {
private lateinit var app: Application
private lateinit var packageManager: PackageManager
private lateinit var checker: AppUpdateChecker
private var rcUpdatesEnabled = false
private val currentVersion = "1.21.0"
@@ -70,6 +73,8 @@ class AppUpdateViewModelTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = true
AppUpdateCache.reset()
mockkObject(AuthManager)
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
@@ -79,8 +84,12 @@ 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
rcUpdatesEnabled = false
every { AuthManager.isCheckingReleaseCandidateUpdates() } answers { rcUpdatesEnabled }
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } answers {
rcUpdatesEnabled = firstArg()
Unit
}
app = mockk(relaxed = true)
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
@@ -91,11 +100,17 @@ class AppUpdateViewModelTest {
checker = mockk()
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
fun tearDown() {
UpdateNoticeManager.resetForTests()
UpdateNoticeManager.enabled = false
unmockkAll()
Dispatchers.resetMain()
}
@@ -237,14 +252,14 @@ class AppUpdateViewModelTest {
}
@Test
fun checkForUpdate_marksDoneEvenWhenCheckFails() =
fun checkForUpdate_marksDoneOnlyAfterSuccessfulDiscovery() =
runTest {
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
coVerify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
// ── Update flow ─────────────────────────────────────────────────────
@@ -351,7 +366,7 @@ class AppUpdateViewModelTest {
advanceUntilIdle()
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
assertTrue(AppUpdateCache.dismissed)
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
assertFalse(AppUpdateCache.isDialogVisible)
}
@@ -392,7 +407,7 @@ class AppUpdateViewModelTest {
@Test
fun init_adoptsCachedRcOfferWhenOptedIn() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
rcUpdatesEnabled = true
AppUpdateCache.update(
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
)
@@ -419,6 +434,7 @@ class AppUpdateViewModelTest {
advanceUntilIdle()
assertTrue(vm.checkReleaseCandidateUpdates.value)
verify(exactly = 1) { UpdateNoticeManager.channelChanged(true) }
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)
@@ -435,13 +451,14 @@ class AppUpdateViewModelTest {
advanceUntilIdle()
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
verify(exactly = 0) { UpdateNoticeManager.channelChanged(any()) }
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
}
@Test
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
rcUpdatesEnabled = true
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
val vm = createViewModel()
@@ -487,7 +504,7 @@ class AppUpdateViewModelTest {
@Test
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
runTest {
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
rcUpdatesEnabled = true
val rcGate = CompletableDeferred<Unit>()
coEvery { checker.fetchLatestRelease(true) } coAnswers {
withContext(NonCancellable) { rcGate.await() }