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:
+2
-1
@@ -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,
|
* 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 {
|
class FakeUpdateRepository(private val store: FakeStore) : UpdateRepository {
|
||||||
override val state: Flow<UpdateState> = store.derive { it.update }
|
override val state: Flow<UpdateState> = store.derive { it.update }
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ sealed interface UpdateState {
|
|||||||
data class NeedsInstallPermission(val info: UpdateInfo) : 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 {
|
sealed interface UpdateCheck {
|
||||||
/** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */
|
/** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */
|
||||||
data object UpToDate : UpdateCheck
|
data object UpToDate : UpdateCheck
|
||||||
@@ -51,7 +51,16 @@ sealed interface UpdateCheck {
|
|||||||
|
|
||||||
interface UpdateRepository {
|
interface UpdateRepository {
|
||||||
val state: Flow<UpdateState>
|
val state: Flow<UpdateState>
|
||||||
|
|
||||||
|
/** The automatic check (cold start, resume): at most one request every 6 hours (AC-16.2). */
|
||||||
suspend fun check(): UpdateCheck
|
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()
|
suspend fun install()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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).
|
* ("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 latest: ApkLatestDto? = null
|
||||||
private var lastCheckAt: Long? = 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. */
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
override suspend fun check(): UpdateCheck {
|
override suspend fun check(): UpdateCheck {
|
||||||
if (debugBuild) return UpdateCheck.UpToDate
|
if (debugBuild) return UpdateCheck.UpToDate
|
||||||
val now = clock.nowMillis()
|
lastCheckAt?.let { if (clock.nowMillis() - it < CHECK_INTERVAL_MILLIS) return known() }
|
||||||
lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return known() }
|
return fetch()
|
||||||
lastCheckAt = now
|
}
|
||||||
|
|
||||||
|
/** 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()) {
|
return when (val result = api.apkLatest()) {
|
||||||
is ApiResult.Ok -> {
|
is ApiResult.Ok -> {
|
||||||
val dto = result.value
|
val dto = result.value
|
||||||
|
|||||||
@@ -109,10 +109,49 @@ class UpdaterTest {
|
|||||||
assertEquals(2, h.requests.count { it.url.encodedPath == "/apk/latest" })
|
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
|
@Test
|
||||||
fun debugBuildsNeverCheck() = runSync {
|
fun debugBuildsNeverCheck() = runSync {
|
||||||
serve(latest(Harness.APP_VERSION + 1))
|
serve(latest(Harness.APP_VERSION + 1))
|
||||||
repo(debug = true).check()
|
repo(debug = true).check()
|
||||||
|
assertEquals(UpdateCheck.UpToDate, repo(debug = true).checkNow())
|
||||||
assertTrue(h.requests.isEmpty())
|
assertTrue(h.requests.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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
|
* "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() {
|
fun check() {
|
||||||
val s = state.value ?: return
|
val s = state.value ?: return
|
||||||
@@ -49,7 +50,7 @@ class AboutViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
local.update { it.copy(checking = true, bannerHidden = false) }
|
local.update { it.copy(checking = true, bannerHidden = false) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val message = when (update.check()) {
|
val message = when (update.checkNow()) {
|
||||||
UpdateCheck.UpToDate -> UiMessage(R.string.settings_about_up_to_date)
|
UpdateCheck.UpToDate -> UiMessage(R.string.settings_about_up_to_date)
|
||||||
UpdateCheck.Failed -> UiMessage(DesignR.string.common_could_not_reach_server)
|
UpdateCheck.Failed -> UiMessage(DesignR.string.common_could_not_reach_server)
|
||||||
is UpdateCheck.Available -> null
|
is UpdateCheck.Available -> null
|
||||||
|
|||||||
Reference in New Issue
Block a user