> ## 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 (basic)

> Present the hosted connect page in a Chrome Custom Tab and read the wallet address on your custom URL scheme.

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

On Android the [hosted connect page](/docs/connections/overview) contract is the same as every other platform. The basic integration is one Kotlin file, `FireblocksConnect.kt`, opening the flow in a **Chrome Custom Tab**.

For a fully native wallet list with signing, see [Android headless](/docs/connections/android-headless).

<Tip>
  Present with a Chrome Custom Tab: Android's secure, sandboxed in-app browser. The page returns to `<scheme>://wallet-callback`, caught by `FireblocksRedirectActivity`.
</Tip>

<AccordionGroup>
  <Accordion title="View FireblocksConnect.kt (copy-paste ready)">
    ````kotlin FireblocksConnect.kt theme={"system"}
    package com.fireblocks.connect

    import android.app.Activity
    import android.content.Context
    import android.content.Intent
    import android.net.Uri
    import android.os.Bundle
    import androidx.browser.customtabs.CustomTabsIntent
    import java.security.SecureRandom

    // ── Result ───────────────────────────────────────────────────────────────────

    /** A wallet the user connected through the hosted Fireblocks flow. */
    data class WalletConnection(
        val address: String,
        /** "evm" | "solana" | "bitcoin" */
        val chain: String,
        val walletName: String,
        /** Icon URL — usually an SVG-sprite URL, so render it in a WebView <img>. */
        val walletImage: String,
        /** The flow's session id, echoed back on the callback. Log it so a support
         *  report can be tied to a specific attempt. */
        val sessionId: String,
        /**
         * The Solana network this wallet SESSION is bound to ("mainnet-beta" |
         * "devnet"), when the wallet's protocol binds one at connect time
         * (Phantom's redirect protocol does). The session decides what the
         * wallet will sign for, so send UIs must follow it — the engine rejects
         * a send on the other network. `null` for wallets with no such binding
         * (their send UI may offer a network choice freely).
         */
        val network: String? = null,
        /**
         * The catalogue key that produced this connection, e.g. "phantom". Not part
         * of the engine's wire protocol (its `connected` message carries no wallet
         * key) — set by the caller that made the connect call, so a later sign/send
         * can rebuild the hosted-page URL. Required by [FireblocksWalletBrowser]'s
         * sign/send, which re-open the page in the wallet's browser. `null` when
         * unknown.
         */
        val walletKey: String? = null,
        /**
         * Set when this connection was made inside the WALLET's own in-app browser
         * ([FireblocksWalletBrowser]) — the in-app-browser template it was opened
         * with. The account belongs to the provider injected in that browser and
         * exists nowhere else, so sign/send have to be re-opened there too: pass
         * this back to [FireblocksWalletBrowser.signMessage] /
         * [FireblocksWalletBrowser.sendTransaction]. `null` for every other path
         * (headless engine, Custom Tab visible flow).
         */
        val walletBrowserUrl: String? = null,
    )

    sealed class FireblocksConnectResult {
        data class Success(val wallet: WalletConnection) : FireblocksConnectResult()
        /** User dismissed the browser, or the flow reported a cancel. */
        object Cancelled : FireblocksConnectResult()
        /** Nonce mismatch (possible CSRF), an unparseable return, or a failure the
         *  flow reported. `code` is a stable machine code (e.g. "user_rejected")
         *  when the flow reported one; `sessionId` names the attempt. */
        data class Error(val reason: String, val code: String? = null, val sessionId: String? = null) :
            FireblocksConnectResult()
    }

    /** Result of [FireblocksConnect.sendTransaction]. */
    sealed class FireblocksSendResult {
        data class Success(val txHash: String) : FireblocksSendResult()
        /** User dismissed the browser, or the flow reported a cancel. */
        object Cancelled : FireblocksSendResult()
        /** Nonce mismatch (possible CSRF), an unparseable return, or a failure
         *  the flow reported (e.g. "address_mismatch", "chain_mismatch",
         *  "send_failed" — see [FireblocksConnect.sendTransaction]'s docs).
         *  `code` is a stable machine code when the flow reported one. */
        data class Error(val reason: String, val code: String? = null) : FireblocksSendResult()
    }

    /** Result of [FireblocksConnect.signMessage]. */
    sealed class FireblocksSignResult {
        data class Success(val signature: String) : FireblocksSignResult()
        /** User dismissed the browser, or the flow reported a cancel. */
        object Cancelled : FireblocksSignResult()
        /** Nonce mismatch (possible CSRF), an unparseable return, or a failure
         *  the flow reported (e.g. "address_mismatch", "sign_failed" — see
         *  [FireblocksConnect.signMessage]'s docs). `code` is a stable machine
         *  code when the flow reported one. */
        data class Error(val reason: String, val code: String? = null) : FireblocksSignResult()
    }

    /** Result of [FireblocksConnect.fundFromExchange]. */
    sealed class FireblocksFundResult {
        data class Success(
            /**
             * The EXCHANGE's transfer id — not an on-chain hash. The exchange
             * broadcasts on its own schedule, so there may be no hash yet at all;
             * poll the exchange (or the destination address) for settlement.
             */
            val transferId: String,
            /** Which exchange the funds came from, e.g. "coinbase". */
            val exchange: String,
            /**
             * Amount and currency the exchange accepted, echoed back — these can
             * differ from what was requested (e.g. rounded to the currency's
             * precision).
             */
            val amount: String,
            val currency: String,
            /** The exchange's own status string when it reports one (e.g. "pending"). */
            val status: String?,
        ) : FireblocksFundResult()

        /** User dismissed the browser without completing the flow. */
        object Cancelled : FireblocksFundResult()

        /** Nonce mismatch, an unparseable return, or a failure the flow reported
         *  (e.g. the exchange isn't enabled for this environment, no balance is
         *  available, the exchange refused the transfer). `code` is a stable
         *  machine code when the flow reported one. */
        data class Error(val reason: String, val code: String? = null) : FireblocksFundResult()
    }

    // ── Flow ─────────────────────────────────────────────────────────────────────

    /**
     * Connect a self-custodial wallet through a hosted Fireblocks page — no SDK.
     *
     * ```kotlin
     * FireblocksConnect.present(
     *     context = this,
     *     hostedPageUrl = "https://connect.example.com/",
     *     scheme = "myapp",          // must match the intent-filter in AndroidManifest
     * ) { result ->
     *     when (result) {
     *         is FireblocksConnectResult.Success -> { /* result.wallet.address, .chain … */ }
     *         is FireblocksConnectResult.Cancelled -> {}
     *         is FireblocksConnectResult.Error -> { /* result.reason */ }
     *     }
     * }
     * ```
     *
     * Opens the page in a **Chrome Custom Tab** — Android's secure, sandboxed in-app
     * browser (the analog of iOS `ASWebAuthenticationSession`). The page returns to
     * `<scheme>://wallet-callback?address=…&nonce=…`, which `FireblocksRedirectActivity`
     * catches (see the AndroidManifest snippet in the README). This object appends
     * `redirect_uri` + a random `nonce` + `embedded=1`, verifies the nonce, and calls
     * your callback.
     *
     * Single in-flight connection at a time; the callback is held statically. For a
     * production app, prefer holding it in a ViewModel so it survives process death.
     */
    object FireblocksConnect {
        private const val CALLBACK_HOST = "wallet-callback"

        /**
         * The host Phantom's in-app-browser bridge return button targets (see
         * src/redirect.ts's RETURN_HOST) — a zero-payload "bring the app forward"
         * tap, NOT a real result. [FireblocksRedirectActivity] must recognize
         * this host and skip its normal dismiss-the-Custom-Tab handling: that
         * Custom Tab is often still open, waiting on this same connection's OWN
         * WalletConnect approval over the relay, and dismissing it here would
         * race that delivery.
         */
        internal const val RETURN_HOST = "wallet-return"

        private var pendingNonce: String? = null
        private var pendingCallback: ((FireblocksConnectResult) -> Unit)? = null

        private var pendingSendNonce: String? = null
        private var pendingSendCallback: ((FireblocksSendResult) -> Unit)? = null

        private var pendingSignNonce: String? = null
        private var pendingSignCallback: ((FireblocksSignResult) -> Unit)? = null

        private var pendingFundNonce: String? = null
        private var pendingFundCallback: ((FireblocksFundResult) -> Unit)? = null
        private var pendingFundExchange: String = "coinbase"

        /**
         * @param environmentId Dynamic environment ID for the hosted page to use
         *   instead of its own default, sent as `?environmentId=<uuid>`. `null`/blank
         *   omits the param entirely.
         * @param locale UI locale for the hosted page, sent as `?locale=<value>` —
         *   e.g. `"en_US"`, `"es_LA"`; see the iframe app's README for the
         *   allow-list. `null`/blank omits the param, leaving the page on its
         *   own default.
         * @param theme UI theme for the hosted page, sent as `?theme=<value>` —
         *   `"light"` or `"dark"`. `null`/blank omits the param.
         */
        fun present(
            context: Context,
            hostedPageUrl: String,
            scheme: String,
            environmentId: String? = null,
            locale: String? = null,
            theme: String? = null,
            onResult: (FireblocksConnectResult) -> Unit,
        ) {
            pendingNonce = randomNonce()
            pendingCallback = onResult
            val url = buildUrl(hostedPageUrl, scheme, pendingNonce!!, environmentId, locale, theme)
            CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
        }

        /** Called by [FireblocksRedirectActivity] when the return URL fires. */
        internal fun handleRedirect(uri: Uri) {
            val callback = pendingCallback ?: return
            val expected = pendingNonce
            pendingCallback = null
            pendingNonce = null

            if (uri.getQueryParameter("nonce") != expected) {
                callback(FireblocksConnectResult.Error("nonce mismatch"))
                return
            }
            // The flow reports failures through this same callback (status=error /
            // cancelled) so we learn the outcome and which session it was.
            val sessionId = uri.getQueryParameter("session_id").orEmpty()
            when (uri.getQueryParameter("status")) {
                "cancelled" -> {
                    callback(FireblocksConnectResult.Cancelled)
                    return
                }
                "error" -> {
                    val code = uri.getQueryParameter("error_code") ?: "unknown"
                    callback(FireblocksConnectResult.Error("connection failed", code, sessionId))
                    return
                }
            }
            val address = uri.getQueryParameter("address").orEmpty()
            if (address.isEmpty()) {
                callback(FireblocksConnectResult.Error("malformed result"))
                return
            }
            callback(
                FireblocksConnectResult.Success(
                    WalletConnection(
                        address = address,
                        chain = uri.getQueryParameter("chain").orEmpty(),
                        walletName = uri.getQueryParameter("walletName").orEmpty(),
                        walletImage = uri.getQueryParameter("walletImage").orEmpty(),
                        sessionId = sessionId,
                    )
                )
            )
        }

        private fun buildUrl(
            hostedPageUrl: String,
            scheme: String,
            nonce: String,
            environmentId: String?,
            locale: String? = null,
            theme: String? = null,
        ): String {
            val sep = if (hostedPageUrl.contains("?")) "&" else "?"
            val environment = environmentId
                ?.takeIf { it.isNotBlank() }
                ?.let { "&environmentId=" + Uri.encode(it) }
                .orEmpty()
            val localeParam = locale
                ?.takeIf { it.isNotBlank() }
                ?.let { "&locale=" + Uri.encode(it) }
                .orEmpty()
            val themeParam = theme
                ?.takeIf { it.isNotBlank() }
                ?.let { "&theme=" + Uri.encode(it) }
                .orEmpty()
            return hostedPageUrl + sep +
                "redirect_uri=" + Uri.encode("$scheme://$CALLBACK_HOST") +
                "&nonce=" + Uri.encode(nonce) +
                "&embedded=1" +
                environment + localeParam + themeParam
        }

        /**
         * Send an EVM transaction with a wallet that can ONLY connect through
         * this visible flow — Base Account / Coinbase Smart Wallet today, and by
         * extension any other installed/SDK EVM wallet, since the mechanism is
         * generic. [FireblocksHeadlessConnect] can't do this for these wallets:
         * their signer opens a real system-browser popup for EVERY signing call,
         * not just the first connect, and the headless engine's hidden WebView
         * can't reliably host that popup — so, like [present] above, this opens
         * a Custom Tab rather than driving a hidden engine.
         *
         * NEVER silent: expect the Custom Tab to open and at least one wallet
         * approval popup, even for a wallet already connected via [present]
         * earlier — Base Account's signer requires a fresh popup for every
         * signing operation, by design. The page itself asks for two separate
         * taps ("Connect", then "Confirm & send") before returning here.
         *
         * @param context Used to launch the Custom Tab.
         * @param hostedPageUrl Your hosted Fireblocks connect page URL — same one you
         *   pass to [present], e.g. "https://connect.example.com/".
         * @param scheme Your app's registered URL scheme (must match the
         *   intent-filter in AndroidManifest — see [present]'s docs).
         * @param walletKey Catalogue key for the wallet to use, e.g. "baseaccount".
         * @param to Recipient address, `0x`-prefixed hex. Required.
         * @param chainId `0x`-prefixed hex chain id. Required.
         * @param expectedAddress REQUIRED, `0x`-prefixed hex: the address you
         *   already know this wallet connected as (e.g. from an earlier
         *   [present] call's [WalletConnection.address]). Without this the page
         *   would send from whatever account happens to connect — a
         *   wrong-account-send risk for any wallet capable of holding more than
         *   one account. The page itself refuses to send on a mismatch; this
         *   parameter is what it checks against, not a hint.
         * @param value / @param data / @param gasLimit Optional, `0x`-prefixed
         *   hex (`value` defaults to `"0x0"`, `data` to `"0x"`).
         * @param environmentId Dynamic environment ID for the hosted page to use
         *   instead of its own default. `null`/blank omits the param entirely.
         * @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,
            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,
        ) {
            pendingSendNonce = randomNonce()
            pendingSendCallback = onResult
            val url = buildSendUrl(
                hostedPageUrl, scheme, pendingSendNonce!!, walletKey, to, value, data, chainId, gasLimit,
                expectedAddress, environmentId, locale, theme,
            )
            CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
        }

        /** Called by [FireblocksRedirectActivity] when the return URL fires.
         *  Returns `false` (and does nothing else) if there's no pending
         *  [sendTransaction] call, so the caller can fall through to
         *  [handleRedirect] for a normal connect-flow return. */
        internal fun handleSendRedirect(uri: Uri): Boolean {
            val callback = pendingSendCallback ?: return false
            val expected = pendingSendNonce
            pendingSendCallback = null
            pendingSendNonce = null

            if (uri.getQueryParameter("nonce") != expected) {
                callback(FireblocksSendResult.Error("nonce mismatch"))
                return true
            }
            // Matches the send flow's actual redirect contract
            // (?error=1&code=&message=&nonce= / ?txHash=&address=&chain=&nonce=)
            // — see src/SendTxIntent.tsx / src/redirect.ts (buildSendTxResultUrl).
            if (uri.getQueryParameter("error") == "1") {
                val code = uri.getQueryParameter("code") ?: "unknown"
                val message = uri.getQueryParameter("message").orEmpty()
                callback(FireblocksSendResult.Error(message.ifEmpty { "send failed" }, code))
                return true
            }
            val txHash = uri.getQueryParameter("txHash").orEmpty()
            if (txHash.isEmpty()) {
                callback(FireblocksSendResult.Error("malformed result"))
                return true
            }
            callback(FireblocksSendResult.Success(txHash))
            return true
        }

        private fun buildSendUrl(
            hostedPageUrl: String,
            scheme: String,
            nonce: String,
            walletKey: String,
            to: String,
            value: String,
            data: String,
            chainId: String,
            gasLimit: String?,
            expectedAddress: String,
            environmentId: String?,
            locale: String? = null,
            theme: String? = null,
        ): String {
            val sep = if (hostedPageUrl.contains("?")) "&" else "?"
            val gas = gasLimit
                ?.takeIf { it.isNotBlank() }
                ?.let { "&gasLimit=" + Uri.encode(it) }
                .orEmpty()
            val environment = environmentId
                ?.takeIf { it.isNotBlank() }
                ?.let { "&environmentId=" + Uri.encode(it) }
                .orEmpty()
            val localeParam = locale
                ?.takeIf { it.isNotBlank() }
                ?.let { "&locale=" + Uri.encode(it) }
                .orEmpty()
            val themeParam = theme
                ?.takeIf { it.isNotBlank() }
                ?.let { "&theme=" + Uri.encode(it) }
                .orEmpty()
            return hostedPageUrl + sep +
                "intent=sendTx" +
                "&walletKey=" + Uri.encode(walletKey) +
                "&to=" + Uri.encode(to) +
                "&value=" + Uri.encode(value) +
                "&data=" + Uri.encode(data) +
                "&chainId=" + Uri.encode(chainId) +
                gas +
                "&expectedAddress=" + Uri.encode(expectedAddress) +
                "&redirect_uri=" + Uri.encode("$scheme://$CALLBACK_HOST") +
                "&nonce=" + Uri.encode(nonce) +
                environment + localeParam + themeParam
        }

        /**
         * Sign a message with a wallet that can ONLY connect through this
         * visible flow — Base Account / Coinbase Smart Wallet today, and by
         * extension any other installed/SDK EVM wallet. Same reasoning as
         * [sendTransaction] above for why this needs its own flow rather than
         * going through [FireblocksHeadlessConnect]: their signer opens a real
         * system-browser popup for `personal_sign` too, not just
         * `eth_sendTransaction` — there's no "sign is cheaper than send"
         * shortcut here.
         *
         * NEVER silent: expect the Custom Tab to open and at least one wallet
         * approval popup, even for a wallet already connected via [present]
         * earlier. The page itself asks for two separate taps ("Connect", then
         * "Confirm & sign") before returning here.
         *
         * @param context Used to launch the Custom Tab.
         * @param hostedPageUrl Your hosted Fireblocks connect page URL — same one you
         *   pass to [present], e.g. "https://connect.example.com/".
         * @param scheme Your app's registered URL scheme (must match the
         *   intent-filter in AndroidManifest — see [present]'s docs).
         * @param walletKey Catalogue key for the wallet to use, e.g. "baseaccount".
         * @param message The message to sign, as plain text.
         * @param expectedAddress REQUIRED, `0x`-prefixed hex: the address you
         *   already know this wallet connected as. Without this the page would
         *   sign with whatever account happens to connect. The page itself
         *   refuses to sign on a mismatch; this parameter is what it checks
         *   against, not a hint.
         * @param environmentId Dynamic environment ID for the hosted page to use
         *   instead of its own default. `null`/blank omits the param entirely.
         * @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,
            walletKey: String,
            message: String,
            expectedAddress: String,
            environmentId: String? = null,
            locale: String? = null,
            theme: String? = null,
            onResult: (FireblocksSignResult) -> Unit,
        ) {
            pendingSignNonce = randomNonce()
            pendingSignCallback = onResult
            val url = buildSignUrl(
                hostedPageUrl, scheme, pendingSignNonce!!, walletKey, message, expectedAddress, environmentId,
                locale, theme,
            )
            CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
        }

        /** Called by [FireblocksRedirectActivity] when the return URL fires.
         *  Returns `false` (and does nothing else) if there's no pending
         *  [signMessage] call, so the caller can fall through to the next
         *  handler in the chain. */
        internal fun handleSignRedirect(uri: Uri): Boolean {
            val callback = pendingSignCallback ?: return false
            val expected = pendingSignNonce
            pendingSignCallback = null
            pendingSignNonce = null

            if (uri.getQueryParameter("nonce") != expected) {
                callback(FireblocksSignResult.Error("nonce mismatch"))
                return true
            }
            // Matches the sign flow's actual redirect contract
            // (?error=1&code=&message=&nonce= / ?signature=&address=&nonce=) —
            // see src/SignMessageIntent.tsx / src/redirect.ts (buildSignMessageResultUrl).
            if (uri.getQueryParameter("error") == "1") {
                val code = uri.getQueryParameter("code") ?: "unknown"
                val message = uri.getQueryParameter("message").orEmpty()
                callback(FireblocksSignResult.Error(message.ifEmpty { "sign failed" }, code))
                return true
            }
            val signature = uri.getQueryParameter("signature").orEmpty()
            if (signature.isEmpty()) {
                callback(FireblocksSignResult.Error("malformed result"))
                return true
            }
            callback(FireblocksSignResult.Success(signature))
            return true
        }

        private fun buildSignUrl(
            hostedPageUrl: String,
            scheme: String,
            nonce: String,
            walletKey: String,
            message: String,
            expectedAddress: String,
            environmentId: String?,
            locale: String? = null,
            theme: String? = null,
        ): String {
            val sep = if (hostedPageUrl.contains("?")) "&" else "?"
            val environment = environmentId
                ?.takeIf { it.isNotBlank() }
                ?.let { "&environmentId=" + Uri.encode(it) }
                .orEmpty()
            val localeParam = locale
                ?.takeIf { it.isNotBlank() }
                ?.let { "&locale=" + Uri.encode(it) }
                .orEmpty()
            val themeParam = theme
                ?.takeIf { it.isNotBlank() }
                ?.let { "&theme=" + Uri.encode(it) }
                .orEmpty()
            return hostedPageUrl + sep +
                "intent=signMessage" +
                "&walletKey=" + Uri.encode(walletKey) +
                "&message=" + Uri.encode(message) +
                "&expectedAddress=" + Uri.encode(expectedAddress) +
                "&redirect_uri=" + Uri.encode("$scheme://$CALLBACK_HOST") +
                "&nonce=" + Uri.encode(nonce) +
                environment + localeParam + themeParam
        }

        /**
         * Fund [to] from the user's exchange account — Coinbase today.
         *
         * This is NOT a wallet operation and needs no connected wallet. The hosted
         * page signs the user in to the EXCHANGE over OAuth, reads their balances,
         * and the exchange itself signs and broadcasts the withdrawal. There is no
         * headless path either: OAuth has nowhere to run inside
         * [FireblocksHeadlessConnect]'s hidden WebView (a popup has no surface, and
         * a cross-origin redirect is refused by its navigation delegate), which is
         * why this lives here in the Custom Tab flow, like [sendTransaction].
         *
         * NEVER silent: expect the Custom Tab to open, an exchange sign-in, and —
         * for an account with two-step verification on, or a transfer whose
         * jurisdictions require recipient details — one or two further prompts on
         * the page before it returns.
         *
         * @param to Destination address. Deliberately unvalidated: an exchange
         *   withdrawal can target any chain the exchange supports (EVM `0x…`, a
         *   Solana base58 key, a BTC address…). The exchange decides whether it
         *   matches [currency]/[network] and refuses the transfer if not.
         * @param exchange Catalogue key; only "coinbase" is wired today.
         * @param currency Token symbol to debit (e.g. "USDC"). When it matches
         *   exactly one balance, the page skips its balance picker.
         * @param amount Decimal string prefill. The user still confirms it on the
         *   page, which refuses more than the available balance.
         * @param network Network slug passed through to the exchange (e.g. "base").
         *   Get this right — the exchange, not this app, decides which chain the
         *   funds land on.
         */
        fun fundFromExchange(
            context: Context,
            hostedPageUrl: String,
            scheme: String,
            to: String,
            exchange: String = "coinbase",
            currency: String? = null,
            amount: String? = null,
            network: String? = null,
            environmentId: String? = null,
            locale: String? = null,
            theme: String? = null,
            onResult: (FireblocksFundResult) -> Unit,
        ) {
            pendingFundNonce = randomNonce()
            pendingFundCallback = onResult
            pendingFundExchange = exchange
            val url = buildFundUrl(
                hostedPageUrl, scheme, pendingFundNonce!!, to, exchange, currency, amount, network,
                environmentId, locale, theme,
            )
            CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
        }

        /** Called by [FireblocksRedirectActivity] when the return URL fires.
         *  Returns `false` (and does nothing else) if there's no pending
         *  [fundFromExchange] call, so the caller can fall through to the other
         *  flows that share the wallet-callback host. */
        internal fun handleFundRedirect(uri: Uri): Boolean {
            val callback = pendingFundCallback ?: return false
            val expected = pendingFundNonce
            pendingFundCallback = null
            pendingFundNonce = null

            if (uri.getQueryParameter("nonce") != expected) {
                callback(FireblocksFundResult.Error("nonce mismatch"))
                return true
            }
            // Matches the flow's redirect contract (buildExchangeTransferResultUrl
            // in src/redirect.ts): ?error=1&code=&message=&nonce= /
            // ?transferId=&exchange=&amount=&currency=&status=&nonce=
            if (uri.getQueryParameter("error") == "1") {
                val code = uri.getQueryParameter("code") ?: "unknown"
                val message = uri.getQueryParameter("message").orEmpty()
                callback(FireblocksFundResult.Error(message.ifEmpty { "transfer failed" }, code))
                return true
            }
            val transferId = uri.getQueryParameter("transferId").orEmpty()
            if (transferId.isEmpty()) {
                callback(FireblocksFundResult.Error("malformed result"))
                return true
            }
            callback(
                FireblocksFundResult.Success(
                    transferId = transferId,
                    exchange = uri.getQueryParameter("exchange") ?: pendingFundExchange,
                    amount = uri.getQueryParameter("amount").orEmpty(),
                    currency = uri.getQueryParameter("currency").orEmpty(),
                    status = uri.getQueryParameter("status"),
                )
            )
            return true
        }

        private fun buildFundUrl(
            hostedPageUrl: String,
            scheme: String,
            nonce: String,
            to: String,
            exchange: String,
            currency: String?,
            amount: String?,
            network: String?,
            environmentId: String?,
            locale: String? = null,
            theme: String? = null,
        ): String {
            val sep = if (hostedPageUrl.contains("?")) "&" else "?"
            fun optional(name: String, value: String?) = value
                ?.takeIf { it.isNotBlank() }
                ?.let { "&$name=" + Uri.encode(it) }
                .orEmpty()
            return hostedPageUrl + sep +
                "intent=fundFromExchange" +
                "&exchange=" + Uri.encode(exchange) +
                "&to=" + Uri.encode(to) +
                optional("currency", currency) +
                optional("amount", amount) +
                optional("network", network) +
                "&redirect_uri=" + Uri.encode("$scheme://$CALLBACK_HOST") +
                "&nonce=" + Uri.encode(nonce) +
                optional("environmentId", environmentId) +
                optional("locale", locale) +
                optional("theme", theme)
        }

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

    // ── Return catcher ─────────────────────────────────────────────────────────────

    /**
     * Receives `<scheme>://wallet-callback` (and `<scheme>://wallet-browser`, and
     * `<scheme>://phantom-headless`), hands it to the flow waiting for it, and
     * bounces back to the app (dismissing the Custom Tab); and
     * `<scheme>://wallet-return` (Phantom's in-app-browser bridge's zero-payload
     * "bring the app forward" tap), which must NOT dismiss the Custom Tab. Declare
     * it in your AndroidManifest with `launchMode="singleTask"` and an
     * intent-filter for your scheme + all of these hosts (see README).
     */
    class FireblocksRedirectActivity : Activity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            handleRedirectIntent(intent)
        }

        // singleTask: a callback arriving while this instance is still alive is
        // delivered here instead of to a fresh onCreate().
        override fun onNewIntent(intent: Intent) {
            super.onNewIntent(intent)
            setIntent(intent)
            handleRedirectIntent(intent)
        }

        private fun handleRedirectIntent(intent: Intent?) {
            if (intent?.data?.host == FireblocksConnect.RETURN_HOST) {
                // Zero-payload return: do nothing but pop ourselves off. The Custom
                // Tab launched WITHOUT FLAG_ACTIVITY_NEW_TASK (see
                // FireblocksConnect.present), so it shares this same Android task —
                // singleTask puts us on top of it, and finishing without starting
                // anything else just reveals it again, still alive. Starting
                // MainActivity here (even with reorder-only flags, no CLEAR_TOP)
                // still forces this task's top activity back down to MainActivity,
                // which evicts the Custom Tab the same way CLEAR_TOP does — there is
                // no flag combination that both brings the app forward AND keeps a
                // lower activity in the same task alive. The Custom Tab's own page
                // resolves this properly moments later: once it gets the pending
                // WalletConnect approval over the relay, it navigates to the REAL
                // wallet-callback below, which correctly dismisses itself.
                finish()
                return
            }
            intent?.data?.let { uri ->
                // Headless Phantom's zero-payload <scheme>://phantom-headless is
                // consumed first (it only brings the app forward); then a pending sendTransaction()
                // or signMessage() (checked before handleRedirect since all three
                // share the same wallet-callback host — only one is ever actually
                // pending at a time in practice, but each no-ops and returns false
                // if it isn't the one waiting); otherwise it's a normal
                // visible-flow connect return.
                if (!FireblocksHeadlessConnect.handleReturnURL(uri) &&
                    // Then <scheme>://wallet-browser — the visible flow finishing
                    // inside a WALLET's own in-app browser (the only path Phantom
                    // EVM has). Its own host, so it can't cross-complete with the
                    // Custom Tab flows below, which all share wallet-callback.
                    !FireblocksWalletBrowser.handleRedirect(uri) &&
                    !FireblocksConnect.handleSendRedirect(uri) &&
                    !FireblocksConnect.handleSignRedirect(uri) &&
                    !FireblocksConnect.handleFundRedirect(uri)
                ) {
                    FireblocksConnect.handleRedirect(uri)
                }
            }
            // Bring the app's own UI back to the front, closing the Custom Tab.
            packageManager.getLaunchIntentForPackage(packageName)?.let {
                it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
                startActivity(it)
            }
            finish()
        }
    }
    ````
  </Accordion>
