From 34a84b37091301a32acd2507d4189d047714dc74 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 7 Nov 2026 21:53:36 -0500 Subject: [PATCH] fix(appupdates): stop reporting device-side Play failures from the optional update check FlexibleUpdates.check() passed every appUpdateInfo failure to trace(error = ...), which sends it to Bugsnag. Since #1771 that has filed InstallException -6 (download not allowed in the current device state) and RemoteException "AppUpdateService: Binder has died" (the Play Store process died mid-call) from 2026.10.4. Neither is something the app can fix. Those failures, plus the other InstallErrorCodes that describe the device or install (API not available, Play Store not found, app not owned), now leave a breadcrumb only. Other codes, such as -4 for a malformed request, are still reported. The check also rethrows CancellationException instead of logging a cancelled check as a failure. --- .../app/updates/internal/FlexibleUpdates.kt | 41 +++++++++++- .../updates/internal/FlexibleUpdatesTest.kt | 62 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/FlexibleUpdates.kt b/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/FlexibleUpdates.kt index 9536becfaa..17fa642d32 100644 --- a/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/FlexibleUpdates.kt +++ b/apps/flipcash/shared/appupdates/src/main/kotlin/com/flipcash/app/updates/internal/FlexibleUpdates.kt @@ -1,16 +1,20 @@ package com.flipcash.app.updates.internal import android.app.Activity +import android.os.RemoteException import com.flipcash.app.updates.OptionalUpdate import com.getcode.utils.trace import com.google.android.play.core.appupdate.AppUpdateInfo import com.google.android.play.core.appupdate.AppUpdateManager import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallException import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.InstallErrorCode import com.google.android.play.core.install.model.AppUpdateType import com.google.android.play.core.install.model.InstallStatus import com.google.android.play.core.install.model.UpdateAvailability import com.google.android.gms.tasks.Task +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -56,9 +60,14 @@ internal class FlexibleUpdates( return } - val info = runCatching { manager.appUpdateInfo.await() } - .onFailure { trace(tag = TAG, message = "Failed to check for an optional update", error = it) } - .getOrNull() + val info = try { + manager.appUpdateInfo.await() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + traceCheckFailure(e) + null + } this.info = info if (info == null) { _update.value = null @@ -154,7 +163,33 @@ internal class FlexibleUpdates( } } + /** + * Play refusing the check because of the device (its state, a missing or outdated Store, a + * sideloaded install) or the Store process dying mid-call is not something the app can fix, + * so it stays a breadcrumb. Anything else, like a malformed request, still goes to Bugsnag. + */ + private fun traceCheckFailure(error: Exception) { + val fromDevice = error is RemoteException || + (error is InstallException && error.errorCode in deviceInstallErrors) + if (fromDevice) { + trace( + tag = TAG, + message = "Optional update check unavailable on this device", + metadata = { "error" to (error.message ?: error::class.simpleName) }, + ) + } else { + trace(tag = TAG, message = "Failed to check for an optional update", error = error) + } + } + private companion object { const val TAG = "App Updates" + + val deviceInstallErrors = setOf( + InstallErrorCode.ERROR_API_NOT_AVAILABLE, + InstallErrorCode.ERROR_INSTALL_NOT_ALLOWED, + InstallErrorCode.ERROR_PLAY_STORE_NOT_FOUND, + InstallErrorCode.ERROR_APP_NOT_OWNED, + ) } } diff --git a/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/FlexibleUpdatesTest.kt b/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/FlexibleUpdatesTest.kt index 89a2123210..558a7b5b49 100644 --- a/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/FlexibleUpdatesTest.kt +++ b/apps/flipcash/shared/appupdates/src/test/kotlin/com/flipcash/app/updates/internal/FlexibleUpdatesTest.kt @@ -8,6 +8,15 @@ import com.flipcash.app.updates.OptionalUpdate import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager +import android.os.RemoteException +import com.getcode.utils.ErrorReporter +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceManager +import com.google.android.gms.tasks.Tasks +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.install.InstallException +import com.google.android.play.core.install.model.InstallErrorCode +import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.verify @@ -41,6 +50,59 @@ class FlexibleUpdatesTest { private fun idleMain() = shadowOf(Looper.getMainLooper()).idle() + /** Runs [block] with a reporter attached and returns what reached Bugsnag. */ + private suspend fun reportedDuring(block: suspend () -> Unit): List { + val reported = mutableListOf() + val reporter = object : ErrorReporter { + override fun report(error: Throwable, cause: Throwable, isNotifiable: Boolean) { reported += error } + } + TraceManager.initialize() + ErrorUtils.addReporter(reporter) + try { + block() + } finally { + ErrorUtils.removeReporter(reporter) + } + return reported + } + + private fun failCheckWith(error: Exception) { + every { fake.appUpdateInfo } returns Tasks.forException(error) + } + + @Test + fun `a check Play refuses for device state is not reported`() = runTest { + failCheckWith(InstallException(InstallErrorCode.ERROR_INSTALL_NOT_ALLOWED)) + val updates = flexibleUpdates() + + val reported = reportedDuring { updates.check() } + + assertNull(updates.update.value) + assertTrue(reported.isEmpty()) + } + + @Test + fun `a check cut off by the Play Store dying is not reported`() = runTest { + failCheckWith(RemoteException("AppUpdateService : Binder has died.")) + val updates = flexibleUpdates() + + val reported = reportedDuring { updates.check() } + + assertNull(updates.update.value) + assertTrue(reported.isEmpty()) + } + + @Test + fun `a malformed check request is still reported`() = runTest { + failCheckWith(InstallException(InstallErrorCode.ERROR_INVALID_REQUEST)) + val updates = flexibleUpdates() + + val reported = reportedDuring { updates.check() } + + assertNull(updates.update.value) + assertEquals(1, reported.size) + } + @Test fun `reads priority and treats unknown staleness as zero`() = runTest { fake.setUpdateAvailable(currentVersion + 1)