Skip to content

feat(sdk): keep the MFA inbound-SMS hand-off inside the host app - #279

Merged
Mourtadah-Karim-IMI merged 7 commits into
mainfrom
feat/sms-composer-handoff
Oct 13, 2026
Merged

Mourtadah-Karim-IMI merged 7 commits into
mainfrom
feat/sms-composer-handoff

Conversation

@CAMOBAP

@CAMOBAP CAMOBAP commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

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.sendTextMessage

SEND_SMS is 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 INTERNET only.

What this PR does

HCaptchaSmsLink.java (new) — RFC 5724 parsing

  • Splits on ? first, so sms:+1555;phone-context=+1?body=code keeps its body. A ; inside a query is body text, not a separator.
  • Handles the empty leading query parameter the live challenge actually emits: sms:+46769432675?&body=…, not ?body=….
  • Percent-decodes as UTF-8 by hand: URLDecoder turns + into a space, which would corrupt both the number and a body containing a plus.
  • The body is passed through byte-exact — the backend matches the one-time code against the message it receives, and the code is hyphenated in the SMS body but unhyphenated in "Show Details". Never trimmed, reformatted or logged.

HCaptchaWebViewHelper.java — both WebView entry points

The challenge's "Open SMS" button is an anchor with target="_blank", so it arrives through WebChromeClient.onCreateWindow — not shouldOverrideUrlLoading. Both now route through the same handler, mirroring iOS, which handles decidePolicyFor and createWebViewWith alike. Covering only the navigation path would have left the live flow on the old code.

The hand-off is ACTION_SENDTO on smsto:<number> with the message in sms_body (+ EXTRA_TEXT), falling back to the raw sms: link for apps that only read the query, then to the existing INTERNAL_ERROR. No resolveActivity call, so no <queries> entry is needed.

Concrete wins, measured on device:

before after
app chooser offered WhatsApp, which the challenge tells users not to use none — smsto: is not claimed by non-SMS apps
one-time code in logcat whole URL logged, code included never logged
FLAG_ACTIVITY_NEW_TASK always set only when there is no Activity context

Tests

  • 14 unit tests in HCaptchaSmsLinkTest: the live link shape, subscriber params, ;-in-query, empty leading query parameter, legacy ;body=, + preservation, UTF-8, malformed escapes, smsto:/sms:///case-insensitivity.
  • testSmsHandled and testSmsHandledFromNewWindow drive a plain anchor and a target="_blank" anchor respectively, asserting the byte-exact body and the absence of FLAG_ACTIVITY_NEW_TASK. The fixture uses the real link shape observed on a live sitekey instead of the invented sms:+123-456-789?body=Hello%20World.
  • ./gradlew check green 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:

[webview] sms link intercepted, recipient: true body: true
[webview] messaging app launched via android.intent.action.SENDTO
DialogFragment.onStart                                  <- returned to the app
onLoadResource https://api.hcaptcha.com/mfa/smsin/check/…
JSInterface.onPass
HCaptcha.onSuccess

Messages opened straight to the conversation, pre-filled byte-exact including the hyphenated code; Confirm Code re-enabled itself web-side on return; the app received a P1_… 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, +46769439873 and +37066803047 were all observed. Nothing here hardcodes or caches it.

Known limitations

  • back does not return to the challenge in one press on Google Messages. Its LaunchConversationActivity is LAUNCH_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. Dropping FLAG_ACTIVITY_NEW_TASK still 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.
  • Android cannot report sent/cancelled/failed back to the app. That part of the iOS PR has no equivalent and is not attempted.
  • Re-enabling the challenge's Confirm button stays web-side; untouched.
  • Espresso's event injection is broken on Android 17 (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

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>
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Diffuse report:

OLD: sdk-main.aar
NEW: sdk-pr.aar

 AAR      │ old       │ new       │ diff     
