Phone verification¶
Verification is optional and fully decoupled. The library ships no verification backend, on
purpose: a bundled fake that accepts 123456 is the kind of thing that reaches production. You
implement one interface against your own SMS, Firebase Auth or backend provider; the library sequences
the calls, renders the state, and enforces the rules that are easy to get wrong.
1. Implement the handler¶
class MyVerificationHandler(private val api: MyAuthApi) : PhoneNumberVerificationHandler {
override suspend fun requestVerification(phoneNumber: PhoneNumberValue): VerificationRequestResult =
try {
val challenge = api.sendCode(phoneNumber.e164Number!!)
VerificationRequestResult.Success(
verificationId = challenge.id,
destination = phoneNumber.internationalNumber,
resendAvailableAtMillis = System.currentTimeMillis() + 30_000,
)
} catch (e: IOException) {
VerificationRequestResult.Failure(UiText.resource(Res.string.network_error), retryable = true)
}
override suspend fun verifyCode(verificationId: String, code: String): VerificationResult =
when (api.checkCode(verificationId, code)) {
CheckResult.Ok -> VerificationResult.Verified(api.numberFor(verificationId))
CheckResult.WrongCode -> VerificationResult.Invalid()
CheckResult.Expired -> VerificationResult.Expired
}
override suspend fun resendCode(verificationId: String): VerificationRequestResult =
requestVerificationAgain(verificationId)
}
Nothing in the interface touches Compose, so it can live in a data module and be unit-tested without
Android. The library guarantees phoneNumber.isValid is true before calling
requestVerification. Throwing is acceptable (the controller surfaces Failed), but returning a
Failure with a message is better.
| Result type | Cases |
|---|---|
VerificationRequestResult |
Success(verificationId, destination, resendAvailableAtMillis?), Failure(message, retryable) |
VerificationResult |
Verified(e164Number), Invalid(message?), Expired, Failure(message, retryable) |
resendAvailableAtMillis is epoch millis rather than java.time.Instant so the library stays usable
at minSdk 24 without core-library desugaring.
2. Create a controller and attach it to the field¶
val verification = rememberPhoneVerificationController(MyVerificationHandler(api))
PhoneNumberField(
state = phoneState,
onValueChange = {},
verificationController = verification,
)
With a controller attached the field shows a tick once the current number is verified, and tells the controller whenever the number changes.
3. Drive the flow¶
val scope = rememberCoroutineScope()
Button(
enabled = phoneState.value.isValid && !verification.state.isInProgress,
onClick = { scope.launch { verification.requestCode(phoneState.value) } },
) { Text("Send code") }
when (val state = verification.state) {
is PhoneVerificationState.CodeSent -> {
Text("Code sent to ${state.destination}")
OtpEntry(onSubmit = { code -> scope.launch { verification.submitCode(code) } })
ResendButton(controller = verification)
}
is PhoneVerificationState.Verifying -> CircularProgressIndicator()
is PhoneVerificationState.Verified -> Text("Verified: ${state.e164Number}")
is PhoneVerificationState.Failed -> Text(state.message.resolve(), color = MaterialTheme.colorScheme.error)
PhoneVerificationState.Expired -> Text("Code expired. Request a new one.")
PhoneVerificationState.Idle, PhoneVerificationState.SendingCode -> Unit
}
Resend with a countdown¶
The controller intentionally runs no timer of its own, so it never keeps a coroutine alive after the UI is gone. Tick from a composable:
@Composable
fun ResendButton(controller: PhoneVerificationController) {
var secondsLeft by remember { mutableIntStateOf(controller.resendAvailableInSeconds()) }
LaunchedEffect(controller.state) {
while (true) {
secondsLeft = controller.resendAvailableInSeconds()
if (secondsLeft == 0) break
delay(1_000)
}
}
val scope = rememberCoroutineScope()
TextButton(
enabled = secondsLeft == 0,
onClick = { scope.launch { controller.resendCode() } },
) { Text(if (secondsLeft == 0) "Resend code" else "Resend in ${secondsLeft}s") }
}
States¶
sealed interface PhoneVerificationState {
data object Idle
data object SendingCode
data class CodeSent(val verificationId: String, val destination: String, val resendAvailableAtMillis: Long?)
data class Verifying(val verificationId: String)
data class Verified(val e164Number: String)
data class Failed(val message: UiText, val verificationId: String?, val retryable: Boolean)
data object Expired
val isInProgress: Boolean // SendingCode or Verifying
val activeVerificationId: String? // the challenge id, if any
}
This is the whole observable surface of verification. A host with its own OTP screen can drive it from these states and ignore the library's UI entirely.
Rules the controller enforces¶
- Only a number with
isValid == truecan start verification. CallingrequestCodewith an invalid number producesFailedwith an actionable message rather than silently doing nothing. - A request in flight blocks a second one, so double-tapping "Send code" sends one SMS and double-tapping "Verify" consumes one attempt against a rate-limited endpoint.
- Resend is refused until the handler's cooldown elapses, so the cooldown is honoured rather than merely displayed.
- Editing the number invalidates any verification already obtained. Any change to the E.164 value
resets the flow to
Idle. A form can never submit "verified" for a number the user has since changed.isVerified(value)checks the controller against a specific number for the same reason. - Every method is
suspendand runs on the caller's dispatcher; nothing is launched internally, so cancellation is yours to control. The controller can be driven from a ViewModel as easily as from a composable.
reset() clears everything, for example when a form is reset.