Implementing seamless OTP auto verification is a baseline requirement for high-conversion onboarding flows, particularly in FinTech applications. While standard implementation of the Google Play Services SMS Retriever API operates predictably on Pixel and Samsung devices, it exhibits a notorious, silent failure rate on Xiaomi, Redmi, and Poco devices running MIUI or HyperOS.
Instead of automatically capturing the one-time password, the application hangs waiting for a broadcast that never arrives, ultimately resulting in a 5-minute timeout. This architectural bottleneck directly impacts user activation metrics.
This article dissects the root cause of this OEM-specific interference and provides a production-ready Kotlin implementation to bypass it.
The Root Cause: MIUI's Custom Permission Architecture
Under normal circumstances, the Android SMS Retriever API operates without requiring the sensitive READ_SMS permission. Play Services listens for incoming SMS messages containing a specific 11-character hash unique to your application's signing certificate. Upon matching the hash, Play Services broadcasts the SmsRetriever.SMS_RETRIEVED_ACTION directly to your app.
However, MIUI employs a highly aggressive, proprietary battery and permission management subsystem. By default, MIUI intercepts incoming service messages (like OTPs) and evaluates them against a custom, OEM-specific MIUI SMS permission system.
If your application lacks explicit, user-granted authorization to read "Service SMS" in MIUI's hidden settings:
- Play Services successfully reads the SMS.
- Play Services attempts to fire the broadcast intent to your application.
- The MIUI framework intercepts and drops the intent before it reaches your application's
BroadcastReceiver. - Your application fails silently until the Google API times out.
Prompting the user to navigate deep into MIUI's settings to enable a non-standard permission is terrible UX. Instead, the most reliable technical solution is dynamically detecting the OEM and pivoting to a hybrid architecture utilizing the SMS User Consent API.
The Solution: Hybrid OTP Verification Architecture
To maintain rigorous FinTech app security standards while ensuring high deliverability, applications must gracefully fallback from the hash-based Retriever API to the SMS User Consent API. The Consent API relies on a system-level Play Services bottom sheet rather than a background broadcast intent, entirely bypassing MIUI's background execution limits.
Step 1: Reliable MIUI Detection
Before altering the verification flow, accurately identify if the device is running the MIUI framework. We accomplish this by querying system properties via reflection.
import android.annotation.SuppressLint
import java.lang.reflect.Method
object DeviceUtils {
private const val SYSTEM_PROPERTIES_CLASS = "android.os.SystemProperties"
private const val MIUI_VERSION_PROPERTY = "ro.miui.ui.version.name"
@SuppressLint("PrivateApi")
fun isMiuiDevice(): Boolean {
return try {
val systemProperties = Class.forName(SYSTEM_PROPERTIES_CLASS)
val getMethod: Method = systemProperties.getMethod("get", String::class.java)
val miuiVersion = getMethod.invoke(systemProperties, MIUI_VERSION_PROPERTY) as String
miuiVersion.isNotEmpty()
} catch (e: Exception) {
false
}
}
}
Step 2: Implementing the Modern Activity Result API
Deprecated methods like startActivityForResult should no longer be used. We will leverage the modern Activity Result API to handle the Play Services consent intent.
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.auth.api.phone.SmsRetriever
class OtpVerificationActivity : AppCompatActivity() {
private val smsConsentLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE)
message?.let { extractAndSubmitOtp(it) }
} else {
// Fallback to manual entry UI
showManualOtpEntry()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_otp_verification)
startSmsListener()
}
private fun startSmsListener() {
val client = SmsRetriever.getClient(this)
if (DeviceUtils.isMiuiDevice()) {
// Bypass MIUI block: Use User Consent API
client.startSmsUserConsent(null) // null allows listening to any sender
.addOnSuccessListener {
Log.d("OTP", "User Consent API started successfully")
}
.addOnFailureListener { e ->
Log.e("OTP", "Failed to start User Consent API", e)
}
} else {
// Standard Hash-based Retriever API for standard Android
client.startSmsRetriever()
.addOnSuccessListener {
Log.d("OTP", "Standard SMS Retriever started")
}
}
}
private fun extractAndSubmitOtp(message: String) {
val otpRegex = Regex("(\\d{6})") // Assumes a 6-digit OTP
val matchResult = otpRegex.find(message)
matchResult?.value?.let { otp ->
// Submit OTP to your backend
Log.d("OTP", "Extracted OTP: $otp")
}
}
private fun showManualOtpEntry() {
// Render manual fallback UI
}
}
Step 3: Handling the Broadcast Receiver (Android 14 Ready)
Whether using the hash-based retriever or the consent API, a BroadcastReceiver is required to catch the Play Services callback. Starting in Android 14 (API level 34), receivers must explicitly declare export behavior.
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
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
class SmsBroadcastReceiver(
private val onConsentIntentFound: (Intent) -> Unit,
private val onSmsRetrieved: (String) -> Unit,
private val onTimeout: () -> Unit
) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) {
val extras = intent.extras
val status = extras?.get(SmsRetriever.EXTRA_STATUS) as? Status
when (status?.statusCode) {
CommonStatusCodes.SUCCESS -> {
// Handle Standard Hash API
val message = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE)
if (message != null) {
onSmsRetrieved(message)
return
}
// Handle User Consent API
val consentIntent = extras.getParcelable<Intent>(SmsRetriever.EXTRA_CONSENT_INTENT)
if (consentIntent != null) {
onConsentIntentFound(consentIntent)
}
}
CommonStatusCodes.TIMEOUT -> {
onTimeout()
}
}
}
}
companion object {
fun register(context: Context, receiver: SmsBroadcastReceiver) {
val intentFilter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)
// Required for API 34+ when interacting with Play Services broadcasts
ContextCompat.registerReceiver(
context,
receiver,
intentFilter,
ContextCompat.RECEIVER_EXPORTED
)
}
}
}
To integrate the receiver with the Activity, instantiate and register it during the lifecycle events (onResume and onPause):
// Inside OtpVerificationActivity
private lateinit var smsReceiver: SmsBroadcastReceiver
override fun onResume() {
super.onResume()
smsReceiver = SmsBroadcastReceiver(
onConsentIntentFound = { intent ->
try {
smsConsentLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
showManualOtpEntry()
}
},
onSmsRetrieved = { message ->
extractAndSubmitOtp(message)
},
onTimeout = {
showManualOtpEntry()
}
)
SmsBroadcastReceiver.register(this, smsReceiver)
}
override fun onPause() {
super.onPause()
unregisterReceiver(smsReceiver)
}
Why This Architecture Works
The Android SMS Retriever API relies on implicit background broadcasts. MIUI's security model inherently distrusts these intents, classifying them as unauthorized background activity unless explicit permission is granted.
By switching to startSmsUserConsent(), Play Services does not attempt to pass the SMS content directly to your app in the background. Instead, it passes an Intent via the broadcast. When your app launches this Intent, the Google Play Services UI renders an OS-level prompt asking the user to allow the app to read the specific message.
Because the prompt is a system-level interaction managed by Google—not a background process executed by a third-party application—MIUI allows the transaction to proceed unhindered.
Crucial Edge Cases for Production Security
Google Play App Signing Hash Mismatch
If you use the standard SMS Retriever API for non-MIUI devices, ensure your 11-character hash is generated based on the Google Play App Signing certificate, not your local upload key. A mismatched hash will result in a standard timeout on all devices. To generate the correct hash, download the App Signing certificate from the Google Play Console, generate a keystore, and run the hash generation script against it.
Sender Number Formatting
The User Consent API (startSmsUserConsent(senderPhoneNumber)) allows you to filter by the sender's number. If you pass a formatted number (e.g., +1-555-0199) but the carrier delivers it without the country code, the consent prompt will not trigger. Passing null allows the API to listen to the next incoming message containing an alphanumeric OTP, which is often safer for global applications with varying SMS gateways.
Play Services Availability
Both APIs require Google Play Services. For Huawei devices running HarmonyOS or AOSP devices without GMS, the getClient() invocation will fail. Always wrap your initialization in a try-catch block or check GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) before executing the listener. Ensure your application always has a well-designed manual input fallback.