> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Hosted wallet connector on Android (headless)

> Render your own native wallet list with a hidden WebView engine. Connect, sign messages, send a display-unit amount, and sign transactions. No wallet SDK in your app.

> [!NOTE]
>
> This is an enterprise-only feature. Please [contact us](https://www.dynamic.xyz/book-a-call) to enable.

The same headless architecture as iOS: render your own native list, and drive a **hidden** `WebView` that runs the Dynamic SDK and returns results (including message and transaction signatures) over a JS bridge. No wallet SDK in the app.

The [basic Android flow](https://www.dynamic.xyz/docs/connections/android.md) is the recommended default.

> [!NOTE]
>
> **No SDK in your app.** Your app links no wallet SDK. It needs a hidden `WebView` pointed at the hosted engine page (`https://connect.dynamicauth.com/headless.html`, set as `ENGINE_URL` in `FireblocksHeadlessConnect`) and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list) comes from that hosted view. Same bridge contract as iOS.

Copy `FireblocksHeadlessConnect.kt` (below) and `FireblocksConnect.kt` from the [basic Android guide](https://www.dynamic.xyz/docs/connections/android.md) (visible fallback).

## 1. Get the wallet menu (no static file)

The engine derives the list live from the Dynamic catalog and pushes it over the bridge (a `wallets` message). Set `FireblocksHeadlessConnect.onWallets`.

| Field | Type | Description |
| - | - | - |
| `key` | `String` | Catalog key you pass back on tap (e.g. `metamask`). |
| `name` / `icon` | `String` | Display name and icon URL for the row. |
| `chains` | `List<String>` | `evm` / `solana`. Drives a native chain picker. |
| `mode` | `"headless" \| "fallback"` | `headless` connects silently; `fallback` opens the [visible flow](https://www.dynamic.xyz/docs/connections/android.md). |
| `featured` | `Boolean` | Show by default; the rest of the catalog rides along for search. |

## 2. Drop in FireblocksHeadlessConnect

Owns a hidden `WebView`, bridges to it (`addJavascriptInterface` + `evaluateJavascript`), opens the wallet deeplink it returns, and calls you back. Pre-warm it at launch. Set `environmentId` before `prewarm()` to target a different Dynamic environment, and `locale` / `theme` the same way to override the engine's UI locale and theme; all three are forwarded to the iframe app as query parameters.

```kotlin MainActivity.kt theme={"system"}
FireblocksHeadlessConnect.environmentId = "b1e3aca9-0646-411a-b4ab-c31ce49935b3"
FireblocksHeadlessConnect.prewarm(this)               // at launch
FireblocksHeadlessConnect.onWallets = { render(it) }  // the live list

FireblocksHeadlessConnect.connect(this, "metamask", "evm") { result ->
    when (result) {
        is FireblocksHeadlessConnect.Result.Success -> { /* result.wallet */ }
        is FireblocksHeadlessConnect.Result.FallbackRequired -> { /* visible flow */ }
        is FireblocksHeadlessConnect.Result.Failure -> { /* result.code */ }
    }
}
```

> [accordion] View FireblocksHeadlessConnect.kt (copy-paste ready)
>
> ````kotlin FireblocksHeadlessConnect.kt theme={"system"}
> package com.fireblocks.connect
>
> import android.annotation.SuppressLint
> import android.app.Activity
> import android.content.Context
> import android.content.Intent
> import android.graphics.Bitmap
> import android.net.Uri
> import android.os.Handler
> import android.os.Looper
> import android.util.Log
> import android.view.ViewGroup
> import android.webkit.JavascriptInterface
> import android.webkit.WebResourceRequest
> import android.webkit.WebView
> import android.webkit.WebViewClient
> import java.util.UUID
> import org.json.JSONArray
> import org.json.JSONObject
>
> // ── Headless connect engine (Android) ────────────────────────────────────────
>
> /**
>  * Runs the hosted Fireblocks connect logic (the Dynamic SDK) inside a HIDDEN
>  * [WebView], so the app can render its own native wallet list and still keep
>  * every bit of connection logic in the web layer. The Android analog of iOS's
>  * `FireblocksHeadlessConnect`.
>  *
>  * For WalletConnect-protocol wallets (MetaMask, Rainbow, Trust, …) the pairing
>  * is relay-based: the engine mints a URI, we open the wallet via deeplink, the
>  * user approves, and the approval resolves over a WebSocket — no visible page.
>  * Wallets with no such path (Base Account passkey/email, …) come back as
>  * [Result.FallbackRequired] so the caller opens the visible [FireblocksConnect].
>  *
>  * The app links **no wallet SDK**: it loads a URL, relays JSON messages over a
>  * bridge, opens a deeplink, and renders a list. Everything wallet-specific is
>  * JavaScript in the hidden WebView.
>  *
>  * ```kotlin
>  * FireblocksHeadlessConnect.prewarm(activity)                 // at launch
>  * FireblocksHeadlessConnect.connect(activity, "rainbow", "evm") { result ->
>  *     when (result) {
>  *         is FireblocksHeadlessConnect.Result.Success -> { /* result.wallet */ }
>  *         is FireblocksHeadlessConnect.Result.FallbackRequired -> { /* visible flow */ }
>  *         is FireblocksHeadlessConnect.Result.Failure -> { /* result.code */ }
>  *     }
>  * }
>  * ```
>  */
> object FireblocksHeadlessConnect {
>
>     private const val TAG = "FireblocksHeadlessConnect"
>
>     private const val DEFAULT_ENGINE_BASE_URL = "https://connect.dynamicauth.com/headless.html"
>
>     /**
>      * The no-UI engine page. Only this page's scheme, host and port may post
>      * messages to the app or load top-level in the engine WebView.
>      *
>      * Set this BEFORE [prewarm]/[connect], like [environmentId]: the URL is
>      * resolved when the hidden WebView is first created. Point it at your own
>      * deployment of the engine, never at a URL taken from untrusted input.
>      */
>     var engineBaseUrl: String = DEFAULT_ENGINE_BASE_URL
>
>     /** Points Phantom's redirect at your app scheme so it returns to the app.
>      *  Replace `myapp` with your scheme. */
>     private const val RETURN_SCHEME = "myapp"
>
>     /**
>      * Dynamic environment ID for the engine page to use instead of the one it was
>      * built with, sent as `?environmentId=<uuid>`. `null`/blank leaves the page on
>      * its own default, which is the pre-existing behavior.
>      *
>      * Set this BEFORE [prewarm]/[connect]: the URL is resolved when the hidden
>      * WebView is first created, so a later change only takes effect on the next
>      * engine reload. Pass the same value to [FireblocksConnect.present] so the
>      * visible fallback flow lands on the same environment.
>      */
>     var environmentId: String? = null
>
>     /**
>      * UI locale for the iframe app, sent as `?locale=<value>` — e.g. `"en_US"`,
>      * `"es_LA"`; see the iframe app's README for the current allow-list.
>      * `null`/blank leaves the page on its own default (`en_US`). Never changes
>      * anything on the native side — it's forwarded as-is to the iframe app.
>      *
>      * Set this BEFORE [prewarm]/[connect]: same resolution timing as [environmentId].
>      */
>     var locale: String? = null
>
>     /**
>      * UI theme for the iframe app, sent as `?theme=<value>` — `"light"` or
>      * `"dark"`. `null`/blank leaves the page on its own default (`light`).
>      * Never changes anything on the native side — it's forwarded as-is to
>      * the iframe app.
>      *
>      * Set this BEFORE [prewarm]/[connect]: same resolution timing as [environmentId].
>      */
>     var theme: String? = null
>
>     /**
>      * How the iframe app connects Phantom, sent as `?phantomMode=<value>` —
>      * `"in-app"` (default) or `"redirect"`; see the iframe app's README.
>      * `null`/blank omits the param. Forwarded as-is to the iframe app.
>      *
>      * Set this BEFORE [prewarm]/[connect]: same resolution timing as [environmentId].
>      */
>     var phantomMode: String? = null
>
>     /**
>      * The app's own https URL Phantom returns to, sent as
>      * `?walletReturnUrl=<value>` and used as Phantom's `redirect_link` when
>      * [phantomMode] is `"redirect"`. `null`/blank omits the param and the
>      * engine falls back to `<returnScheme>://phantom-headless`. Forwarded
>      * as-is.
>      *
>      * Set this BEFORE [prewarm]/[connect]: same resolution timing as [environmentId].
>      */
>     var walletReturnUrl: String? = null
>
>     /** If the engine hasn't produced a deeplink within this window, fall back to
>      *  the visible flow. Cancelled once the wallet opens. */
>     private const val STARTUP_TIMEOUT_MS = 20_000L
>
>     private const val SIGN_TIMEOUT_MS = 60_000L
>
>     /** Longer than [SIGN_TIMEOUT_MS]: a send can involve two sequential wallet
>      *  approvals (switch network, then send) rather than one, so 60s is too
>      *  tight a budget to fail loud on before the user's had a fair chance to
>      *  clear both prompts. */
>     private const val SEND_TIMEOUT_MS = 120_000L
>
>     /** Wallet universal-link hosts iOS/Android won't hand to the wallet app from
>      *  inside a WebView (only Phantom's redirect navigates the WebView today) —
>      *  the WebViewClient opens these externally. */
>     private val WALLET_HOSTS = setOf(
>         "phantom.app", "phantom.com",
>         "link.metamask.io", "metamask.app.link",
>         "link.trustwallet.com", "rnbwapp.com", "rainbow.me",
>         "www.okx.com", "link.okx.com", "zerion.io",
>     )
>
>     internal fun isWalletHost(host: String): Boolean = WALLET_HOSTS.any { host == it || host.endsWith(".$it") }
>
>     /** Bridge message types that aren't tied to a single connect() attempt, so
>      *  they're exempt from the per-attempt `requestId` check in `handleMessage`.
>      *  "openWallet" carries a sign/send requestId (informational), never a
>      *  connect attempt id — gating it on the connect slot would drop it. */
>     private val NO_REQUEST_ID_TYPES = setOf("ready", "wallets", "event", "openWallet")
>     private val SIGN_TYPES = setOf("signed", "signFailed")
>     private val SIGN_TX_TYPES = setOf("signedTx", "signTxFailed")
>     private val SEND_TX_TYPES = setOf("sentTx", "sentTxFailed")
>
>     /** [engineBaseUrl] plus the params the engine reads on load. */
>     private fun engineUrl(): String {
>         val builder = Uri.parse(engineBaseUrl)
>             .buildUpon()
>             .appendQueryParameter("returnScheme", RETURN_SCHEME)
>         environmentId
>             ?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("environmentId", it) }
>         locale
>             ?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("locale", it) }
>         theme
>             ?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("theme", it) }
>         phantomMode
>             ?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("phantomMode", it) }
>         walletReturnUrl
>             ?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("walletReturnUrl", it) }
>         return builder.build().toString()
>     }
>
>     /** A wallet in the native list, delivered live by the engine (derived from
>      *  the Dynamic catalogue — no static file). */
>     data class Wallet(
>         val key: String,
>         val name: String,
>         val icon: String?,
>         val chains: List<String>,
>         /** "headless" → drive this engine; "fallback" → the visible flow. */
>         val mode: String,
>         /** Shown by default; the rest of the catalogue rides along for search. */
>         val featured: Boolean,
>         /**
>          * The wallet's in-app-browser URL template (`{{encodedDappURI}}`), when
>          * the wallet book carries one — `null` otherwise.
>          *
>          * NOT one of [chains]: it means "this wallet can open a URL in its own
>          * browser", where it injects its providers. Some wallets are only
>          * reachable that way for some chains (Phantom on EVM — see
>          * [FireblocksWalletBrowser]). The template says nothing about WHICH
>          * chains that browser serves, so don't infer chain support from it.
>          */
>         val inAppBrowser: String? = null,
>     ) {
>         val isMultiChain: Boolean get() = chains.size > 1
>     }
>
>     sealed class Result {
>         data class Success(val wallet: WalletConnection) : Result()
>         data class FallbackRequired(val reason: String) : Result()
>         data class Failure(val code: String, val message: String) : Result()
>     }
>
>     sealed class SignResult {
>         data class Success(val signature: String) : SignResult()
>         data class Failure(val code: String, val message: String) : SignResult()
>     }
>
>     /** Result of [signTransaction], which signs without broadcasting. */
>     sealed class SignTxResult {
>         data class Success(val signedTransaction: String, val chain: String) : SignTxResult()
>         data class Failure(val code: String, val message: String) : SignTxResult()
>     }
>
>     sealed class SendResult {
>         /** [chain] is "evm", "solana", or "bitcoin" — [txHash] is the
>          *  on-chain transaction hash (EVM), signature (Solana, which IS the
>          *  transaction's identifier), or transaction ID (Bitcoin); either way
>          *  it's already submitted. */
>         data class Success(val txHash: String, val chain: String) : SendResult()
>         data class Failure(val code: String, val message: String) : SendResult()
>     }
>
>     private val main = Handler(Looper.getMainLooper())
>     private var appContext: Context? = null
>     private var webView: WebView? = null
>     private var ready = false
>     private val pendingReady = mutableListOf<() -> Unit>()
>     // Phantom return URLs that arrived before the page was ready (a cold start
>     // or an engine reset): evaluating them then would hit an undefined headlessConnect.
>     private val pendingReturnScripts = mutableListOf<String>()
>
>     // Single in-flight attempt (mirrors the iOS engine). For production, hold
>     // this in a ViewModel so it survives configuration changes / process death.
>     private var handler: ((Result) -> Unit)? = null
>     private var timeout: Runnable? = null
>
>     // UUID per attempt, not a constant — a guessable/fixed request ID lets any
>     // JS in this WebView forge a `connected` message for a request it never
>     // made. `handleMessage` drops any message whose `requestId` doesn't match.
>     // Written and read only on `main`.
>     private var pendingRequestId: String? = null
>
>     // Sign/signTx/sendTx are independent of the connect attempt above (a wallet
>     // stays connected while the caller signs/sends any number of times), so
>     // they get their own single-slot handler + requestId + timeout rather than
>     // sharing `handler`/`pendingRequestId`/`timeout` — those three are (and
>     // stay) connect-only.
>     private var signHandler: ((SignResult) -> Unit)? = null
>     private var signRequestId: String? = null
>     private var signTimeout: Runnable? = null
>
>     private var signTxHandler: ((SignTxResult) -> Unit)? = null
>     private var signTxRequestId: String? = null
>     private var signTxTimeout: Runnable? = null
>
>     private var sendTxHandler: ((SendResult) -> Unit)? = null
>     private var sendTxRequestId: String? = null
>     private var sendTxTimeout: Runnable? = null
>
>     // The origin (scheme+host+port) of the page currently committed as this
>     // WebView's top-level document. `@JavascriptInterface` gives no origin, so
>     // this is tracked from the WebViewClient callbacks (main thread) and
>     // consulted in `handleMessage` to reject bridge messages from anything
>     // other than the engine. @Volatile because it's written on `main` and read
>     // on the JS-interface binder thread in `Bridge.postMessage` — a single
>     // immutable reference swap, so volatility alone (no lock) is sufficient.
>     @Volatile
>     private var committedOrigin: Uri? = null
>
>     private var walletsList: List<Wallet> = emptyList()
>     /** Set to receive the wallet menu. Replayed immediately if already delivered. */
>     var onWallets: ((List<Wallet>) -> Unit)? = null
>         set(value) {
>             field = value
>             if (walletsList.isNotEmpty()) value?.invoke(walletsList)
>         }
>
>     // ── Public API ────────────────────────────────────────────────────────────
>
>     /** Build + load the hidden WebView ahead of time so the first connect is
>      *  fast. Safe to call more than once. */
>     fun prewarm(activity: Activity) = main.post { ensureWebView(activity) }
>
>     fun connect(
>         activity: Activity,
>         walletKey: String,
>         chain: String?,
>         onResult: (Result) -> Unit,
>     ) = main.post {
>         ensureWebView(activity)
>         // A previous attempt is still in flight (e.g. the user opened MetaMask,
>         // ignored the prompt, and is now trying another wallet). The SDK can hold
>         // a stuck pending connection that blocks the next mint — reset to a fresh
>         // engine so the new wallet gets a clean slate.
>         if (handler != null) resetEngine()
>         handler = onResult
>         val requestId = UUID.randomUUID().toString()
>         pendingRequestId = requestId
>         scheduleStartupTimeout()
>         val work = { drive(requestId, walletKey, chain) }
>         if (ready) work() else pendingReady.add(work)
>     }
>
>     // Reload the hidden WebView to abandon a stuck previous attempt. The reloaded
>     // page re-fires `ready` (flushing any queued connect) and re-pushes the list.
>     private fun resetEngine() {
>         handler = null
>         pendingRequestId = null
>         clearTimeout()
>         ready = false
>         pendingReady.clear()
>         pendingReturnScripts.clear()
>         webView?.reload()
>     }
>
>     /** Abort the in-flight attempt (e.g. the user backed out of the list). */
>     fun cancel() = main.post {
>         webView?.evaluateJavascript("window.headlessConnect && window.headlessConnect.cancel('');", null)
>         clearTimeout()
>         handler = null
>         pendingRequestId = null
>     }
>
>     /** Sign [message] with the currently-connected wallet. Resolves once via
>      *  [onResult]. Requires the engine URL to point to a build that includes
>      *  sign support. Works for whatever chain the connected wallet is on
>      *  (EVM or Solana) — this method doesn't know or care which. */
>     fun sign(message: String, onResult: (SignResult) -> Unit) = main.post {
>         signHandler?.let { finishSign(SignResult.Failure("superseded", "superseded by a new sign() call")) }
>         val requestId = UUID.randomUUID().toString()
>         signHandler = onResult
>         signRequestId = requestId
>         scheduleSignTimeout()
>         val params = JSONObject().put("requestId", requestId).put("message", message)
>         webView?.evaluateJavascript("window.headlessConnect && window.headlessConnect.sign($params);", null)
>     }
>
>     /**
>      * Send a transaction with the currently-connected wallet — EVM, Solana, or
>      * Bitcoin.
>      * Resolves once via [onResult].
>      *
>      * The wallet signs AND broadcasts in one step: `eth_sendTransaction` for
>      * EVM, `signAndSendTransaction` for Solana (the wallet-adapter-standard
>      * analog every Solana wallet implements).
>      *
>      * [transaction] format depends on which chain the connected wallet is on
>      * — this method doesn't know or care which, it's just an opaque string
>      * the engine interprets:
>      *  - EVM: JSON string `{"to":"0x…","value":"0x0","data":"0x","chainId":"0x1"}`
>      *    — `chainId` is required; the engine verifies it against the wallet's
>      *    own active network before sending (see [FireblocksConnect.sendTransaction]
>      *    for the same `SendResult.Failure` codes this can surface —
>      *    `chain_switch_unavailable`, `chain_not_configured`,
>      *    `chain_switch_rejected`, `chain_mismatch`).
>      *  - Solana: base64-encoded, pre-built `VersionedTransaction` (or legacy
>      *    `Transaction`) bytes — the caller is responsible for building it (a
>      *    native SOL transfer vs. an SPL-token transfer for USDC are both just
>      *    "whatever instructions are already in the bytes"); the engine only
>      *    signs and submits.
>      *  - Bitcoin: JSON string `{"recipientAddress":"bc1…","amountSats":"12345"}`
>      *    — `amountSats` is the amount in satoshis as a decimal-digit string.
>      *    The wallet's own `sendBitcoin` RPC signs and broadcasts it.
>      */
>     fun sendTransaction(transaction: String, onResult: (SendResult) -> Unit) = dispatchSend(
>         jsFunction = "sendTx",
>         params = JSONObject().put("transaction", transaction),
>         supersededMessage = "superseded by a new sendTransaction() call",
>         unsupportedMessage = "engine build predates sendTx — redeploy connect.dynamicauth.com",
>         onResult = onResult,
>     )
>
>     /** ERC-20 / SPL token for [transferAmount]: contract (or mint) [address]
>      *  and the token's [decimals]. */
>     data class TransferToken(val address: String, val decimals: Int)
>
>     /**
>      * Send a display-unit amount from the connected wallet — EVM or Solana.
>      * Resolves once via [onResult]; the caller never builds a transaction or
>      * converts to wei/lamports. The engine owns all validation, so the
>      * `SendResult.Failure` codes it surfaces include `invalid_amount`,
>      * `missing_recipient`, `missing_from`, `invalid_token`,
>      * `invalid_network_id`, `account_not_found`, `fallback_required`,
>      * `unsupported_chain`, `unsupported_wallet`, `chain_not_configured`,
>      * `chain_switch_unavailable`, `chain_switch_rejected`, `chain_mismatch`,
>      * `invalid_param`, `method_unavailable`, `user_rejected`, `timeout` and
>      * `unsupported_engine`.
>      *
>      *  - [from]: address of the connected account to send from (EVM addresses
>      *    match case-insensitively).
>      *  - [amount]: positive decimal string in display units, e.g. `"0.01"`.
>      *  - [recipient]: destination address.
>      *  - [networkId]: optional. EVM: decimal chainId, e.g. `"137"`. Solana:
>      *    `"mainnet-beta"` or `"devnet"`. Omitting it on EVM means the engine
>      *    performs no network check and sends on whatever network the wallet is
>      *    currently on.
>      *  - [token]: optional ERC-20 / SPL token; omit for the native asset.
>      *
>      * v1 exclusions: Bitcoin returns `unsupported_chain`; Solana with Phantom
>      * in redirect mode returns `unsupported_wallet`.
>      */
>     fun transferAmount(
>         from: String,
>         amount: String,
>         recipient: String,
>         networkId: String? = null,
>         token: TransferToken? = null,
>         onResult: (SendResult) -> Unit,
>     ) = dispatchSend(
>         jsFunction = "transferAmount",
>         params = JSONObject()
>             .put("from", from)
>             .put("amount", amount)
>             .put("recipient", recipient)
>             .also { params ->
>                 if (networkId != null) params.put("networkId", networkId)
>                 if (token != null) {
>                     params.put("token", JSONObject().put("address", token.address).put("decimals", token.decimals))
>                 }
>             },
>         supersededMessage = "superseded by a new transferAmount() call — the superseded transfer may still have been submitted; check the explorer before retrying.",
>         unsupportedMessage = "engine build predates transferAmount — redeploy connect.dynamicauth.com",
>         onResult = onResult,
>     )
>
>     private fun dispatchSend(
>         jsFunction: String,
>         params: JSONObject,
>         supersededMessage: String,
>         unsupportedMessage: String,
>         onResult: (SendResult) -> Unit,
>     ) = main.post {
>         sendTxHandler?.let { finishSendTx(SendResult.Failure("superseded", supersededMessage)) }
>         val requestId = UUID.randomUUID().toString()
>         sendTxHandler = onResult
>         sendTxRequestId = requestId
>         sendTxTimeout = Runnable {
>             finishSendTx(
>                 SendResult.Failure(
>                     "timeout",
>                     "Wallet did not respond in time — if you approved it, the transaction " +
>                         "may still have been submitted; check the explorer before retrying.",
>                 ),
>             )
>         }.also { main.postDelayed(it, SEND_TIMEOUT_MS) }
>         params.put("requestId", requestId)
>         // `sendTx` and `transferAmount` are newer additions to the engine than
>         // everything else this class calls — a deployment that predates them
>         // would otherwise leave this request silently unanswered until the
>         // timeout, indistinguishable from a dead wallet. Self-report
>         // immediately instead, straight over the same `walletNative` channel
>         // `Bridge.postMessage` already listens on.
>         webView?.evaluateJavascript(
>             "if (window.headlessConnect && window.headlessConnect.$jsFunction) {" +
>                 "  window.headlessConnect.$jsFunction($params);" +
>                 "} else if (window.walletNative && window.walletNative.postMessage) {" +
>                 "  window.walletNative.postMessage(JSON.stringify({" +
>                 "    type: 'sentTxFailed'," +
>                 "    requestId: ${JSONObject.quote(requestId)}," +
>                 "    code: 'unsupported_engine'," +
>                 "    message: ${JSONObject.quote(unsupportedMessage)}" +
>                 "  }));" +
>                 "}",
>             null,
>         )
>     }
>
>     /**
>      * Sign a transaction with the currently-connected wallet without
>      * broadcasting it. Resolves once via [onResult].
>      *
>      * [transaction] format depends on which chain the connected wallet is on:
>      *  - EVM: JSON string `{"to":"0x…","value":"0x0","data":"0x","chainId":"0x1"}`
>      *  - Solana: base64-encoded, pre-built `VersionedTransaction` (or legacy
>      *    `Transaction`) bytes.
>      *  - Bitcoin: JSON string
>      *    `{"unsignedPsbtBase64","allowedSighash","signature"}`. The result is
>      *    a base64 signed PSBT; the caller must finalize and broadcast it.
>      */
>     fun signTransaction(transaction: String, onResult: (SignTxResult) -> Unit) = main.post {
>         signTxHandler?.let { finishSignTx(SignTxResult.Failure("superseded", "superseded by a new signTransaction() call")) }
>         val requestId = UUID.randomUUID().toString()
>         signTxHandler = onResult
>         signTxRequestId = requestId
>         signTxTimeout = Runnable {
>             finishSignTx(SignTxResult.Failure("timeout", "Wallet did not respond in time"))
>         }.also { main.postDelayed(it, SIGN_TIMEOUT_MS) }
>         val params = JSONObject().put("requestId", requestId).put("transaction", transaction)
>         webView?.evaluateJavascript(
>             "if (window.headlessConnect && window.headlessConnect.signTx) {" +
>                 "  window.headlessConnect.signTx($params);" +
>                 "} else if (window.walletNative && window.walletNative.postMessage) {" +
>                 "  window.walletNative.postMessage(JSON.stringify({" +
>                 "    type: 'signTxFailed'," +
>                 "    requestId: ${JSONObject.quote(requestId)}," +
>                 "    code: 'unsupported_engine'," +
>                 "    message: 'engine build predates signTx — redeploy connect.dynamicauth.com'" +
>                 "  }));" +
>                 "}",
>             null,
>         )
>     }
>
>     /** Called when Phantom returns to the app: `<scheme>://phantom-headless`
>      *  (from [FireblocksRedirectActivity]) or, when [walletReturnUrl] is set,
>      *  that https link (from the activity that owns your universal link).
>      *  Forwards the full URL into the WebView and lets the engine decide: in
>      *  `redirect` mode it completes the connection from Phantom's payload, in
>      *  `in-app` mode it ignores the payload-free tap. The engine re-checks the
>      *  https link itself. Returns true if it consumed the URL. */
>     fun handleReturnURL(uri: Uri): Boolean {
>         val isPhantomReturn = isPhantomReturn(uri)
>         if (!isPhantomReturn) return false
>         val js = "window.headlessConnect && window.headlessConnect.handleReturnURL(${JSONObject.quote(uri.toString())});"
>         main.post { if (ready) webView?.evaluateJavascript(js, null) else pendingReturnScripts.add(js) }
>         return true
>     }
>
>     private fun isPhantomReturn(uri: Uri): Boolean {
>         val isHeadlessHost = uri.host?.lowercase() == "phantom-headless"
>         val configuredReturnUrl = walletReturnUrl?.trim()?.takeIf { it.isNotEmpty() }?.let(Uri::parse)
>         val isReturnUrl = configuredReturnUrl != null && uri.isSameHttpsTarget(configuredReturnUrl)
>         return isHeadlessHost || isReturnUrl
>     }
>
>     private fun Uri.isSameHttpsTarget(other: Uri): Boolean {
>         val bothHttps = scheme.equals("https", ignoreCase = true) && other.scheme.equals("https", ignoreCase = true)
>         val sameHost = host.equals(other.host, ignoreCase = true)
>         val samePort = effectiveHttpsPort() == other.effectiveHttpsPort()
>         val samePath = pathOrRoot() == other.pathOrRoot()
>         return bothHttps && sameHost && samePort && samePath
>     }
>
>     private fun Uri.effectiveHttpsPort(): Int = if (port == -1) 443 else port
>
>     private fun Uri.pathOrRoot(): String = path?.takeIf { it.isNotEmpty() } ?: "/"
>
>     // ── WebView lifecycle ───────────────────────────────────────────────────
>
>     @SuppressLint("SetJavaScriptEnabled")
>     private fun ensureWebView(activity: Activity) {
>         if (webView != null) return
>         appContext = activity.applicationContext
>         val wv = WebView(activity)
>         wv.settings.javaScriptEnabled = true
>         wv.settings.domStorageEnabled = true
>         wv.addJavascriptInterface(Bridge(), "walletNative")
>         wv.webViewClient = object : WebViewClient() {
>             // The engine navigates to wallet deeplinks (Phantom's redirect); the
>             // system won't open those from inside a WebView, so we do.
>             override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
>                 val url = request.url
>                 val scheme = url.scheme?.lowercase()
>                 if (scheme != "http" && scheme != "https") {
>                     openExternally(url); return true
>                 }
>                 val host = url.host?.lowercase()
>                 if (host != null && isWalletHost(host)) {
>                     openExternally(url); return true
>                 }
>                 // Deny-by-default: only the engine's own origin may load top-level
>                 // in this privileged WebView (the bridge is attached regardless of
>                 // what page is showing). Logged rather than silently dropped, since
>                 // a denial here means something unexpected tried to navigate this
>                 // WebView — see the Critical bridge-trust finding.
>                 //
>                 // Deliberately NOT scoped to `request.isForMainFrame()` — this also
>                 // denies subframe navigation. `@JavascriptInterface` exposes
>                 // `walletNative` to every frame in this WebView (Android gives no way
>                 // to restrict it to the top frame), while `committedOrigin` below
>                 // only ever tracks the *main*-frame origin. If this were narrowed
>                 // to main-frame-only, a hostile iframe could load here and post
>                 // forged bridge messages that `handleMessage`'s origin check would
>                 // wrongly accept (it'd still see the engine's own top-level
>                 // origin). Don't narrow this without also making the bridge
>                 // origin check frame-aware.
>                 if (!isEngineOrigin(url)) {
>                     Log.w(TAG, "Denying navigation to non-engine origin: $url")
>                     return true
>                 }
>                 return false
>             }
>
>             // Tracks the top-level origin currently loaded, so the bridge (which
>             // gets no origin info from @JavascriptInterface) can validate against
>             // it. Fires for every main-frame navigation, including the initial
>             // `loadUrl` and `resetEngine()`'s reload — both same-origin already.
>             override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
>                 committedOrigin = url?.let(Uri::parse)
>             }
>         }
>         // Keep it in the hierarchy (1×1) so its JS + relay socket keep running,
>         // and DON'T call onPause() — that would suspend the socket. We rely on
>         // Android keeping a short app-switch alive; for long approvals consider a
>         // foreground service.
>         val root = activity.findViewById<ViewGroup>(android.R.id.content)
>         root.addView(wv, 1, 1)
>         wv.loadUrl(engineUrl())
>         webView = wv
>     }
>
>     private fun drive(requestId: String, walletKey: String, chain: String?) {
>         val params = JSONObject()
>             .put("requestId", requestId)
>             .put("walletKey", walletKey)
>         if (chain != null) params.put("chain", chain)
>         webView?.evaluateJavascript("window.headlessConnect && window.headlessConnect.connect($params);", null)
>     }
>
>     private fun openExternally(uri: Uri) {
>         val ctx = appContext ?: return
>         try {
>             ctx.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
>         } catch (_: Exception) {
>             // No app to handle it — the engine will surface an error/timeout.
>         }
>     }
>
>     // ── Origin gating ─────────────────────────────────────────────────────────
>
>     // Shared by the bridge (below) and the WebViewClient above: only the
>     // engine's own scheme+host+port may post a message or load top-level in
>     // this privileged WebView. Compared this way — not just host — so a
>     // same-host page served on a different scheme/port can't slip through.
>     private fun isEngineOrigin(uri: Uri?): Boolean {
>         if (uri == null) return false
>         // Compared against the BASE url: this gate is about origin only, so a
>         // caller-supplied `environmentId` can never widen what counts as the engine.
>         return sameOrigin(uri, Uri.parse(engineBaseUrl))
>     }
>
>     private fun sameOrigin(a: Uri, b: Uri): Boolean {
>         val schemeA = a.scheme?.lowercase()
>         val schemeB = b.scheme?.lowercase()
>         if (schemeA == null || schemeA != schemeB) return false
>         if (a.host?.lowercase() != b.host?.lowercase()) return false
>         // Uri reports -1 for "no explicit port" — resolve that to the scheme's
>         // real default so https://x.com and https://x.com:443 compare equal.
>         val defaultPort = if (schemeA == "https") 443 else if (schemeA == "http") 80 else -1
>         val portA = if (a.port == -1) defaultPort else a.port
>         val portB = if (b.port == -1) defaultPort else b.port
>         return portA == portB
>     }
>
>     // ── Bridge (JS → native) ──────────────────────────────────────────────────
>
>     // @JavascriptInterface methods run on a binder thread, with no origin info
>     // of their own. Snapshot `committedOrigin` here — on the calling thread,
>     // at the moment JS posted — rather than re-reading it after hopping to
>     // main, so a navigation racing the hop can't retroactively change which
>     // origin this message is attributed to. committedOrigin is @Volatile, so
>     // this read is safe without a lock.
>     private class Bridge {
>         @JavascriptInterface
>         fun postMessage(json: String) {
>             val origin = committedOrigin
>             main.post { handleMessage(json, origin) }
>         }
>     }
>
>     private fun handleMessage(json: String, origin: Uri?) {
>         // Reject anything not posted by the engine's own top-level frame. Without
>         // this, any JS that ends up running in this WebView — a compromised
>         // script, an XSS on the hosted page, or a page the navigation gate above
>         // missed — could forge a `connected` message and native would believe an
>         // attacker-chosen wallet address is connected. See the Critical
>         // bridge-trust finding.
>         if (!isEngineOrigin(origin)) {
>             Log.w(TAG, "Dropping bridge message from non-engine origin: $origin")
>             return
>         }
>         val o = runCatching { JSONObject(json) }.getOrNull() ?: return
>         val type = o.optString("type")
>         // "ready"/"wallets"/"event" aren't tied to one attempt; connect's own
>         // messages ("deeplink"/"opening"/"connected"/"fallback"/"error") must
>         // carry the requestId of the connect() attempt in flight — sign's and
>         // sendTx's messages are checked against their OWN independent
>         // requestId slot below, since all three kinds of request can be
>         // in flight independently of each other. Stale or forged IDs (e.g. a
>         // superseded attempt's late message) are dropped rather than resolved
>         // against the current handler.
>         if (type !in NO_REQUEST_ID_TYPES && type !in SIGN_TYPES && type !in SIGN_TX_TYPES && type !in SEND_TX_TYPES && !matchesPendingRequest(o)) return
>         if (type in SIGN_TYPES && !matchesSignRequest(o)) return
>         if (type in SIGN_TX_TYPES && !matchesSignTxRequest(o)) return
>         if (type in SEND_TX_TYPES && !matchesSendTxRequest(o)) return
>         when (type) {
>             "ready" -> {
>                 ready = true
>                 val returns = pendingReturnScripts.toList()
>                 pendingReturnScripts.clear()
>                 returns.forEach { webView?.evaluateJavascript(it, null) }
>                 val work = pendingReady.toList()
>                 pendingReady.clear()
>                 work.forEach { it() }
>             }
>             "wallets" -> {
>                 walletsList = parseWallets(o.optJSONArray("wallets"))
>                 onWallets?.invoke(walletsList)
>             }
>             "deeplink" -> {
>                 clearTimeout() // the wallet is opening; wait for the user now
>                 o.optString("url").takeIf { it.isNotEmpty() }?.let { openExternally(Uri.parse(it)) }
>             }
>             // A WalletConnect sign/send request is waiting — wake the wallet
>             // app so its approval prompt surfaces (the request already went
>             // over the relay; without this it sits invisibly until the user
>             // opens the wallet by hand). No connect bookkeeping to touch.
>             "openWallet" ->
>                 o.optString("url").takeIf { it.isNotEmpty() }?.let { openExternally(Uri.parse(it)) }
>             "opening" -> clearTimeout()
>             "connected" -> {
>                 // A missing/empty (or non-string — `optString` would otherwise
>                 // coerce e.g. a JSON number into a "successful" address) address
>                 // is a malformed payload, not a "connected" wallet with a blank
>                 // address — route it to failure instead of silently succeeding
>                 // (see Finding 19).
>                 val address = (o.opt("address") as? String)?.takeIf { it.isNotEmpty() }
>                 if (address == null) {
>                     finish(Result.Failure("malformed_result", "connected message missing address"))
>                     return
>                 }
>                 finish(
>                     Result.Success(
>                         WalletConnection(
>                             address = address,
>                             chain = o.optString("chain"),
>                             walletName = o.optString("walletName"),
>                             walletImage = o.optString("walletImage"),
>                             sessionId = o.optString("sessionId"),
>                             // Present only for session-cluster-bound wallets
>                             // (Phantom) — see WalletConnection.network's docs.
>                             network = o.optString("network").ifEmpty { null },
>                             networkId = o.optString("networkId").ifEmpty { null },
>                         ),
>                     ),
>                 )
>             }
>             "fallback" -> finish(Result.FallbackRequired(o.optString("reason")))
>             "error" -> finish(Result.Failure(o.optString("code", "unknown"), o.optString("message")))
>             "signed" -> finishSign(SignResult.Success(o.optString("signature")))
>             "signFailed" -> finishSign(SignResult.Failure(o.optString("code", "unknown"), o.optString("message")))
>             "signedTx" -> finishSignTx(SignTxResult.Success(o.optString("signedTransaction"), o.optString("chain")))
>             "signTxFailed" -> finishSignTx(SignTxResult.Failure(o.optString("code", "unknown"), o.optString("message")))
>             "sentTx" -> finishSendTx(SendResult.Success(o.optString("txHash"), o.optString("chain")))
>             "sentTxFailed" -> finishSendTx(SendResult.Failure(o.optString("code", "unknown"), o.optString("message")))
>             // "event" — diagnostic timeline; hook up logging/analytics if wanted.
>         }
>     }
>
>     private fun matchesPendingRequest(o: JSONObject): Boolean {
>         val incoming = o.optString("requestId").takeIf { it.isNotEmpty() } ?: return false
>         return incoming == pendingRequestId
>     }
>
>     private fun matchesSignRequest(o: JSONObject): Boolean {
>         val incoming = o.optString("requestId").takeIf { it.isNotEmpty() } ?: return false
>         return incoming == signRequestId
>     }
>
>     private fun matchesSignTxRequest(o: JSONObject): Boolean {
>         val incoming = o.optString("requestId").takeIf { it.isNotEmpty() } ?: return false
>         return incoming == signTxRequestId
>     }
>
>     private fun matchesSendTxRequest(o: JSONObject): Boolean {
>         val incoming = o.optString("requestId").takeIf { it.isNotEmpty() } ?: return false
>         return incoming == sendTxRequestId
>     }
>
>     private fun parseWallets(arr: JSONArray?): List<Wallet> {
>         if (arr == null) return emptyList()
>         return (0 until arr.length()).mapNotNull { i ->
>             val w = arr.optJSONObject(i) ?: return@mapNotNull null
>             val chains = w.optJSONArray("chains")
>             Wallet(
>                 key = w.optString("key"),
>                 name = w.optString("name"),
>                 icon = w.optString("icon").takeIf { it.isNotEmpty() },
>                 chains = if (chains == null) emptyList()
>                 else (0 until chains.length()).map { chains.optString(it) },
>                 mode = w.optString("mode", "fallback"),
>                 featured = w.optBoolean("featured", false),
>                 inAppBrowser = w.optString("inAppBrowser").takeIf { it.isNotEmpty() },
>             )
>         }
>     }
>
>     private fun finish(result: Result) {
>         val cb = handler ?: return
>         handler = null
>         pendingRequestId = null
>         clearTimeout()
>         main.post { cb(result) }
>     }
>
>     private fun scheduleStartupTimeout() {
>         clearTimeout()
>         timeout = Runnable {
>             finish(Result.FallbackRequired("headless startup timeout"))
>         }.also { main.postDelayed(it, STARTUP_TIMEOUT_MS) }
>     }
>
>     private fun finishSign(result: SignResult) {
>         val cb = signHandler ?: return
>         signHandler = null
>         signRequestId = null
>         signTimeout?.let { main.removeCallbacks(it) }
>         signTimeout = null
>         main.post { cb(result) }
>     }
>
>     private fun scheduleSignTimeout() {
>         signTimeout?.let { main.removeCallbacks(it) }
>         signTimeout = Runnable {
>             finishSign(SignResult.Failure("timeout", "Wallet did not respond in time"))
>         }.also { main.postDelayed(it, SIGN_TIMEOUT_MS) }
>     }
>
>     private fun finishSignTx(result: SignTxResult) {
>         val cb = signTxHandler ?: return
>         signTxHandler = null
>         signTxRequestId = null
>         signTxTimeout?.let { main.removeCallbacks(it) }
>         signTxTimeout = null
>         main.post { cb(result) }
>     }
>
>     private fun finishSendTx(result: SendResult) {
>         val cb = sendTxHandler ?: return
>         sendTxHandler = null
>         sendTxRequestId = null
>         sendTxTimeout?.let { main.removeCallbacks(it) }
>         sendTxTimeout = null
>         main.post { cb(result) }
>     }
>
>     private fun clearTimeout() {
>         timeout?.let { main.removeCallbacks(it) }
>         timeout = null
>     }
> }
> ````

