diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index 0acb0bb1..f573f230 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 8616b737..2fa0580b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index b2865ac8..960d2710 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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) } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt new file mode 100644 index 00000000..baf1fc6b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt @@ -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.Idle) + val state: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt new file mode 100644 index 00000000..fa2f806d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt @@ -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 + } diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt new file mode 100644 index 00000000..51b53e08 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 2445f3ad..00eb34c0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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(), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/UpdateNoticeBanner.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/UpdateNoticeBanner.kt new file mode 100644 index 00000000..0f9b0524 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/UpdateNoticeBanner.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt index bb915a9f..614ce011 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index df85bc8a..e05eba67 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0cc9fde7..340ebfe2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -983,6 +983,10 @@ Done Close + + Hermes Mobile %1$s is available + Update + No running processes Background processes for this session will appear here. diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 7b5ac117..1c29fed8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -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/") diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt new file mode 100644 index 00000000..cd6b8338 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt @@ -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() + 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() + 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() + 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() + 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() + 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() + 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)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt index 05b855b4..8557a0e1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt @@ -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