feat: opt-in release candidate update channel (#1199)
This commit is contained in:
17 files changed
+1071
-60
No files matched your search
+97
@@ -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 showTokensPerSecond: Boolean = true,
|
||||||
val showModelProvider: Boolean = false,
|
val showModelProvider: Boolean = false,
|
||||||
val keepConnectedInBackground: 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
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
|||||||
@@ -873,6 +873,16 @@ object AuthManager {
|
|||||||
|
|
||||||
// ── In-app update check (issue #867) ─────────────────────────────────
|
// ── 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). */
|
/** App version the silent update check last completed for (null = never). */
|
||||||
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
|
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import java.io.IOException
|
|||||||
data class UpdateInfo(
|
data class UpdateInfo(
|
||||||
val tagName: String = "",
|
val tagName: String = "",
|
||||||
val body: 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(),
|
val assets: List<Asset> = emptyList(),
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -61,6 +64,32 @@ fun isNewerVersion(
|
|||||||
return a.prerelease.size > b.prerelease.size
|
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(
|
private data class ParsedVersion(
|
||||||
val core: List<Int>,
|
val core: List<Int>,
|
||||||
val prerelease: List<String>,
|
val prerelease: List<String>,
|
||||||
@@ -96,6 +125,19 @@ private fun comparePrereleaseIdentifier(
|
|||||||
return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y)
|
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
|
* 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
|
* #867). Network methods are `open` so tests can fake them; the pure logic
|
||||||
@@ -104,27 +146,44 @@ private fun comparePrereleaseIdentifier(
|
|||||||
*/
|
*/
|
||||||
open class AppUpdateChecker(
|
open class AppUpdateChecker(
|
||||||
private val client: OkHttpClient = OkHttpProvider.base,
|
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
|
* Fetch the newest installable release metadata.
|
||||||
* release yet (404) or the body can't be parsed. Throws [IOException]
|
*
|
||||||
* on network failure so the caller can surface a friendly error.
|
* 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) {
|
withContext(Dispatchers.IO) {
|
||||||
val request =
|
if (includeReleaseCandidates) {
|
||||||
Request
|
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
|
||||||
.Builder()
|
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
|
||||||
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
|
} else {
|
||||||
.header("Accept", "application/vnd.github+json")
|
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
|
||||||
.build()
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) return@use null
|
|
||||||
val body = response.body.string().orEmpty()
|
|
||||||
parseUpdateInfo(body)
|
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
|
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
||||||
* [onProgress]. True on success; false (with the partial file deleted)
|
* [onProgress]. True on success; false (with the partial file deleted)
|
||||||
@@ -167,6 +226,9 @@ open class AppUpdateChecker(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DEFAULT_BUFFER_SIZE = 8192
|
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 {
|
runCatching {
|
||||||
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
|
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
|
||||||
}.getOrNull()
|
}.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.BuildConfig
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
|
|||||||
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
|
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
|
||||||
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
|
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) {
|
scope.launch(ioDispatcher) {
|
||||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
checker.fetchLatestRelease()
|
checker.fetchLatestRelease(includeReleaseCandidates)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return@launch
|
return@launch
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val info = result ?: 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 apk = info.apkAsset ?: return@launch
|
||||||
val state =
|
val state =
|
||||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||||
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The tag the chat banner should advertise, or null when nothing newer is
|
* 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? {
|
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
||||||
if (!enabled) return null
|
if (!enabled) return null
|
||||||
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
|
|||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
if (candidateTag == dismissed) return null
|
if (candidateTag == dismissed) return null
|
||||||
|
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
|
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.AppUpdateChecker
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.data.update.isNewerVersion
|
import com.m57.hermescontrol.data.update.isNewerVersion
|
||||||
|
import com.m57.hermescontrol.data.update.isReleaseCandidateVersion
|
||||||
import com.m57.hermescontrol.data.update.releaseTag
|
import com.m57.hermescontrol.data.update.releaseTag
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -42,15 +45,35 @@ class AppUpdateViewModel(
|
|||||||
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
||||||
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
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 downloadJob: kotlinx.coroutines.Job? = null
|
||||||
private var lastAvailable: AppUpdateState.UpdateAvailable? = 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 {
|
init {
|
||||||
val cached = AppUpdateCache.state.value
|
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
|
lastAvailable = cached
|
||||||
_state.value = cached
|
_state.value = cached
|
||||||
} else if (cached is AppUpdateState.UpToDate) {
|
} else if (cached is AppUpdateState.UpToDate && !blocked) {
|
||||||
_state.value = cached
|
_state.value = cached
|
||||||
} else {
|
} else {
|
||||||
checkForUpdate()
|
checkForUpdate()
|
||||||
@@ -60,56 +83,132 @@ class AppUpdateViewModel(
|
|||||||
/** Manual check from the About row or dialog. */
|
/** Manual check from the About row or dialog. */
|
||||||
fun checkForUpdate() {
|
fun checkForUpdate() {
|
||||||
if (_state.value is AppUpdateState.Checking) return
|
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
|
_state.value = AppUpdateState.Checking
|
||||||
viewModelScope.launch(ioDispatcher) {
|
checkJob =
|
||||||
val now = System.currentTimeMillis()
|
viewModelScope.launch(ioDispatcher) {
|
||||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
val now = System.currentTimeMillis()
|
||||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
val result =
|
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||||
try {
|
val result =
|
||||||
checker.fetchLatestRelease()
|
try {
|
||||||
} catch (e: IOException) {
|
checker.fetchLatestRelease(includeReleaseCandidates)
|
||||||
_state.value = AppUpdateState.Error(NETWORK_ERROR)
|
} catch (e: CancellationException) {
|
||||||
return@launch
|
// Cancellation is control flow — it must never be reported
|
||||||
} catch (e: Exception) {
|
// as a failed check.
|
||||||
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
|
throw e
|
||||||
return@launch
|
} catch (e: IOException) {
|
||||||
}
|
if (isCurrentCheck(generation, includeReleaseCandidates)) {
|
||||||
val info =
|
_state.value = AppUpdateState.Error(NETWORK_ERROR)
|
||||||
result ?: run {
|
}
|
||||||
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
|
return@launch
|
||||||
return@launch
|
} catch (e: Exception) {
|
||||||
}
|
if (isCurrentCheck(generation, includeReleaseCandidates)) {
|
||||||
val apk =
|
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
|
||||||
info.apkAsset ?: run {
|
}
|
||||||
_state.value = AppUpdateState.Error(NO_APK_ERROR)
|
return@launch
|
||||||
return@launch
|
}
|
||||||
}
|
|
||||||
_state.value =
|
// The OkHttp call underneath blocks and cannot be interrupted, so a
|
||||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
// newer check — or a channel switch — may have happened while it ran.
|
||||||
AppUpdateState
|
ensureActive()
|
||||||
.UpdateAvailable(
|
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,
|
latestTag = info.tagName,
|
||||||
apkUrl = apk.browserDownloadUrl,
|
apkUrl = apk.browserDownloadUrl,
|
||||||
sizeBytes = apk.size,
|
sizeBytes = apk.size,
|
||||||
releaseNotes = info.body,
|
releaseNotes = info.body,
|
||||||
).also { lastAvailable = it }
|
)
|
||||||
} else {
|
} else {
|
||||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
}
|
}
|
||||||
// Keep the launch notice (issue #890) in sync with manual checks.
|
// Fail closed: a release-candidate result is never published once
|
||||||
AppUpdateCache.update(_state.value)
|
// the RC channel is off, however it reached this point.
|
||||||
_state.value.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
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 =
|
val available =
|
||||||
(_state.value as? AppUpdateState.UpdateAvailable)
|
(_state.value as? AppUpdateState.UpdateAvailable)
|
||||||
?: lastAvailable
|
?: lastAvailable
|
||||||
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
|
?: (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
|
lastAvailable = available
|
||||||
if (!canRequestInstalls()) {
|
if (!canRequestInstalls()) {
|
||||||
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
||||||
@@ -166,10 +265,7 @@ class AppUpdateViewModel(
|
|||||||
fun resumeInstallAfterPermission() {
|
fun resumeInstallAfterPermission() {
|
||||||
if (canRequestInstalls()) {
|
if (canRequestInstalls()) {
|
||||||
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
||||||
val available =
|
val available = updateAvailableForInstall()
|
||||||
(_state.value as? AppUpdateState.UpdateAvailable)
|
|
||||||
?: lastAvailable
|
|
||||||
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
|
|
||||||
if (dest.exists() && dest.length() > 0 && available != null) {
|
if (dest.exists() && dest.length() > 0 && available != null) {
|
||||||
_state.value = AppUpdateState.Installing(available.latestTag)
|
_state.value = AppUpdateState.Installing(available.latestTag)
|
||||||
launchInstaller(dest)
|
launchInstaller(dest)
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ internal fun SettingsAboutPage(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
|
|
||||||
@@ -294,8 +295,10 @@ internal fun SettingsAboutPage(
|
|||||||
) {
|
) {
|
||||||
AboutSection(
|
AboutSection(
|
||||||
updateState = updateState,
|
updateState = updateState,
|
||||||
|
checkReleaseCandidateUpdates = checkReleaseCandidateUpdates,
|
||||||
onCheckUpdate = viewModel::checkForUpdate,
|
onCheckUpdate = viewModel::checkForUpdate,
|
||||||
onStartUpdate = viewModel::startUpdate,
|
onStartUpdate = viewModel::startUpdate,
|
||||||
|
onCheckReleaseCandidateUpdatesChange = viewModel::setCheckReleaseCandidateUpdates,
|
||||||
onOpenInstallSettings = {
|
onOpenInstallSettings = {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(
|
Intent(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.settings.components
|
package com.m57.hermescontrol.ui.settings.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -12,6 +13,8 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -28,8 +31,10 @@ import com.m57.hermescontrol.ui.settings.SectionCard
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun AboutSection(
|
internal fun AboutSection(
|
||||||
updateState: AppUpdateState = AppUpdateState.Idle,
|
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||||
|
checkReleaseCandidateUpdates: Boolean = false,
|
||||||
onCheckUpdate: () -> Unit = {},
|
onCheckUpdate: () -> Unit = {},
|
||||||
onStartUpdate: () -> Unit = {},
|
onStartUpdate: () -> Unit = {},
|
||||||
|
onCheckReleaseCandidateUpdatesChange: (Boolean) -> Unit = {},
|
||||||
onOpenInstallSettings: () -> Unit = {},
|
onOpenInstallSettings: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
SectionCard {
|
SectionCard {
|
||||||
@@ -57,6 +62,15 @@ internal fun AboutSection(
|
|||||||
onStartUpdate = onStartUpdate,
|
onStartUpdate = onStartUpdate,
|
||||||
onOpenInstallSettings = onOpenInstallSettings,
|
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(
|
InfoRow(
|
||||||
label = stringResource(R.string.settings_about_build),
|
label = stringResource(R.string.settings_about_build),
|
||||||
value =
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,6 +244,8 @@
|
|||||||
<string name="settings_about_update_allow_sources">السماح بتثبيت التطبيقات من هذا المصدر</string>
|
<string name="settings_about_update_allow_sources">السماح بتثبيت التطبيقات من هذا المصدر</string>
|
||||||
<string name="settings_about_update_open_settings">فتح الإعدادات</string>
|
<string name="settings_about_update_open_settings">فتح الإعدادات</string>
|
||||||
<string name="settings_about_update_retry">إعادة المحاولة</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_profiles">تم حفظ %1$d</string>
|
||||||
<string name="settings_summary_typing_on">تأثير الكتابة مفعّل</string>
|
<string name="settings_summary_typing_on">تأثير الكتابة مفعّل</string>
|
||||||
<string name="settings_summary_typing_off">تأثير الكتابة معطّل</string>
|
<string name="settings_summary_typing_off">تأثير الكتابة معطّل</string>
|
||||||
|
|||||||
@@ -246,6 +246,8 @@
|
|||||||
<string name="settings_about_update_allow_sources">이 소스의 설치 허용</string>
|
<string name="settings_about_update_allow_sources">이 소스의 설치 허용</string>
|
||||||
<string name="settings_about_update_open_settings">설정 열기</string>
|
<string name="settings_about_update_open_settings">설정 열기</string>
|
||||||
<string name="settings_about_update_retry">다시 시도</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="action_save">저장</string>
|
||||||
<string name="settings_action_add_profile">프로필 추가</string>
|
<string name="settings_action_add_profile">프로필 추가</string>
|
||||||
<string name="settings_action_edit_profile">프로필 편집</string>
|
<string name="settings_action_edit_profile">프로필 편집</string>
|
||||||
|
|||||||
@@ -244,6 +244,8 @@
|
|||||||
<string name="settings_about_update_allow_sources">允许安装来自此来源的应用</string>
|
<string name="settings_about_update_allow_sources">允许安装来自此来源的应用</string>
|
||||||
<string name="settings_about_update_open_settings">打开设置</string>
|
<string name="settings_about_update_open_settings">打开设置</string>
|
||||||
<string name="settings_about_update_retry">重试</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_profiles">已保存 %1$d 个</string>
|
||||||
<string name="settings_summary_typing_on">打字效果已开启</string>
|
<string name="settings_summary_typing_on">打字效果已开启</string>
|
||||||
<string name="settings_summary_typing_off">打字效果已关闭</string>
|
<string name="settings_summary_typing_off">打字效果已关闭</string>
|
||||||
|
|||||||
@@ -265,6 +265,8 @@
|
|||||||
<string name="settings_about_update_allow_sources">Allow installs from this source</string>
|
<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_open_settings">Open settings</string>
|
||||||
<string name="settings_about_update_retry">Retry</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_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_message">If Hermes Mobile saves you time, you can support its development.</string>
|
||||||
<string name="settings_support_action">Support me on Ko-fi</string>
|
<string name="settings_support_action">Support me on Ko-fi</string>
|
||||||
|
|||||||
@@ -93,4 +93,18 @@ class ServerStoreTest {
|
|||||||
val decoded = json.decodeFromString<ServerStoreState>("{}")
|
val decoded = json.decodeFromString<ServerStoreState>("{}")
|
||||||
assertFalse(decoded.keepConnectedInBackground)
|
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("not json at all"))
|
||||||
assertNull(parseUpdateInfo(""))
|
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.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||||
every { AuthManager.getDismissedUpdateTag() } returns null
|
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||||
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||||
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||||
|
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -259,4 +261,85 @@ class UpdateNoticeManagerTest {
|
|||||||
|
|
||||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
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.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -75,6 +79,8 @@ class AppUpdateViewModelTest {
|
|||||||
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||||
every { AuthManager.getDismissedUpdateTag() } returns null
|
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||||
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||||
|
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||||
|
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
|
||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||||
@@ -348,4 +354,244 @@ class AppUpdateViewModelTest {
|
|||||||
assertTrue(AppUpdateCache.dismissed)
|
assertTrue(AppUpdateCache.dismissed)
|
||||||
assertFalse(AppUpdateCache.isDialogVisible)
|
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()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user