From c0f4345ee19557acd85c752b4766194b2b8c1a5a Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 21 Oct 2026 20:56:46 +0200 Subject: [PATCH] Update check: S-22 "Check for update" always asks the server (D-140) UpdateRepository.checkNow() (default: check(), for throttle-free fakes) skips the 6-hour throttle of the automatic check (AC-16.2) and restarts its window; AboutViewModel calls it. Debug builds still never check (AC-16.5). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../kvitt/fakes/FakeSettingsRepositories.kt | 3 +- .../kotlin/se/hagstedt/kvitt/model/Sync.kt | 11 +++++- .../se/hagstedt/kvitt/data/update/Updater.kt | 27 ++++++++----- .../hagstedt/kvitt/data/update/UpdaterTest.kt | 39 +++++++++++++++++++ .../feature/settings/about/AboutViewModel.kt | 5 ++- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt index 8c8ae5a..45ddfbc 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt @@ -277,7 +277,8 @@ class FakeSyncRepository(private val store: FakeStore) : SyncRepository { /** * The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, - * AC-16.5). `check` reports the scenario's state: `Available` as found, anything else as up to date. + * AC-16.5). `check` (and `checkNow`, which has no throttle to skip here) reports the scenario's state: `Available` as + * found, anything else as up to date. */ class FakeUpdateRepository(private val store: FakeStore) : UpdateRepository { override val state: Flow = store.derive { it.update } diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt index e4ec246..5dbe7e6 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt @@ -38,7 +38,7 @@ sealed interface UpdateState { data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState } -/** The outcome of [UpdateRepository.check] (S-22, AC-16.6). */ +/** The outcome of [UpdateRepository.check] and [UpdateRepository.checkNow] (S-22, AC-16.6). */ sealed interface UpdateCheck { /** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */ data object UpToDate : UpdateCheck @@ -51,7 +51,16 @@ sealed interface UpdateCheck { interface UpdateRepository { val state: Flow + + /** The automatic check (cold start, resume): at most one request every 6 hours (AC-16.2). */ suspend fun check(): UpdateCheck + + /** + * S-22 "Check for update" (AC-16.6, D-140): always asks the server and restarts the 6-hour window. An + * implementation without a throttle (the fakes) needs nothing more than [check]. + */ + suspend fun checkNow(): UpdateCheck = check() + suspend fun install() } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt index 24a0df0..d057e33 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt @@ -58,7 +58,7 @@ object ApkVerifier { /** * FR-16. Release builds check `GET /apk/latest` on cold start and at most every 6 hours while resumed (`:app` calls - * [check] on both); a newer `version_code` is [UpdateState.Available]. [install] explains "Install unknown apps" once + * [check] on both); S-22's button calls [checkNow], which always asks (D-140). A newer `version_code` is [UpdateState.Available]. [install] explains "Install unknown apps" once * before the first install, downloads to the cache, verifies size and SHA-256, deletes the file on any failure * ("Download failed", AC-16.4) and installs through [ApkInstaller]. Debug builds never check (AC-16.5). */ @@ -76,16 +76,25 @@ class UpdateRepositoryImpl( private var latest: ApkLatestDto? = null private var lastCheckAt: Long? = null - /** - * Within 6 hours of the last check that reached the server the stored answer is reported without a request. A 404 - * (nothing published) is "up to date"; another error or no answer is [UpdateCheck.Failed] and leaves [state] - * unchanged (no answer also lets the next call try again at once). - */ + /** Within 6 hours of the last check that reached the server the stored answer is reported without a request. */ override suspend fun check(): UpdateCheck { if (debugBuild) return UpdateCheck.UpToDate - val now = clock.nowMillis() - lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return known() } - lastCheckAt = now + lastCheckAt?.let { if (clock.nowMillis() - it < CHECK_INTERVAL_MILLIS) return known() } + return fetch() + } + + /** S-22 (AC-16.6, D-140): asks whatever the throttle says and restarts the 6-hour window. */ + override suspend fun checkNow(): UpdateCheck { + if (debugBuild) return UpdateCheck.UpToDate + return fetch() + } + + /** + * One `GET /apk/latest`. A 404 (nothing published) is "up to date"; another error or no answer is + * [UpdateCheck.Failed] and leaves [state] unchanged (no answer also lets the next call try again at once). + */ + private suspend fun fetch(): UpdateCheck { + lastCheckAt = clock.nowMillis() return when (val result = api.apkLatest()) { is ApiResult.Ok -> { val dto = result.value diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt index b9bd3ed..aab033d 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt @@ -109,10 +109,49 @@ class UpdaterTest { assertEquals(2, h.requests.count { it.url.encodedPath == "/apk/latest" }) } + @Test + fun manualCheckInsideTheWindow_asksTheServer() = runSync { + serve(latest(Harness.APP_VERSION)) + val repo = repo() + assertEquals(UpdateCheck.UpToDate, repo.check()) + h.clock.advance(60_000) + repo.checkNow() + assertEquals(2, h.requests.count { it.url.encodedPath == "/apk/latest" }) + } + + @Test + fun automaticCheckInsideTheWindow_doesNotAsk() = runSync { + serve(latest(Harness.APP_VERSION)) + val repo = repo() + repo.checkNow() + h.clock.advance(UpdateRepositoryImpl.CHECK_INTERVAL_MILLIS - 1) + repo.check() + assertEquals("the manual check restarted the window", 1, h.requests.count { it.url.encodedPath == "/apk/latest" }) + h.clock.advance(1) + repo.check() + assertEquals(2, h.requests.count { it.url.encodedPath == "/apk/latest" }) + } + + @Test + fun manualCheckFindsNewerVersion_insideTheWindow() = runSync { + serve(latest(Harness.APP_VERSION)) + val repo = repo() + assertEquals(UpdateCheck.UpToDate, repo.check()) + assertEquals(UpdateState.None, repo.state.first()) + serve(latest(Harness.APP_VERSION + 1)) + h.clock.advance(60_000) + assertEquals("the automatic check keeps the stored answer", UpdateCheck.UpToDate, repo.check()) + val info = UpdateInfo(Harness.APP_VERSION + 1, "2026.11.02-1a2b3c4", apk.size.toLong()) + assertEquals(UpdateCheck.Available(info), repo.checkNow()) + assertEquals(UpdateState.Available(info), repo.state.first()) + assertEquals(UpdateCheck.Available(info), repo.check()) + } + @Test fun debugBuildsNeverCheck() = runSync { serve(latest(Harness.APP_VERSION + 1)) repo(debug = true).check() + assertEquals(UpdateCheck.UpToDate, repo(debug = true).checkNow()) assertTrue(h.requests.isEmpty()) } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt index a8ecb33..fda9424 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt @@ -38,7 +38,8 @@ class AboutViewModel @Inject constructor( /** * "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner (Available), "Du har den - * senaste versionen" (UpToDate, AC-16.6) or "Kunde inte nå servern" (Failed). + * senaste versionen" (UpToDate, AC-16.6) or "Kunde inte nå servern" (Failed). Always asks the server, whatever the + * 6-hour throttle of the automatic check says (D-140). */ fun check() { val s = state.value ?: return @@ -49,7 +50,7 @@ class AboutViewModel @Inject constructor( } local.update { it.copy(checking = true, bannerHidden = false) } viewModelScope.launch { - val message = when (update.check()) { + val message = when (update.checkNow()) { UpdateCheck.UpToDate -> UiMessage(R.string.settings_about_up_to_date) UpdateCheck.Failed -> UiMessage(DesignR.string.common_could_not_reach_server) is UpdateCheck.Available -> null