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

@@ -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"
}
}
@@ -30,6 +30,9 @@ data class ServerStoreState(
val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false,
val keepConnectedInBackground: Boolean = false,
// Opt-in update channel: when true the in-app update check also considers
// pre-release release-candidate GitHub releases, not just stable ones.
val checkReleaseCandidateUpdates: Boolean = false,
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
@@ -873,6 +873,16 @@ object AuthManager {
// ── In-app update check (issue #867) ─────────────────────────────────
/**
* Whether the user opted into release-candidate updates. Stable-only by
* default; when true the update check also considers pre-release RC tags.
*/
fun isCheckingReleaseCandidateUpdates(): Boolean = serverStore.getLatestState().checkReleaseCandidateUpdates
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
serverStore.update { it.copy(checkReleaseCandidateUpdates = enabled) }
}
/** App version the silent update check last completed for (null = never). */
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
@@ -18,6 +18,9 @@ import java.io.IOException
data class UpdateInfo(
val tagName: String = "",
val body: String = "",
/** GitHub release flags: a draft is unpublished, a prerelease is pre-stable. */
val draft: Boolean = false,
val prerelease: Boolean = false,
val assets: List<Asset> = emptyList(),
) {
@Serializable
@@ -61,6 +64,32 @@ fun isNewerVersion(
return a.prerelease.size > b.prerelease.size
}
/**
* Pick the newest installable release out of a GitHub `releases` list payload.
*
* Drafts and releases without an APK asset are always ignored. Pre-releases are
* only eligible when [includeReleaseCandidates] is set *and* the tag is a
* release candidate — alpha/beta/dev pre-releases stay out of the RC channel.
*
* Selection uses [isNewerVersion] rather than list order because the API orders
* releases by publication date, which is not a version oracle.
*/
fun selectLatestUpdate(
releases: List<UpdateInfo>,
includeReleaseCandidates: Boolean = false,
): UpdateInfo? =
releases
.filter { !it.draft && it.apkAsset != null }
.filter {
if (!it.prerelease) {
true
} else {
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
}
}.reduceOrNull { best, candidate ->
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
}
private data class ParsedVersion(
val core: List<Int>,
val prerelease: List<String>,
@@ -96,6 +125,19 @@ private fun comparePrereleaseIdentifier(
return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y)
}
/** `rc`, `rc1`, `RC2` — but never alpha/beta/dev. */
private val RC_IDENTIFIER = Regex("^rc[0-9]*$", RegexOption.IGNORE_CASE)
/**
* True when [tag] is a release-candidate version such as "v1.25.0-rc.1",
* "v1.25-rc1", or the legacy dot form "v1.25.rc.1".
*
* Only the first pre-release identifier is inspected, so alpha/beta builds are
* never treated as release candidates.
*/
fun isReleaseCandidateVersion(tag: String): Boolean =
parseVersion(tag)?.prerelease?.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
/**
* Talks to the GitHub releases API and downloads the release APK (issue
* #867). Network methods are `open` so tests can fake them; the pure logic
@@ -104,27 +146,44 @@ private fun comparePrereleaseIdentifier(
*/
open class AppUpdateChecker(
private val client: OkHttpClient = OkHttpProvider.base,
private val apiBaseUrl: String = "https://api.github.com/repos/Hy4ri/hermes-mobile",
) {
/**
* Fetch the latest release metadata. Returns null when there is no
* release yet (404) or the body can't be parsed. Throws [IOException]
* on network failure so the caller can surface a friendly error.
* Fetch the newest installable release metadata.
*
* Stable-only by default: the `releases/latest` endpoint deliberately
* excludes pre-releases. With [includeReleaseCandidates] the releases list
* is scanned instead so a newer release-candidate tag becomes eligible.
*
* Returns null when there is no usable release (404) or the body can't be
* parsed. Throws [IOException] on network failure so the caller can
* surface a friendly error.
*/
open suspend fun fetchLatestRelease(): UpdateInfo? =
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body.string().orEmpty()
if (includeReleaseCandidates) {
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
} else {
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
parseUpdateInfo(body)
}
}
/** GET [url] as JSON text, or null on any non-2xx response. */
private fun get(url: String): String? {
val request =
Request
.Builder()
.url(url)
.header("Accept", "application/vnd.github+json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body.string().orEmpty()
}
}
/**
* Stream a release APK asset to [dest], reporting progress 0..1 via
* [onProgress]. True on success; false (with the partial file deleted)
@@ -167,6 +226,9 @@ open class AppUpdateChecker(
private companion object {
const val DEFAULT_BUFFER_SIZE = 8192
/** How many recent releases the opt-in RC scan inspects. */
const val RELEASE_LIST_PAGE_SIZE = 20
}
}
@@ -175,3 +237,9 @@ fun parseUpdateInfo(json: String): UpdateInfo? =
runCatching {
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
}.getOrNull()
/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */
fun parseReleaseList(json: String): List<UpdateInfo>? =
runCatching {
OkHttpProvider.json.decodeFromString<List<UpdateInfo>>(json)
}.getOrNull()
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
// Captured once so the check and the notice path agree on the channel.
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
AuthManager.setLastUpdateCheckTimestamp(now)
val result =
try {
checker.fetchLatestRelease()
checker.fetchLatestRelease(includeReleaseCandidates)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
return@launch
} catch (e: Exception) {
return@launch
}
val info = result ?: return@launch
// The user may have switched back to stable while this request was in
// flight — an RC result must never be published on the stable channel.
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return@launch
}
val apk = info.apkAsset ?: return@launch
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
/**
* The tag the chat banner should advertise, or null when nothing newer is
* known or if the user explicitly dismissed this tag.
* known, if the user explicitly dismissed this tag, or if it is a
* release-candidate tag while the user is on the stable channel.
*/
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
?: return null
if (candidateTag == dismissed) return null
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
return null
}
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
}
}
@@ -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>(AppUpdateState.Idle)
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
private val _checkReleaseCandidateUpdates =
MutableStateFlow(AuthManager.isCheckingReleaseCandidateUpdates())
/** Whether the RC update channel is enabled (issue: RC update toggle). */
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _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<Application>().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)
@@ -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(
@@ -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,
),
)
}
}
+2
View File
@@ -244,6 +244,8 @@
<string name="settings_about_update_allow_sources">السماح بتثبيت التطبيقات من هذا المصدر</string>
<string name="settings_about_update_open_settings">فتح الإعدادات</string>
<string name="settings_about_update_retry">إعادة المحاولة</string>
<string name="settings_about_rc_updates_title">التحقق من الإصدارات المرشّحة (RC)</string>
<string name="settings_about_rc_updates_desc">تثبيت إصدارات ما قبل الإطلاق. قد تكون أقل استقرارًا.</string>
<string name="settings_summary_profiles">تم حفظ %1$d</string>
<string name="settings_summary_typing_on">تأثير الكتابة مفعّل</string>
<string name="settings_summary_typing_off">تأثير الكتابة معطّل</string>
+2
View File
@@ -246,6 +246,8 @@
<string name="settings_about_update_allow_sources">이 소스의 설치 허용</string>
<string name="settings_about_update_open_settings">설정 열기</string>
<string name="settings_about_update_retry">다시 시도</string>
<string name="settings_about_rc_updates_title">릴리스 후보 업데이트 확인</string>
<string name="settings_about_rc_updates_desc">사전 출시(RC) 빌드를 설치합니다. 안정성이 낮을 수 있습니다.</string>
<string name="action_save">저장</string>
<string name="settings_action_add_profile">프로필 추가</string>
<string name="settings_action_edit_profile">프로필 편집</string>
+2
View File
@@ -244,6 +244,8 @@
<string name="settings_about_update_allow_sources">允许安装来自此来源的应用</string>
<string name="settings_about_update_open_settings">打开设置</string>
<string name="settings_about_update_retry">重试</string>
<string name="settings_about_rc_updates_title">检查候选版本更新</string>
<string name="settings_about_rc_updates_desc">安装预发布候选版本 (RC)。稳定性可能较低。</string>
<string name="settings_summary_profiles">已保存 %1$d 个</string>
<string name="settings_summary_typing_on">打字效果已开启</string>
<string name="settings_summary_typing_off">打字效果已关闭</string>
+2
View File
@@ -265,6 +265,8 @@
<string name="settings_about_update_allow_sources">Allow installs from this source</string>
<string name="settings_about_update_open_settings">Open settings</string>
<string name="settings_about_update_retry">Retry</string>
<string name="settings_about_rc_updates_title">Check release candidate updates</string>
<string name="settings_about_rc_updates_desc">Install pre-release RC builds. They may be less stable.</string>
<string name="settings_support_title">Support Hermes Mobile</string>
<string name="settings_support_message">If Hermes Mobile saves you time, you can support its development.</string>
<string name="settings_support_action">Support me on Ko-fi</string>
@@ -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()) }
}
}