## 3. Sign a message

The hosted engine supports `window.headlessConnect.sign`. After a successful connect, invoke `sign()` with any string. The wallet app prompts the user; the callback delivers a hex signature.

```kotlin MainActivity.kt theme={"system"}
FireblocksHeadlessConnect.sign(
    message = "Sign in to MyApp · ${Instant.now()}"
) { result ->
    when (result) {
        is SignResult.Success -> { /* result.signature: hex string */ }
        is SignResult.Failure -> { /* result.code, result.message */ }
    }
}
```

Only available for wallets connected through the headless engine. Visible-flow wallets do not hold an open session.

## 4. Sign a transaction

Pass a serialized transaction. Signing only, no broadcast.

```kotlin MainActivity.kt (EVM) theme={"system"}
FireblocksHeadlessConnect.signTransaction(
    transaction = """{"to":"${wallet.address}","value":"0x0","data":"0x","chainId":"0x1"}"""
) { result ->
    when (result) {
        is SignTxResult.Success ->
            /* result.signedTransaction: RLP-encoded hex, broadcast with eth_sendRawTransaction */
        is SignTxResult.Failure -> { /* result.code */ }
    }
}
```

```kotlin MainActivity.kt (Solana) theme={"system"}
// txBytes: ByteArray of your serialized VersionedTransaction
val b64 = Base64.encodeToString(txBytes, Base64.NO_WRAP)
FireblocksHeadlessConnect.signTransaction(transaction = b64) { result ->
    when (result) {
        is SignTxResult.Success ->
            /* result.signedTransaction: base64-encoded signed transaction bytes */
        is SignTxResult.Failure -> { /* result.code */ }
    }
}
```

