Skip to content

Commit e18ec58

Browse files
committed
feat(phone): add SMS OTP auto-fill via Google Play Services
Dual-listen with SMS Retriever (silent, needs app hash) and SMS User Consent (one-tap dialog fallback). When the backend adds the app hash, users auto-upgrade to the silent path with no client changes. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent fe22f2b commit e18ec58

4 files changed

Lines changed: 135 additions & 0 deletions

File tree

apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCodeScreen.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign
2828
import androidx.compose.ui.text.style.TextDecoration
2929
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3030
import com.flipcash.app.phone.components.OtpInputField
31+
import com.flipcash.app.phone.components.SmsOtpAutofill
3132
import com.flipcash.features.contact.verification.R
3233
import com.getcode.theme.CodeTheme
3334
import com.getcode.ui.components.OnWindowFocusedRequester
@@ -54,6 +55,12 @@ private fun PhoneCodeScreenContent(
5455
val focusRequester = remember { FocusRequester() }
5556
val keyboard = rememberKeyboardController()
5657

58+
SmsOtpAutofill(
59+
otpState = state.codeTextFieldState,
60+
otpLength = state.otpLength,
61+
attempts = state.attempts,
62+
)
63+
5764
CodeScaffold(
5865
modifier = Modifier
5966
.fillMaxSize()

apps/flipcash/shared/phone/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ dependencies {
1111

1212
api(libs.rinku.compose)
1313

14+
implementation(libs.play.services.auth.api.phone)
15+
1416
testImplementation(kotlin("test"))
1517
testImplementation(libs.bundles.unit.testing)
1618
testImplementation(libs.robolectric)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.flipcash.app.phone.components
2+
3+
import android.app.Activity.RESULT_OK
4+
import android.content.BroadcastReceiver
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.content.IntentFilter
8+
import android.os.Build
9+
import android.os.Bundle
10+
import androidx.activity.compose.rememberLauncherForActivityResult
11+
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
12+
import androidx.compose.foundation.text.input.TextFieldState
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.DisposableEffect
15+
import androidx.compose.runtime.getValue
16+
import androidx.compose.runtime.mutableStateOf
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.rememberUpdatedState
19+
import androidx.compose.ui.platform.LocalContext
20+
import androidx.core.os.BundleCompat
21+
import com.getcode.utils.trace
22+
import com.google.android.gms.auth.api.phone.SmsRetriever
23+
import com.google.android.gms.common.api.CommonStatusCodes
24+
import com.google.android.gms.common.api.Status
25+
26+
@Composable
27+
fun SmsOtpAutofill(
28+
otpState: TextFieldState,
29+
otpLength: Int,
30+
attempts: Int,
31+
) {
32+
val context = LocalContext.current
33+
val currentOtpState by rememberUpdatedState(otpState)
34+
val currentOtpLength by rememberUpdatedState(otpLength)
35+
val filled = remember { mutableStateOf(false) }
36+
37+
val consentLauncher = rememberLauncherForActivityResult(StartActivityForResult()) { result ->
38+
if (result.resultCode == RESULT_OK) {
39+
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE)
40+
?: return@rememberLauncherForActivityResult
41+
parseAndFill(message, currentOtpLength, currentOtpState, filled)
42+
}
43+
}
44+
45+
DisposableEffect(attempts) {
46+
filled.value = false
47+
48+
val client = SmsRetriever.getClient(context)
49+
client.startSmsRetriever()
50+
.addOnSuccessListener { trace("SmsRetriever started") }
51+
.addOnFailureListener { trace("SmsRetriever failed to start: ${it.message}") }
52+
client.startSmsUserConsent(null)
53+
.addOnSuccessListener { trace("SmsUserConsent started") }
54+
.addOnFailureListener { trace("SmsUserConsent failed to start: ${it.message}") }
55+
56+
val filter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)
57+
58+
val receiver = object : BroadcastReceiver() {
59+
override fun onReceive(ctx: Context, intent: Intent) {
60+
if (intent.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
61+
val extras: Bundle = intent.extras ?: return
62+
// GMS classes (Status) need the GMS class loader for deserialization
63+
extras.classLoader = SmsRetriever::class.java.classLoader
64+
65+
val status = BundleCompat.getParcelable(extras, SmsRetriever.EXTRA_STATUS, Status::class.java)
66+
if (status?.statusCode != CommonStatusCodes.SUCCESS) {
67+
trace("SMS retrieval failed with status: ${status?.statusCode}")
68+
return
69+
}
70+
71+
// Retriever path: SMS message directly available
72+
val message = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE)
73+
if (message != null) {
74+
trace("SmsRetriever received message")
75+
parseAndFill(message, currentOtpLength, currentOtpState, filled)
76+
return
77+
}
78+
79+
// User Consent path: need to launch consent dialog
80+
if (filled.value) return
81+
82+
val consentIntent = BundleCompat.getParcelable(
83+
extras,
84+
SmsRetriever.EXTRA_CONSENT_INTENT,
85+
Intent::class.java
86+
)
87+
if (consentIntent != null) {
88+
trace("Launching SMS consent dialog")
89+
try {
90+
consentLauncher.launch(consentIntent)
91+
} catch (e: Exception) {
92+
trace("Failed to launch consent dialog: ${e.message}")
93+
}
94+
}
95+
}
96+
}
97+
98+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
99+
context.registerReceiver(
100+
receiver, filter, SmsRetriever.SEND_PERMISSION,
101+
null, Context.RECEIVER_EXPORTED,
102+
)
103+
} else {
104+
context.registerReceiver(receiver, filter, SmsRetriever.SEND_PERMISSION, null)
105+
}
106+
107+
onDispose {
108+
context.unregisterReceiver(receiver)
109+
}
110+
}
111+
}
112+
113+
private fun parseAndFill(
114+
message: String,
115+
otpLength: Int,
116+
state: TextFieldState,
117+
filled: androidx.compose.runtime.MutableState<Boolean>,
118+
) {
119+
if (filled.value) return
120+
val otp = """\b(\d{$otpLength})\b""".toRegex().find(message)?.groupValues?.get(1) ?: return
121+
trace("Auto-filling OTP")
122+
state.edit { replace(0, length, otp) }
123+
filled.value = true
124+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ grpc-android = "1.82.0"
5252
slf4j = "1.7.36"
5353
firebase-bom = "34.14.1"
5454
play-service-ml-barcode = "18.3.1"
55+
play-services-auth-api-phone = "18.3.0"
5556
google-play-billing = "9.0.0"
5657
google-play-updates = "2.1.0"
5758

@@ -220,6 +221,7 @@ firebase-perf = { module = "com.google.firebase:firebase-perf" }
220221
# Google Play Services
221222
play-integrity = { module = "com.google.android.play:integrity", version = "1.6.0" }
222223
play-service-ml-barcode = { module = "com.google.android.gms:play-services-mlkit-barcode-scanning", version.ref = "play-service-ml-barcode" }
224+
play-services-auth-api-phone = { module = "com.google.android.gms:play-services-auth-api-phone", version.ref = "play-services-auth-api-phone" }
223225
play-services-wallet = { module = "com.google.android.gms:play-services-wallet", version = "20.0.0" }
224226

225227
# Google Play Billing

0 commit comments

Comments
 (0)