──────────┼───────────┼───────────┼──────────
      jar │  95.4 KiB │  98.8 KiB │ +3.4 KiB 
 manifest │     411 B │     411 B │      0 B 
      res │  28.3 KiB │  28.3 KiB │      0 B 
    other │     1 KiB │     1 KiB │      0 B 
──────────┼───────────┼───────────┼──────────
    total │ 125.1 KiB │ 128.5 KiB │ +3.4 KiB 

 JAR     │ old │ new │ diff         
─────────┼─────┼─────┼──────────────
 classes │  61 │  62 │  +1 (+1 -0)  
 methods │ 805 │ 835 │ +30 (+34 -4) 
  fields │ 272 │ 287 │ +15 (+15 -0)
AAR
 size     │ diff     │ path          
──────────┼──────────┼───────────────
 98.8 KiB │ +3.4 KiB │ ∆ classes.jar 
──────────┼──────────┼───────────────
 98.8 KiB │ +3.4 KiB │ (total)
JAR
CLASSES:

   old │ new │ diff       
  ─────┼─────┼────────────
   61  │ 62  │ +1 (+1 -0) 
  
  + com.hcaptcha.sdk.HCaptchaSmsLink
  

METHODS:

   old │ new │ diff         
  ─────┼─────┼──────────────
   805 │ 835 │ +30 (+34 -4) 
  
  + com.hcaptcha.sdk.HCaptchaSmsLink <init>(String, String)
  + com.hcaptcha.sdk.HCaptchaSmsLink decodeEscape(String, int) → int
  + com.hcaptcha.sdk.HCaptchaSmsLink firstRecipient(String) → String
  + com.hcaptcha.sdk.HCaptchaSmsLink flush(StringBuilder, ByteArrayOutputStream)
  + com.hcaptcha.sdk.HCaptchaSmsLink getBody() → String
  + com.hcaptcha.sdk.HCaptchaSmsLink getRecipient() → String
  + com.hcaptcha.sdk.HCaptchaSmsLink indexOfAny(String, String, int) → int
  + com.hcaptcha.sdk.HCaptchaSmsLink parse(String) → HCaptchaSmsLink
  + com.hcaptcha.sdk.HCaptchaSmsLink percentDecode(String) → String
  + com.hcaptcha.sdk.HCaptchaSmsLink value(String, String, String) → String
  + com.hcaptcha.sdk.HCaptchaWebViewHelper access_100(HCaptchaWebViewHelper, String) → boolean
  + com.hcaptcha.sdk.HCaptchaWebViewHelper access_200(HCaptchaWebViewHelper) → HCaptchaWebView
  + com.hcaptcha.sdk.HCaptchaWebViewHelper access_300(HCaptchaWebViewHelper) → IHCaptchaVerifier
  + com.hcaptcha.sdk.HCaptchaWebViewHelper access_400(HCaptchaWebViewHelper) → Context
  + com.hcaptcha.sdk.HCaptchaWebViewHelper isBackedByActivity(Context) → boolean
  + com.hcaptcha.sdk.HCaptchaWebViewHelper openSmsComposer(String) → boolean
  + com.hcaptcha.sdk.HCaptchaWebViewHelper smsComposerIntent(String, String) → Intent
  + com.hcaptcha.sdk.HCaptchaWebViewHelper startActivitySafely(Intent) → boolean
  + android.content.ContextWrapper getBaseContext() → Context
  + android.content.Intent getAction() → String
  + android.content.Intent putExtra(String, String) → Intent
  + java.io.ByteArrayOutputStream <init>(int)
  + java.io.ByteArrayOutputStream toString(String) → String
  + java.io.ByteArrayOutputStream write(byte[], int, int)
  + java.io.ByteArrayOutputStream write(int)
  + java.lang.Character digit(char, int) → int
  + java.lang.String charAt(int) → char
  + java.lang.String equalsIgnoreCase(String) → boolean
  + java.lang.String indexOf(int) → int
  + java.lang.String indexOf(int, int) → int
  + java.lang.String toLowerCase(Locale) → String
  + java.lang.StringBuilder <init>(int)
  + java.lang.StringBuilder append(char) → StringBuilder
  + java.lang.StringBuilder setLength(int)
  
  - com.hcaptcha.sdk.HCaptchaWebViewHelper access_100(HCaptchaWebViewHelper) → Context
  - com.hcaptcha.sdk.HCaptchaWebViewHelper access_200(HCaptchaWebViewHelper) → IHCaptchaVerifier
  - com.hcaptcha.sdk.HCaptchaWebViewHelper access_300(HCaptchaWebViewHelper) → HCaptchaWebView
  - android.webkit.WebChromeClient onCreateWindow(WebView, boolean, boolean, Message) → boolean
  