## 5. Send an amount

`transferAmount()` sends a display-unit amount without a serialized transaction. The engine converts to wei or lamports and builds the transfer itself, for EVM or Solana. Bitcoin returns `unsupported_chain`, and Solana with Phantom in redirect mode returns `unsupported_wallet`.

```kotlin MainActivity.kt theme={"system"}
FireblocksHeadlessConnect.transferAmount(
    from = wallet.address,
    amount = "0.01",
    recipient = "0x…",
    networkId = "137", // optional: decimal chainId on EVM, "mainnet-beta" or "devnet" on Solana
    token = FireblocksHeadlessConnect.TransferToken(address = "0x…", decimals = 6), // optional; omit for the native asset
) { result ->
    when (result) {
        is SendResult.Success -> { /* result.txHash, result.chain: already broadcast */ }
        is SendResult.Failure -> { /* result.code, result.message */ }
    }
}
```

`from` is the connected account's address and `amount` is a positive decimal string in display units. `token` is an ERC-20 or SPL token and takes the contract (or mint) address plus the token's decimals; omit it for the native asset. Omitting `networkId` on EVM skips the engine's network check and sends on the wallet's active network. An engine deployment that predates `transferAmount` fails fast with `unsupported_engine`.

## 6. Wire the manifest and redirect

