feat(update): notify about app updates on launch (chat banner) (#891)

Fires one silent update check per installed version at app start
(UpdateNoticeManager, release builds only) and shows a non-blocking
banner on the chat screen when a newer release exists. The About tab
adopts the launch result instead of re-pinging GitHub; dismissals
return on the next launch via the persisted latest tag.

Closes #890

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-13 01:08:42 +03:00
1 parent ff8370bb8d
commit daf33dcb4c
14 files changed
+535 -31

No files matched your search

@@ -9,6 +9,7 @@ import coil.decode.ImageDecoderDecoder
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
class HermesControlApp :
@@ -25,6 +26,9 @@ class HermesControlApp :
// so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this)
// Issue #890: silent once-per-version update check right after launch,
// so the chat screen can show an update banner without user interaction.
UpdateNoticeManager.checkOnLaunch()
}
override fun newImageLoader(): ImageLoader =
@@ -28,4 +28,8 @@ data class ServerStoreState(
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
// its one-time check again (re-check on app version bump).
val updateCheckDoneForVersion: String? = null,
// Latest release tag the silent check (issue #890) last saw. Persisted so
// a dismissed chat banner can return on a later launch without re-pinging
// GitHub (the once-per-version guard skips the check by then).
val lastKnownLatestTag: String? = null,
)
@@ -648,4 +648,11 @@ object AuthManager {
fun setUpdateCheckDoneForVersion(version: String) {
serverStore.update { it.copy(updateCheckDoneForVersion = version) }
}
/** Latest release tag the launch check (issue #890) persisted. */
fun getLastKnownLatestTag(): String? = serverStore.getLatestState().lastKnownLatestTag
fun setLastKnownLatestTag(tag: String) {
serverStore.update { it.copy(lastKnownLatestTag = tag) }
}
}
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.update
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide holder of the latest update-check result (issue #890). The
* launch check ([UpdateNoticeManager]) writes here so the chat banner can
* appear without a second network call, and the About tab adopts the same
* result so both surfaces agree on what the last check found.
*/
object AppUpdateCache {
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
/**
* Session-only dismissal of the chat banner. Snapshot-backed so the chat
* screen recomposes when it flips; dies with the process, so a dismissed
* banner returns on the next launch (the persisted latest tag drives it).
*/
var dismissed by mutableStateOf(false)
private set
fun update(state: AppUpdateState) {
_state.value = state
}
fun dismiss() {
dismissed = true
}
/** Test hook: clear state and dismissal between tests. */
internal fun reset() {
dismissed = false
_state.value = AppUpdateState.Idle
}
}
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.data.update
/**
* State of the in-app updater (issue #867) — shared by the About tab's
* [com.m57.hermescontrol.ui.settings.AppUpdateViewModel] and the launch
* notice (issue #890) via [AppUpdateCache].
*/
sealed interface AppUpdateState {
/** Nothing shown yet (no check run in this session). */
data object Idle : AppUpdateState
/** A check is in flight. */
data object Checking : AppUpdateState
/** Latest release equals the installed version. */
data class UpToDate(val latestTag: String) : AppUpdateState
/** A newer release with an APK asset exists. */
data class UpdateAvailable(
val latestTag: String,
val apkUrl: String,
val sizeBytes: Long,
) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */
data class Downloading(val progress: Float) : AppUpdateState
/** Download finished and the system package installer was launched. */
data class Installing(val latestTag: String) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */
data object NeedsUnknownSourcesPermission : AppUpdateState
data class Error(val message: String) : AppUpdateState
}
/** The release tag a state carries, when it carries one. */
fun AppUpdateState.releaseTag(): String? =
when (this) {
is AppUpdateState.UpToDate -> latestTag
is AppUpdateState.UpdateAvailable -> latestTag
is AppUpdateState.Installing -> latestTag
else -> null
}
@@ -0,0 +1,82 @@
package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.IOException
/**
* Startup update check (issue #890): one silent GitHub ping per installed
* version, fired right after launch, so the chat screen can show an update
* banner without the user ever opening the About tab.
*
* The result lands in [AppUpdateCache] (consumed by the chat banner and
* adopted by the About tab) and the latest tag is persisted via
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
* later launch. Failures leave the cache Idle — the About tab still has its
* own manual retry path.
*/
object UpdateNoticeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* Release-only feature (issue #890): the launch check and chat banner are
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
* hit the GitHub API or nag about updates. Tests flip this to true.
*/
var enabled: Boolean = !BuildConfig.DEBUG
internal set
/** Run from Application.onCreate, after AuthManager.init. No-op once per version. */
fun checkOnLaunch(
checker: AppUpdateChecker = AppUpdateChecker(),
currentVersion: String = BuildConfig.VERSION_NAME,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
if (!enabled) return
if (AuthManager.getUpdateCheckDoneForVersion() == currentVersion) return
scope.launch(ioDispatcher) {
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
val result =
try {
checker.fetchLatestRelease()
} catch (e: IOException) {
return@launch
} catch (e: Exception) {
return@launch
}
val info = result ?: return@launch
val apk = info.apkAsset ?: return@launch
val state =
if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable(
latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size,
)
} else {
AppUpdateState.UpToDate(latestTag = info.tagName)
}
AppUpdateCache.update(state)
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
}
}
/**
* The tag the chat banner should advertise, or null when nothing newer is
* known. Prefers the in-process check result; falls back to the persisted
* latest tag (issue #890) so a banner dismissed on a previous launch — or
* a launch whose check was skipped by the once-per-version guard — still
* comes back.
*/
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
if (!enabled) return null
(AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.let { return it.latestTag }
val persisted = AuthManager.getLastKnownLatestTag() ?: return null
return persisted.takeIf { isNewerVersion(it, currentVersion) }
}
}
@@ -77,8 +77,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.SettingsAbout
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -102,6 +106,7 @@ import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.UpdateNoticeBanner
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -518,6 +523,25 @@ fun ChatScreen(
)
}
// Issue #890: launch update check — non-blocking banner when a
// newer release exists. "Update" jumps to the About-tab install
// flow; "Later" dismisses for the session (it returns next launch
// via the persisted latest tag). Release-only builds
// (UpdateNoticeManager.enabled = !BuildConfig.DEBUG).
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag =
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
?: UpdateNoticeManager.noticeTag()
if (noticeTag != null) {
UpdateNoticeBanner(
latestTag = noticeTag,
onUpdate = { NavigationController.navigateTo(SettingsAbout) },
onDismiss = { AppUpdateCache.dismiss() },
)
}
}
AnimatedVisibility(
visible = searchState.isActive,
enter = expandVertically() + fadeIn(),
@@ -0,0 +1,64 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
/**
* Chat-screen banner for the launch update check (issue #890): a newer
* Hermes Mobile release exists. "Update" jumps into the About-tab install
* flow; "Later" dismisses for the session (the banner returns next launch).
*/
@Composable
fun UpdateNoticeBanner(
latestTag: String,
onUpdate: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Update,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 2.dp),
)
Text(
text = stringResource(R.string.update_notice_banner_text, latestTag),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onUpdate) {
Text(
text = stringResource(R.string.update_notice_banner_action),
style = MaterialTheme.typography.labelSmall,
)
}
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.action_dismiss),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -8,8 +8,11 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
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.releaseTag
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,36 +22,6 @@ import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
/** UI state of the About-tab in-app updater (issue #867). */
sealed interface AppUpdateState {
/** Nothing shown yet (no check run in this session). */
data object Idle : AppUpdateState
/** A check is in flight. */
data object Checking : AppUpdateState
/** Latest release equals the installed version. */
data class UpToDate(val latestTag: String) : AppUpdateState
/** A newer release with an APK asset exists. */
data class UpdateAvailable(
val latestTag: String,
val apkUrl: String,
val sizeBytes: Long,
) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */
data class Downloading(val progress: Float) : AppUpdateState
/** Download finished and the system package installer was launched. */
data class Installing(val latestTag: String) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */
data object NeedsUnknownSourcesPermission : AppUpdateState
data class Error(val message: String) : AppUpdateState
}
/**
* Drives the in-app self-update flow (issue #867): check the GitHub
* releases API, download the release APK, and hand it to the system package
@@ -76,6 +49,14 @@ class AppUpdateViewModel(
// on every visit.
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
checkForUpdate()
} else {
// Issue #890: the launch check (UpdateNoticeManager) already ran
// for this version — adopt its result instead of pinging GitHub
// again, so the About tab agrees with the chat banner.
val cached = AppUpdateCache.state.value
if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) {
_state.value = cached
}
}
}
@@ -115,6 +96,9 @@ class AppUpdateViewModel(
} 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) }
}
}
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.AppUpdateState
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.ui.settings.InfoRow
import com.m57.hermescontrol.ui.settings.SectionCard
+4
View File
@@ -983,6 +983,10 @@
<string name="action_done">Done</string>
<string name="action_close">Close</string>
<!-- Update notice banner (issue #890) -->
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string>
<string name="update_notice_banner_action">Update</string>
<!-- Processes Screen (issue #532) -->
<string name="processes_empty_title">No running processes</string>
<string name="processes_empty_desc">Background processes for this session will appear here.</string>
@@ -134,6 +134,14 @@ class AuthManagerTest {
verify { mockEditor.apply() }
}
@Test
fun testGetAndSetLastKnownLatestTag() {
AuthManager.ensureDefaultProfile()
assertNull(AuthManager.getLastKnownLatestTag())
AuthManager.setLastKnownLatestTag("v1.22.0")
assertEquals("v1.22.0", AuthManager.getLastKnownLatestTag())
}
@Test
fun testGetHostAndPortFromBaseUrl() {
AuthManager.setBaseUrl("https://10.0.0.1:9090/")
@@ -0,0 +1,210 @@
package com.m57.hermescontrol.data.update
import com.m57.hermescontrol.data.local.AuthManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.IOException
/**
* Launch update check (issue #890): the once-per-version guard, the
* cache write, and the persisted latest tag.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class UpdateNoticeManagerTest {
private val testDispatcher = StandardTestDispatcher()
private val currentVersion = "1.21.0"
private fun updateInfo(tag: String = "v1.22.0"): UpdateInfo =
UpdateInfo(
tagName = tag,
assets =
listOf(
UpdateInfo.Asset(
name = "hermes-mobile-v1.22.0.apk",
size = 12345678L,
browserDownloadUrl = "https://example.com/hermes-mobile-v1.22.0.apk",
),
),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
AppUpdateCache.reset()
// Unit tests run against the debug variant where the feature is
// disabled by default — flip the knob on so the behavior is testable.
UpdateNoticeManager.enabled = true
mockkObject(AuthManager)
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
every { AuthManager.getLastKnownLatestTag() } returns null
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
}
@After
fun tearDown() {
UpdateNoticeManager.enabled = false
unmockkAll()
Dispatchers.resetMain()
}
@Test
fun checkOnLaunch_runsOnceWhenNeverChecked() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns updateInfo()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
assertEquals(
"v1.22.0",
(AppUpdateCache.state.value as AppUpdateState.UpdateAvailable).latestTag,
)
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
verify(exactly = 1) { AuthManager.setLastKnownLatestTag("v1.22.0") }
}
@Test
fun checkOnLaunch_skipsWhenAlreadyCheckedForThisVersion() =
runTest {
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns updateInfo()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
@Test
fun checkOnLaunch_upToDateWhenSameVersion() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.21.0")
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.UpToDate("v1.21.0"), AppUpdateCache.state.value)
verify(exactly = 1) { AuthManager.setLastKnownLatestTag("v1.21.0") }
}
@Test
fun checkOnLaunch_networkFailure_leavesCacheIdle() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
// Still marked done so a dead network can't spam the API per launch.
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
}
@Test
fun checkOnLaunch_noRelease_leavesCacheIdle() =
runTest {
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns null
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
assertFalse(AppUpdateCache.dismissed)
}
// ── noticeTag (dismissed/restart banner return) ─────────────────────
@Test
fun noticeTag_prefersFreshCacheResult() {
AppUpdateCache.update(
AppUpdateState.UpdateAvailable(
latestTag = "v1.22.0",
apkUrl = "https://example.com/apk",
sizeBytes = 1L,
),
)
every { AuthManager.getLastKnownLatestTag() } returns "v0.9.0"
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun noticeTag_nullWhenPersistedTagNotNewer() {
every { AuthManager.getLastKnownLatestTag() } returns "v1.21.0"
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
@Test
fun noticeTag_nullWhenNothingKnown() {
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
// ── debug builds: feature disabled by default (BuildConfig.DEBUG) ───
@Test
fun checkOnLaunch_disabled_makesNoNetworkCallAndLeavesCacheIdle() =
runTest {
UpdateNoticeManager.enabled = false
val checker = mockk<AppUpdateChecker>()
coEvery { checker.fetchLatestRelease() } returns updateInfo()
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
}
@Test
fun noticeTag_disabled_returnsNullEvenWhenNewerTagPersisted() {
UpdateNoticeManager.enabled = false
AppUpdateCache.update(
AppUpdateState.UpdateAvailable(
latestTag = "v1.22.0",
apkUrl = "https://example.com/apk",
sizeBytes = 1L,
),
)
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
}
}
@@ -6,7 +6,9 @@ import android.content.pm.PackageManager
import android.net.Uri
import androidx.core.content.FileProvider
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.update.AppUpdateCache
import com.m57.hermescontrol.data.update.AppUpdateChecker
import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateInfo
import io.mockk.coEvery
import io.mockk.coVerify
@@ -63,9 +65,11 @@ class AppUpdateViewModelTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
AppUpdateCache.reset()
mockkObject(AuthManager)
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
app = mockk(relaxed = true)
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
@@ -126,6 +130,30 @@ class AppUpdateViewModelTest {
coVerify(exactly = 1) { checker.fetchLatestRelease() }
}
@Test
fun init_adoptsLaunchCheckResultWithoutNetwork() =
runTest {
// Issue #890: the launch check already ran for this version and
// found an update — the About tab must adopt it, not ping GitHub.
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
AppUpdateCache.update(
AppUpdateState.UpdateAvailable(
latestTag = "v1.22.0",
apkUrl = "https://example.com/hermes-mobile-v1.22.0.apk",
sizeBytes = 12345678L,
),
)
val vm = createViewModel()
advanceUntilIdle()
coVerify(exactly = 0) { checker.fetchLatestRelease() }
assertEquals(
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/hermes-mobile-v1.22.0.apk", 12345678L),
vm.state.value,
)
}
// ── Manual check ────────────────────────────────────────────────────
@Test