</AccordionGroup>

## 1. Add the dependency and return activity

```kotlin build.gradle.kts theme={"system"}
implementation("androidx.browser:browser:1.8.0")
```

Register `FireblocksRedirectActivity` in `AndroidManifest.xml` (replace `myapp` with your scheme) so the OS routes the return to your app:

```xml AndroidManifest.xml theme={"system"}
<activity
    android:name="com.fireblocks.connect.FireblocksRedirectActivity"
    android:exported="true"
    android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="wallet-callback" />
        <!-- Phantom's zero-payload "Return to app" tap. Keeps the Custom Tab open. -->
        <data android:scheme="myapp" android:host="wallet-return" />
    </intent-filter>
</activity>
```

## 2. Present the flow

```kotlin MainActivity.kt theme={"system"}
FireblocksConnect.present(
    context = this,
    hostedPageUrl = "https://connect.dynamicauth.com/",
    scheme = "myapp",
    environmentId = "b1e3aca9-0646-411a-b4ab-c31ce49935b3",
) { result ->
    when (result) {
        is FireblocksConnectResult.Success -> { /* result.wallet.address, .chain … */ }
        is FireblocksConnectResult.Cancelled -> {}
        is FireblocksConnectResult.Error -> { /* result.reason */ }
    }
}
```

Pass `locale` and `theme` (plain strings, e.g. `"es_LA"`, `"dark"`) the same way to override the hosted page's UI locale and theme.

## 3. Use the result

`WalletConnection` carries `address`, `chain` (`evm` or `solana`), `walletName`, and `walletImage`. Render `walletImage` in a `WebView` `<img>`. It is an SVG sprite that `ImageView` cannot draw.

<Note>
  Real wallet round-trips need a physical device with a wallet installed, not a bare emulator. Serve the connect page over HTTPS.
</Note>


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