Beyond the basic flow, headless needs two manifest additions: a `phantom-headless` host on the same `FireblocksRedirectActivity` intent-filter, and a `<queries>` block so the app can open wallet deeplinks on Android 11+.

```xml AndroidManifest.xml theme={"system"}
<!-- inside the FireblocksRedirectActivity intent-filter -->
<data android:scheme="myapp" android:host="wallet-callback" />
<data android:scheme="myapp" android:host="wallet-return" />
<!-- Headless Phantom's zero-payload return: just brings the app forward. -->
<data android:scheme="myapp" android:host="phantom-headless" />

<!-- Android 11+ package visibility, at <manifest> level -->
<queries>
  <intent><action android:name="android.intent.action.VIEW" />
    <data android:scheme="metamask" /></intent>
  <intent><action android:name="android.intent.action.VIEW" />
    <data android:scheme="phantom" /></intent>
</queries>
```

Route the return activity to the engine first, then fall through to the visible flow. The engine only consumes `<scheme>://phantom-headless`, which carries no data: Phantom connects inside its own in-app browser, the engine receives the connection over the WalletConnect relay, and the link only brings your app forward.

```kotlin FireblocksRedirectActivity theme={"system"}
intent?.data?.let { uri ->
    if (!FireblocksHeadlessConnect.handleReturnURL(uri)) {
        FireblocksConnect.handleRedirect(uri)
    }
}
```

> [!NOTE]
>
> The hidden `WebView` needs the `INTERNET` permission. Do not call `webView.onPause()` on it. That suspends the relay socket.

## 7. Render the list (your UI)

`MainActivity` is a sample list (search, chain picker, connecting state, auto-fallback) you would swap for your own design.

