Blog Infos
Author
Published
Topics
,
Published

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!

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")
}
}
}
}
}
view raw SmsRetriever.kt hosted with ❤ by GitHub

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

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!"
view raw EmulateSms.kt hosted with ❤ by GitHub
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.

 

That’s all folks! Feel free to comment or message me if you have any questions.

GitHub | LinkedIn | Twitter

Thanks to Mario Sanoguera de Lorenzo, Chirag Kunder, and Stojan Anastasov.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu