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

# Hosted wallet connector on Android (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.

On Android the [hosted connect page](https://www.dynamic.xyz/docs/connections/overview.md) 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](https://www.dynamic.xyz/docs/connections/android-headless.md).

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

> [accordion] 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,
>     /**
>      * CAIP-2 chain id (e.g. "eip155:1") of the connected network or the manually
>      * chosen one, from the redirect's `networkId` param or the headless engine's
>      * `networkId`. Not [network], which is the Phantom Solana session cluster.
>      * `null` when the wallet did not report one.
>      */
>     val networkId: String? = null,
>     /** Every query parameter the hosted flow's redirect carried, in order. Empty
>      *  for connections that didn't come from a redirect (the headless engine). */
>     val params: Map<String, String> = emptyMap(),
> )
>
> 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.
>  *
>  * A host that renders the hosted page itself (an in-app WebView) skips the Custom
>  * Tab and uses the same contract directly: load [hostedUrl] with a fresh
>  * [newNonce], intercept the navigation to `<scheme>://wallet-callback`, and pass
>  * that URI to [parseCallback].
>  */
> object FireblocksConnect {
>     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.
>      * @param phantomMode How the hosted page connects Phantom, sent as
>      *   `?phantomMode=<value>` — `"in-app"` or `"redirect"`. `null`/blank omits
>      *   the param and the page uses its own default (`in-app`).
>      */
>     fun present(
>         context: Context,
>         hostedPageUrl: String,
>         scheme: String,
>         environmentId: String? = null,
>         locale: String? = null,
>         theme: String? = null,
>         phantomMode: String? = null,
>         onResult: (FireblocksConnectResult) -> Unit,
>     ) {
>         pendingNonce = newNonce()
>         pendingCallback = onResult
>         val url = hostedUrl(hostedPageUrl, scheme, pendingNonce!!, environmentId, locale, theme, phantomMode)
>         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 ?: return
>         pendingCallback = null
>         pendingNonce = null
>         callback(parseCallback(uri, expected))
>     }
>
>     /**
>      * Verifies and parses a `<scheme>://wallet-callback` return: checks the
>      * `nonce` against [expectedNonce] (a mismatch is an [FireblocksConnectResult.Error]),
>      * maps the flow's `status=cancelled|error` reports, and otherwise builds the
>      * [WalletConnection] from the query parameters.
>      */
>     fun parseCallback(uri: Uri, expectedNonce: String): FireblocksConnectResult {
>         if (uri.getQueryParameter("nonce") != expectedNonce) {
>             return FireblocksConnectResult.Error("nonce mismatch")
>         }
>         // 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" -> return FireblocksConnectResult.Cancelled
>             "error" -> {
>                 val code = uri.getQueryParameter("error_code") ?: "unknown"
>                 return FireblocksConnectResult.Error("connection failed", code, sessionId)
>             }
>         }
>         val address = uri.getQueryParameter("address").orEmpty()
>         if (address.isEmpty()) return FireblocksConnectResult.Error("malformed result")
>         return FireblocksConnectResult.Success(
>             WalletConnection(
>                 address = address,
>                 chain = uri.getQueryParameter("chain").orEmpty(),
>                 walletName = uri.getQueryParameter("walletName").orEmpty(),
>                 walletImage = uri.getQueryParameter("walletImage").orEmpty(),
>                 sessionId = sessionId,
>                 networkId = uri.getQueryParameter("networkId")?.takeIf { it.isNotEmpty() },
>                 params = uri.queryParameterNames.associateWith { uri.getQueryParameter(it).orEmpty() },
>             )
>         )
>     }
>
>     /**
>      * The hosted page URL with the contract params appended: `redirect_uri`,
>      * [nonce], `embedded=1`, and whichever of the optional params are non-blank.
>      */
>     fun hostedUrl(
>         hostedPageUrl: String,
>         scheme: String,
>         nonce: String,
>         environmentId: String?,
>         locale: String? = null,
>         theme: String? = null,
>         phantomMode: 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()
>         val phantomModeParam = phantomMode
>             ?.takeIf { it.isNotBlank() }
>             ?.let { "&phantomMode=" + Uri.encode(it) }
>             .orEmpty()
>         return hostedPageUrl + sep +
>             "redirect_uri=" + Uri.encode("$scheme://$CALLBACK_HOST") +
>             "&nonce=" + Uri.encode(nonce) +
>             "&embedded=1" +
>             environment + localeParam + themeParam + phantomModeParam
>     }
>
>     /**
>      * 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 = newNonce()
>         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 = newNonce()
>         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 = newNonce()
>         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). */
>     fun newNonce(): 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 <scheme>://phantom-headless (redirect mode's
>             // payload, or in-app mode's bare tap) is forwarded to the engine
>             // first; 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()
>     }
> }
> ````

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

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

> [!NOTE]
>
> Real wallet round-trips need a physical device with a wallet installed, not a bare emulator. Serve the connect page over HTTPS.


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