> [accordion] View MainActivity.kt (sample list UI)
>
> ```kotlin MainActivity.kt theme={"system"}
> package com.fireblocks.connect.sample
>
> import android.app.Activity
> import android.app.AlertDialog
> import android.graphics.Color
> import android.net.Uri
> import android.os.Bundle
> import android.view.Gravity
> import android.view.View
> import android.view.ViewGroup.LayoutParams.MATCH_PARENT
> import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
> import android.widget.Button
> import android.widget.EditText
> import android.widget.LinearLayout
> import android.widget.ProgressBar
> import android.widget.RadioButton
> import android.widget.RadioGroup
> import android.widget.ScrollView
> import android.widget.TextView
> import androidx.core.widget.doAfterTextChanged
> import com.fireblocks.connect.FireblocksConnect
> import com.fireblocks.connect.FireblocksConnectResult
> import com.fireblocks.connect.FireblocksHeadlessConnect
> import com.fireblocks.connect.FireblocksHeadlessConnect.SendResult
> import com.fireblocks.connect.FireblocksHeadlessConnect.SignResult
> import com.fireblocks.connect.FireblocksHeadlessConnect.Wallet
> import com.fireblocks.connect.FireblocksFundResult
> import com.fireblocks.connect.FireblocksSendResult
> import com.fireblocks.connect.FireblocksSignResult
> import com.fireblocks.connect.FireblocksWalletBrowser
> import com.fireblocks.connect.WalletConnection
> import java.math.BigInteger
>
> private const val BARE_ROW_ID = "bare-auth-webview"
>
> // Base Account's passkey/email connect needs a visible page, so FallbackRequired
> // is the correct answer there and a bug anywhere else.
> private val EXPECTED_FALLBACK = setOf("baseaccount-evm")
>
> /**
>  * Internal connect-API comparison demo, NOT the recommended integration
>  * pattern (see git history for the original headless-first / auto-fallback
>  * wallet picker).
>  *
>  * Every wallet+chain pair the catalogue reports is listed three times, once
>  * per section, and each section calls exactly one underlying API no matter
>  * what that wallet/chain actually needs — so tapping a row shows, in
>  * isolation, what that specific API does for that specific wallet+chain:
>  *
>  * 1. "Open Authenticate Webview" — [FireblocksConnect.present] with no wallet
>  *    preselected; the web app's own UI runs inside a Custom Tab.
>  * 2. "Connect wallet via Authenticate Webview" — same API, but with
>  *    `?wallet=&chain=` preselected so it jumps straight to one wallet+chain.
>  * 3. "Connect wallet via Hidden Webview" — [FireblocksHeadlessConnect.connect],
>  *    the hidden no-UI WebView engine. A wallet/chain with no headless path
>  *    reports back `FallbackRequired` instead of connecting — shown inline
>  *    rather than auto-chaining into another flow.
>  *
>  * Once connected, offers sign message + send transaction so you can see the
>  * full flow end-to-end — EVM (a plain JSON tx object, no chain SDK needed) or
>  * Solana (a native SOL transfer; the ENGINE builds the transaction, this app
>  * only picks network/recipient/amount — see `resolveSolanaTx` in
>  * `src/headless.ts` for why: a Solana tx needs a live blockhash, unlike EVM's
>  * self-contained {to,value,data,chainId}).
>  */
> class MainActivity : Activity() {
>
>     private val hostedPageUrl = "https://connect.dynamicauth.com/"
>     private val scheme = "myapp" // must match the intent-filter in AndroidManifest
>
>     // Which Dynamic environment the hosted pages should use. `null` leaves them on
>     // whatever they were built with; set a UUID here (or read one from
>     // BuildConfig / a remote config) to point this build at a specific
>     // environment without redeploying the web layer.
>     private val environmentId: String? = null
>
>     private var all: List<Wallet> = emptyList()
>     private var connectingRowId: String? = null
>     private val rowOutcomes = mutableMapOf<String, RowOutcome>()
>     private var listError: String? = null
>     private var connectedWallet: WalletConnection? = null
>
>     private lateinit var status: TextView
>     private lateinit var listContainer: LinearLayout
>     private lateinit var connectedContainer: LinearLayout
>
>     // Solana network toggle state — which cluster the NEXT send builds
>     // against. See connectedContainer's Solana section for why the wallet
>     // itself must already be set to the same one.
>     private var solNetwork = "mainnet-beta"
>
>     private data class RowOutcome(val text: String, val isExpected: Boolean)
>
>     private data class WalletChainRow(val wallet: Wallet, val chain: String) {
>         val id: String get() = "${wallet.key}-$chain"
>     }
>
>     private enum class Flow { AUTH_WEBVIEW, HIDDEN_WEBVIEW }
>
>     override fun onCreate(savedInstanceState: Bundle?) {
>         super.onCreate(savedInstanceState)
>
>         val root = LinearLayout(this).apply {
>             orientation = LinearLayout.VERTICAL
>             setPadding(40, 60, 40, 40)
>             setBackgroundColor(Color.WHITE)
>         }
>         root.addView(TextView(this).apply {
>             text = "Connect a wallet"
>             textSize = 22f
>             setTextColor(Color.parseColor("#0E121B"))
>         })
>         status = TextView(this).apply {
>             textSize = 14f
>             setTextColor(Color.parseColor("#606770"))
>             setPadding(0, 16, 0, 16)
>         }
>         root.addView(status)
>         connectedContainer = LinearLayout(this).apply {
>             orientation = LinearLayout.VERTICAL
>             visibility = View.GONE
>         }
>         root.addView(connectedContainer)
>         listContainer = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
>         root.addView(ScrollView(this).apply { addView(listContainer) })
>         setContentView(root)
>
>         // Must be set before prewarm — the engine URL is resolved when the hidden
>         // WebView is created.
>         FireblocksHeadlessConnect.environmentId = environmentId
>         // Pre-warm the engine so the first connect is fast, and receive the
>         // wallet list it derives from the catalogue.
>         FireblocksHeadlessConnect.prewarm(this)
>         FireblocksHeadlessConnect.onWallets = { wallets ->
>             runOnUiThread { all = wallets; renderList() }
>         }
>         renderList()
>     }
>
>     private fun rows(): List<WalletChainRow> =
>         all.filter { it.featured }.flatMap { wallet -> chains(wallet).map { WalletChainRow(wallet, it) } }
>
>     /** The engine's own chains, plus a synthetic "evm" for a wallet whose EVM
>      *  surface only exists inside its own in-app browser (Phantom), which
>      *  therefore never appears in [Wallet.chains]. */
>     private fun chains(wallet: Wallet): List<String> =
>         wallet.chains + if (offersInAppBrowserEvm(wallet)) listOf("evm") else emptyList()
>
>     /**
>      * Scoped to Phantom on purpose, and NOT derived from "has an in-app-browser
>      * template" — a template only means the wallet can open a URL in its own
>      * browser; it says nothing about which chains that browser injects a
>      * provider for. The evidence for Phantom specifically is
>      * `phantomevm.injectedConfig.windowLocations: ["phantom.ethereum"]`.
>      */
>     private fun offersInAppBrowserEvm(wallet: Wallet): Boolean =
>         wallet.key.lowercase() == "phantom" &&
>             wallet.inAppBrowser != null &&
>             !wallet.chains.contains("evm")
>
>     private fun renderList() {
>         listContainer.removeAllViews()
>         if (all.isEmpty()) {
>             listContainer.addView(TextView(this).apply { text = "Loading wallets…" })
>             return
>         }
>         listError?.let { message ->
>             listContainer.addView(TextView(this).apply {
>                 text = message
>                 textSize = 12f
>                 setTextColor(Color.parseColor("#EA580C"))
>                 setPadding(0, 0, 0, 8)
>             })
>         }
>
>         listContainer.addView(sectionCard(
>             "Calls FireblocksConnect.present(...) with no wallet preselected — " +
>                 "the web app's own UI runs inside a Custom Tab.",
>         ))
>         listContainer.addView(demoRow("Open Authenticate Webview", BARE_ROW_ID) { openBareAuthWebview() })
>
>         val rows = rows()
>         listContainer.addView(sectionCard(
>             "Calls FireblocksConnect.present(...) with ?wallet=&chain= preselected — " +
>                 "same Custom Tab, but jumps straight to one wallet+chain.",
>         ))
>         for (row in rows) listContainer.addView(walletChainRow(row, Flow.AUTH_WEBVIEW))
>
>         listContainer.addView(sectionCard(
>             "Calls FireblocksHeadlessConnect.connect(context, walletKey, chain) — the hidden " +
>                 "WebView engine. Wallets/chains with no headless path report back " +
>                 "FallbackRequired instead of connecting.",
>         ))
>         for (row in rows) listContainer.addView(walletChainRow(row, Flow.HIDDEN_WEBVIEW))
>     }
>
>     private fun sectionCard(text: String) = label(text).apply { setPadding(0, 32, 0, 4) }
>
>     private fun walletChainRow(row: WalletChainRow, flow: Flow): View {
>         val id = rowId(flow, row)
>         return demoRow("${row.wallet.name} — ${chainLabel(row.chain)}", id) { connect(row, flow, id) }
>     }
>
>     private fun demoRow(title: String, id: String, onTap: () -> Unit): View =
>         LinearLayout(this).apply {
>             orientation = LinearLayout.HORIZONTAL
>             gravity = Gravity.CENTER_VERTICAL
>             setBackgroundColor(Color.parseColor("#F0F2F5"))
>             setPadding(32, 28, 32, 28)
>             isEnabled = connectingRowId == null
>             setOnClickListener { onTap() }
>             layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { topMargin = 12 }
>             addView(
>                 TextView(this@MainActivity).apply {
>                     text = title
>                     textSize = 14f
>                     setTextColor(Color.parseColor("#0E121B"))
>                 },
>                 LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f),
>             )
>             addView(trailing(id))
>         }
>
>     private fun trailing(id: String): View {
>         val outcome = rowOutcomes[id]
>         return when {
>             connectingRowId == id ->
>                 ProgressBar(this, null, android.R.attr.progressBarStyleSmall)
>             outcome != null -> TextView(this).apply {
>                 text = outcome.text
>                 textSize = 11f
>                 gravity = Gravity.END
>                 maxWidth = 420
>                 setTextColor(Color.parseColor(if (outcome.isExpected) "#16A34A" else "#EA580C"))
>             }
>             else -> TextView(this).apply {
>                 text = "›"
>                 textSize = 18f
>                 setTextColor(Color.parseColor("#9CA3AF"))
>             }
>         }
>     }
>
>     private fun rowId(flow: Flow, row: WalletChainRow) = "$flow-${row.id}"
>
>     private fun openBareAuthWebview() {
>         listError = null
>         connectingRowId = BARE_ROW_ID
>         renderList()
>         FireblocksConnect.present(this, hostedPageUrl, scheme, environmentId) { result ->
>             runOnUiThread {
>                 connectingRowId = null
>                 when (result) {
>                     is FireblocksConnectResult.Success -> showConnected(result.wallet)
>                     is FireblocksConnectResult.Cancelled -> renderList()
>                     is FireblocksConnectResult.Error -> {
>                         listError = "Couldn't connect (${result.code ?: result.reason})."
>                         renderList()
>                     }
>                 }
>             }
>         }
>     }
>
>     private fun connect(row: WalletChainRow, flow: Flow, id: String) {
>         listError = null
>         rowOutcomes.remove(id)
>         connectingRowId = id
>         renderList()
>
>         when (flow) {
>             Flow.AUTH_WEBVIEW -> FireblocksConnect.present(
>                 this,
>                 hostedUrl(row.wallet.key, row.chain),
>                 scheme,
>                 environmentId,
>             ) { result ->
>                 runOnUiThread {
>                     connectingRowId = null
>                     when (result) {
>                         is FireblocksConnectResult.Success -> showConnected(result.wallet)
>                         is FireblocksConnectResult.Cancelled -> renderList()
>                         is FireblocksConnectResult.Error -> {
>                             rowOutcomes[id] = RowOutcome("Failed (${result.code ?: result.reason})", false)
>                             renderList()
>                         }
>                     }
>                 }
>             }
>
>             Flow.HIDDEN_WEBVIEW -> FireblocksHeadlessConnect.connect(this, row.wallet.key, row.chain) { result ->
>                 runOnUiThread {
>                     connectingRowId = null
>                     when (result) {
>                         is FireblocksHeadlessConnect.Result.Success -> showConnected(result.wallet)
>                         is FireblocksHeadlessConnect.Result.FallbackRequired -> {
>                             val expected = row.id in EXPECTED_FALLBACK
>                             rowOutcomes[id] = RowOutcome(
>                                 if (expected) "✅ fallbackRequired\n(expected)" else "⚠️ fallbackRequired\n(unexpected)",
>                                 expected,
>                             )
>                             renderList()
>                         }
>                         is FireblocksHeadlessConnect.Result.Failure -> {
>                             rowOutcomes[id] = RowOutcome("Failed (${result.code})", false)
>                             renderList()
>                         }
>                     }
>                 }
>             }
>         }
>     }
>
>     /** [FireblocksConnect.present] appends redirect_uri, nonce, embedded and
>      *  environmentId itself, so only the preselection goes here. */
>     private fun hostedUrl(wallet: String, chain: String): String =
>         Uri.parse(hostedPageUrl).buildUpon()
>             .appendQueryParameter("wallet", wallet)
>             .appendQueryParameter("chain", chain)
>             .build()
>             .toString()
>
>     private fun chainLabel(chain: String) = when (chain) {
>         "evm" -> "EVM"
>         "solana" -> "SVM"
>         "bitcoin" -> "BTC"
>         else -> chain.uppercase()
>     }
>
>     private fun showConnected(wallet: WalletConnection) {
>         connectingRowId = null
>         connectedWallet = wallet
>         // A session-bound network (Phantom's redirect protocol) is the only
>         // network this wallet will sign for — follow it. The toggle is
>         // replaced by a read-only note for such connections (renderConnected).
>         wallet.network?.let { solNetwork = it }
>         val addr = wallet.address.let { if (it.length > 12) "${it.take(6)}…${it.takeLast(4)}" else it }
>         status.text = "${wallet.walletName}\n${wallet.chain} · $addr"
>         (listContainer.parent as View).visibility = View.GONE
>         connectedContainer.visibility = View.VISIBLE
>         renderConnected()
>     }
>
>     private fun disconnect() {
>         connectedWallet = null
>         status.text = ""
>         connectedContainer.visibility = View.GONE
>         connectedContainer.removeAllViews()
>         (listContainer.parent as View).visibility = View.VISIBLE
>         renderList()
>     }
>
>     /** "Fund from Coinbase": opens the hosted page's ?intent=fundFromExchange
>      *  flow in a Custom Tab. Needs no connected wallet and no signature — the
>      *  user signs in to the exchange and the exchange sends the funds. The
>      *  sample targets the connected wallet's address purely because that's the
>      *  address on screen. See [FireblocksConnect.fundFromExchange] for why
>      *  there's no headless path. */
>     private fun renderFundFromExchange(wallet: WalletConnection) {
>         connectedContainer.addView(label("Fund from Coinbase"))
>         // Left blank on purpose: a prefilled currency narrows the hosted
>         // page's balance picker down to just that currency (see its
>         // preferredSources()) — fine for an integrator who already knows
>         // what they want, but it means this sample would only ever show
>         // USDC unless the field is cleared first. Empty shows every balance.
>         val currencyField = field("Currency, e.g. USDC (optional — leave blank to see every balance)")
>         connectedContainer.addView(currencyField)
>         val amountField = field("Amount (e.g. 10)")
>         connectedContainer.addView(amountField)
>
>         val resultView = resultText().apply { visibility = View.GONE }
>         val errorView = errorText().apply { visibility = View.GONE }
>         val button = Button(this).apply {
>             text = "Fund from Coinbase"
>             isAllCaps = false
>         }
>         button.setOnClickListener {
>             button.isEnabled = false
>             button.text = "Waiting for Coinbase…"
>             val done = {
>                 runOnUiThread {
>                     button.isEnabled = true
>                     button.text = "Fund from Coinbase"
>                 }
>             }
>             FireblocksConnect.fundFromExchange(
>                 context = this,
>                 hostedPageUrl = hostedPageUrl,
>                 scheme = scheme,
>                 to = wallet.address,
>                 currency = currencyField.text.toString().trim(),
>                 amount = amountField.text.toString().trim(),
>                 environmentId = environmentId,
>             ) { result ->
>                 done()
>                 runOnUiThread {
>                     when (result) {
>                         is FireblocksFundResult.Success -> {
>                             errorView.visibility = View.GONE
>                             // The id is the exchange's, not a tx hash — label it
>                             // so nobody pastes it into a block explorer.
>                             resultView.text = "Coinbase transfer ${result.transferId}\n" +
>                                 "${result.amount} ${result.currency}" +
>                                 (result.status?.let { " · $it" } ?: "") +
>                                 "\nexchange id, not an on-chain hash"
>                             resultView.visibility = View.VISIBLE
>                         }
>                         is FireblocksFundResult.Cancelled -> Unit
>                         is FireblocksFundResult.Error -> {
>                             resultView.visibility = View.GONE
>                             errorView.text = "[${result.code ?: "unknown"}] ${result.reason}"
>                             errorView.visibility = View.VISIBLE
>                         }
>                     }
>                 }
>             }
>         }
>         connectedContainer.addView(button)
>         connectedContainer.addView(resultView)
>         connectedContainer.addView(errorView)
>     }
>
>
>     // ── Connected: sign + send ────────────────────────────────────────────────
>
>     private fun label(text: String) = TextView(this).apply {
>         this.text = text
>         textSize = 12f
>         setTextColor(Color.parseColor("#6B7280"))
>         setPadding(0, 16, 0, 4)
>     }
>
>     private fun field(hint: String, initialText: String = ""): EditText = EditText(this).apply {
>         this.hint = hint
>         setText(initialText)
>     }
>
>     private fun resultText(): TextView = TextView(this).apply {
>         textSize = 12f
>         setTextColor(Color.parseColor("#111827"))
>         setPadding(0, 8, 0, 0)
>         setTextIsSelectable(true)
>     }
>
>     private fun errorText(): TextView = TextView(this).apply {
>         textSize = 12f
>         setTextColor(Color.parseColor("#DC2626"))
>         setPadding(0, 8, 0, 0)
>     }
>
>     private fun renderConnected() {
>         val wallet = connectedWallet ?: return
>         connectedContainer.removeAllViews()
>
>         // Network comes first, before anything else. Two cases:
>         //  - session-bound network (Phantom's redirect protocol,
>         //    wallet.network != null): the session decides what the wallet
>         //    will sign for — show it read-only, no choice to offer (the
>         //    engine rejects a send on the other network anyway; reconnect
>         //    with the other picker option to switch).
>         //  - no binding (other Solana wallets): free toggle, and everything
>         //    below (sign, send) uses that choice.
>         if (wallet.chain.lowercase() == "solana") {
>             if (wallet.network != null) renderSolanaNetworkLocked(wallet.network)
>             else renderSolanaNetworkToggle()
>         }
>
>         val signResultView = resultText().apply { visibility = View.GONE }
>         val signErrorView = errorText().apply { visibility = View.GONE }
>         connectedContainer.addView(Button(this).apply {
>             text = "Sign a message"
>             isAllCaps = false
>             setOnClickListener {
>                 isEnabled = false
>                 text = "Waiting for wallet…"
>                 val message = "Fireblocks Android sample sign test — ${System.currentTimeMillis()}"
>                 val done = { signature: String?, error: String? ->
>                     runOnUiThread {
>                         isEnabled = true
>                         text = "Sign a message"
>                         if (signature != null) {
>                             signErrorView.visibility = View.GONE
>                             signResultView.text = "Signature: $signature"
>                             signResultView.visibility = View.VISIBLE
>                         } else if (error != null) {
>                             signResultView.visibility = View.GONE
>                             signErrorView.text = error
>                             signErrorView.visibility = View.VISIBLE
>                         }
>                     }
>                 }
>                 // A connection made inside the WALLET's own browser (Phantom on
>                 // EVM) has to be signed there too — that's the only place its
>                 // injected provider exists; neither the headless engine nor the
>                 // Custom Tab flow can see it.
>                 val template = wallet.walletBrowserUrl
>                 val walletKey = wallet.walletKey
>                 if (template != null && walletKey != null) {
>                     FireblocksWalletBrowser.signMessage(
>                         context = this@MainActivity,
>                         hostedPageUrl = hostedPageUrl,
>                         scheme = scheme,
>                         walletBrowserUrl = template,
>                         walletKey = walletKey,
>                         message = message,
>                         expectedAddress = wallet.address,
>                         environmentId = environmentId,
>                     ) { result ->
>                         when (result) {
>                             is FireblocksSignResult.Success -> done(result.signature, null)
>                             is FireblocksSignResult.Cancelled -> done(null, null)
>                             is FireblocksSignResult.Error ->
>                                 done(null, "[${result.code ?: "unknown"}] ${result.reason}")
>                         }
>                     }
>                     return@setOnClickListener
>                 }
>                 FireblocksHeadlessConnect.sign(message) { result ->
>                     when (result) {
>                         is SignResult.Success -> done(result.signature, null)
>                         is SignResult.Failure -> done(null, "[${result.code}] ${result.message}")
>                     }
>                 }
>             }
>         })
>         connectedContainer.addView(signResultView)
>         connectedContainer.addView(signErrorView)
>
>         if (wallet.chain.lowercase() == "solana") {
>             renderSolanaSend()
>         } else {
>             renderEvmSend()
>         }
>
>         renderFundFromExchange(wallet)
>
>         connectedContainer.addView(Button(this).apply {
>             text = "Use a different wallet"
>             isAllCaps = false
>             setOnClickListener {
>                 FireblocksHeadlessConnect.cancel()
>                 disconnect()
>             }
>         })
>     }
>
>     /// EVM send: a plain JSON tx object — no chain SDK needed to build it,
>     /// unlike Solana below.
>     private fun renderEvmSend() {
>         connectedContainer.addView(label("Chain ID"))
>         val chainIdField = field("e.g. 1 (Ethereum Mainnet)", "1")
>         connectedContainer.addView(chainIdField)
>
>         // Asset — native token, or a custom ERC-20 by contract address (e.g.
>         // a token not worth curating a chain-id table for, like B3). No RPC
>         // call backs this app, so decimals/symbol are typed in rather than
>         // looked up — see the caution label below the fields.
>         connectedContainer.addView(label("Asset"))
>         val assetGroup = RadioGroup(this).apply { orientation = LinearLayout.HORIZONTAL }
>         val nativeRadio = RadioButton(this).apply { id = View.generateViewId(); text = "Native"; isChecked = true }
>         val customRadio = RadioButton(this).apply { id = View.generateViewId(); text = "Custom…" }
>         assetGroup.addView(nativeRadio)
>         assetGroup.addView(customRadio)
>         connectedContainer.addView(assetGroup)
>
>         val customTokenContainer = LinearLayout(this).apply {
>             orientation = LinearLayout.VERTICAL
>             visibility = View.GONE
>         }
>         customTokenContainer.addView(label("Token contract address (0x…)"))
>         val customTokenAddressField = field("0x…")
>         customTokenContainer.addView(customTokenAddressField)
>         val customTokenRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
>         val customTokenSymbolField = field("Symbol, e.g. B3").apply {
>             layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
>         }
>         val customTokenDecimalsField = field("Decimals, e.g. 18", "18").apply {
>             layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
>         }
>         customTokenRow.addView(customTokenSymbolField)
>         customTokenRow.addView(customTokenDecimalsField)
>         customTokenContainer.addView(customTokenRow)
>         customTokenContainer.addView(
>             label(
>                 "This app makes no RPC call to verify decimals against the token " +
>                     "contract — wrong decimals sends the wrong amount. Double-check on a " +
>                     "block explorer before sending.",
>             ),
>         )
>         connectedContainer.addView(customTokenContainer)
>
>         connectedContainer.addView(label("Recipient (0x…)"))
>         val toField = field("0x…", "0xc0ffee254729296a45a3885639AC7E10F9d54979")
>         connectedContainer.addView(toField)
>
>         val amountLabel = label("Amount (native token)")
>         connectedContainer.addView(amountLabel)
>         val amountField = field("e.g. 0.001")
>         connectedContainer.addView(amountField)
>
>         fun refreshAmountLabel() {
>             amountLabel.text = if (customRadio.isChecked) {
>                 val symbol = customTokenSymbolField.text.toString().trim()
>                 "Amount (${symbol.ifEmpty { "TOKEN" }.uppercase()})"
>             } else {
>                 "Amount (native token)"
>             }
>         }
>         assetGroup.setOnCheckedChangeListener { _, checkedId ->
>             customTokenContainer.visibility = if (checkedId == customRadio.id) View.VISIBLE else View.GONE
>             refreshAmountLabel()
>         }
>         customTokenSymbolField.doAfterTextChanged { refreshAmountLabel() }
>
>         val resultView = resultText().apply { visibility = View.GONE }
>         val errorView = errorText().apply { visibility = View.GONE }
>         val sendButton = Button(this).apply {
>             text = "Send a transaction (signs + broadcasts)"
>             isAllCaps = false
>         }
>         sendButton.setOnClickListener {
>             val chainId = chainIdField.text.toString().trim().toIntOrNull()
>             val recipient = toField.text.toString().trim()
>             val isCustom = customRadio.isChecked
>             val tokenAddress = customTokenAddressField.text.toString().trim()
>             val decimals = if (isCustom) customTokenDecimalsField.text.toString().trim().toIntOrNull() else 18
>             val units = decimals?.let { parseUnits(amountField.text.toString(), it) }
>             val validRecipient = recipient.matches(Regex("^0x[0-9a-fA-F]{40}$"))
>             val validToken = !isCustom || tokenAddress.matches(Regex("^0x[0-9a-fA-F]{40}$"))
>             if (chainId == null || !validRecipient || decimals == null || units == null || !validToken) {
>                 errorView.text = if (isCustom) {
>                     "Enter a valid chain ID, token contract address, recipient (0x + 40 hex " +
>                         "chars), decimals, and amount."
>                 } else {
>                     "Enter a valid chain ID, recipient (0x + 40 hex chars), and amount."
>                 }
>                 errorView.visibility = View.VISIBLE
>                 resultView.visibility = View.GONE
>                 return@setOnClickListener
>             }
>             // The `to`/`value`/`data` this send needs: a plain native transfer,
>             // or an ERC-20 `transfer(address,uint256)` call against the token
>             // contract when a custom asset is selected.
>             val to = if (isCustom) tokenAddress else recipient
>             val value = if (isCustom) "0x0" else "0x" + units.toString(16)
>             val data = if (isCustom) erc20TransferCalldata(recipient, units) else "0x"
>             AlertDialog.Builder(this)
>                 .setTitle("Send this transaction?")
>                 .setMessage(
>                     "This sends a REAL transaction on chain $chainId and spends real gas " +
>                         "from the connected wallet. This cannot be undone.",
>                 )
>                 .setNegativeButton("Cancel", null)
>                 .setPositiveButton("Send") { _, _ ->
>                     sendButton.isEnabled = false
>                     sendButton.text = "Sending…"
>                     val done = { txHash: String?, error: String? ->
>                         runOnUiThread {
>                             sendButton.isEnabled = true
>                             sendButton.text = "Send a transaction (signs + broadcasts)"
>                             if (txHash != null) {
>                                 errorView.visibility = View.GONE
>                                 resultView.text = "Sent — tx hash: $txHash"
>                                 resultView.visibility = View.VISIBLE
>                             } else if (error != null) {
>                                 resultView.visibility = View.GONE
>                                 errorView.text = error
>                                 errorView.visibility = View.VISIBLE
>                             }
>                         }
>                     }
>                     // Same reasoning as the sign button's wallet-browser branch:
>                     // the wallet only exposes this account inside its own
>                     // browser, so the send has to be confirmed there.
>                     val connected = connectedWallet
>                     val template = connected?.walletBrowserUrl
>                     val walletKey = connected?.walletKey
>                     if (connected != null && template != null && walletKey != null) {
>                         FireblocksWalletBrowser.sendTransaction(
>                             context = this@MainActivity,
>                             hostedPageUrl = hostedPageUrl,
>                             scheme = scheme,
>                             walletBrowserUrl = template,
>                             walletKey = walletKey,
>                             to = to,
>                             chainId = "0x" + chainId.toString(16),
>                             expectedAddress = connected.address,
>                             value = value,
>                             data = data,
>                             environmentId = environmentId,
>                         ) { result ->
>                             when (result) {
>                                 is FireblocksSendResult.Success -> done(result.txHash, null)
>                                 is FireblocksSendResult.Cancelled -> done(null, null)
>                                 is FireblocksSendResult.Error ->
>                                     done(null, "[${result.code ?: "unknown"}] ${result.reason}")
>                             }
>                         }
>                         return@setPositiveButton
>                     }
>                     val transaction = org.json.JSONObject()
>                         .put("to", to)
>                         .put("value", value)
>                         .put("data", data)
>                         .put("chainId", "0x" + chainId.toString(16))
>                         .toString()
>                     FireblocksHeadlessConnect.sendTransaction(transaction) { result ->
>                         when (result) {
>                             is SendResult.Success -> done(result.txHash, null)
>                             is SendResult.Failure -> done(null, "[${result.code}] ${result.message}")
>                         }
>                     }
>                 }
>                 .show()
>         }
>         connectedContainer.addView(sendButton)
>         connectedContainer.addView(resultView)
>         connectedContainer.addView(errorView)
>     }
>
>     /// Solana send: a native SOL transfer. This app only picks
>     /// network/recipient/amount — the ENGINE builds the actual transaction
>     /// (fetches a recent blockhash from the chosen cluster, adds a System
>     /// Program transfer instruction; see `resolveSolanaTx` in
>     /// `src/headless.ts`). `network` only picks which cluster THIS APP asks
>     /// the engine to build against — the connected wallet (e.g. Phantom)
>     /// broadcasts on whatever cluster IT is configured for, so a mismatch
>     /// fails with something like "blockhash not found".
>     private fun renderSolanaNetworkToggle() {
>         connectedContainer.addView(label("Network"))
>         val networkRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
>         val mainnetButton = Button(this).apply { text = "Mainnet"; isAllCaps = false }
>         val devnetButton = Button(this).apply { text = "Devnet"; isAllCaps = false }
>         fun refreshNetworkButtons() {
>             mainnetButton.setBackgroundColor(
>                 Color.parseColor(if (solNetwork == "mainnet-beta") "#2563EB" else "#F0F2F5"),
>             )
>             mainnetButton.setTextColor(Color.parseColor(if (solNetwork == "mainnet-beta") "#FFFFFF" else "#0E121B"))
>             devnetButton.setBackgroundColor(
>                 Color.parseColor(if (solNetwork == "devnet") "#2563EB" else "#F0F2F5"),
>             )
>             devnetButton.setTextColor(Color.parseColor(if (solNetwork == "devnet") "#FFFFFF" else "#0E121B"))
>         }
>         refreshNetworkButtons()
>         mainnetButton.setOnClickListener { solNetwork = "mainnet-beta"; refreshNetworkButtons() }
>         devnetButton.setOnClickListener { solNetwork = "devnet"; refreshNetworkButtons() }
>         networkRow.addView(mainnetButton, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
>         networkRow.addView(devnetButton, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
>         connectedContainer.addView(networkRow)
>     }
>
>     /// Read-only replacement for [renderSolanaNetworkToggle] when the wallet
>     /// session is BOUND to a network (Phantom's redirect protocol): the
>     /// session decides what the wallet will sign for, so there's no choice
>     /// to offer — switching means reconnecting with the other picker option
>     /// in the wallet list.
>     private fun renderSolanaNetworkLocked(network: String) {
>         connectedContainer.addView(TextView(this).apply {
>             text = "🔒 Network: ${if (network == "devnet") "Devnet" else "Mainnet"} — chosen at " +
>                 "connect. Reconnect to switch."
>             textSize = 12f
>             setTextColor(Color.parseColor("#6B7280"))
>             setBackgroundColor(Color.parseColor("#F0F2F5"))
>             setPadding(24, 20, 24, 20)
>         })
>     }
>
>     private fun renderSolanaSend() {
>         connectedContainer.addView(label("Recipient (base58 Solana address)"))
>         val toField = field("Base58 Solana address")
>         connectedContainer.addView(toField)
>
>         connectedContainer.addView(label("Amount (SOL)"))
>         val amountField = field("e.g. 0.01")
>         connectedContainer.addView(amountField)
>
>         connectedContainer.addView(TextView(this).apply {
>             text = "Make sure the connected wallet is also set to the same network — this " +
>                 "app can't switch it for you."
>             textSize = 11f
>             setTextColor(Color.parseColor("#9CA3AF"))
>             setPadding(0, 8, 0, 0)
>         })
>
>         val resultView = resultText().apply { visibility = View.GONE }
>         val errorView = errorText().apply { visibility = View.GONE }
>         val sendButton = Button(this).apply {
>             text = "Send SOL (signs + broadcasts)"
>             isAllCaps = false
>         }
>         sendButton.setOnClickListener {
>             val to = toField.text.toString().trim()
>             val lamports = parseUnits(amountField.text.toString(), 9)
>             if (!to.matches(Regex("^[1-9A-HJ-NP-Za-km-z]{32,44}$")) || lamports == null) {
>                 errorView.text = "Enter a valid Solana recipient address and amount."
>                 errorView.visibility = View.VISIBLE
>                 resultView.visibility = View.GONE
>                 return@setOnClickListener
>             }
>             val network = solNetwork
>             AlertDialog.Builder(this)
>                 .setTitle("Send this transaction?")
>                 .setMessage(
>                     "This sends a REAL SOL transfer on Solana " +
>                         (if (network == "mainnet-beta") "Mainnet" else "Devnet") +
>                         " and spends real SOL for network fees from the connected wallet. " +
>                         "This cannot be undone.",
>                 )
>                 .setNegativeButton("Cancel", null)
>                 .setPositiveButton("Send") { _, _ ->
>                     sendButton.isEnabled = false
>                     sendButton.text = "Sending…"
>                     val transaction = org.json.JSONObject()
>                         .put("to", to)
>                         .put("lamports", lamports.toString())
>                         .put("network", network)
>                         .toString()
>                     FireblocksHeadlessConnect.sendTransaction(transaction) { result ->
>                         runOnUiThread {
>                             sendButton.isEnabled = true
>                             sendButton.text = "Send SOL (signs + broadcasts)"
>                             when (result) {
>                                 is SendResult.Success -> {
>                                     errorView.visibility = View.GONE
>                                     resultView.text = "Sent — signature: ${result.txHash}"
>                                     resultView.visibility = View.VISIBLE
>                                 }
>                                 is SendResult.Failure -> {
>                                     resultView.visibility = View.GONE
>                                     errorView.text = "[${result.code}] ${result.message}"
>                                     errorView.visibility = View.VISIBLE
>                                 }
>                             }
>                         }
>                     }
>                 }
>                 .show()
>         }
>         connectedContainer.addView(sendButton)
>         connectedContainer.addView(resultView)
>         connectedContainer.addView(errorView)
>     }
>
>     /// Parses a decimal-string amount (e.g. "10.5") into the smallest unit for
>     /// a token with [decimals] decimal places, as a [BigInteger] — no
>     /// floating point, so precision never gets lost the way `Double` would
>     /// with large amounts. Returns `null` for empty/malformed input, too many
>     /// fractional digits, or a zero amount. Mirrors `_parseUnits` in the
>     /// Flutter sample's `example_screen.dart`.
>     private fun parseUnits(input: String, decimals: Int): BigInteger? {
>         val trimmed = input.trim()
>         if (trimmed.isEmpty() || !Regex("^\\d+(\\.\\d+)?$").matches(trimmed)) return null
>         val parts = trimmed.split(".")
>         val whole = parts[0]
>         val frac = if (parts.size > 1) parts[1] else ""
>         if (frac.length > decimals) return null
>         val value = (whole + frac.padEnd(decimals, '0')).toBigIntegerOrNull() ?: return null
>         return if (value == BigInteger.ZERO) null else value
>     }
>
>     /// Encodes an ERC-20 `transfer(address,uint256)` call: 4-byte selector
>     /// (`0xa9059cbb`) + the recipient left-padded to 32 bytes + the amount
>     /// left-padded to 32 bytes. Mirrors `_erc20TransferCalldata` in the
>     /// Flutter sample's `example_screen.dart`.
>     private fun erc20TransferCalldata(recipient: String, amount: BigInteger): String {
>         val addressWord = recipient.removePrefix("0x").lowercase().padStart(64, '0')
>         val amountWord = amount.toString(16).padStart(64, '0')
>         return "0xa9059cbb$addressWord$amountWord"
>     }
> }
> ```

