diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 7d5db4c..fbb3160 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateCheckSchedule.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateCheckSchedule.kt new file mode 100644 index 0000000..230ea47 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateCheckSchedule.kt @@ -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 +} diff --git a/app/src/test/java/com/ponzischeme89/memby/update/UpdateCheckScheduleTest.kt b/app/src/test/java/com/ponzischeme89/memby/update/UpdateCheckScheduleTest.kt new file mode 100644 index 0000000..bd50fe2 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/update/UpdateCheckScheduleTest.kt @@ -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, + ) +}