From cac9aa76a95ad071ce776aa7f0d537fac5836da5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 13:04:22 -0400 Subject: [PATCH 1/4] fix(chat): guard MediaStore.getPickImagesMaxLimit on older pickers rememberChatPhotoPicker called getPickImagesMaxLimit() during composition with no version check. The method exists only on API 33+, or on API 30-32 with SDK extension R 2+, so an Android 12 device without the MediaProvider update threw NoSuchMethodError every time a chat opened (Bugsnag 6ac91865991833fd09984195, Samsung SM-N976V). Only call it where the system picker provides it. Elsewhere androidx falls back to a picker with no cap of its own, and the result callback already trims the selection to the remaining slots. --- .../shared/chat/ui/media/ChatPhotoPicker.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt index ab8a45e73..024cfa891 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt @@ -1,6 +1,8 @@ package com.flipcash.shared.chat.ui.media import android.net.Uri +import android.os.Build +import android.os.ext.SdkExtensions import android.provider.MediaStore import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -30,7 +32,19 @@ fun rememberChatPhotoPicker( val single = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) currentOnPicked(listOf(uri)) } - val max = remaining.coerceIn(2, maxOf(2, MediaStore.getPickImagesMaxLimit())) + // getPickImagesMaxLimit() only exists where the system picker does (API 33+, or SDK + // extension R 2+); elsewhere androidx falls back to a picker with no cap of its own. + val systemMax = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) >= 2) + ) { + MediaStore.getPickImagesMaxLimit() + } else { + Int.MAX_VALUE + } + } + val max = remaining.coerceIn(2, maxOf(2, systemMax)) val multiple = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(max), ) { uris -> From 7f23f4f4326a4ffe65ee82875537fa351827dcd9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 13:07:10 -0400 Subject: [PATCH 2/4] fix(chat): cap the fallback photo picker at the open slots Int.MAX_VALUE never reached the picker, since the clamp came out to remaining anyway, but it read as no cap. Fall back to remaining directly. --- .../com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt index 024cfa891..09878fe09 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt @@ -33,7 +33,7 @@ fun rememberChatPhotoPicker( if (uri != null) currentOnPicked(listOf(uri)) } // getPickImagesMaxLimit() only exists where the system picker does (API 33+, or SDK - // extension R 2+); elsewhere androidx falls back to a picker with no cap of its own. + // extension R 2+). Without it, the open slots are the only cap. val systemMax = remember { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && @@ -41,10 +41,10 @@ fun rememberChatPhotoPicker( ) { MediaStore.getPickImagesMaxLimit() } else { - Int.MAX_VALUE + null } } - val max = remaining.coerceIn(2, maxOf(2, systemMax)) + val max = maxOf(2, systemMax?.let { minOf(remaining, it) } ?: remaining) val multiple = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(max), ) { uris -> From b21cca234609c807f071a4cd1236774ca2a7a9c3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 13:23:31 -0400 Subject: [PATCH 3/4] ci(lint): lint library modules from the app's lintDebug CI runs :apps:flipcash:app:lintDebug, but checkDependencies defaults to false, so lint only looked at the app module. The unguarded MediaStore.getPickImagesMaxLimit() call in chat-ui (#1741) shipped because no lint run ever saw it; with checkDependencies on, lint fails on it with NewApi. Turning it on surfaces three other existing errors, which go in the baseline so the check gates new issues only: WrongConstant in AppSettingsMapper, UnspecifiedRegisterReceiverFlag in SmsOtpAutofill (the pre-34 branch, where the flag isn't available), and UnrememberedMutableState in Flippable. --- apps/flipcash/app/build.gradle.kts | 1 + apps/flipcash/app/lint-baseline.xml | 33 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 7e7d57417..fc4b0d59f 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -115,6 +115,7 @@ android { lint { checkReleaseBuilds = false + checkDependencies = true baseline = file("lint-baseline.xml") } diff --git a/apps/flipcash/app/lint-baseline.xml b/apps/flipcash/app/lint-baseline.xml index 1f61656eb..dadd1b76c 100644 --- a/apps/flipcash/app/lint-baseline.xml +++ b/apps/flipcash/app/lint-baseline.xml @@ -377,4 +377,37 @@ column="31"/> + + + + + + + + + + + + From b3159493bb9a35a8e51ac230d97257d83e43c2bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 13:34:45 -0400 Subject: [PATCH 4/4] fix(lint): clear the three errors baselined in #1743 - WrongConstant: AppSettingMapper passed Biometrics.TEST_AUTH across a module boundary, where lint can't see it is a valid authenticator flag. The check now goes through Biometrics.authenticationState(), and TEST_AUTH is private. - UnspecifiedRegisterReceiverFlag: SmsOtpAutofill only passed RECEIVER_EXPORTED on API 34+. ContextCompat.registerReceiver takes the flag on every version; the receiver stays exported, as SMS_RETRIEVED comes from Play services and SEND_PERMISSION still guards it. - UnrememberedMutableState: FlippableCard built a new State object on each composition when animateFlip is false. It now uses rememberUpdatedState. The three entries come out of the app's lint baseline. --- apps/flipcash/app/lint-baseline.xml | 33 ------------------- .../appsettings/internal/AppSettingsMapper.kt | 2 +- .../app/phone/components/SmsOtpAutofill.kt | 14 +++----- .../com/getcode/libs/biometrics/Biometrics.kt | 9 ++--- .../com/getcode/ui/components/Flippable.kt | 8 ++--- 5 files changed, 13 insertions(+), 53 deletions(-) diff --git a/apps/flipcash/app/lint-baseline.xml b/apps/flipcash/app/lint-baseline.xml index dadd1b76c..1f61656eb 100644 --- a/apps/flipcash/app/lint-baseline.xml +++ b/apps/flipcash/app/lint-baseline.xml @@ -377,37 +377,4 @@ column="31"/> - - - - - - - - - - - - diff --git a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt index 0183a209c..9a9772d2e 100644 --- a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt +++ b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt @@ -15,7 +15,7 @@ class AppSettingMapper @Inject constructor( override suspend fun map(from: AppSetting): AppSettingsItem { return when (from.type) { AppSettingValue.BiometricsRequired -> { - val biometricsState = biometricManager.canAuthenticate(Biometrics.TEST_AUTH) + val biometricsState = Biometrics.authenticationState(biometricManager) val canUseBiometrics = !(biometricsState == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE || biometricsState == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE || diff --git a/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/components/SmsOtpAutofill.kt b/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/components/SmsOtpAutofill.kt index 932dcc95e..c0e6e3e20 100644 --- a/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/components/SmsOtpAutofill.kt +++ b/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/components/SmsOtpAutofill.kt @@ -5,7 +5,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.os.Build import android.os.Bundle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult @@ -17,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.core.os.BundleCompat import com.getcode.utils.trace import com.google.android.gms.auth.api.phone.SmsRetriever @@ -95,14 +95,10 @@ fun SmsOtpAutofill( } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - context.registerReceiver( - receiver, filter, SmsRetriever.SEND_PERMISSION, - null, Context.RECEIVER_EXPORTED, - ) - } else { - context.registerReceiver(receiver, filter, SmsRetriever.SEND_PERMISSION, null) - } + ContextCompat.registerReceiver( + context, receiver, filter, SmsRetriever.SEND_PERMISSION, + null, ContextCompat.RECEIVER_EXPORTED, + ) onDispose { context.unregisterReceiver(receiver) diff --git a/libs/biometrics/src/main/kotlin/com/getcode/libs/biometrics/Biometrics.kt b/libs/biometrics/src/main/kotlin/com/getcode/libs/biometrics/Biometrics.kt index d8439e64b..ec75c90c4 100644 --- a/libs/biometrics/src/main/kotlin/com/getcode/libs/biometrics/Biometrics.kt +++ b/libs/biometrics/src/main/kotlin/com/getcode/libs/biometrics/Biometrics.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.fragment.app.FragmentActivity -import com.getcode.libs.biometrics.Biometrics.TEST_AUTH import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber @@ -55,10 +54,12 @@ object Biometrics { private const val AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL - const val TEST_AUTH = BiometricManager.Authenticators.BIOMETRIC_WEAK + private const val TEST_AUTH = BiometricManager.Authenticators.BIOMETRIC_WEAK + + fun authenticationState(manager: BiometricManager): Int = manager.canAuthenticate(TEST_AUTH) fun canAuthenticate(context: Context): Boolean { - val state = BiometricManager.from(context).canAuthenticate(TEST_AUTH) + val state = authenticationState(BiometricManager.from(context)) return !(state == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE || state == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE || @@ -67,7 +68,7 @@ object Biometrics { } fun hasNoneEnrolled(context: Context): Boolean { - val state = BiometricManager.from(context).canAuthenticate(TEST_AUTH) + val state = authenticationState(BiometricManager.from(context)) return state == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt index 054536241..63d0d95c5 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Flippable.kt @@ -7,10 +7,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer @@ -54,11 +54,7 @@ fun FlippableCard( ) ) } else { - object : State { - override val value: Float - get() = cardFace.angle - - } + rememberUpdatedState(cardFace.angle) } val showFront by remember { derivedStateOf { rotation.value <= 90f } }