Cross-platform one-time-code (OTP) SMS autofill for Mob apps.
Opinionated wiring around the two platforms' native mechanisms — iOS reads the code from QuickType above the keyboard, Android reads it via Google Play Services' SMS Retriever — presented as one consistent shape for the common case: "I sent a code via SMS; when it arrives, put it in my text field."
Cross-platform behaviour
The received code arrives as {:change, tag, code} on both platforms —
the same shape a text field's on_change fires. Iron out the asymmetry
at the plugin layer so the screen has one handler for both paths:
# On the screen
def mount(_params, _session, socket) do
{:ok, MobSms.OneTimeCode.arm(socket, on_receive: :code)}
end
def render(assigns) do
~MOB"""
<Column>
<TextField
value={@code}
on_change={{self(), :code}}
keyboard={:number}
text_content_type={:one_time_code}
placeholder="Verification code"
/>
</Column>
"""
end
def handle_info({:change, :code, value}, socket) do
# Fires on BOTH platforms:
# • iOS — user tapped the QuickType suggestion above the
# keyboard (Messages parsed the SMS, offered the code)
# • Android — SMS Retriever delivered the code, plugin forwarded
# it in the same shape a keystroke would have taken
{:noreply, Mob.Socket.assign(socket, code: value)}
end
def handle_info({:sms_otp, :timeout}, socket) do
# Optional. Fires on Android when the 5-minute Retriever window
# closes with no SMS, GMS failed to start, or a subsequent arm/2
# evicted this one. Ignore if you're happy to let the user retype.
{:noreply, Mob.Socket.assign(socket, status: "Please retype the code")}
endUnder the hood each platform uses a different native mechanism.
iOS
Setting text_content_type: :one_time_code on a <TextField> sets
UITextContentType.oneTimeCode on the underlying SwiftUI TextField.
iOS Messages parses incoming SMSes looking for natural-language code
patterns (e.g. "Your code is 123456") and, when the tagged field is
focused, surfaces the code as a QuickType suggestion above the
keyboard. The user taps it; the code flows in as the field's normal
on_change value.
No arm/2 call is required on iOS. No entitlement, no framework, no
server-side hash. Requires mob 0.9.1+ (the text_content_type prop is
new in that release).
The SMS just needs to contain the code in a natural-language pattern
that iOS Messages recognises. "Your code is 123456" works.
Android
arm/2 registers a scoped BroadcastReceiver (only Google Play Services
can broadcast to it — the receiver is gated on
com.google.android.gms.auth.api.phone.permission.SEND) and calls
SmsRetrieverClient.startSmsRetriever() to tell GMS to route matching
SMSes to your app. When a matching SMS arrives, the plugin extracts
the code from the body and forwards it to the screen as
{:change, tag, code}.
The server-side SMS must be in the format Google requires:
Your ExampleApp code is: 123456
<blank line>
FA+9qCX9VSuwhere FA+9qCX9VSu is the 11-char hash unique to this app's package +
signing cert (see below). The Retriever needs no user permission and
no user-visible dialog — the hash is the routing key.
Computing the 11-char hash
The hash is base64(sha256("<package_name> <signing_cert_sha256_lowercase_hex>"))[:11].
For the release cert it is a stable value computed once; for the debug
cert it differs per developer. Log both during development using
Google's AppSignatureHelper.getAppSignatures() (drop it into
MainActivity.kt, Logcat prints the hash on first run, delete after
copying). Full source at
https://developers.google.com/identity/sms-retriever/verify.
The hash is a routing key, not a security boundary — a spoofed SMS with a valid hash reaches your app, but Google Messages' spam filters
- the scoped-permission BroadcastReceiver make cross-app spoofing effectively impossible.
Requirements
- mob
~> 0.9.1(text_content_typeprop on<TextField>). - Android target: Google Play Services present — 99%+ of Play-installed
devices. Aftermarket ROMs / de-Googled devices without GMS fall
straight to the
{:sms_otp, :timeout}path fromarm/2.
What this is NOT
Not a full SMS-reading API. iOS has no such thing for third-party apps
and Google Play locks READ_SMS behind a review gate that rejects most
non-SMS-centric apps. If the app needs to browse SMS history or
intercept all incoming messages, this plugin is not the right shape —
see the "What this plugin is NOT" section in MobSms's @moduledoc.
Summary
Functions
Arm the platform's OTP delivery.
Types
@type code() :: String.t()
The extracted verification code from the SMS body.
Functions
@spec arm(Mob.Socket.t(), keyword()) :: Mob.Socket.t()
Arm the platform's OTP delivery.
Cross-platform delivery model: on both platforms the received code
arrives as {:change, tag, code} — the same shape a text field's
on_change handler fires. iOS emits this through QuickType autofill
directly (nothing to arm), Android emits it through this plugin when
Google Play Services delivers the SMS.
Set the SAME atom as your text field's on_change:
MobSms.OneTimeCode.arm(socket, on_receive: :code)
# in render:
<TextField
value={@code}
on_change={{self(), :code}}
text_content_type={:one_time_code}
/>
# one handle_info clause covers both platforms:
def handle_info({:change, :code, value}, socket), do: ...On the sad path (Android 5-minute timeout, GMS start failure, or an
arm/2 re-arm evicting a prior one), the plugin sends
{:sms_otp, :timeout} to the caller so screens that want to surface
"code didn't arrive" can distinguish it. Ignore the message if you don't
care — the user can always type the code manually.
Options
:on_receive— the tag to fire on the caller as{:change, tag, code}when Android's SMS Retriever delivers a code. Should match theon_changeatom on the destination text field so the same handler catches both keyboard input (including iOS QuickType autofill) and SMS Retriever delivery. Default:otp(rename it — this default is only useful for the demo screen; production apps should pass their field's tag explicitly).
Returns the socket unchanged.
Per-platform notes
iOS: no NIF is loaded, so this call falls straight through to a no-op. The autofill mechanism is entirely native — set
text_content_type: :one_time_codeon the TextField and iOS Messages surfaces the code as a QuickType suggestion above the keyboard when the field is focused; the user taps it, the code flows in as the field's normalon_changevalue.arm/2returning early on iOS keeps yourmount/3cross-platform.Android: spawns a short-lived proxy process that catches the SMS Retriever's raw delivery and forwards it as
{:change, tag, code}to the calling process. Idle for 6 minutes then dies (SMS Retriever's own timeout is 5 min; the extra minute absorbs the late-delivery edge). Re-arming from the same screen replaces the prior arm and delivers{:sms_otp, :timeout}to the prior proxy so nothing hangs.