All notable changes to mob_sms are documented here.
Format: Keep a Changelog. Versioning: SemVer.
[0.2.2] - 2026-10-01
Changed
- Releases are signed with the mob first-party key.
priv/mob_plugin.pub(the shared first-party public key, fingerprinted25519:nc56w+1Kx0gIt/4EkHxnMZCKHMzp4+S5kS/HoSzEZkg=) is now committed, and the release workflow regeneratespriv/mob_plugin.sigfrom the current manifest before every Hex publish, somob_devhosts verify the package instead of reporting it unsigned.
[0.2.1] - 2026-09-18
⚠ Consumer action required
If you copied the 0.2.0 OTP example from README.md or MobSms.OneTimeCode's @moduledoc, your <TextField> currently reads on_change={:code} (bare atom). Mob's renderer silently drops that form, so no handle_info({:change, :code, value}, _) clause ever fires — the field fills visually from iOS QuickType or Android SMS Retriever, but your assign doesn't run. Change it to on_change={{self(), :code}} (a {pid, tag} tuple).
Fixed
- Demo screen
on_changewiring:MobSms.DemoScreen's OTP<TextField>used the same wrong bare-atom form as the docs. Fixed toon_change={{self(), :code}}. Verified end-to-end on physical iPhone SE (iOS QuickType) and Moto G Power 5G 2024 (Android SMS Retriever) after the fix. - Regression test: source-level assertion in
MobSms.DemoScreenTestwalksdemo_screen.exand fails if anyon_change=oron_tap=binding is a bare atom rather than a{pid, tag}tuple. This exact bug is what slipped through 0.2.0's publish; the test pins the form so it can't silently regress.
Docs
- README +
MobSms.OneTimeCode@moduledoc:on_change={:code}→on_change={{self(), :code}}in the usage examples so the pattern the docs teach is one that actually fires. Same correction applied toMobSms.DemoScreen's @moduledoc and inline comment.
[0.2.0] - 2026-09-18
Added
MobSms.OneTimeCode.arm(socket, on_receive: tag)— cross-platform SMS-delivered OTP autofill. Both platforms deliver the code as{:change, tag, code}(same shape a text field'son_changefires), so onehandle_infoclause covers iOS QuickType autofill AND Android's SMS Retriever. Pass the SAME atom as your text field'son_changetag.- iOS: no-op at the Elixir layer. Autofill is driven by the new
text_content_type: :one_time_codeprop on<TextField>(added in mob 0.9.1). iOS Messages parses the incoming SMS, surfaces the code as a QuickType suggestion above the keyboard, user taps to fill. Code flows in via the field's normalon_change. No entitlement, no framework, no server-side hash. - Android: registers a scoped BroadcastReceiver (only Google Play Services can broadcast to it, via the
com.google.android.gms.auth.api.phone.permission.SENDgate) and callsSmsRetrieverClient.startSmsRetriever(). Delivery lands via a short-lived Elixir proxy process that translates the raw{:sms_otp, code}into{:change, tag, code}and forwards to the caller. NoREAD_SMSpermission, no Play Store review gate. - Sad path: on Android,
{:sms_otp, :timeout}fires on 5-min retriever timeout, GMS start failure, or eviction by a subsequentarm/2. Optional handler — a retype prompt is a fine default. - Server-side SMS format for Android: body + blank line + 11-char hash suffix. Full spec +
AppSignatureHelpersnippet for computing the hash inMobSms.OneTimeCode's @moduledoc.
- iOS: no-op at the Elixir layer. Autofill is driven by the new
- Demo screen now exercises the OTP flow alongside the composer.
Changed
- mob floor bumped to
~> 0.9.1. The OTP flow's iOS half needs thetext_content_typeprop, added in that release. Bump generated apps accordingly. - Plugin manifest declares
com.google.android.gms:play-services-auth-api-phone:18.0.2as agradle_depscontribution. Present on 99%+ of Play-installed devices; aftermarket ROMs without Google Play Services fall straight to{:sms_otp, :timeout}fromarm/2.
Fixed (0.2.0 review pass, before publish)
- Kotlin bridge: re-arm evicts the previous receiver AND delivers
{:sms_otp, :timeout}to the prior caller instead of leaving it hanging. Idempotency guard ononReceive(compare receiver identity, not just presence) prevents double-delivery on queued-post-unregister edge.startSmsRetrieverwrapped intry/catchfor the synchronous-throw path GMS-absent devices sometimes take. - Kotlin regex:
extractCodenow prefers the LAST 4–8-digit run in the SMS body proper (stripping the 11-char hash suffix first). Previous.find(body)behaviour returned the first digit run — "MyApp2024 code is 123456" would deliver "2024" instead of "123456". - Zig NIF:
sendSmsOtphandles theenif_make_new_binaryOOM path.
[0.1.2] - 2026-09-18
Fixed
- iOS: use-after-free crash on overlapping composes (adversarial review of 0.1.1 caught this).
MobSmsDelegate'srelease_activecalledremoveAllObjectson the retention array — if two composes overlapped, the first one to finish dropped every strong ref, including the still-active second delegate. UIKit then delivereddidFinishWithResult:on a dangling pointer. Now takes the finishing delegate and callsremoveObject:so overlapping composes each survive to their own callback. Reproduction: fireMobSms.compose/2twice from a screen without waiting for the first{:sms, _}message; the second sheet's Send/Cancel crashed the app. - iOS: top-VC walk now bails when the top VC is mid-transition. Presenting on a VC whose
isBeingDismissedorisBeingPresentedis set silently no-ops in UIKit and the delegate never fires, leaving the caller waiting forever for a{:sms, _}. The plugin now returns:not_availablein that state. - Android: JVM-teardown and never-registered-bridge paths delivered no result to the caller.
callBridgeCompose'sget_jenvand null-bridge-class branches previously returned:errorwithout sending{:sms, :not_available}— the caller waited forever. Both branches now honour the "one message per compose" contract.
Docs
- Comment on
sms_str_from_iolist(iOS NIF) explains the correct-but-double-negate!enif_inspect_binary && !enif_inspect_iolist_as_binaryguard so a future reader doesn't "clean it up" into the wrong semantics.
Tests
MobSms.compose/2's argument-coercion path is now split intoMobSms.normalize_opts/1, a testable helper. The previous tests wrapped the coercion insideassert_raise ErlangError, which fired on the missing NIF instead of on the coercion — deleting the|> to_string()calls did not fail the suite. The rewritten tests observenormalize_opts/1's output directly, so a regression to the coercion is caught. Also assertsProtocol.UndefinedErrorpropagates for values with noString.Charsimpl (rather than being swallowed).
[0.1.1] - 2026-09-18
Docs
- Rework the "not silent-send" section of
MobSms's @moduledoc and the same section in README.md so ex_doc's function-link resolver isn't asked to resolve a hypothetical futuresend/2on this module. Removes amix docswarning; the deferred-until-a-user-asks stance on silent send is unchanged.
[0.1.0] - 2026-09-18
Initial release. Cross-platform SMS composer plugin.
Added
MobSms.compose/2— opens the system SMS composer pre-filled with the given:toand:body. Async result delivered as{:sms, atom}to the calling process.iOS:
MFMessageComposeViewControllerpresented from the top VC, delegate reports:sent | :cancelled | :failed.- Android:
Intent.ACTION_SENDTOwithsmsto:hands off to the user's default SMS app, delivers:composer_opened(Android cannot observe the send outcome). - Both platforms deliver
:not_availablewhen the device cannot send SMS (simulator on iOS; no SMS-capable app on Android). MobSms.DemoScreen— a ready-to-run screen exercising the composer flow, auto-listed by the host's plugin home if it enumeratesMob.Plugins.screens/0.- Plugin manifest declares the MessageUI framework on iOS and no permissions on either platform.