This is an enterprise-only feature. Please contact us to enable.
FireblocksConnect.kt, opening the flow in a Chrome Custom Tab.
For a fully native wallet list with signing, see Android headless.
Present with a Chrome Custom Tab: Android’s secure, sandboxed in-app browser. The page returns to
<scheme>://wallet-callback, caught by FireblocksRedirectActivity.View FireblocksConnect.kt (copy-paste ready)
View FireblocksConnect.kt (copy-paste ready)
FireblocksConnect.kt
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=¤cy=&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()
}
}
1. Add the dependency and return activity
build.gradle.kts
implementation("androidx.browser:browser:1.8.0")
FireblocksRedirectActivity in AndroidManifest.xml (replace myapp with your scheme) so the OS routes the return to your app:
AndroidManifest.xml
<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
MainActivity.kt
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 */ }
}
}
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.
Real wallet round-trips need a physical device with a wallet installed, not a bare emulator. Serve the connect page over HTTPS.