## 8. Connect Phantom

Connect Phantom on EVM or Solana with the same call as any other wallet: `FireblocksHeadlessConnect.connect(this, "phantom", "evm")`, or `"solana"` as the chain. Phantom has no WalletConnect entry in Dynamic's wallet book. In the default `in-app` [Phantom mode](#phantom-modes), the engine bridges through Phantom's own in-app browser:

1. The engine mints a WalletConnect pairing and sends a `deeplink` that opens your hosted page inside Phantom.
2. The page approves the pairing with Phantom's injected provider, and the engine receives the connection over the WalletConnect relay.
3. Phantom's "Return to app" button opens `<scheme>://phantom-headless` with no payload. `FireblocksHeadlessConnect.handleReturnURL` (called from `FireblocksRedirectActivity`) consumes it, and your `connect` callback receives the result from the engine.

No connection data comes back through your URL scheme. The engine offers a chain only when your Dynamic environment has a network enabled for it, and it fails with `timeout` if Phantom does not approve within 60 seconds.

### Phantom modes

Set `FireblocksHeadlessConnect.phantomMode` before `prewarm()` to choose how the engine connects Phantom:

* **`in-app`** (default) uses the WalletConnect bridge described above. It works on EVM and Solana but only connects; sign and send calls return `fallback_required`.
* **`redirect`** uses Phantom's encrypted deeplinks on Solana only. Phantom returns to your app after the connect and after every signature, and `handleReturnURL` passes the response to the engine. This mode works only through the headless engine, not the visible flows.