FIELDS:

   old │ new │ diff         
  ─────┼─────┼──────────────
   272 │ 287 │ +15 (+15 -0) 
  
  + com.hcaptcha.sdk.HCaptchaSmsLink BODY_KEY: String
  + com.hcaptcha.sdk.HCaptchaSmsLink CHARSET_NAME: String
  + com.hcaptcha.sdk.HCaptchaSmsLink ESCAPE_LENGTH: int
  + com.hcaptcha.sdk.HCaptchaSmsLink HEAD_SEPARATORS: String
  + com.hcaptcha.sdk.HCaptchaSmsLink HEX_RADIX: int
  + com.hcaptcha.sdk.HCaptchaSmsLink HEX_SHIFT: int
  + com.hcaptcha.sdk.HCaptchaSmsLink NOT_FOUND: int
  + com.hcaptcha.sdk.HCaptchaSmsLink QUERY_SEPARATORS: String
  + com.hcaptcha.sdk.HCaptchaSmsLink SMSTO_SCHEME: String
  + com.hcaptcha.sdk.HCaptchaSmsLink SMS_SCHEME: String
  + com.hcaptcha.sdk.HCaptchaSmsLink body: String
  + com.hcaptcha.sdk.HCaptchaSmsLink recipient: String
  + com.hcaptcha.sdk.HCaptchaWebViewHelper SMSTO_SCHEME: String
  + com.hcaptcha.sdk.HCaptchaWebViewHelper SMS_BODY_EXTRA: String
  + java.util.Locale ROOT: Locale

@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Benchmark report:

Test name Time ms. (median) Allocations (median)
com.hcaptcha.sdk.HCaptchaBenchmarkTest.EMULATOR_UNLOCKED_benchmarkInvisibleVerification +819.59 +26
com.hcaptcha.sdk.HCaptchaBenchmarkTest.EMULATOR_UNLOCKED_benchmarkInvisibleVerificationColdRun +1030.24 +547
com.hcaptcha.sdk.HCaptchaBenchmarkTest.EMULATOR_UNLOCKED_benchmarkInvisibleSetup +20.55 +515
com.hcaptcha.sdk.HCaptchaDebugInfoTest.EMULATOR_UNLOCKED_benchmarkDebugInfo +39.53 -5570
com.hcaptcha.sdk.HCaptchaDebugInfoTest.EMULATOR_UNLOCKED_benchmarkDebugSys +220.28 +1

@CAMOBAP CAMOBAP self-assigned this Sep 20, 2026
@CAMOBAP

CAMOBAP commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

E2E verified on a real device against a live MFA sitekey

Pixel 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 03baab9.

The bug the e2e found

The challenge's "Open SMS" button is an anchor with target="_blank". It therefore reaches the SDK through WebChromeClient.onCreateWindow, not shouldOverrideUrlLoading — so the first commit never ran on the live flow:

D hcaptcha: [webview] opened target=_blank link in browser: sms:+46769432675?&body=Return%20to%20the%20app...
I ActivityTaskManager: START u0 {act=android.intent.action.VIEW dat=sms:... flg=0x10000000 cmp=android/...ResolverActivity}

flg=0x10000000 is FLAG_ACTIVITY_NEW_TASK — the old path, and an app chooser offering WhatsApp, which the challenge's own copy tells users not to use. iOS handles both decidePolicyFor and createWebViewWith; only the first had been ported. Fixed, with a regression test (testSmsHandledFromNewWindow) driving a target="_blank" anchor.

