In this post, we will explore composing a One-Tap phone number login experience using Google’s Sms Verification APIs. The premise of a phone number login flow is fairly straightforward. We enter a phone number, get a one-time verification code on the phone, enter the code and voila! Let’s get into the nitty–gritty!
Getting the phone number
Look no further than the docs for Obtaining phone number and we have a good starting point for our solution. The dependencies are listed here. As we are working with Jetpack Compose, we can abstract it as a function and drop wherever we need this functionality.
@Composable | |
fun PhoneNumberConsent( | |
onPhoneNumberFetchedFromDevice: (phoneNumber: String) -> Unit, | |
) { | |
val context = LocalContext.current | |
val getPhoneNumberConsent = | |
rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult? -> | |
if (result?.resultCode == Activity.RESULT_OK && result.data != null) { | |
val credential = result.data!!.getParcelableExtra<Credential>(Credential.EXTRA_KEY) | |
if (credential != null) { | |
Timber.d("Phone number fetched from auto fill") | |
onPhoneNumberFetchedFromDevice(credential.id) | |
} | |
} else { | |
Timber.d("No number selected or unavailable. User can type manually.") | |
} | |
} | |
LaunchedEffect(Unit) { | |
val credentialsClient = Credentials.getClient(context) | |
val hintRequest = HintRequest.Builder() | |
.setPhoneNumberIdentifierSupported(true) | |
.build() | |
val intent = credentialsClient.getHintPickerIntent(hintRequest) | |
getPhoneNumberConsent.launch(IntentSenderRequest.Builder(intent.intentSender).build()) | |
} | |
} |
What’s happening here?
- We create the CredentialClient in LaunchedEffect so it is created only once and used till the composable leaves the composition.
- We use setPhoneNumberIdentifierSupported in building the HintRequest as we are really only interested in phone number hints.
- Instead of using onActivityResult, we are using ActivityResultsContracts to get the result. We launch the IntentSenderRequest using the HintPicker Intent.
- Finally, inside the lambda, we get the credential extra where id is the phone number as we set that in creating the HintRequest object.
This can be used by dropping in the PhoneNumberConsent Composable on the Phone number login screen.
@Composable | |
fun PhoneNumberLoginScreen() { | |
// UI | |
PhoneNumberConsent { viewModel.onPhoneNumberFetchedFromDevice(it) } | |
} |
Reading the verification code SMS
Now that we have fetched the phone number and made the login request to our backend, we are expecting an SMS with the one-time verification code so let’s start listening for the message. There are two APIs, Sms Retriever or SMS User Consent, either of them can be used and we’ll cover them both but a more detailed guide to which API might suit you best can be found here.
SMS User Consent API, also known as One Tap verification API is useful when we don’t have control of the contents of the message and the message always contains a 4–10 digit alphanumeric code.
The docs show an example of how that can be achieved and here’s the same behaviour written as a drop–in composable.
@Composable | |
fun SmsRetrieverUserConsentBroadcast( | |
smsCodeLength: Int = SMS_CODE_LENGTH, | |
onSmsReceived: (message: String, code: String) -> Unit, | |
) { | |
val context = LocalContext.current | |
var shouldRegisterReceiver by remember { mutableStateOf(false) } | |
LaunchedEffect(Unit) { | |
Timber.d("Initializing Sms Retriever client") | |
SmsRetriever.getClient(context) | |
.startSmsUserConsent(null) | |
.addOnSuccessListener { | |
Timber.d("SmsRetriever started successfully") | |
shouldRegisterReceiver = true | |
} | |
} | |
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { | |
if (it?.resultCode == Activity.RESULT_OK && it.data != null) { | |
val message: String? = it.data!!.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE) | |
message?.let { | |
Timber.d("Sms received: $message") | |
val verificationCode = getVerificationCodeFromSms(message, smsCodeLength) | |
Timber.d("Verification code parsed: $verificationCode") | |
onSmsReceived(message, verificationCode) | |
} | |
shouldRegisterReceiver = false | |
} else { | |
Timber.d("Consent denied. User can type OTC manually.") | |
} | |
} | |
if (shouldRegisterReceiver) { | |
SystemBroadcastReceiver( | |
systemAction = SmsRetriever.SMS_RETRIEVED_ACTION, | |
broadCastPermission = SmsRetriever.SEND_PERMISSION, | |
) { intent -> | |
if (intent != null && SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) { | |
val extras = intent.extras | |
val smsRetrieverStatus = extras?.get(SmsRetriever.EXTRA_STATUS) as Status | |
when (smsRetrieverStatus.statusCode) { | |
CommonStatusCodes.SUCCESS -> { | |
// Get consent intent | |
val consentIntent = extras.getParcelable<Intent>(SmsRetriever.EXTRA_CONSENT_INTENT) | |
try { | |
// Start activity to show consent dialog to user, activity must be started in | |
// 5 minutes, otherwise you'll receive another TIMEOUT intent | |
launcher.launch(consentIntent) | |
} catch (e: ActivityNotFoundException) { | |
Timber.e(e, "Activity Not found for SMS consent API") | |
} | |
} | |
CommonStatusCodes.TIMEOUT -> Timber.d("Timeout in sms verification receiver") | |
} | |
} | |
} | |
} | |
What’s happening here?
- Similar to PhoneNumberConsent, we create the SmsRetrieverClient in LaunchedEffect and we call startSmsUserConsent(). The param can be the phone number which can be useful if we know the phone number that will send the message, in our case we set it to null which means we listen to the next message containing an alphanumeric code of 4–10 digits from a phone number that’s not in the contact list.
- On success, we register the broadcast receiver that has the permission similar to what’s mentioned in the docs. We are using the SystemBroadcastReceiver composable from the interoperability docs, which handles unregistering itself on leaving the composition, so when the shouldRegisterReceiver state is set to false, the receiver is unregistered.
- Here again, we are using ActivityResultsContracts to get the result. We launch the ActivityResultContract using the Consent Intent. In case the activity is not found, we catch the exception and the user would have to enter the code manually.
- Finally, inside the LauncherForActivityResult lambda we get the SMS message. The system opens a bottom sheet with the message and the user can allow or deny the permission to read that specific message. We proceed to extract the code from the message and call the onSmsReceived function.
An easy helper function to extract a numerical verification code from a message.
internal fun getVerificationCodeFromSms(sms: String, smsCodeLength: Int): String = | |
sms.filter { it.isDigit() } | |
.substring(0 until smsCodeLength) |
The method above still requires 2 taps (1 for the phone number consent and 1 for SMS bottom sheet permission), let’s try to eliminate the second tap altogether. If the backend service we own can send the message with an 11 digit hash based on the app’s signing key we can eliminate the bottom sheet by directly getting access to the message, autofill the verification code text field and straight away log in the app. Let’s see how we can do that with SmsRetriever API.
@Composable | |
fun SmsRetriever( | |
smsCodeLength: Int = SMS_CODE_LENGTH, | |
onSmsReceived: (message: String, code: String) -> Unit, | |
) { | |
val context = LocalContext.current | |
var shouldRegisterReceiver by remember { mutableStateOf(false) } | |
LaunchedEffect(Unit) { | |
SmsRetriever.getClient(context) | |
.startSmsRetriever() | |
.addOnSuccessListener { | |
shouldRegisterReceiver = true | |
} | |
} | |
if (shouldRegisterReceiver) { | |
SystemBroadcastReceiver( | |
systemAction = SmsRetriever.SMS_RETRIEVED_ACTION, | |
broadCastPermission = SmsRetriever.SEND_PERMISSION, | |
) { intent -> | |
if (SmsRetriever.SMS_RETRIEVED_ACTION == intent?.action) { | |
val extras = intent.extras | |
val smsRetrieverStatus = extras?.get(SmsRetriever.EXTRA_STATUS) as Status | |
when (smsRetrieverStatus.statusCode) { | |
CommonStatusCodes.SUCCESS -> { | |
val message: String? = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE) | |
message?.let { | |
val verificationCode = getVerificationCodeFromSms(message, smsCodeLength) | |
onSmsReceived(message, verificationCode) | |
} | |
shouldRegisterReceiver = false | |
} | |
CommonStatusCodes.TIMEOUT -> Timber.d("Timeout in sms verification receiver") | |
} | |
} | |
} | |
} | |
} |
The only difference from the Sms User Consent API is that we call startSmsRetriever on SmsRetrieverClient and get the SMS as a String in an extra in the broadcasted message. So we can get rid of the LauncherForActivityResult altogether. The usages for both look quite similar, as we can see here.
@Composable | |
fun VerificationCodeScreen() { | |
// UI | |
SmsRetrieverUserConsent { _, code -> viewModel.onSmsReceived(code) } | |
} | |
Job Offers
Testing with ADB
If you’d like to test receiving the Sms without calling the backend, maybe backend is WIP or you’d like to save costs from a 3rd party phone number authentication service, it’s possible to emulate an incoming message on the emulator with the following command.
adb emu sms send "+351910000001" "Your verification code is 12345. Always be riding!" |
Separating country code and national number
If you’d like to separate the country dialing code and national number from the phone number fetched from the device, you can use libphonenumber or libphonenumber-android which is an android optimized version of the same library. Here we have a formatter that is injected with PhoneNumberUtil which basically does all the heavy lifting for parsing the phoneNumber String into a PhoneNumber object which has the countryCode and nationalNumber as fields which are returned as a Pair.
class PhoneNumberFormatter @Inject constructor( | |
private val phoneNumberUtil: PhoneNumberUtil | |
) { | |
fun formatToCountryCodeAndNationalNumber(phoneNumberWithCountryCode: String): Pair<Int, Long>? = | |
try { | |
// Can pass a default country code as the second param if there is one or an empty string if not | |
val number = phoneNumberUtil.parse(phoneNumberWithCountryCode, "") | |
Pair(number.countryCode, number.nationalNumber) | |
} catch (e: NumberParseException) { | |
Timber.e(e, "Error getting country code and national number") | |
null | |
} | |
} |
Bonus
- If you’d like to create an 11-digit hash for the specific package id and signing key, the instructions are documented here but I tend to use this script which is quite helpful and straightforward for generating the hash in one step.
- Check Google Play Services Release Notes for the latest updates to the APIs in case the docs are not updated.
- Browser Apps can make use of SMSCodeBrowserClient to read the message for a specific host.