### Return Phantom to an https link

In `redirect` mode, Phantom returns to `<scheme>://phantom-headless` by default. To use your app's https App Link instead, set `walletReturnUrl` before `prewarm()` and forward the incoming link to `handleReturnURL`:

```kotlin MainActivity.kt theme={"system"}
FireblocksHeadlessConnect.phantomMode = "redirect"
FireblocksHeadlessConnect.walletReturnUrl = "https://your-app.com/phantom-return"
FireblocksHeadlessConnect.prewarm(this)

// in the activity that owns the App Link
intent?.data?.let { FireblocksHeadlessConnect.handleReturnURL(it) }
```

The URL must use https, or the engine falls back to the custom scheme. `handleReturnURL` and the engine accept a returning link only when its origin and path match `walletReturnUrl`.

## 9. Use a wallet's own browser (Phantom on EVM)

Phantom injects its EVM provider (`window.phantom.ethereum`) only inside its own in-app browser. To sign or send with it, open your hosted page **inside Phantom's browser**: connect there, then reopen the same browser for each sign or send.

Each operation is one round trip: Phantom comes to the foreground with your page in it, the user approves, and the result arrives on `<scheme>://wallet-browser`.

> [!NOTE]
>
> The engine reports each wallet's in-app-browser template in the `wallets` bridge message as `inAppBrowser`. The template contains `{{encodedDappURI}}`, and you replace **every** occurrence (Phantom's uses it twice). A template is not a chain: it only means the wallet can open a URL in its own browser, so you decide per wallet which chains that browser serves.

> [accordion] View FireblocksWalletBrowser.kt (copy-paste ready)
>
> ```kotlin FireblocksWalletBrowser.kt theme={"system"}
> package com.fireblocks.connect
>
> import android.content.ActivityNotFoundException
> import android.content.Context
> import android.content.Intent
> import android.net.Uri
> import android.os.Handler
> import android.os.Looper
> import android.util.Log
> import java.security.SecureRandom
>
> /**
>  * Drives the hosted visible flow **inside a wallet's own in-app browser**.
>  *
>  * ## Why this exists (and why it isn't [FireblocksConnect])
>  *
>  * [FireblocksConnect] opens the hosted page in a Chrome Custom Tab — a system
>  * browser. That covers every wallet reachable by WalletConnect, a deeplink, or
>  * an SDK (Base Account). It cannot cover a wallet whose only mobile surface for
>  * a given chain is the provider it injects into its OWN browser, because that
>  * provider isn't there in a Custom Tab.
>  *
>  * Phantom on EVM is exactly that case, and it's why "Phantom EVM" never worked:
>  * no `phantom*` entry in Dynamic's wallet book has a `walletConnect` block, and
>  * `phantomevm` carries no mobile deeplink at all — its only surface is
>  * `window.phantom.ethereum`, injected inside Phantom's in-app browser (see
>  * `classifyChain`'s doc comment in `src/utils/chain`). So the page has to be
>  * opened THERE, and the result handed back over this app's URL scheme.
>  *
>  * ## Mechanics
>  *
>  * 1. Build the hosted-page URL (`?wallet=…&chain=evm`, or an
>  *    `?intent=signMessage|sendTx` variant) with
>  *    `redirect_uri=<scheme>://[CALLBACK_HOST]` and a fresh nonce.
>  * 2. Wrap it in the wallet's in-app-browser template (`{{encodedDappURI}}`,
>  *    replaced everywhere — Phantom's uses it twice) and hand it to the OS with
>  *    `ACTION_VIEW`. The template comes from the engine's wallet catalogue
>  *    ([FireblocksHeadlessConnect.Wallet.inAppBrowser]), i.e. from Dynamic's
>  *    wallet book — never hard-coded here.
>  * 3. The wallet opens its browser on the page; the user connects/signs with the
>  *    injected provider; the page redirects to `<scheme>://[CALLBACK_HOST]`,
>  *    which [FireblocksRedirectActivity] catches and routes here.
>  *
>  * The callback host differs from the Custom Tab flow's `wallet-callback` on
>  * purpose: a return from the wallet's app must not be able to complete (or
>  * steal) an in-flight Custom Tab request, and vice versa.
>  *
>  * ## Caveats worth knowing before shipping this
>  *
>  * - Step 2 hands an `https` app link to the OS. It reaches the wallet only if
>  *   the wallet's app links are verified for that domain; otherwise it can land
>  *   in Chrome, where nothing is injected and the page correctly reports that
>  *   the wallet has no path for this chain. That message in a browser that ISN'T
>  *   the wallet means the hand-off went to the wrong app, not that the wallet
>  *   can't do it.
>  * - Whether a wallet's browser honours a custom-scheme navigation is the
>  *   wallet's choice. The hosted page always ALSO renders a "Return to the app"
>  *   anchor for custom-scheme targets (a real tap is the reliable path where a
>  *   programmatic navigation is ignored), so the user has a way back either way.
>  * - Nothing here is silent: the wallet app comes to the foreground with a web
>  *   page in it, plus the wallet's own approval prompt.
>  *
>  * Single in-flight request at a time (the callback carries no request id beyond
>  * its nonce). Held statically, like [FireblocksConnect]; a production app should
>  * prefer a ViewModel so it survives process death.
>  */
> object FireblocksWalletBrowser {
>     const val CALLBACK_HOST = "wallet-browser"
>
>     /**
>      * How long to wait for the return URL before giving up. The user is in
>      * another app for this whole window (open, connect, approve, come back), so
>      * this is generous by design; it exists so an abandoned flow can't leave a
>      * callback pending for the process lifetime.
>      */
>     private const val TIMEOUT_MS = 5 * 60 * 1000L
>
>     private const val TAG = "FbWalletBrowser"
>
>     private sealed class Pending {
>         abstract val nonce: String
>
>         class Connect(
>             override val nonce: String,
>             val walletKey: String,
>             val walletBrowserUrl: String,
>             val chain: String,
>             val onResult: (FireblocksConnectResult) -> Unit,
>         ) : Pending()
>
>         class Sign(
>             override val nonce: String,
>             val onResult: (FireblocksSignResult) -> Unit,
>         ) : Pending()
>
>         class Send(
>             override val nonce: String,
>             val onResult: (FireblocksSendResult) -> Unit,
>         ) : Pending()
>     }
>
>     private var pending: Pending? = null
>     private val handler = Handler(Looper.getMainLooper())
>     private var timeout: Runnable? = null
>
>     /**
>      * Connect [walletKey] on [chain] inside the wallet's own browser.
>      *
>      * @param walletBrowserUrl the wallet's in-app-browser template, from
>      *   [FireblocksHeadlessConnect.Wallet.inAppBrowser].
>      * @param locale UI locale for the hosted page — e.g. `"en_US"`, `"es_LA"`.
>      *   `null`/blank omits the param.
>      * @param theme UI theme for the hosted page — `"light"` or `"dark"`.
>      *   `null`/blank omits the param.
>      * @param phantomMode How the hosted page connects Phantom — `"in-app"` or
>      *   `"redirect"`. `null`/blank omits the param.
>      *
>      * The resulting [WalletConnection] carries [WalletConnection.walletBrowserUrl]
>      * and [WalletConnection.walletKey] so later sign/send calls can be routed
>      * back into the same browser — the account lives with that injected provider
>      * and nowhere else.
>      */
>     fun connect(
>         context: Context,
>         hostedPageUrl: String,
>         scheme: String,
>         walletBrowserUrl: String,
>         walletKey: String,
>         chain: String = "evm",
>         environmentId: String? = null,
>         locale: String? = null,
>         theme: String? = null,
>         phantomMode: String? = null,
>         onResult: (FireblocksConnectResult) -> Unit,
>     ) {
>         val nonce = randomNonce()
>         val pageUrl = buildPageUrl(
>             hostedPageUrl, scheme, nonce, environmentId, locale, theme, phantomMode,
>             mapOf("wallet" to walletKey, "chain" to chain),
>         )
>         arm(Pending.Connect(nonce, walletKey, walletBrowserUrl, chain, onResult)) {
>             onResult(FireblocksConnectResult.Cancelled)
>         }
>         if (!open(context, walletBrowserUrl, pageUrl)) {
>             clear()
>             onResult(FireblocksConnectResult.Error("could not open the wallet app — is it installed?"))
>         }
>     }
>
>     /**
>      * Sign [message] with a wallet connected through [connect].
>      *
>      * [expectedAddress] is REQUIRED and checked by the page itself: it connects
>      * again inside the wallet's browser before signing, and without this it
>      * would sign with whatever account happens to connect.
>      *
>      * @param locale UI locale for the hosted page — e.g. `"en_US"`, `"es_LA"`.
>      *   `null`/blank omits the param.
>      * @param theme UI theme for the hosted page — `"light"` or `"dark"`.
>      *   `null`/blank omits the param.
>      * @param phantomMode Same as [connect]'s.
>      */
>     fun signMessage(
>         context: Context,
>         hostedPageUrl: String,
>         scheme: String,
>         walletBrowserUrl: String,
>         walletKey: String,
>         message: String,
>         expectedAddress: String,
>         environmentId: String? = null,
>         locale: String? = null,
>         theme: String? = null,
>         phantomMode: String? = null,
>         onResult: (FireblocksSignResult) -> Unit,
>     ) {
>         val nonce = randomNonce()
>         val pageUrl = buildPageUrl(
>             hostedPageUrl, scheme, nonce, environmentId, locale, theme, phantomMode,
>             mapOf(
>                 "intent" to "signMessage",
>                 "walletKey" to walletKey,
>                 "message" to message,
>                 "expectedAddress" to expectedAddress,
>             ),
>         )
>         arm(Pending.Sign(nonce, onResult)) { onResult(FireblocksSignResult.Cancelled) }
>         if (!open(context, walletBrowserUrl, pageUrl)) {
>             clear()
>             onResult(FireblocksSignResult.Error("could not open the wallet app — is it installed?"))
>         }
>     }
>
>     /**
>      * Send an EVM transaction with a wallet connected through [connect].
>      * [to] / [chainId] / [value] / [data] / [gasLimit] are `0x`-prefixed hex,
>      * same contract as [FireblocksConnect.sendTransaction]. The wallet signs AND
>      * broadcasts: a success is already on-chain.
>      *
>      * @param locale UI locale for the hosted page — e.g. `"en_US"`, `"es_LA"`.
>      *   `null`/blank omits the param.
>      * @param theme UI theme for the hosted page — `"light"` or `"dark"`.
>      *   `null`/blank omits the param.
>      * @param phantomMode Same as [connect]'s.
>      */
>     fun sendTransaction(
>         context: Context,
>         hostedPageUrl: String,
>         scheme: String,
>         walletBrowserUrl: String,
>         walletKey: String,
>         to: String,
>         chainId: String,
>         expectedAddress: String,
>         value: String = "0x0",
>         data: String = "0x",
>         gasLimit: String? = null,
>         environmentId: String? = null,
>         locale: String? = null,
>         theme: String? = null,
>         phantomMode: String? = null,
>         onResult: (FireblocksSendResult) -> Unit,
>     ) {
>         val nonce = randomNonce()
>         val params = buildMap {
>             put("intent", "sendTx")
>             put("walletKey", walletKey)
>             put("to", to)
>             put("value", value)
>             put("data", data)
>             put("chainId", chainId)
>             put("expectedAddress", expectedAddress)
>             gasLimit?.takeIf { it.isNotBlank() }?.let { put("gasLimit", it) }
>         }
>         val pageUrl = buildPageUrl(hostedPageUrl, scheme, nonce, environmentId, locale, theme, phantomMode, params)
>         arm(Pending.Send(nonce, onResult)) { onResult(FireblocksSendResult.Cancelled) }
>         if (!open(context, walletBrowserUrl, pageUrl)) {
>             clear()
>             onResult(FireblocksSendResult.Error("could not open the wallet app — is it installed?"))
>         }
>     }
>
>     /** Abandon the in-flight request (e.g. the user navigated away in the app). */
>     fun cancel() {
>         val inFlight = pending ?: return
>         clear()
>         when (inFlight) {
>             is Pending.Connect -> inFlight.onResult(FireblocksConnectResult.Cancelled)
>             is Pending.Sign -> inFlight.onResult(FireblocksSignResult.Cancelled)
>             is Pending.Send -> inFlight.onResult(FireblocksSendResult.Cancelled)
>         }
>     }
>
>     /**
>      * Called by [FireblocksRedirectActivity]. Returns `true` when the URL was
>      * this flow's callback (consumed, whether or not a request was waiting), so
>      * the activity can fall through to the Custom Tab flows for anything else.
>      */
>     internal fun handleRedirect(uri: Uri): Boolean {
>         if (!uri.host.equals(CALLBACK_HOST, ignoreCase = true)) return false
>         val inFlight = pending ?: run {
>             Log.d(TAG, "callback with no request in flight: $uri")
>             return true
>         }
>         // A stale callback (an old page re-opened in the wallet's browser) must
>         // not resolve the request that IS in flight: drop it and keep waiting,
>         // rather than failing the live request with a nonce mismatch.
>         if (uri.getQueryParameter("nonce") != inFlight.nonce) {
>             Log.d(TAG, "dropped callback with mismatched nonce")
>             return true
>         }
>         clear()
>         when (inFlight) {
>             is Pending.Connect -> {
>                 val address = uri.getQueryParameter("address").orEmpty()
>                 if (address.isEmpty()) {
>                     inFlight.onResult(FireblocksConnectResult.Error("malformed result"))
>                 } else {
>                     inFlight.onResult(
>                         FireblocksConnectResult.Success(
>                             WalletConnection(
>                                 address = address,
>                                 chain = uri.getQueryParameter("chain") ?: inFlight.chain,
>                                 walletName = uri.getQueryParameter("walletName").orEmpty(),
>                                 walletImage = uri.getQueryParameter("walletImage").orEmpty(),
>                                 sessionId = "",
>                                 walletKey = inFlight.walletKey,
>                                 walletBrowserUrl = inFlight.walletBrowserUrl,
>                                 networkId = uri.getQueryParameter("networkId")?.takeIf { it.isNotEmpty() },
>                             )
>                         )
>                     )
>                 }
>             }
>
>             is Pending.Sign -> {
>                 if (uri.getQueryParameter("error") == "1") {
>                     val message = uri.getQueryParameter("message").orEmpty()
>                     inFlight.onResult(
>                         FireblocksSignResult.Error(
>                             message.ifEmpty { "sign failed" },
>                             uri.getQueryParameter("code") ?: "unknown",
>                         )
>                     )
>                 } else {
>                     val signature = uri.getQueryParameter("signature").orEmpty()
>                     if (signature.isEmpty()) {
>                         inFlight.onResult(FireblocksSignResult.Error("malformed result"))
>                     } else {
>                         inFlight.onResult(FireblocksSignResult.Success(signature))
>                     }
>                 }
>             }
>
>             is Pending.Send -> {
>                 if (uri.getQueryParameter("error") == "1") {
>                     val message = uri.getQueryParameter("message").orEmpty()
>                     inFlight.onResult(
>                         FireblocksSendResult.Error(
>                             message.ifEmpty { "send failed" },
>                             uri.getQueryParameter("code") ?: "unknown",
>                         )
>                     )
>                 } else {
>                     val txHash = uri.getQueryParameter("txHash").orEmpty()
>                     if (txHash.isEmpty()) {
>                         inFlight.onResult(FireblocksSendResult.Error("malformed result"))
>                     } else {
>                         inFlight.onResult(FireblocksSendResult.Success(txHash))
>                     }
>                 }
>             }
>         }
>         return true
>     }
>
>     // ── Private ──────────────────────────────────────────────────────────────
>
>     /** Replace whatever was waiting (see the object's docs) and arm the timeout. */
>     private fun arm(next: Pending, onTimeout: () -> Unit) {
>         cancel()
>         pending = next
>         val runnable = Runnable {
>             // Only if this very request is still the one waiting.
>             if (pending === next) {
>                 clear()
>                 onTimeout()
>             }
>         }
>         timeout = runnable
>         handler.postDelayed(runnable, TIMEOUT_MS)
>     }
>
>     private fun clear() {
>         pending = null
>         timeout?.let(handler::removeCallbacks)
>         timeout = null
>     }
>
>     /** Expand the template and hand it to the OS. `false` = nothing took it. */
>     private fun open(context: Context, walletBrowserUrl: String, pageUrl: String): Boolean {
>         // Some templates (Phantom's) use the placeholder more than once — as
>         // both the browse target and the `ref` — so replace every occurrence.
>         // Uri.encode matches JavaScript's encodeURIComponent allow-list, which
>         // is what the template expects.
>         val target = walletBrowserUrl.replace("{{encodedDappURI}}", Uri.encode(pageUrl))
>         return try {
>             context.startActivity(
>                 Intent(Intent.ACTION_VIEW, Uri.parse(target))
>                     .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
>             )
>             true
>         } catch (e: ActivityNotFoundException) {
>             Log.w(TAG, "no app handled $target", e)
>             false
>         }
>     }
>
>     private fun buildPageUrl(
>         hostedPageUrl: String,
>         scheme: String,
>         nonce: String,
>         environmentId: String?,
>         locale: String? = null,
>         theme: String? = null,
>         phantomMode: String? = null,
>         params: Map<String, String>,
>     ): String {
>         val builder = Uri.parse(hostedPageUrl).buildUpon()
>         params.forEach { (key, value) -> builder.appendQueryParameter(key, value) }
>         builder.appendQueryParameter("redirect_uri", "$scheme://$CALLBACK_HOST")
>         builder.appendQueryParameter("nonce", nonce)
>         // The page runs inside a wallet's WebView here — say so explicitly
>         // rather than leaving it to user-agent guessing (see `getEnvInfo` in
>         // `src/env.ts`): among other things this stops it offering a deeplink
>         // connector that would bounce out of that browser.
>         builder.appendQueryParameter("embedded", "1")
>         environmentId?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("environmentId", it) }
>         locale?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("locale", it) }
>         theme?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("theme", it) }
>         phantomMode?.takeIf { it.isNotBlank() }
>             ?.let { builder.appendQueryParameter("phantomMode", it) }
>         return builder.build().toString()
>     }
>
>     /** Cryptographically-random nonce (CSRF correlation; not a secret). */
>     private fun randomNonce(): String {
>         val bytes = ByteArray(16)
>         SecureRandom().nextBytes(bytes)
>         return bytes.joinToString("") { "%02x".format(it) }
>     }
> }
> ```

### Carry the two extra fields

Add the template to `FireblocksHeadlessConnect.Wallet` and remember it on the connection, or sign and send fall back to the engine.

```kotlin theme={"system"}
data class Wallet(
    // …
    /** The wallet's own in-app-browser template, or null if it has none. */
    val inAppBrowser: String? = null,
)

