0.2.51 - update checker fixes

This commit is contained in:
ponzischeme89
2026-08-11 17:26:48 +12:00
parent 226136b4e7
commit 0e183cf560
3 changed files with 85 additions and 6 deletions
@@ -165,6 +165,7 @@ import com.ponzischeme89.memby.ui.theme.MembyTheme
import com.ponzischeme89.memby.ui.setup.SignInContent
import com.ponzischeme89.memby.update.InstallPermission
import com.ponzischeme89.memby.update.RequiredUpdateSignal
import com.ponzischeme89.memby.update.nextUpdateCheckDelay
import com.ponzischeme89.memby.update.requiredUpdateSatisfied
import com.ponzischeme89.memby.update.ServerUpdateService
import androidx.tv.material3.Button
@@ -247,7 +248,10 @@ class MainActivity : ComponentActivity() {
*/
private const val ONBOARDING_CHECK_TIMEOUT_MS = 2_500L
private const val UPDATE_CHECK_TIMEOUT_MS = 2_500L
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
// Update policy is live operator state, just like maintenance. The verdict is an in-memory
// gateway read, so keeping this close to the maintenance poll means a set that was already
// open sees a newly-required release promptly instead of as much as an hour later.
private const val UPDATE_CHECK_INTERVAL_MS = 30_000L
/**
* How long to wait before asking again while the gateway has this build retired.
@@ -347,6 +351,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// How many checks in a row have been made because this build is retired. Loop
// state rather than app state: nothing outside this effect decides when to ask.
var requiredAttempts = 0
// A launch timeout used to be treated like a successful "nothing to say" verdict,
// which put the next attempt an hour away. A television's first DNS/TLS connection
// is often the slowest one it makes, so failed or timed-out checks retry on the same
// bounded schedule as a retired build until the gateway gives a real answer.
var failedAttempts = 0
while (true) {
// What the gateway has already said about this build, read from disk rather
// than from this loop's memory — the refusal is commonly one process old.
@@ -379,6 +388,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
initialUpdateCheckComplete = true
firstCheck = false
}
failedAttempts = if (result == null || result.isFailure) {
failedAttempts + 1
} else {
0
}
requiredAttempts = if (retired != null && appUpdate == null) {
requiredAttempts + 1
} else {
@@ -388,11 +402,14 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// now. While this build is retired and no verdict has arrived, that schedule is
// seconds rather than an hour — the alternative is a television sitting on a
// screen it cannot leave, waiting on an answer nobody has asked for.
val wait = when {
requiredAttempts == 0 -> UPDATE_CHECK_INTERVAL_MS
requiredAttempts <= UPDATE_REQUIRED_FAST_ATTEMPTS -> UPDATE_REQUIRED_RETRY_MS
else -> UPDATE_REQUIRED_BACKOFF_MS
}
val wait = nextUpdateCheckDelay(
requiredAttempts = requiredAttempts,
failedAttempts = failedAttempts,
fastAttempts = UPDATE_REQUIRED_FAST_ATTEMPTS,
fastRetryMillis = UPDATE_REQUIRED_RETRY_MS,
backoffMillis = UPDATE_REQUIRED_BACKOFF_MS,
regularPollMillis = UPDATE_CHECK_INTERVAL_MS,
)
withTimeoutOrNull(wait) { updateWake.receive() }
}
}
@@ -510,6 +527,10 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// it a moment later, twice, during the one stretch of a launch that is busiest.
// Hoisted to one call site, the screen and its clip survive the whole cold start.
val openingQuoteStyle = when {
// A required update is a service gate, not launcher content. Once the gateway
// answers, uncover it immediately instead of making the viewer finish the
// decorative opening clip first. Optional prompts may wait for the intro.
appUpdate?.isMandatory == true -> null
// Memby's own clip runs to its end before anything else is drawn. It is the one
// thing the app owns and it was, in practice, never seen: the launcher uncovered
// it whenever it happened to be ready, which on a warm start was a fraction of a
@@ -0,0 +1,23 @@
package com.ponzischeme89.memby.update
/**
* Chooses when the app should next ask for live update policy.
*
* A timeout and a positive "no update" response are deliberately different states. The
* latter may wait for the regular policy poll; the former has learned nothing and retries
* quickly before settling onto a bounded backoff.
*/
internal fun nextUpdateCheckDelay(
requiredAttempts: Int,
failedAttempts: Int,
fastAttempts: Int,
fastRetryMillis: Long,
backoffMillis: Long,
regularPollMillis: Long,
): Long = when {
requiredAttempts in 1..fastAttempts -> fastRetryMillis
requiredAttempts > fastAttempts -> backoffMillis
failedAttempts in 1..fastAttempts -> fastRetryMillis
failedAttempts > fastAttempts -> backoffMillis
else -> regularPollMillis
}
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.update
import org.junit.Assert.assertEquals
import org.junit.Test
class UpdateCheckScheduleTest {
@Test
fun `a real verdict uses the regular live policy poll`() {
assertEquals(30_000L, delay(requiredAttempts = 0, failedAttempts = 0))
}
@Test
fun `a timed out launch check retries promptly`() {
assertEquals(5_000L, delay(requiredAttempts = 0, failedAttempts = 1))
}
@Test
fun `repeated failures settle onto bounded backoff`() {
assertEquals(60_000L, delay(requiredAttempts = 0, failedAttempts = 5))
}
@Test
fun `a retired build keeps the urgent schedule even after a successful empty check`() {
assertEquals(5_000L, delay(requiredAttempts = 1, failedAttempts = 0))
}
private fun delay(requiredAttempts: Int, failedAttempts: Int) = nextUpdateCheckDelay(
requiredAttempts = requiredAttempts,
failedAttempts = failedAttempts,
fastAttempts = 4,
fastRetryMillis = 5_000L,
backoffMillis = 60_000L,
regularPollMillis = 30_000L,
)
}