diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCodeScreen.kt index f27d00a03..b59ccd11a 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCodeScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.phone.components.OtpInputField +import com.flipcash.app.phone.components.SmsOtpAutofill import com.flipcash.features.contact.verification.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.OnWindowFocusedRequester @@ -54,6 +55,12 @@ private fun PhoneCodeScreenContent( val focusRequester = remember { FocusRequester() } val keyboard = rememberKeyboardController() + SmsOtpAutofill( + otpState = state.codeTextFieldState, + otpLength = state.otpLength, + attempts = state.attempts, + ) + CodeScaffold( modifier = Modifier .fillMaxSize() diff --git a/apps/flipcash/shared/phone/build.gradle.kts b/apps/flipcash/shared/phone/build.gradle.kts index 54a8cfa0b..1b7f87c0d 100644 --- a/apps/flipcash/shared/phone/build.gradle.kts +++ b/apps/flipcash/shared/phone/build.gradle.kts @@ -11,6 +11,8 @@ dependencies { api(libs.rinku.compose) + implementation(libs.play.services.auth.api.phone) + testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) 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 new file mode 100644 index 000000000..932dcc95e --- /dev/null +++ b/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/components/SmsOtpAutofill.kt @@ -0,0 +1,124 @@ +package com.flipcash.app.phone.components + +import android.app.Activity.RESULT_OK +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 +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.core.os.BundleCompat +import com.getcode.utils.trace +import com.google.android.gms.auth.api.phone.SmsRetriever +import com.google.android.gms.common.api.CommonStatusCodes +import com.google.android.gms.common.api.Status + +@Composable +fun SmsOtpAutofill( + otpState: TextFieldState, + otpLength: Int, + attempts: Int, +) { + val context = LocalContext.current + val currentOtpState by rememberUpdatedState(otpState) + val currentOtpLength by rememberUpdatedState(otpLength) + val filled = remember { mutableStateOf(false) } + + val consentLauncher = rememberLauncherForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE) + ?: return@rememberLauncherForActivityResult + parseAndFill(message, currentOtpLength, currentOtpState, filled) + } + } + + DisposableEffect(attempts) { + filled.value = false + + val client = SmsRetriever.getClient(context) + client.startSmsRetriever() + .addOnSuccessListener { trace("SmsRetriever started") } + .addOnFailureListener { trace("SmsRetriever failed to start: ${it.message}") } + client.startSmsUserConsent(null) + .addOnSuccessListener { trace("SmsUserConsent started") } + .addOnFailureListener { trace("SmsUserConsent failed to start: ${it.message}") } + + val filter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION) + + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action != SmsRetriever.SMS_RETRIEVED_ACTION) return + val extras: Bundle = intent.extras ?: return + // GMS classes (Status) need the GMS class loader for deserialization + extras.classLoader = SmsRetriever::class.java.classLoader + + val status = BundleCompat.getParcelable(extras, SmsRetriever.EXTRA_STATUS, Status::class.java) + if (status?.statusCode != CommonStatusCodes.SUCCESS) { + trace("SMS retrieval failed with status: ${status?.statusCode}") + return + } + + // Retriever path: SMS message directly available + val message = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE) + if (message != null) { + trace("SmsRetriever received message") + parseAndFill(message, currentOtpLength, currentOtpState, filled) + return + } + + // User Consent path: need to launch consent dialog + if (filled.value) return + + val consentIntent = BundleCompat.getParcelable( + extras, + SmsRetriever.EXTRA_CONSENT_INTENT, + Intent::class.java + ) + if (consentIntent != null) { + trace("Launching SMS consent dialog") + try { + consentLauncher.launch(consentIntent) + } catch (e: Exception) { + trace("Failed to launch consent dialog: ${e.message}") + } + } + } + } + + 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) + } + + onDispose { + context.unregisterReceiver(receiver) + } + } +} + +private fun parseAndFill( + message: String, + otpLength: Int, + state: TextFieldState, + filled: androidx.compose.runtime.MutableState, +) { + if (filled.value) return + val otp = """\b(\d{$otpLength})\b""".toRegex().find(message)?.groupValues?.get(1) ?: return + trace("Auto-filling OTP") + state.edit { replace(0, length, otp) } + filled.value = true +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 136671c65..0dc2235ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ grpc-android = "1.82.0" slf4j = "1.7.36" firebase-bom = "34.14.1" play-service-ml-barcode = "18.3.1" +play-services-auth-api-phone = "18.3.0" google-play-billing = "9.0.0" google-play-updates = "2.1.0" @@ -220,6 +221,7 @@ firebase-perf = { module = "com.google.firebase:firebase-perf" } # Google Play Services play-integrity = { module = "com.google.android.play:integrity", version = "1.6.0" } play-service-ml-barcode = { module = "com.google.android.gms:play-services-mlkit-barcode-scanning", version.ref = "play-service-ml-barcode" } +play-services-auth-api-phone = { module = "com.google.android.gms:play-services-auth-api-phone", version.ref = "play-services-auth-api-phone" } play-services-wallet = { module = "com.google.android.gms:play-services-wallet", version = "20.0.0" } # Google Play Billing