data class WalletConnection(
    // …
    /** Set when the connection was made inside the wallet's browser. */
    val walletBrowserUrl: String? = null,
)
```

### Register the callback host

Add a third host to the `FireblocksRedirectActivity` intent-filter and route it before the Custom Tab flows, which all share `wallet-callback`.

```xml AndroidManifest.xml theme={"system"}
<data android:scheme="myapp" android:host="wallet-browser" />
```

```kotlin FireblocksRedirectActivity theme={"system"}
intent?.data?.let { uri ->
    if (!FireblocksHeadlessConnect.handleReturnURL(uri) &&
        !FireblocksWalletBrowser.handleRedirect(uri)
    ) {
        FireblocksConnect.handleRedirect(uri)
    }
}
```

### Offer the option only for Phantom

Do not derive EVM support from the presence of a template. Phantom's template comes from its Sui wallet-book entry, so a template on its own says nothing about EVM. The evidence for Phantom specifically is `phantomevm.injectedConfig.windowLocations: ["phantom.ethereum"]`, an EIP-1193 provider inside its browser.

```kotlin MainActivity.kt theme={"system"}
private fun offersInAppBrowserEvm(wallet: Wallet): Boolean =
    wallet.key.lowercase() == "phantom" &&
        wallet.inAppBrowser != null &&
        !wallet.chains.contains("evm")

private fun chains(wallet: Wallet): List<String> =
    wallet.chains + if (offersInAppBrowserEvm(wallet)) listOf("evm") else emptyList()
```

Show it as its own chain row with "Opens in the wallet's own browser" underneath. The current engine already lists `evm` in Phantom's `chains` and connects it headlessly ([Connect Phantom](#8-connect-phantom)), so the `!wallet.chains.contains("evm")` guard keeps the synthetic row from doubling it: it only appears for engines that predate the headless bridge, or if you want the row to route into the wallet-browser connect below instead, which is what signing needs. A headless Phantom connection is connect-only.

### Connect, sign, and send

```kotlin MainActivity.kt theme={"system"}
// connect: the synthetic picker value routes here
val template = wallet.inAppBrowser ?: return
FireblocksWalletBrowser.connect(
    context = this,
    hostedPageUrl = hostedPageUrl,
    scheme = scheme,
    walletBrowserUrl = template,
    walletKey = wallet.key,
) { result ->
    runOnUiThread {
        when (result) {
            is FireblocksConnectResult.Success -> showConnected(result.wallet)
            is FireblocksConnectResult.Cancelled -> renderList()
            is FireblocksConnectResult.Error -> fail(result.reason)
        }
    }
}

// sign and send: reopen the SAME browser
connection.walletBrowserUrl?.let { browserUrl ->
    FireblocksWalletBrowser.signMessage(
        context = this,
        hostedPageUrl = hostedPageUrl,
        scheme = scheme,
        walletBrowserUrl = browserUrl,
        walletKey = connection.walletKey!!,
        message = "Hello from Android",
        expectedAddress = connection.address,   // the page refuses a mismatch
    ) { result -> /* Success / Cancelled / Error */ }

    FireblocksWalletBrowser.sendTransaction(
        context = this,
        hostedPageUrl = hostedPageUrl,
        scheme = scheme,
        walletBrowserUrl = browserUrl,
        walletKey = connection.walletKey!!,
        to = "0xRecipientAddress",
        chainId = "0x1",
        expectedAddress = connection.address,
        value = "0x2386f26fc10000",             // 0.01 ETH, hex wei
    ) { result -> /* Success / Cancelled / Error */ }
}
```

### What travels on the URL

| Parameter | Value |
| - | - |
| `wallet` and `chain=evm` | connect, or `intent=signMessage` / `intent=sendTx` with `walletKey` |
| `redirect_uri` | `<scheme>://wallet-browser` |
| `nonce` | random per attempt, verified on return, mismatches dropped |
| `embedded=1` | the page is inside a wallet web view, so it must not offer a connector that bounces out of it |
| `environmentId` | optional Dynamic environment ID |

The callback carries `address` and `chain` (connect), `signature` (sign), or `txHash` (send), or `error=1&code=&message=`, always with the `nonce` echoed back. A send is already broadcast when the hash arrives. One request is in flight at a time, a new one supersedes the previous, and an abandoned one times out after five minutes.

### Phantom pitfalls

* **Keep the template on the connection.** Sign and send must reopen the same browser, because the account exists nowhere else. Losing the stored template sends the request to the engine, which reports no wallet connected.
* **Use a separate callback host.** `wallet-callback` is claimed by the visible flow, so a link arriving from Phantom would be dropped or complete an unrelated request.
* **Watch where the template lands on Android.** The template is an `https` app link and reaches Phantom only if its app links are verified. Otherwise Android can hand it to Chrome, where nothing is injected and the page correctly reports no EVM path. That message in a browser that is not Phantom means the hand-off went to the wrong app.
* **Offer the return anchor.** The page renders a "Return to the app" link for browsers that ignore a programmatic redirect.

## 10. The bridge (for reference)

Identical to iOS.

`networkId` is the CAIP-2 id of the network the wallet is on. It is not `network`, the Phantom Solana session cluster. See [Network fields](https://www.dynamic.xyz/docs/connections/overview.md#network-fields).

```text bridge messages theme={"system"}
// web → app (connect)   request-scoped messages carry requestId
ready                                      engine initialized
wallets    { wallets: […] }                the wallet menu (live)
deeplink   { requestId, url }              app opens the wallet
connected  { requestId, address, chain, networkId?, … } success
fallback   { requestId, reason }           can't go headless → visible flow
error      { requestId, code, message }    failed
event      { requestId?, event, sessionId, t }  diagnostic timeline

// web → app (sign)
signed     { requestId, signature }        message signed (hex string)
signFailed { requestId, code, message }    sign failed
signedTx   { requestId, signedTransaction, chain }  tx signed
signTxFailed { requestId, code, message }           tx sign failed

// app → web
window.headlessConnect.connect({ requestId, walletKey, chain })
window.headlessConnect.cancel(requestId)
window.headlessConnect.sign({ requestId, message })
window.headlessConnect.signTx({ requestId, transaction })
```

## Common pitfalls

* **Do not call `webView.onPause()`** on the hidden `WebView`. That suspends its relay socket.
* **Cancellation is not auto-detected** by Custom Tabs. Treat resumed-with-no-result as cancelled, or hold the pending flow in a `ViewModel`.
* **Test on a device with a wallet installed**, not a bare emulator.
* **Serve over HTTPS.** The flow mints WalletConnect URIs via WebCrypto.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.