From f712ce44c663aebf3bc9d2099998fed87b34cd3d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 20:07:21 -0400 Subject: [PATCH 1/3] fix(auth): stop reporting Block Store calls Play services doesn't support On devices whose Play services predates blockstore_retrieve_bytes_with_options v3, retrieveBytes fails with UnsupportedApiCallException. PlayBlockStoreBytes already swallows it and returns null, but trace(error = ...) forwards it to Bugsnag, so each launch filed 2-3 warnings (error 6abc0c6b991833fd09b525ad: 10 events, 3 users, older vivo and realme phones on Android 11/12). Log that case as a breadcrumb instead. Other failures are still reported, and return values are unchanged. --- .../internal/accounts/PlayBlockStoreBytes.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt index c302302a46..4c0657c6aa 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt @@ -7,6 +7,7 @@ import com.google.android.gms.auth.blockstore.Blockstore import com.google.android.gms.auth.blockstore.DeleteBytesRequest import com.google.android.gms.auth.blockstore.RetrieveBytesRequest import com.google.android.gms.auth.blockstore.StoreBytesData +import com.google.android.gms.common.api.UnsupportedApiCallException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -21,7 +22,8 @@ import javax.inject.Singleton * leaving it unset deletes previously backed-up cloud data on the next sync. * * Every Play services failure is swallowed: no Play services means no durable list, not a broken - * login. + * login. A Play services build too old for these calls is expected on some devices, so that case + * is left as a breadcrumb instead of being reported. */ @Singleton internal class PlayBlockStoreBytes @Inject constructor( @@ -39,7 +41,7 @@ internal class PlayBlockStoreBytes @Inject constructor( ?.bytes ?: ByteArray(0) }.getOrElse { error -> - trace(tag = TAG, message = "Block Store read failed", error = error, type = TraceType.Error) + traceFailure("Block Store read failed", error) null } @@ -56,7 +58,7 @@ internal class PlayBlockStoreBytes @Inject constructor( client.storeBytes(data).await() true }.getOrElse { error -> - trace(tag = TAG, message = "Block Store write failed", error = error, type = TraceType.Error) + traceFailure("Block Store write failed", error) false } @@ -67,7 +69,15 @@ internal class PlayBlockStoreBytes @Inject constructor( .build() client.deleteBytes(request).await() }.onFailure { error -> - trace(tag = TAG, message = "Block Store delete failed", error = error, type = TraceType.Error) + traceFailure("Block Store delete failed", error) + } + } + + private fun traceFailure(message: String, error: Throwable) { + if (error is UnsupportedApiCallException) { + trace(tag = TAG, message = "$message: unsupported by Play services", type = TraceType.Log) + } else { + trace(tag = TAG, message = message, error = error, type = TraceType.Error) } } From 31006f69ca49f26b560867c04376016e151aab79 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 20:11:24 -0400 Subject: [PATCH 2/3] fix(auth): skip Block Store reads once Play services reports them unsupported Launch reads Block Store 2-3 times, and on these devices every read makes a Play services call that fails the same way. Remember the first UnsupportedApiCallException for the process and return null after that. There is no reliable up-front check: checkApiAvailability passes because the Block Store API exists, and the error reports is_fully_rolled_out=false, so a Play services version gate would also misjudge devices. Writes and deletes use different features and keep calling through. --- .../internal/accounts/PlayBlockStoreBytes.kt | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt index 4c0657c6aa..5046c55b32 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt @@ -32,17 +32,29 @@ internal class PlayBlockStoreBytes @Inject constructor( private val client = Blockstore.getClient(context) - override suspend fun read(): ByteArray? = runCatching { - val request = RetrieveBytesRequest.Builder() - .setKeys(listOf(KEY)) - .build() - client.retrieveBytes(request).await() - .blockstoreDataMap[KEY] - ?.bytes - ?: ByteArray(0) - }.getOrElse { error -> - traceFailure("Block Store read failed", error) - null + /** + * Set once a read fails as unsupported, so the rest of the process skips the call. Play services + * gates the feature by rollout as well as version, so there is no reliable check up front. Writes + * and deletes use different features and are not gated by this. + */ + @Volatile + private var readUnsupported = false + + override suspend fun read(): ByteArray? { + if (readUnsupported) return null + return runCatching { + val request = RetrieveBytesRequest.Builder() + .setKeys(listOf(KEY)) + .build() + client.retrieveBytes(request).await() + .blockstoreDataMap[KEY] + ?.bytes + ?: ByteArray(0) + }.getOrElse { error -> + if (error is UnsupportedApiCallException) readUnsupported = true + traceFailure("Block Store read failed", error) + null + } } override suspend fun write(bytes: ByteArray): Boolean = runCatching { From caa43027e982a447b5b31cb04da1f5d4ba79009a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 20:15:05 -0400 Subject: [PATCH 3/3] fix(auth): rethrow coroutine cancellation from Block Store calls runCatching also caught CancellationException, so a cancelled read, write or delete was traced as a Block Store failure and returned a fallback value instead of cancelling. In write, a cancel during the isEndToEndEncryptionAvailable check fell back to false and went on to storeBytes with cloud backup off. That call starts in Play services before its await sees the cancellation, and an unset backup flag deletes previously backed-up data on the next sync. Each failure path now calls ensureActive() first. It throws only when the calling coroutine is cancelled, so a Play services task that fails with its own CancellationException is still handled as a failure. --- .../auth/internal/accounts/PlayBlockStoreBytes.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt index 5046c55b32..6cbba013fc 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/internal/accounts/PlayBlockStoreBytes.kt @@ -9,6 +9,8 @@ import com.google.android.gms.auth.blockstore.RetrieveBytesRequest import com.google.android.gms.auth.blockstore.StoreBytesData import com.google.android.gms.common.api.UnsupportedApiCallException import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton @@ -23,7 +25,8 @@ import javax.inject.Singleton * * Every Play services failure is swallowed: no Play services means no durable list, not a broken * login. A Play services build too old for these calls is expected on some devices, so that case - * is left as a breadcrumb instead of being reported. + * is left as a breadcrumb instead of being reported. Cancellation of the calling coroutine is not + * a failure and is rethrown. */ @Singleton internal class PlayBlockStoreBytes @Inject constructor( @@ -51,6 +54,7 @@ internal class PlayBlockStoreBytes @Inject constructor( ?.bytes ?: ByteArray(0) }.getOrElse { error -> + currentCoroutineContext().ensureActive() if (error is UnsupportedApiCallException) readUnsupported = true traceFailure("Block Store read failed", error) null @@ -58,8 +62,13 @@ internal class PlayBlockStoreBytes @Inject constructor( } override suspend fun write(bytes: ByteArray): Boolean = runCatching { + // Without the cancellation check, a cancel here would fall through to storeBytes with backup + // off, and that starts in Play services before the await below can notice. val canEncrypt = runCatching { client.isEndToEndEncryptionAvailable.await() } - .getOrDefault(false) + .getOrElse { + currentCoroutineContext().ensureActive() + false + } val data = StoreBytesData.Builder() .setKey(KEY) @@ -70,6 +79,7 @@ internal class PlayBlockStoreBytes @Inject constructor( client.storeBytes(data).await() true }.getOrElse { error -> + currentCoroutineContext().ensureActive() traceFailure("Block Store write failed", error) false } @@ -81,6 +91,7 @@ internal class PlayBlockStoreBytes @Inject constructor( .build() client.deleteBytes(request).await() }.onFailure { error -> + currentCoroutineContext().ensureActive() traceFailure("Block Store delete failed", error) } }