After the fix, the same tap:

D hcaptcha: [webview] sms link intercepted, recipient: true body: true
I ActivityTaskManager: START u0 {act=android.intent.action.SENDTO dat=smsto:... cmp=com.google.android.apps.messaging/.ui.conversation.LaunchConversationActivity (has extras)}
D hcaptcha: [webview] messaging app launched via android.intent.action.SENDTO

No chooser — smsto: is not claimed by WhatsApp, so Messages opens directly.

Also corrected: the real link shape

The live link has an empty leading query parameter — sms:+46769432675?&body=..., not ?body=. The parser already skipped it; nothing tested it. Now covered in both the unit tests and the androidTest fixture.

Full flow completed

A real SMS was sent and the challenge confirmed, end to end:

00:06:03 [webview] sms link intercepted, recipient: true body: true
00:06:03 [webview] messaging app launched via android.intent.action.SENDTO
00:06:55 DialogFragment.onStart                                   <- returned to the app
00:06:57 onLoadResource https://api.hcaptcha.com/mfa/smsin/check/0f80da0e-...
00:06:58 onLoadResource https://api.hcaptcha.com/checkcaptcha/0f80da0e-...
00:06:58 JSInterface.onPass
00:06:58 HCaptcha.onSuccess

The example app showed Status: Success with a P1_… token. ~52s from composer to return, well inside the ~5 min TTL. The body reached Messages byte-exact, including the hyphenated code, and Confirm Code re-enabled itself web-side on return, as expected.

Also confirmed from the brief: the number comes from a pool (+46769432675 this run), and the code is hyphenated in the SMS body (mcsp-u3oz-s4du) but unhyphenated in "Show Details" (mcspu3ozs4du).

Correction to a claim in the PR description

I wrote that dropping FLAG_ACTIVITY_NEW_TASK makes back return to the challenge, with a caveat about singleTask messaging apps. On Google Messages the caveat is what actually happens. Its LaunchConversationActivity starts with LAUNCH_SINGLE_TASK and pulls up its own task, so:

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 note

Espresso's event injection is broken on Android 17 (NoSuchMethodException: android.hardware.input.InputManager.getInstance), so the instrumentation tests can't run on that Pixel 7 — the pre-existing testSmsHandled fails there too. Both SMS tests pass on the API 35 emulator; ./gradlew check is green across all modules.

🤖 Generated with Claude Code

CAMOBAP and others added 2 commits September 22, 2026 00:55
…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>
@CAMOBAP
CAMOBAP force-pushed the feat/sms-composer-handoff branch from 03baab9 to 5a05ade Compare September 21, 2026 22:56
@CAMOBAP
CAMOBAP marked this pull request as ready for review September 21, 2026 22:57
@CAMOBAP
CAMOBAP requested a review from e271828- October 4, 2026 13:10
Comment thread compose-sdk/build.gradle Outdated
Comment thread sdk/src/main/java/com/hcaptcha/sdk/HCaptchaWebViewHelper.java Outdated
Comment thread test/src/androidTest/java/com/hcaptcha/sdk/HCaptchaDialogFragmentTest.java Outdated
Comment thread sdk/src/main/java/com/hcaptcha/sdk/HCaptchaSmsLink.java Outdated
Comment thread sdk/src/main/java/com/hcaptcha/sdk/HCaptchaWebViewHelper.java Outdated
Comment thread sdk/src/main/java/com/hcaptcha/sdk/HCaptchaWebViewHelper.java
@Mourtadah-Karim-IMI
Mourtadah-Karim-IMI merged commit fcbd188 into main Oct 13, 2026
12 checks passed
@Mourtadah-Karim-IMI
Mourtadah-Karim-IMI deleted the feat/sms-composer-handoff branch October 13, 2026 12:29
Comment thread CHANGES.md
Comment thread CHANGES.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants