From 03ce82211913b50fd2d401dbdd48d9d92a3136f8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:25:49 +0300 Subject: [PATCH] feat: opt-in release candidate update channel (#1199) --- .../AboutSectionReleaseCandidateTest.kt | 97 +++++++ .../data/config/ServerStoreState.kt | 3 + .../hermescontrol/data/local/AuthManager.kt | 10 + .../data/update/AppUpdateChecker.kt | 94 ++++++- .../data/update/UpdateNoticeManager.kt | 19 +- .../ui/settings/AppUpdateViewModel.kt | 186 +++++++++---- .../ui/settings/SettingsSubPages.kt | 3 + .../ui/settings/components/AboutSection.kt | 55 ++++ app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../data/config/ServerStoreTest.kt | 14 + .../update/AppUpdateCheckerNetworkTest.kt | 158 +++++++++++ .../data/update/AppUpdateCheckerTest.kt | 155 +++++++++++ .../data/update/UpdateNoticeManagerTest.kt | 83 ++++++ .../ui/settings/AppUpdateViewModelTest.kt | 246 ++++++++++++++++++ 17 files changed, 1071 insertions(+), 60 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt new file mode 100644 index 00000000..7a36421d --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/AboutSectionReleaseCandidateTest.kt @@ -0,0 +1,97 @@ +package com.m57.hermescontrol.ui.settings.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.update.AppUpdateState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The opt-in release-candidate switch on the About tab: stable-only default, + * one callback per tap, and no channel flip while an APK is in flight. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class AboutSectionReleaseCandidateTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun stableOnlyByDefault_tapReportsOptIn() { + var reported: Boolean? = null + composeTestRule.setContent { + AboutSection( + updateState = AppUpdateState.Idle, + checkReleaseCandidateUpdates = false, + onCheckReleaseCandidateUpdatesChange = { reported = it }, + ) + } + + composeTestRule.onNodeWithText(RC_TITLE).assertIsDisplayed() + composeTestRule.onNode(isToggleable()).assertIsOff() + + composeTestRule.onNode(isToggleable()).performClick() + + assertEquals(true, reported) + } + + @Test + fun optedIn_switchShowsChecked() { + composeTestRule.setContent { + AboutSection( + updateState = AppUpdateState.Idle, + checkReleaseCandidateUpdates = true, + ) + } + + composeTestRule.onNode(isToggleable()).assertIsOn() + } + + @Test + fun downloading_disablesTheChannelSwitch() { + composeTestRule.setContent { + AboutSection( + updateState = AppUpdateState.Downloading(0.5f), + checkReleaseCandidateUpdates = false, + ) + } + + composeTestRule.onNode(isToggleable()).assertIsNotEnabled() + } + + @Test + fun installing_disablesTheChannelSwitch() { + composeTestRule.setContent { + AboutSection( + updateState = AppUpdateState.Installing("v1.25.0-rc.3"), + checkReleaseCandidateUpdates = true, + ) + } + + composeTestRule.onNode(isToggleable()).assertIsNotEnabled() + } + + @Test + fun idle_keepsTheChannelSwitchEnabled() { + composeTestRule.setContent { + AboutSection(updateState = AppUpdateState.UpToDate("v1.24.2")) + } + + composeTestRule.onNode(isToggleable()).assertIsEnabled() + } + + private companion object { + const val RC_TITLE = "Check release candidate updates" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 931dd246..f069698d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index a9a12d33..f8c0c98c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt index b20c3a22..a6887ba3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -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 = 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, + 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, val prerelease: List, @@ -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(json) }.getOrNull() + +/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */ +fun parseReleaseList(json: String): List? = + runCatching { + OkHttpProvider.json.decodeFromString>(json) + }.getOrNull() diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt index 28dc8aae..b4ae9020 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt @@ -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) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt index 14f6568d..b02aa97b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt @@ -12,9 +12,12 @@ 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.isNewerVersion +import com.m57.hermescontrol.data.update.isReleaseCandidateVersion import com.m57.hermescontrol.data.update.releaseTag +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,15 +45,35 @@ class AppUpdateViewModel( private val _state = MutableStateFlow(AppUpdateState.Idle) val state: StateFlow = _state.asStateFlow() + private val _checkReleaseCandidateUpdates = + MutableStateFlow(AuthManager.isCheckingReleaseCandidateUpdates()) + + /** Whether the RC update channel is enabled (issue: RC update toggle). */ + val checkReleaseCandidateUpdates: StateFlow = _checkReleaseCandidateUpdates.asStateFlow() + + private var checkJob: kotlinx.coroutines.Job? = null private var downloadJob: kotlinx.coroutines.Job? = null private var lastAvailable: AppUpdateState.UpdateAvailable? = null + /** + * Bumped by every check. The blocking OkHttp call underneath cannot be + * interrupted, so a cancelled check may still be running; results are only + * published when this token is unchanged (and the channel still matches). + */ + private var checkGeneration = 0 + init { val cached = AppUpdateCache.state.value - if (cached is AppUpdateState.UpdateAvailable) { + // An RC offer cached while the RC channel was on must not be adopted + // after the user switched back to stable-only. + val blocked = + !_checkReleaseCandidateUpdates.value && + cached.releaseTag()?.let(::isReleaseCandidateVersion) == true + if (blocked) AppUpdateCache.reset() + if (cached is AppUpdateState.UpdateAvailable && !blocked) { lastAvailable = cached _state.value = cached - } else if (cached is AppUpdateState.UpToDate) { + } else if (cached is AppUpdateState.UpToDate && !blocked) { _state.value = cached } else { checkForUpdate() @@ -60,56 +83,132 @@ class AppUpdateViewModel( /** Manual check from the About row or dialog. */ fun checkForUpdate() { if (_state.value is AppUpdateState.Checking) return + runCheck() + } + + /** + * Opt in/out of release-candidate updates and immediately re-check on the + * newly selected channel. Switching back to stable drops any cached RC + * offer, and [updateAvailableForInstall] refuses an RC on the stable channel, + * so a stale offer can never be installed. + */ + fun setCheckReleaseCandidateUpdates(enabled: Boolean) { + if (_checkReleaseCandidateUpdates.value == enabled) return + AuthManager.setCheckReleaseCandidateUpdates(enabled) + _checkReleaseCandidateUpdates.value = enabled + if (!enabled) { + lastAvailable = null + AppUpdateCache.reset() + } + // A check already in flight captured the previous channel — cancel it and + // bump the generation so a late result can never be published. + checkJob?.cancel() + runCheck() + } + + private fun runCheck() { + val generation = ++checkGeneration + val includeReleaseCandidates = _checkReleaseCandidateUpdates.value _state.value = AppUpdateState.Checking - viewModelScope.launch(ioDispatcher) { - val now = System.currentTimeMillis() - AuthManager.setUpdateCheckDoneForVersion(currentVersion) - AuthManager.setLastUpdateCheckTimestamp(now) - val result = - try { - checker.fetchLatestRelease() - } catch (e: IOException) { - _state.value = AppUpdateState.Error(NETWORK_ERROR) - return@launch - } catch (e: Exception) { - _state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR) - return@launch - } - val info = - result ?: run { - _state.value = AppUpdateState.Error(NO_RELEASE_ERROR) - return@launch - } - val apk = - info.apkAsset ?: run { - _state.value = AppUpdateState.Error(NO_APK_ERROR) - return@launch - } - _state.value = - if (isNewerVersion(info.tagName, currentVersion)) { - AppUpdateState - .UpdateAvailable( + checkJob = + viewModelScope.launch(ioDispatcher) { + val now = System.currentTimeMillis() + AuthManager.setUpdateCheckDoneForVersion(currentVersion) + AuthManager.setLastUpdateCheckTimestamp(now) + val result = + try { + checker.fetchLatestRelease(includeReleaseCandidates) + } catch (e: CancellationException) { + // Cancellation is control flow — it must never be reported + // as a failed check. + throw e + } catch (e: IOException) { + if (isCurrentCheck(generation, includeReleaseCandidates)) { + _state.value = AppUpdateState.Error(NETWORK_ERROR) + } + return@launch + } catch (e: Exception) { + if (isCurrentCheck(generation, includeReleaseCandidates)) { + _state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR) + } + return@launch + } + + // The OkHttp call underneath blocks and cannot be interrupted, so a + // newer check — or a channel switch — may have happened while it ran. + ensureActive() + if (!isCurrentCheck(generation, includeReleaseCandidates)) return@launch + + val info = + result ?: run { + _state.value = AppUpdateState.Error(NO_RELEASE_ERROR) + return@launch + } + val apk = + info.apkAsset ?: run { + _state.value = AppUpdateState.Error(NO_APK_ERROR) + return@launch + } + val state = + if (isNewerVersion(info.tagName, currentVersion)) { + AppUpdateState.UpdateAvailable( latestTag = info.tagName, apkUrl = apk.browserDownloadUrl, sizeBytes = apk.size, releaseNotes = info.body, - ).also { lastAvailable = it } - } else { - AppUpdateState.UpToDate(latestTag = info.tagName) - } - // Keep the launch notice (issue #890) in sync with manual checks. - AppUpdateCache.update(_state.value) - _state.value.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) } - } + ) + } else { + AppUpdateState.UpToDate(latestTag = info.tagName) + } + // Fail closed: a release-candidate result is never published once + // the RC channel is off, however it reached this point. + if (!isAdmissibleOnCurrentChannel(state.releaseTag())) return@launch + if (state is AppUpdateState.UpdateAvailable) lastAvailable = state + _state.value = state + // Keep the launch notice (issue #890) in sync with manual checks. + AppUpdateCache.update(state) + state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) } + } } - /** Download the release APK and launch the system installer. */ - fun startUpdate() { + /** + * True when [generation] is still the newest check *and* the channel that + * check was started for is still the selected one. + */ + private fun isCurrentCheck( + generation: Int, + includeReleaseCandidates: Boolean, + ): Boolean = generation == checkGeneration && includeReleaseCandidates == _checkReleaseCandidateUpdates.value + + /** A release-candidate tag may only be used while the RC channel is on. */ + private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean = + tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value + + /** + * The update this ViewModel may install right now, or null. + * + * Fails closed: a release-candidate offer is refused while the RC channel is + * off, however it reached held state. A refusal drops the stale offer and + * re-checks on the current channel instead of dead-ending. + */ + private fun updateAvailableForInstall(): AppUpdateState.UpdateAvailable? { val available = (_state.value as? AppUpdateState.UpdateAvailable) ?: lastAvailable ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) - ?: return + ?: return null + if (!isAdmissibleOnCurrentChannel(available.latestTag)) { + lastAvailable = null + AppUpdateCache.reset() + checkForUpdate() + return null + } + return available + } + + /** Download the release APK and launch the system installer. */ + fun startUpdate() { + val available = updateAvailableForInstall() ?: return lastAvailable = available if (!canRequestInstalls()) { _state.value = AppUpdateState.NeedsUnknownSourcesPermission @@ -166,10 +265,7 @@ class AppUpdateViewModel( fun resumeInstallAfterPermission() { if (canRequestInstalls()) { val dest = File(getApplication().cacheDir, APK_FILE_NAME) - val available = - (_state.value as? AppUpdateState.UpdateAvailable) - ?: lastAvailable - ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) + val available = updateAvailableForInstall() if (dest.exists() && dest.length() > 0 && available != null) { _state.value = AppUpdateState.Installing(available.latestTag) launchInstaller(dest) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index b9470d4b..8d30bfaf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -262,6 +262,7 @@ internal fun SettingsAboutPage( }, ) { val updateState by viewModel.state.collectAsStateWithLifecycle() + val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle() val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current @@ -294,8 +295,10 @@ internal fun SettingsAboutPage( ) { AboutSection( updateState = updateState, + checkReleaseCandidateUpdates = checkReleaseCandidateUpdates, onCheckUpdate = viewModel::checkForUpdate, onStartUpdate = viewModel::startUpdate, + onCheckReleaseCandidateUpdatesChange = viewModel::setCheckReleaseCandidateUpdates, onOpenInstallSettings = { val intent = Intent( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 2ab8ab54..1a8207f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.settings.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,6 +13,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -28,8 +31,10 @@ import com.m57.hermescontrol.ui.settings.SectionCard @Composable internal fun AboutSection( updateState: AppUpdateState = AppUpdateState.Idle, + checkReleaseCandidateUpdates: Boolean = false, onCheckUpdate: () -> Unit = {}, onStartUpdate: () -> Unit = {}, + onCheckReleaseCandidateUpdatesChange: (Boolean) -> Unit = {}, onOpenInstallSettings: () -> Unit = {}, ) { SectionCard { @@ -57,6 +62,15 @@ internal fun AboutSection( onStartUpdate = onStartUpdate, onOpenInstallSettings = onOpenInstallSettings, ) + ReleaseCandidateRow( + checked = checkReleaseCandidateUpdates, + // Mid-download / mid-install the channel must not flip under the + // in-flight APK. + enabled = + updateState !is AppUpdateState.Downloading && + updateState !is AppUpdateState.Installing, + onCheckedChange = onCheckReleaseCandidateUpdatesChange, + ) InfoRow( label = stringResource(R.string.settings_about_build), value = @@ -223,3 +237,44 @@ private fun ValueText(text: String) { ), ) } + +/** + * Opt-in switch for pre-release (release candidate) updates. Stable-only is + * the default; enabling this makes the update check consider RC tags too. + */ +@Composable +private fun ReleaseCandidateRow( + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_about_rc_updates_title), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.settings_about_rc_updates_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 02399916..6c524f74 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -244,6 +244,8 @@ السماح بتثبيت التطبيقات من هذا المصدر فتح الإعدادات إعادة المحاولة + التحقق من الإصدارات المرشّحة (RC) + تثبيت إصدارات ما قبل الإطلاق. قد تكون أقل استقرارًا. تم حفظ %1$d تأثير الكتابة مفعّل تأثير الكتابة معطّل diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cb0ac616..8d9afa91 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -246,6 +246,8 @@ 이 소스의 설치 허용 설정 열기 다시 시도 + 릴리스 후보 업데이트 확인 + 사전 출시(RC) 빌드를 설치합니다. 안정성이 낮을 수 있습니다. 저장 프로필 추가 프로필 편집 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9947b4d6..7fcf777c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -244,6 +244,8 @@ 允许安装来自此来源的应用 打开设置 重试 + 检查候选版本更新 + 安装预发布候选版本 (RC)。稳定性可能较低。 已保存 %1$d 个 打字效果已开启 打字效果已关闭 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5909224..a7da130e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,6 +265,8 @@ Allow installs from this source Open settings Retry + Check release candidate updates + Install pre-release RC builds. They may be less stable. Support Hermes Mobile If Hermes Mobile saves you time, you can support its development. Support me on Ko-fi diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt index db3c4e73..801b6dc8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt @@ -93,4 +93,18 @@ class ServerStoreTest { val decoded = json.decodeFromString("{}") 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("{}") + assertFalse(decoded.checkReleaseCandidateUpdates) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt new file mode 100644 index 00000000..4bd6a811 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt @@ -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" + } + ] + } +] +""" + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt index e45dfa29..7efd13f5 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerTest.kt @@ -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("{}")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt index f19c5ab2..06195d26 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt @@ -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() + 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() + 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() + 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()) } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt index 34522fbe..3c0079bd 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt @@ -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() + 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() + coEvery { checker.fetchLatestRelease(false) } coAnswers { + gate.await() + updateInfo() + } + coEvery { checker.fetchLatestRelease(true) } returns updateInfo() + + val vm = createViewModel() + val seen = mutableListOf() + 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()) } + } }