> ## Documentation Index
> Fetch the complete documentation index at: https://docs.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, 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.
</Note>

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](/docs/connections/android) 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.
</Note>

Copy `FireblocksHeadlessConnect.kt` (below) and `FireblocksConnect.kt` from the [basic Android guide](/docs/connections/android) (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](/docs/connections/android). |
| `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 */ }
    }
}
```

<AccordionGroup>
  <Accordion title="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"

        /** The no-UI engine page. */
        private const val ENGINE_BASE_URL = "https://connect.dynamicauth.com/headless.html"

        /** 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

        /** 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",
        )

        /** 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")

        /** [ENGINE_BASE_URL] plus the params the engine reads on load. */
        private fun engineUrl(): String {
            val builder = Uri.parse(ENGINE_BASE_URL)
                .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) }
            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>()

        // 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()
            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) = main.post {
            sendTxHandler?.let { finishSendTx(SendResult.Failure("superseded", "superseded by a new sendTransaction() call")) }
            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) }
            val params = JSONObject().put("requestId", requestId).put("transaction", transaction)
            // `sendTx` is a newer addition to the engine than everything else this
            // class calls — a deployment that predates it 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.sendTx) {" +
                    "  window.headlessConnect.sendTx($params);" +
                    "} else if (window.walletNative && window.walletNative.postMessage) {" +
                    "  window.walletNative.postMessage(JSON.stringify({" +
                    "    type: 'sentTxFailed'," +
                    "    requestId: ${JSONObject.quote(requestId)}," +
                    "    code: 'unsupported_engine'," +
                    "    message: 'engine build predates sendTx — redeploy connect.dynamicauth.com'" +
                    "  }));" +
                    "}",
                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 from [FireblocksRedirectActivity]. Consumes `<scheme>://phantom-headless`,
         *  the zero-payload "Return to app" tap from Phantom's in-app browser: the
         *  engine gets the connection over the WalletConnect relay, so the only
         *  thing left is bringing the app forward, which the activity does.
         *  Returns true if it consumed the URL. */
        fun handleReturnURL(uri: Uri): Boolean = uri.host?.lowercase() == "phantom-headless"

        // ── 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 && WALLET_HOSTS.any { host == it || host.endsWith(".$it") }) {
                        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, and the
            // base is a constant — so a caller-supplied `environmentId` can never
            // widen what counts as the engine.
            return sameOrigin(uri, Uri.parse(ENGINE_BASE_URL))
        }

        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 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 },
                            ),
                        ),
                    )
                }
                "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
        }
    }
    ````
  </Accordion>
</AccordionGroup>

## 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. 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.
</Note>

## 6. Render the list (your UI)

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

<AccordionGroup>
  <Accordion title="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"
        }
    }
    ```
  </Accordion>
</AccordionGroup>

## 7. 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, so 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.

## 8. 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.
</Note>

<AccordionGroup>
  <Accordion title="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/redirect.ts`). 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.
         *
         * 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,
            onResult: (FireblocksConnectResult) -> Unit,
        ) {
            val nonce = randomNonce()
            val pageUrl = buildPageUrl(
                hostedPageUrl, scheme, nonce, environmentId, locale, theme,
                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.
         */
        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,
            onResult: (FireblocksSignResult) -> Unit,
        ) {
            val nonce = randomNonce()
            val pageUrl = buildPageUrl(
                hostedPageUrl, scheme, nonce, environmentId, locale, theme,
                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.
         */
        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,
            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, 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,
                                )
                            )
                        )
                    }
                }

                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,
            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) }
            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) }
        }
    }
    ```
  </Accordion>
</AccordionGroup>

### 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](#7-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.

## 9. The bridge (for reference)

Identical to iOS.

```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, … } 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.