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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:56:46 +02:00
parent 50a0d6792a
commit c0f4345ee1
5 changed files with 72 additions and 13 deletions
@@ -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
@@ -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())
}