From a6fbb668990e7fa3cb04fa8c6502b3d82f1bd3ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 21:02:46 -0400 Subject: [PATCH] fix(appupdates): recheck the forced update when the minimum version changes The forced-update check on resume reads the minimum version cached from the last flag fetch. The fetch that runs on the same resume lands after the check has passed, so a minimum raised while the app was backgrounded only blocked the app on the next cold start. The controller now runs the check again whenever the minimum version changes. It skips the first value so building the controller does not check on its own. --- .../internal/GooglePlayAppUpdateController.kt | 17 ++++++++ .../app/updates/internal/ForcedUpdateTest.kt | 43 ++++++++++++++++--- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/GooglePlayAppUpdateController.kt b/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/GooglePlayAppUpdateController.kt index fb18738745..4df0455a53 100644 --- a/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/GooglePlayAppUpdateController.kt +++ b/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/GooglePlayAppUpdateController.kt @@ -27,6 +27,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -58,6 +61,20 @@ class GooglePlayAppUpdateController internal constructor( update?.takeIf { it.isToastEligible(lastShown) } }.stateIn(scope, SharingStarted.Eagerly, null) + init { + // The resume check reads the minimum version cached from the last fetch, and the fetch that + // runs on the same resume lands after it. Checking again when the minimum moves is what + // lets a minimum raised while the app sat in the background block it on return. The first + // value is left to the resume check. + scope.launch { + userFlags.resolvedFlags + .map { it.minimumVersion.effectiveValue } + .distinctUntilChanged() + .drop(1) + .collect { checkForUpdate() } + } + } + override val updateDownloaded: StateFlow get() = flexibleUpdates.downloaded diff --git a/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/ForcedUpdateTest.kt b/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/ForcedUpdateTest.kt index 232eb34654..7b7f73e0fd 100644 --- a/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/ForcedUpdateTest.kt +++ b/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/ForcedUpdateTest.kt @@ -45,18 +45,23 @@ class ForcedUpdateTest { private fun idleMain() = shadowOf(Looper.getMainLooper()).idle() - private fun flags( + private fun resolved( minimumVersion: Int?, override: FieldOverride = FieldOverride.None, isStaff: Boolean = false, - ) { - val resolved = mockk { - every { this@mockk.minimumVersion } returns ResolvedFlag(minimumVersion, override) - every { this@mockk.isStaff } returns ResolvedFlag(isStaff, FieldOverride.None) - } - every { userFlags.resolvedFlags } returns MutableStateFlow(resolved) + ): ResolvedUserFlags = mockk { + every { this@mockk.minimumVersion } returns ResolvedFlag(minimumVersion, override) + every { this@mockk.isStaff } returns ResolvedFlag(isStaff, FieldOverride.None) } + private fun flags( + minimumVersion: Int?, + override: FieldOverride = FieldOverride.None, + isStaff: Boolean = false, + ): MutableStateFlow = + MutableStateFlow(resolved(minimumVersion, override, isStaff)) + .also { every { userFlags.resolvedFlags } returns it } + private fun TestScope.controller() = GooglePlayAppUpdateController( context = activity, versionInfo = VersionInfo(versionCode = currentVersion), @@ -128,6 +133,30 @@ class ForcedUpdateTest { assertIs(start(controller).exceptionOrNull()) } + @Test + fun `a minimum raised after the check shows the gate without another resume`() = runTest { + fake.setUpdateAvailable(currentVersion + 1) + val flags = flags(minimumVersion = currentVersion) + val controller = controller() + controller.check() + assertNull(controller.availableUpdate.value) + + flags.value = resolved(minimumVersion = currentVersion + 1) + idleMain() + + assertEquals(UpdateAvailability.UPDATE_AVAILABLE, controller.availableUpdate.value?.updateAvailability) + } + + @Test + fun `building the controller checks nothing on its own`() = runTest { + fake.setUpdateAvailable(currentVersion + 1) + flags(minimumVersion = currentVersion + 1) + val controller = controller() + idleMain() + + assertNull(controller.availableUpdate.value) + } + @Test fun `starting the update opens Play's immediate flow`() = runTest { fake.setUpdateAvailable(currentVersion + 1)