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)