One-Time Password (OTP) is a security code for single use during a login or transaction processes, providing an added layer of protection to minimize the risk of fake requests and improve security. So, applications use OTP to sure that each login or transaction is verified with a unique code, improving security by preventing unauthorized access and reducing the possible fake activities.
If you just need the code, you can find this solution as a library here.
To start integrating automatic OTP message reading, we first need to choose the appropriate API. Google provides two options, and the image below will help us decide which one to use.
Generally, OTPs are 4, 5, or 6 digits long, we do not need to have sender in our contact list and we want user interaction to accept OTP. Therefore, we will use the SMS User Consent API.
Implementation
To start with the SMS User Consent API we need to add Google Mobile Services library to our project
implementation("com.google.android.gms:play-services-auth:21.2.0") |
Next, we need to initialize the SMS Retriever client. We do this inside a LaunchedEffect
block to avoid unnecessary reinitializations.
LaunchedEffect(key1 = true) { | |
SmsRetriever | |
.getClient(context) | |
.startSmsUserConsent(null) | |
.addOnSuccessListener { | |
shouldRegisterReceiver = true | |
} | |
} |
We need to use ActivityResultLauncher
to handle the result of the SMS Consent API. This launcher listens for the result from the SMS retriever, processes the SMS message. If the consent was granted extracts the verification code. If the consent is denied, it triggers an error handler.
private fun getVerificationCodeFromSms(message: String, smsCodeLength: Int): String = | |
message.filter { it.isDigit() }.take(smsCodeLength) |
We need to register a broadcast receiver and manage the lifecycle of the receiver. Jetpack Compose provides a DisposableEffect
API that allows us to clean up when a composable leaves the composition. Here’s how we use it. Also you can see the base code over the link above.
@Composable | |
internal fun SystemBroadcastReceiver( | |
systemAction: String, | |
onSystemEvent: (intent: Intent?) -> Unit, | |
) { | |
val context = LocalContext.current | |
val currentOnSystemEvent by rememberUpdatedState(onSystemEvent) | |
// If either context or systemAction changes, unregister and register again | |
DisposableEffect(context, systemAction) { | |
val intentFilter = IntentFilter(systemAction) | |
val broadcast = object : BroadcastReceiver() { | |
override fun onReceive(context: Context?, intent: Intent?) { | |
currentOnSystemEvent(intent) | |
} | |
} | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | |
context.registerReceiver(broadcast, intentFilter, RECEIVER_EXPORTED) | |
} else { | |
context.registerReceiver(broadcast, intentFilter) | |
} | |
// Unregister the broadcast receiver when the effect leaves the Composition | |
onDispose { | |
context.unregisterReceiver(broadcast) | |
} | |
} | |
} |
We need to handle the received intent and trigger the consent dialog if applicable. The SystemBroadcastReceiver
will provide us with the received SMS.
if (shouldRegisterReceiver) { | |
SystemBroadcastReceiver(systemAction = SmsRetriever.SMS_RETRIEVED_ACTION) { intent -> | |
if (intent != null && SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) { | |
val extras = intent.extras | |
val smsRetrieverStatus = extras?.parcelable<Status>(SmsRetriever.EXTRA_STATUS) as Status | |
when (smsRetrieverStatus.statusCode) { | |
CommonStatusCodes.SUCCESS -> { | |
val consentIntent = extras.parcelable<Intent>(SmsRetriever.EXTRA_CONSENT_INTENT) | |
try { | |
// Start consent dialog. Timeout intent will be sent after 5 minutes | |
consentIntent?.let { launcher.launch(it) } | |
} catch (e: ActivityNotFoundException) { | |
onError(context.getString(R.string.activity_not_found_error)) | |
} | |
} | |
CommonStatusCodes.TIMEOUT -> onError(context.getString(R.string.sms_timeout_error)) | |
} | |
} | |
} | |
} |
The getParcelable
function are now deprecated, so we can write a simple extension function for Bundle
to retrieve it using BundleCompat
instead.
internal inline fun <reified T : Parcelable> Bundle.parcelable(key: String): T? = | |
BundleCompat.getParcelable(this, key, T::class.java) |
We are ready to use SMS Consent API in action. I combined above codes into a composable function.
@Composable | |
fun SmsUserConsent( | |
smsCodeLength: Int, | |
onOTPReceived: (otp: String) -> Unit, | |
onError: (error: String) -> Unit, | |
) { | |
val context = LocalContext.current | |
var shouldRegisterReceiver by remember { mutableStateOf(false) } | |
LaunchedEffect(key1 = true) { | |
SmsRetriever | |
.getClient(context) | |
.startSmsUserConsent(null) | |
.addOnSuccessListener { | |
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 { | |
val verificationCode = getVerificationCodeFromSms(message, smsCodeLength) | |
onOTPReceived(verificationCode) | |
} | |
shouldRegisterReceiver = false | |
} else { | |
onError(context.getString(R.string.sms_retriever_error_consent_denied)) | |
} | |
} | |
if (shouldRegisterReceiver) { | |
SystemBroadcastReceiver(systemAction = SmsRetriever.SMS_RETRIEVED_ACTION) { intent -> | |
if (intent != null && SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) { | |
val extras = intent.extras | |
val smsRetrieverStatus = extras?.parcelable<Status>(SmsRetriever.EXTRA_STATUS) as Status | |
when (smsRetrieverStatus.statusCode) { | |
CommonStatusCodes.SUCCESS -> { | |
val consentIntent = extras.parcelable<Intent>(SmsRetriever.EXTRA_CONSENT_INTENT) | |
try { | |
// Start consent dialog. Timeout intent will be sent after 5 minutes | |
consentIntent?.let { launcher.launch(it) } | |
} catch (e: ActivityNotFoundException) { | |
onError(context.getString(R.string.activity_not_found_error)) | |
} | |
} | |
CommonStatusCodes.TIMEOUT -> onError(context.getString(R.string.sms_timeout_error)) | |
} | |
} | |
} | |
} | |
} |
Job Offers
BONUS: GitHub Actions For Automation
I shared this OTP reader process as a library and you can integrate it into your application with just one function call. I want to tell how to publish a new release and tag on GitHub by using GitHub Actions as a bonus.
First, add the following folder structure to the root directory: .github/workflows
. You can name the YAML file whatever you prefer.
- The events that trigger the workflow must be specified in the YAML file. We want to trigger the workflow when code is pushed to the
development
branch.
on: | |
push: | |
branches: | |
- 'development' |
Alternatively, you might want to trigger the workflow when a pull request is merged into the main
branch. However, this approach did not work as expected. All jobs ran except for the new tag and release.
on: | |
pull_request: | |
types: [closed] | |
branches: | |
- 'main' |
2. Permissions must be given to push new tag and release.
permissions: | |
contents: write |
3. We need to define jobs that tell GitHub Actions to perform. We will start with a condition to run the job when code is pushed to the development
branch and merged into main. If you just want to trigger workflow on push to development you do not need to add this condition.
jobs: | |
create-new-tag-and-release: | |
if: ${{ github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'development' }} | |
runs-on: ubuntu-latest |
4. Now the things start actually. Firstly we need to checkout out the repository. fetch-dept means that number of commits to fetch. 0 indicates all history for all branches and tags.
- name: Checkout repository | |
uses: actions/checkout@v3 | |
with: | |
fetch-depth: 0 |
5. Get the next version of tag and push it.
name: Get next version | |
id: get_next_version | |
uses: thenativeweb/get-next-version@2.6.2 | |
- name: Push new tag | |
if: ${{ steps.get_next_version.outputs.hasNextVersion == 'true' && steps.check_tag.outcome != 'failure' }} | |
run: | | |
git tag ${{ steps.get_next_version.outputs.version }} | |
git push origin ${{ steps.get_next_version.outputs.version }} |
6. Read the release note and proceed the new release. You can set prerelease to true if you just want to release a beta version. If you are using development it can be set to true.
- name: Read release body from file | |
id: read_release_body | |
run: | | |
echo "RELEASE_BODY=$(cat release-notes.txt)" >> $GITHUB_ENV | |
- name: Create GitHub Release | |
if: ${{ steps.get_next_version.outputs.hasNextVersion == 'true' && steps.check_tag.outcome != 'failure' }} | |
uses: actions/create-release@v1 | |
env: | |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
with: | |
tag_name: ${{ steps.get_next_version.outputs.version }} | |
release_name: 'Auto OTP Reader ${{ steps.get_next_version.outputs.version }}' | |
body: ${{ env.RELEASE_BODY }} | |
draft: false | |
prerelease: false |
https://github.com/burkido/auto-read-otp?source=post_page—–261c8a81722b——————————–
Resources
- https://www.droidcon.com/2021/11/10/one-tap-phone-number-login-with-jetpack-compose/
- https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose#case-study-broadcastreceivers
- https://developer.android.com/sdk/api_diff/33/changes/android.os.Bundle
This article is previously published on proandroiddev.com