Repository navigation
feat(sdk): keep the MFA inbound-SMS hand-off inside the host app - #279
Conversation
The MFA "inbound SMS" challenge emits an `sms:` link that the user must send before returning to the challenge and pressing Confirm. iOS now presents MFMessageComposeViewController as a sheet over the challenge; Android has no equivalent. SmsManager.sendTextMessage needs SEND_SMS, which Google Play restricts to default SMS/Phone/Assistant handlers, and an SDK manifest permission is inherited by every host app - so the hand-off to the messaging app stays. What it can do is stop dropping the user out of the host app. - Parse the link per RFC 5724: split on `?` first so a `;` subscriber parameter does not swallow the body, and treat `;` inside the query as body text. Percent-decode as UTF-8 without URLDecoder's `+`-as-space rule. - Hand it over as the documented pre-filled compose intent (ACTION_SENDTO on `smsto:`, message in `sms_body`), falling back to the raw `sms:` link for apps that only read the query, then to the existing failure. - Drop FLAG_ACTIVITY_NEW_TASK when starting from an Activity - which every render mode does - so the messaging app opens in the host app's task and `back` returns to the still-open challenge instead of the launcher. - Keep the body byte-exact: the backend matches the one-time code against the message it receives. It is never trimmed, reformatted or logged. Tests use the real link shape from a live enterprise sitekey instead of the invented `sms:+123-456-789?body=Hello%20World` fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Diffuse report: AARJAR |
|
Benchmark report:
|
E2E verified on a real device against a live MFA sitekeyPixel 7 (Android 17), example app built against the enterprise sitekey with MFA inbound SMS enabled. This closes the "not verified against the live flow" item — and it found a bug, now fixed in The bug the e2e foundThe challenge's "Open SMS" button is an anchor with
After the fix, the same tap: No chooser — Also corrected: the real link shapeThe live link has an empty leading query parameter — Full flow completedA real SMS was sent and the challenge confirmed, end to end: The example app showed Also confirmed from the brief: the number comes from a pool ( Correction to a claim in the PR descriptionI wrote that dropping
So for Google Messages specifically the flag removal changes nothing about task placement. It still holds for standard-launchMode clients, and the flag is wrong to set regardless when an Activity context is available — but the headline benefit on the most common Android messaging app is the chooser going away and the OTP no longer leaking into logcat, not the back-stack. The description overstated it; this comment is the correction. Test-environment noteEspresso's event injection is broken on Android 17 ( 🤖 Generated with Claude Code |
…mposer Verified against a live MFA sitekey on a Pixel 7: the challenge's "Open SMS" button is an anchor with target="_blank", so the link arrives through WebChromeClient.onCreateWindow, not shouldOverrideUrlLoading. The previous commit only covered the latter, so the live flow still took the old ACTION_VIEW + FLAG_ACTIVITY_NEW_TASK path: [webview] opened target=_blank link in browser: sms:+46769432675?&body=... This mirrors iOS, which handles both decidePolicyFor and createWebViewWith. Also covers the real link shape the challenge emits. It has an empty leading query parameter - `?&body=`, not `?body=` - which the parser already skipped, but nothing tested. After the fix the same tap logs: [webview] sms link intercepted, recipient: true body: true [webview] messaging app launched via android.intent.action.SENDTO and Messages opens straight to the conversation with no app chooser, because `smsto:` is not claimed by WhatsApp - which matches the challenge's own "Only SMS is supported, do not use WhatsApp" instruction. The one-time code also no longer reaches logcat; the old path logged the whole URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
03baab9 to
5a05ade
Compare
Android counterpart to the iOS in-app SMS composer (hCaptcha/HCaptcha-ios-sdk#201, commit
949f061).TL;DR
A straight port is not possible, and one of the four options is blocked by Play policy. Android has no
MFMessageComposeViewController: there is no system compose UI that returns a result to the calling app. What this PR does instead is make the unavoidable hand-off behave well — verified end to end against a live MFA sitekey on a physical device.Why not
SmsManager.sendTextMessageSEND_SMSis a restricted permission. Play requires that apps be "actively registered as the default SMS, Phone, or Assistant handler before prompting users to accept any of SMS or Call Log permissions" (policy). The exception list (SMS-based financial transactions, emergency alerts, backup, connected devices, OS automation) does not cover captcha/MFA, and every exception is subject to Play review plus a Permissions Declaration Form.Decisive for an SDK specifically: a
<uses-permission>in the SDK manifest is merged into every host app, so every integrator would inherit a permission they never asked for and would fail review for it. The July 2026 policy update also moved account-verification use cases explicitly away from SMS permissions toward the SMS Retriever and Digital Credentials APIs.A custom in-app sheet (show number + code, copy buttons, then
ACTION_SENDTO) was also considered and rejected: it still cannot send, adds a tap, duplicates the challenge's own "Show Details" view, and a copy/paste affordance invites the user to hand-edit a body that must arrive byte-exact.The manifest is unchanged — still
INTERNETonly.What this PR does
HCaptchaSmsLink.java(new) — RFC 5724 parsing?first, sosms:+1555;phone-context=+1?body=codekeeps its body. A;inside a query is body text, not a separator.sms:+46769432675?&body=…, not?body=….URLDecoderturns+into a space, which would corrupt both the number and a body containing a plus.HCaptchaWebViewHelper.java— both WebView entry pointsThe challenge's "Open SMS" button is an anchor with
target="_blank", so it arrives throughWebChromeClient.onCreateWindow— notshouldOverrideUrlLoading. Both now route through the same handler, mirroring iOS, which handlesdecidePolicyForandcreateWebViewWithalike. Covering only the navigation path would have left the live flow on the old code.The hand-off is
ACTION_SENDTOonsmsto:<number>with the message insms_body(+EXTRA_TEXT), falling back to the rawsms:link for apps that only read the query, then to the existingINTERNAL_ERROR. NoresolveActivitycall, so no<queries>entry is needed.Concrete wins, measured on device:
smsto:is not claimed by non-SMS appsFLAG_ACTIVITY_NEW_TASKTests
HCaptchaSmsLinkTest: the live link shape, subscriber params,;-in-query, empty leading query parameter, legacy;body=,+preservation, UTF-8, malformed escapes,smsto:/sms:///case-insensitivity.testSmsHandledandtestSmsHandledFromNewWindowdrive a plain anchor and atarget="_blank"anchor respectively, asserting the byte-exact body and the absence ofFLAG_ACTIVITY_NEW_TASK. The fixture uses the real link shape observed on a live sitekey instead of the inventedsms:+123-456-789?body=Hello%20World../gradlew checkgreen across all modules.Verified end to end
Pixel 7, example app built against an enterprise sitekey with MFA inbound SMS enabled. A real SMS was sent and the challenge confirmed:
Messages opened straight to the conversation, pre-filled byte-exact including the hyphenated code;
Confirm Codere-enabled itself web-side on return; the app received aP1_…token. ~52s from composer to return, well inside the ~5 min TTL.Also confirmed from live runs: the inbound number comes from a pool that spans countries —
+46769432675,+46769439873and+37066803047were all observed. Nothing here hardcodes or caches it.Known limitations
backdoes not return to the challenge in one press on Google Messages. ItsLaunchConversationActivityisLAUNCH_SINGLE_TASK, so it pulls up its own task regardless of the flag: back Adjust content overlay color #1 lands on the Messages conversation list, back Enforce consistent overlays #2 on the host app, with the challenge still open and intact. DroppingFLAG_ACTIVITY_NEW_TASKstill holds for standard-launchMode clients, and setting it is wrong regardless when an Activity context is available — but on the most common Android messaging app the real wins are the chooser and the log leak, not the back stack.NoSuchMethodException: android.hardware.input.InputManager.getInstance), so instrumentation tests cannot run on such a device — pre-existing tests fail there too. They pass on an API 35 emulator.Note for reviewers
The version bump to 5.2.0 skips 5.1.0. Intentional as far as I know, but worth a second pair of eyes before merge.
🤖 Generated with Claude Code