This is an enterprise-only feature. Please contact us to enable.
WebView that runs the Dynamic SDK and returns results (including message and transaction signatures) over a JS bridge. No wallet SDK in the app.
The basic Android flow is the recommended default.
No SDK in your app. Your app links no wallet SDK. It needs a hidden
WebView pointed at the hosted engine page (https://connect.dynamicauth.com/headless.html, set as ENGINE_URL in FireblocksHeadlessConnect) and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list) comes from that hosted view. Same bridge contract as iOS.FireblocksHeadlessConnect.kt (below) and FireblocksConnect.kt from the basic Android guide (visible fallback).
1. Get the wallet menu (no static file)
The engine derives the list live from the Dynamic catalog and pushes it over the bridge (awallets message). Set FireblocksHeadlessConnect.onWallets.
| Field | Type | Description |
|---|---|---|
key | String | Catalog key you pass back on tap (e.g. metamask). |
name / icon | String | Display name and icon URL for the row. |
chains | List<String> | evm / solana. Drives a native chain picker. |
mode | "headless" | "fallback" | headless connects silently; fallback opens the visible flow. |
featured | Boolean | Show by default; the rest of the catalog rides along for search. |
2. Drop in FireblocksHeadlessConnect
Owns a hiddenWebView, bridges to it (addJavascriptInterface + evaluateJavascript), opens the wallet deeplink it returns, and calls you back. Pre-warm it at launch. Set environmentId before prewarm() to target a different Dynamic environment.
MainActivity.kt
FireblocksHeadlessConnect.environmentId = "b1e3aca9-0646-411a-b4ab-c31ce49935b3"
FireblocksHeadlessConnect.prewarm(this) // at launch
FireblocksHeadlessConnect.onWallets = { render(it) } // the live list
FireblocksHeadlessConnect.connect(this, "metamask", "evm") { result ->
when (result) {
is FireblocksHeadlessConnect.Result.Success -> { /* result.wallet */ }
is FireblocksHeadlessConnect.Result.FallbackRequired -> { /* visible flow */ }
is FireblocksHeadlessConnect.Result.Failure -> { /* result.code */ }
}
}
View FireblocksHeadlessConnect.kt (copy-paste ready)
View FireblocksHeadlessConnect.kt (copy-paste ready)
FireblocksHeadlessConnect.kt
package com.fireblocks.connect
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
// ── Headless connect engine (Android) ────────────────────────────────────────
/**
* Runs the hosted Fireblocks connect logic (the Dynamic SDK) inside a HIDDEN
* [WebView], so the app can render its own native wallet list and still keep
* every bit of connection logic in the web layer. The Android analog of iOS's
* `FireblocksHeadlessConnect`.
*
* For WalletConnect-protocol wallets (MetaMask, Rainbow, Trust, …) the pairing
* is relay-based: the engine mints a URI, we open the wallet via deeplink, the
* user approves, and the approval resolves over a WebSocket — no visible page.
* Wallets with no such path (Base Account passkey/email, …) come back as
* [Result.FallbackRequired] so the caller opens the visible [FireblocksConnect].
*
* The app links **no wallet SDK**: it loads a URL, relays JSON messages over a
* bridge, opens a deeplink, and renders a list. Everything wallet-specific is
* JavaScript in the hidden WebView.
*
* ```kotlin
* FireblocksHeadlessConnect.prewarm(activity) // at launch
* FireblocksHeadlessConnect.connect(activity, "rainbow", "evm") { result ->
* when (result) {
* is FireblocksHeadlessConnect.Result.Success -> { /* result.wallet */ }
* is FireblocksHeadlessConnect.Result.FallbackRequired -> { /* visible flow */ }
* is FireblocksHeadlessConnect.Result.Failure -> { /* result.code */ }
* }
* }
* ```
*/
object FireblocksHeadlessConnect {
private const val TAG = "FireblocksHeadlessConnect"
/** The no-UI engine page. `returnScheme` points Phantom's redirect at your
* app scheme so it returns to the app. Replace `myapp` with your scheme. */
private const val ENGINE_URL =
"https://connect.dynamicauth.com/headless.html?returnScheme=myapp"
/**
* Dynamic environment ID for the engine page to use instead of the one it was
* built with, sent as `?environmentId=<uuid>`. `null`/blank leaves the page on
* its own default.
*
* Set this BEFORE [prewarm]/[connect]: the URL is resolved when the hidden
* WebView is first created, so a later change only takes effect on the next
* engine reload. Pass the same value to [FireblocksConnect.present] so the
* visible fallback flow lands on the same environment.
*/
var environmentId: String? = null
/** [ENGINE_URL] plus `environmentId` when one is set. */
private fun engineUrl(): String {
val builder = Uri.parse(ENGINE_URL).buildUpon()
environmentId
?.takeIf { it.isNotBlank() }
?.let { builder.appendQueryParameter("environmentId", it) }
return builder.build().toString()
}
/** If the engine hasn't produced a deeplink within this window, fall back to
* the visible flow. Cancelled once the wallet opens. */
private const val STARTUP_TIMEOUT_MS = 20_000L
/** Wallet universal-link hosts iOS/Android won't hand to the wallet app from
* inside a WebView (only Phantom's redirect navigates the WebView today) —
* the WebViewClient opens these externally. */
private val WALLET_HOSTS = setOf(
"phantom.app", "phantom.com",
"link.metamask.io", "metamask.app.link",
"link.trustwallet.com", "rnbwapp.com", "rainbow.me",
"www.okx.com", "link.okx.com", "zerion.io",
)
/** Bridge message types that aren't tied to a single connect() attempt, so
* they're exempt from the per-attempt `requestId` check in `handleMessage`. */
private val NO_REQUEST_ID_TYPES = setOf("ready", "wallets", "event")
/** A wallet in the native list, delivered live by the engine (derived from
* the Dynamic catalogue — no static file). */
data class Wallet(
val key: String,
val name: String,
val icon: String?,
val chains: List<String>,
/** "headless" → drive this engine; "fallback" → the visible flow. */
val mode: String,
/** Shown by default; the rest of the catalogue rides along for search. */
val featured: Boolean,
) {
val isMultiChain: Boolean get() = chains.size > 1
}
sealed class Result {
data class Success(val wallet: WalletConnection) : Result()
data class FallbackRequired(val reason: String) : Result()
data class Failure(val code: String, val message: String) : Result()
}
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var webView: WebView? = null
private var ready = false
private val pendingReady = mutableListOf<() -> Unit>()
// Single in-flight attempt (mirrors the iOS engine). For production, hold
// this in a ViewModel so it survives configuration changes / process death.
private var handler: ((Result) -> Unit)? = null
private var timeout: Runnable? = null
// UUID per attempt, not a constant — a guessable/fixed request ID lets any
// JS in this WebView forge a `connected` message for a request it never
// made. `handleMessage` drops any message whose `requestId` doesn't match.
// Written and read only on `main`.
private var pendingRequestId: String? = null
// The origin (scheme+host+port) of the page currently committed as this
// WebView's top-level document. `@JavascriptInterface` gives no origin, so
// this is tracked from the WebViewClient callbacks (main thread) and
// consulted in `handleMessage` to reject bridge messages from anything
// other than the engine. @Volatile because it's written on `main` and read
// on the JS-interface binder thread in `Bridge.postMessage` — a single
// immutable reference swap, so volatility alone (no lock) is sufficient.
@Volatile
private var committedOrigin: Uri? = null
private var walletsList: List<Wallet> = emptyList()
/** Set to receive the wallet menu. Replayed immediately if already delivered. */
var onWallets: ((List<Wallet>) -> Unit)? = null
set(value) {
field = value
if (walletsList.isNotEmpty()) value?.invoke(walletsList)
}
// ── Public API ────────────────────────────────────────────────────────────
/** Build + load the hidden WebView ahead of time so the first connect is
* fast. Safe to call more than once. */
fun prewarm(activity: Activity) = main.post { ensureWebView(activity) }
fun connect(
activity: Activity,
walletKey: String,
chain: String?,
onResult: (Result) -> Unit,
) = main.post {
ensureWebView(activity)
// A previous attempt is still in flight (e.g. the user opened MetaMask,
// ignored the prompt, and is now trying another wallet). The SDK can hold
// a stuck pending connection that blocks the next mint — reset to a fresh
// engine so the new wallet gets a clean slate.
if (handler != null) resetEngine()
handler = onResult
val requestId = UUID.randomUUID().toString()
pendingRequestId = requestId
scheduleStartupTimeout()
val work = { drive(requestId, walletKey, chain) }
if (ready) work() else pendingReady.add(work)
}
// Reload the hidden WebView to abandon a stuck previous attempt. The reloaded
// page re-fires `ready` (flushing any queued connect) and re-pushes the list.
private fun resetEngine() {
handler = null
pendingRequestId = null
clearTimeout()
ready = false
pendingReady.clear()
webView?.reload()
}
/** Abort the in-flight attempt (e.g. the user backed out of the list). */
fun cancel() = main.post {
webView?.evaluateJavascript("window.headlessConnect && window.headlessConnect.cancel('');", null)
clearTimeout()
handler = null
pendingRequestId = null
}
/** Called from [FireblocksRedirectActivity] when Phantom returns to the
* app's `<scheme>://phantom-headless`. Forwards it into the WebView so the
* engine can complete the connection. Returns true if it consumed the URL. */
fun handleReturnURL(uri: Uri): Boolean {
if (uri.host?.lowercase() != "phantom-headless") return false
val js = "window.headlessConnect && window.headlessConnect.handleReturnURL(${JSONObject.quote(uri.toString())});"
main.post { webView?.evaluateJavascript(js, null) }
return true
}
// ── WebView lifecycle ───────────────────────────────────────────────────
@SuppressLint("SetJavaScriptEnabled")
private fun ensureWebView(activity: Activity) {
if (webView != null) return
appContext = activity.applicationContext
val wv = WebView(activity)
wv.settings.javaScriptEnabled = true
wv.settings.domStorageEnabled = true
wv.addJavascriptInterface(Bridge(), "walletNative")
wv.webViewClient = object : WebViewClient() {
// The engine navigates to wallet deeplinks (Phantom's redirect); the
// system won't open those from inside a WebView, so we do.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
val scheme = url.scheme?.lowercase()
if (scheme != "http" && scheme != "https") {
openExternally(url); return true
}
val host = url.host?.lowercase()
if (host != null && WALLET_HOSTS.any { host == it || host.endsWith(".$it") }) {
openExternally(url); return true
}
// Deny-by-default: only the engine's own origin may load top-level
// in this privileged WebView (the bridge is attached regardless of
// what page is showing). Logged rather than silently dropped, since
// a denial here means something unexpected tried to navigate this
// WebView — see the Critical bridge-trust finding.
//
// Deliberately NOT scoped to `request.isForMainFrame()` — this also
// denies subframe navigation. `@JavascriptInterface` exposes
// `walletNative` to every frame in this WebView (Android gives no way
// to restrict it to the top frame), while `committedOrigin` below
// only ever tracks the *main*-frame origin. If this were narrowed
// to main-frame-only, a hostile iframe could load here and post
// forged bridge messages that `handleMessage`'s origin check would
// wrongly accept (it'd still see the engine's own top-level
// origin). Don't narrow this without also making the bridge
// origin check frame-aware.
if (!isEngineOrigin(url)) {
Log.w(TAG, "Denying navigation to non-engine origin: $url")
return true
}
return false
}
// Tracks the top-level origin currently loaded, so the bridge (which
// gets no origin info from @JavascriptInterface) can validate against
// it. Fires for every main-frame navigation, including the initial
// `loadUrl` and `resetEngine()`'s reload — both same-origin already.
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
committedOrigin = url?.let(Uri::parse)
}
}
// Keep it in the hierarchy (1×1) so its JS + relay socket keep running,
// and DON'T call onPause() — that would suspend the socket. We rely on
// Android keeping a short app-switch alive; for long approvals consider a
// foreground service.
val root = activity.findViewById<ViewGroup>(android.R.id.content)
root.addView(wv, 1, 1)
wv.loadUrl(engineUrl())
webView = wv
}
private fun drive(requestId: String, walletKey: String, chain: String?) {
val params = JSONObject()
.put("requestId", requestId)
.put("walletKey", walletKey)
if (chain != null) params.put("chain", chain)
webView?.evaluateJavascript("window.headlessConnect && window.headlessConnect.connect($params);", null)
}
private fun openExternally(uri: Uri) {
val ctx = appContext ?: return
try {
ctx.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (_: Exception) {
// No app to handle it — the engine will surface an error/timeout.
}
}
// ── Origin gating ─────────────────────────────────────────────────────────
// Shared by the bridge (below) and the WebViewClient above: only the
// engine's own scheme+host+port may post a message or load top-level in
// this privileged WebView. Compared this way — not just host — so a
// same-host page served on a different scheme/port can't slip through.
private fun isEngineOrigin(uri: Uri?): Boolean {
if (uri == null) return false
return sameOrigin(uri, Uri.parse(ENGINE_URL))
}
private fun sameOrigin(a: Uri, b: Uri): Boolean {
val schemeA = a.scheme?.lowercase()
val schemeB = b.scheme?.lowercase()
if (schemeA == null || schemeA != schemeB) return false
if (a.host?.lowercase() != b.host?.lowercase()) return false
// Uri reports -1 for "no explicit port" — resolve that to the scheme's
// real default so https://x.com and https://x.com:443 compare equal.
val defaultPort = if (schemeA == "https") 443 else if (schemeA == "http") 80 else -1
val portA = if (a.port == -1) defaultPort else a.port
val portB = if (b.port == -1) defaultPort else b.port
return portA == portB
}
// ── Bridge (JS → native) ──────────────────────────────────────────────────
// @JavascriptInterface methods run on a binder thread, with no origin info
// of their own. Snapshot `committedOrigin` here — on the calling thread,
// at the moment JS posted — rather than re-reading it after hopping to
// main, so a navigation racing the hop can't retroactively change which
// origin this message is attributed to. committedOrigin is @Volatile, so
// this read is safe without a lock.
private class Bridge {
@JavascriptInterface
fun postMessage(json: String) {
val origin = committedOrigin
main.post { handleMessage(json, origin) }
}
}
private fun handleMessage(json: String, origin: Uri?) {
// Reject anything not posted by the engine's own top-level frame. Without
// this, any JS that ends up running in this WebView — a compromised
// script, an XSS on the hosted page, or a page the navigation gate above
// missed — could forge a `connected` message and native would believe an
// attacker-chosen wallet address is connected. See the Critical
// bridge-trust finding.
if (!isEngineOrigin(origin)) {
Log.w(TAG, "Dropping bridge message from non-engine origin: $origin")
return
}
val o = runCatching { JSONObject(json) }.getOrNull() ?: return
val type = o.optString("type")
// "ready"/"wallets"/"event" aren't tied to one connect() attempt; every
// other message must carry the requestId of the attempt in flight —
// stale or forged IDs (e.g. a superseded attempt's late message) are
// dropped rather than resolved against the current handler.
if (type !in NO_REQUEST_ID_TYPES && !matchesPendingRequest(o)) return
when (type) {
"ready" -> {
ready = true
val work = pendingReady.toList()
pendingReady.clear()
work.forEach { it() }
}
"wallets" -> {
walletsList = parseWallets(o.optJSONArray("wallets"))
onWallets?.invoke(walletsList)
}
"deeplink" -> {
clearTimeout() // the wallet is opening; wait for the user now
o.optString("url").takeIf { it.isNotEmpty() }?.let { openExternally(Uri.parse(it)) }
}
"opening" -> clearTimeout()
"connected" -> {
// A missing/empty (or non-string — `optString` would otherwise
// coerce e.g. a JSON number into a "successful" address) address
// is a malformed payload, not a "connected" wallet with a blank
// address — route it to failure instead of silently succeeding
// (see Finding 19).
val address = (o.opt("address") as? String)?.takeIf { it.isNotEmpty() }
if (address == null) {
finish(Result.Failure("malformed_result", "connected message missing address"))
return
}
finish(
Result.Success(
WalletConnection(
address = address,
chain = o.optString("chain"),
walletName = o.optString("walletName"),
walletImage = o.optString("walletImage"),
sessionId = o.optString("sessionId"),
),
),
)
}
"fallback" -> finish(Result.FallbackRequired(o.optString("reason")))
"error" -> finish(Result.Failure(o.optString("code", "unknown"), o.optString("message")))
// "event" — diagnostic timeline; hook up logging/analytics if wanted.
}
}
private fun matchesPendingRequest(o: JSONObject): Boolean {
val incoming = o.optString("requestId").takeIf { it.isNotEmpty() } ?: return false
return incoming == pendingRequestId
}
private fun parseWallets(arr: JSONArray?): List<Wallet> {
if (arr == null) return emptyList()
return (0 until arr.length()).mapNotNull { i ->
val w = arr.optJSONObject(i) ?: return@mapNotNull null
val chains = w.optJSONArray("chains")
Wallet(
key = w.optString("key"),
name = w.optString("name"),
icon = w.optString("icon").takeIf { it.isNotEmpty() },
chains = if (chains == null) emptyList()
else (0 until chains.length()).map { chains.optString(it) },
mode = w.optString("mode", "fallback"),
featured = w.optBoolean("featured", false),
)
}
}
private fun finish(result: Result) {
val cb = handler ?: return
handler = null
pendingRequestId = null
clearTimeout()
main.post { cb(result) }
}
private fun scheduleStartupTimeout() {
clearTimeout()
timeout = Runnable {
finish(Result.FallbackRequired("headless startup timeout"))
}.also { main.postDelayed(it, STARTUP_TIMEOUT_MS) }
}
private fun clearTimeout() {
timeout?.let { main.removeCallbacks(it) }
timeout = null
}
}
3. Sign a message
The hosted engine supportswindow.headlessConnect.sign. After a successful connect, invoke sign() with any string. The wallet app prompts the user; the callback delivers a hex signature.
Sample harness update needed. The
FireblocksHeadlessConnect.kt sample currently handles connect only. Add sign() / signTransaction() methods and their bridge handlers following the same pattern as the iOS and Flutter harnesses. The bridge protocol is identical.MainActivity.kt
FireblocksHeadlessConnect.sign(
context,
message = "Sign in to MyApp · ${Instant.now()}"
) { result ->
when (result) {
is SignResult.Success -> { /* result.signature: hex string */ }
is SignResult.Failure -> { /* result.code, result.message */ }
}
}
4. Sign a transaction
Pass a serialized transaction. Signing only, no broadcast.MainActivity.kt (EVM)
FireblocksHeadlessConnect.signTransaction(
context,
transaction = """{"to":"${wallet.address}","value":"0x0","data":"0x"}"""
) { result ->
when (result) {
is SignResult.Success ->
/* result.signedTx: RLP-encoded hex, broadcast with eth_sendRawTransaction */
is SignResult.Failure -> { /* result.code */ }
}
}
MainActivity.kt (Solana)
// txBytes: ByteArray of your serialized VersionedTransaction
val b64 = Base64.encodeToString(txBytes, Base64.NO_WRAP)
FireblocksHeadlessConnect.signTransaction(context, transaction = b64) { result ->
when (result) {
is SignResult.Success ->
/* result.signedTx: base64-encoded signed transaction bytes */
is SignResult.Failure -> { /* result.code */ }
}
}
5. Wire the manifest and redirect
Beyond the basic flow, headless needs two manifest additions: aphantom-headless host on the same FireblocksRedirectActivity intent-filter, and a <queries> block so the app can open wallet deeplinks on Android 11+.
AndroidManifest.xml
<!-- inside the FireblocksRedirectActivity intent-filter -->
<data android:scheme="myapp" android:host="wallet-callback" />
<data android:scheme="myapp" android:host="phantom-headless" />
<!-- Android 11+ package visibility, at <manifest> level -->
<queries>
<intent><action android:name="android.intent.action.VIEW" />
<data android:scheme="metamask" /></intent>
<intent><action android:name="android.intent.action.VIEW" />
<data android:scheme="phantom" /></intent>
</queries>
FireblocksRedirectActivity
intent?.data?.let { uri ->
if (!FireblocksHeadlessConnect.handleReturnURL(uri)) {
FireblocksConnect.handleRedirect(uri)
}
}
The hidden
WebView needs the INTERNET permission. Do not call webView.onPause() on it. That suspends the relay socket.6. Render the list (your UI)
MainActivity is a sample list (search, chain picker, connecting state, auto-fallback) you would swap for your own design.
View MainActivity.kt (sample list UI)
View MainActivity.kt (sample list UI)
MainActivity.kt
package com.fireblocks.connect.sample
import android.app.Activity
import android.app.AlertDialog
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import com.fireblocks.connect.FireblocksConnect
import com.fireblocks.connect.FireblocksConnectResult
import com.fireblocks.connect.FireblocksHeadlessConnect
import com.fireblocks.connect.FireblocksHeadlessConnect.Wallet
import com.fireblocks.connect.WalletConnection
// Reference example: the app renders its OWN native wallet list and drives
// FireblocksHeadlessConnect (a hidden WebView). Tapping a headless wallet
// connects with no visible web UI; a fallback wallet (or any headless failure)
// opens the visible FireblocksConnect flow. All connection logic — and the list
// itself — comes from the web layer. This list UI is sample code you'd replace
// with your own design.
class MainActivity : Activity() {
private val flowUrl = "https://connect.dynamicauth.com/"
private val scheme = "myapp" // must match the intent-filter in AndroidManifest
private var all: List<Wallet> = emptyList()
private var connectingKey: String? = null
private lateinit var status: TextView
private lateinit var search: EditText
private lateinit var listContainer: LinearLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(40, 60, 40, 40)
setBackgroundColor(Color.WHITE)
}
root.addView(TextView(this).apply {
text = "Connect a wallet"
textSize = 22f
setTextColor(Color.parseColor("#0E121B"))
})
status = TextView(this).apply {
textSize = 14f
setTextColor(Color.parseColor("#606770"))
setPadding(0, 16, 0, 16)
}
root.addView(status)
search = EditText(this).apply {
hint = "Search for your wallet"
doAfterTextChanged { renderList() }
}
root.addView(search)
listContainer = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
root.addView(ScrollView(this).apply { addView(listContainer) })
setContentView(root)
// Pre-warm the engine so the first connect is fast, and receive the
// wallet list it derives from the catalogue.
FireblocksHeadlessConnect.prewarm(this)
FireblocksHeadlessConnect.onWallets = { wallets ->
runOnUiThread { all = wallets; renderList() }
}
renderList()
}
private fun visible(): List<Wallet> {
val q = search.text.toString().trim().lowercase()
// Default: featured wallets (like the web home). Searching: the whole catalogue.
return if (q.isEmpty()) all.filter { it.featured }
else all.filter { it.name.lowercase().contains(q) || it.key.lowercase().contains(q) }
}
private fun renderList() {
listContainer.removeAllViews()
if (all.isEmpty()) {
listContainer.addView(TextView(this).apply { text = "Loading wallets…" })
return
}
for (w in visible()) listContainer.addView(walletRow(w))
}
private fun walletRow(wallet: Wallet): View = Button(this).apply {
text = wallet.name + if (connectingKey == wallet.key) " · connecting…" else ""
isAllCaps = false
gravity = Gravity.START or Gravity.CENTER_VERTICAL
setBackgroundColor(Color.parseColor("#F0F2F5"))
setTextColor(Color.parseColor("#0E121B"))
isEnabled = connectingKey == null
setOnClickListener { tap(wallet) }
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { topMargin = 12 }
}
private fun tap(wallet: Wallet) {
if (wallet.isMultiChain) {
val chains = wallet.chains.toTypedArray()
AlertDialog.Builder(this)
.setTitle("Choose a chain")
.setItems(chains.map(::chainLabel).toTypedArray()) { _, i -> start(wallet, chains[i]) }
.show()
} else {
start(wallet, wallet.chains.firstOrNull())
}
}
private fun start(wallet: Wallet, chain: String?) {
connectingKey = wallet.key
status.text = "Opening ${wallet.name}…"
renderList()
if (wallet.mode == "fallback") {
openFallback(wallet, chain)
return
}
FireblocksHeadlessConnect.connect(this, wallet.key, chain) { result ->
runOnUiThread {
when (result) {
is FireblocksHeadlessConnect.Result.Success -> showConnected(result.wallet)
is FireblocksHeadlessConnect.Result.FallbackRequired -> openFallback(wallet, chain)
is FireblocksHeadlessConnect.Result.Failure -> fail("Couldn't connect (${result.code})")
}
}
}
}
// The visible flow, deep-linked straight to this wallet via `?wallet=`.
private fun openFallback(wallet: Wallet, chain: String?) {
val builder = Uri.parse(flowUrl).buildUpon().appendQueryParameter("wallet", wallet.key)
if (chain != null) builder.appendQueryParameter("chain", chain)
FireblocksConnect.present(this, builder.build().toString(), scheme) { result ->
runOnUiThread {
when (result) {
is FireblocksConnectResult.Success -> showConnected(result.wallet)
is FireblocksConnectResult.Cancelled -> { connectingKey = null; status.text = ""; renderList() }
is FireblocksConnectResult.Error -> fail("Couldn't connect (${result.reason})")
}
}
}
}
private fun showConnected(wallet: WalletConnection) {
connectingKey = null
val addr = wallet.address.let { if (it.length > 12) "${it.take(6)}…${it.takeLast(4)}" else it }
status.text = "${wallet.walletName}\n${wallet.chain} · $addr"
renderList()
}
private fun fail(message: String) {
connectingKey = null
status.text = message
renderList()
}
private fun chainLabel(chain: String) = if (chain == "solana") "Solana" else "Ethereum & EVM"
}
7. Connect Phantom on EVM (its own browser)
Phantom injects an EVM provider (window.phantom.ethereum) only inside its own in-app browser. It has no WalletConnect entry in Dynamic’s wallet book and no EVM deeplink, so the hidden engine cannot drive it and the visible flow has no provider to talk to. The route that works is to open your hosted page inside Phantom’s browser and take the result back over your URL scheme.
Each operation is one round trip: Phantom comes to the foreground with your page in it, the user approves, and the result arrives on <scheme>://wallet-browser.
The engine reports each wallet’s in-app-browser template in the
wallets bridge message as inAppBrowser. The template contains {{encodedDappURI}}, and you replace every occurrence (Phantom’s uses it twice). A template is not a chain: it only means the wallet can open a URL in its own browser, so you decide per wallet which chains that browser serves.View FireblocksWalletBrowser.kt (copy-paste ready)
View FireblocksWalletBrowser.kt (copy-paste ready)
FireblocksWalletBrowser.kt
package com.fireblocks.connect
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.security.SecureRandom
/**
* Drives the hosted visible flow **inside a wallet's own in-app browser**.
*
* ## Why this exists (and why it isn't [FireblocksConnect])
*
* [FireblocksConnect] opens the hosted page in a Chrome Custom Tab — a system
* browser. That covers every wallet reachable by WalletConnect, a deeplink, or
* an SDK (Base Account). It cannot cover a wallet whose only mobile surface for
* a given chain is the provider it injects into its OWN browser, because that
* provider isn't there in a Custom Tab.
*
* Phantom on EVM is exactly that case:
* no `phantom*` entry in Dynamic's wallet book has a `walletConnect` block, and
* `phantomevm` carries no mobile deeplink at all — its only surface is
* `window.phantom.ethereum`, injected inside Phantom's in-app browser (see
* `classifyChain`'s doc comment in `src/redirect.ts`). So the page has to be
* opened THERE, and the result handed back over this app's URL scheme.
*
* ## Mechanics
*
* 1. Build the hosted-page URL (`?wallet=…&chain=evm`, or an
* `?intent=signMessage|sendTx` variant) with
* `redirect_uri=<scheme>://[CALLBACK_HOST]` and a fresh nonce.
* 2. Wrap it in the wallet's in-app-browser template (`{{encodedDappURI}}`,
* replaced everywhere — Phantom's uses it twice) and hand it to the OS with
* `ACTION_VIEW`. The template comes from the engine's wallet catalogue
* ([FireblocksHeadlessConnect.Wallet.inAppBrowser]), i.e. from Dynamic's
* wallet book — never hard-coded here.
* 3. The wallet opens its browser on the page; the user connects/signs with the
* injected provider; the page redirects to `<scheme>://[CALLBACK_HOST]`,
* which [FireblocksRedirectActivity] catches and routes here.
*
* The callback host differs from the Custom Tab flow's `wallet-callback` on
* purpose: a return from the wallet's app must not be able to complete (or
* steal) an in-flight Custom Tab request, and vice versa.
*
* ## Caveats worth knowing before shipping this
*
* - Step 2 hands an `https` app link to the OS. It reaches the wallet only if
* the wallet's app links are verified for that domain; otherwise it can land
* in Chrome, where nothing is injected and the page correctly reports that
* the wallet has no path for this chain. That message in a browser that ISN'T
* the wallet means the hand-off went to the wrong app, not that the wallet
* can't do it.
* - Whether a wallet's browser honours a custom-scheme navigation is the
* wallet's choice. The hosted page always ALSO renders a "Return to the app"
* anchor for custom-scheme targets (a real tap is the reliable path where a
* programmatic navigation is ignored), so the user has a way back either way.
* - Nothing here is silent: the wallet app comes to the foreground with a web
* page in it, plus the wallet's own approval prompt.
*
* Single in-flight request at a time (the callback carries no request id beyond
* its nonce). Held statically, like [FireblocksConnect]; a production app should
* prefer a ViewModel so it survives process death.
*/
object FireblocksWalletBrowser {
const val CALLBACK_HOST = "wallet-browser"
/**
* How long to wait for the return URL before giving up. The user is in
* another app for this whole window (open, connect, approve, come back), so
* this is generous by design; it exists so an abandoned flow can't leave a
* callback pending for the process lifetime.
*/
private const val TIMEOUT_MS = 5 * 60 * 1000L
private const val TAG = "FbWalletBrowser"
private sealed class Pending {
abstract val nonce: String
class Connect(
override val nonce: String,
val walletKey: String,
val walletBrowserUrl: String,
val chain: String,
val onResult: (FireblocksConnectResult) -> Unit,
) : Pending()
class Sign(
override val nonce: String,
val onResult: (FireblocksSignResult) -> Unit,
) : Pending()
class Send(
override val nonce: String,
val onResult: (FireblocksSendResult) -> Unit,
) : Pending()
}
private var pending: Pending? = null
private val handler = Handler(Looper.getMainLooper())
private var timeout: Runnable? = null
/**
* Connect [walletKey] on [chain] inside the wallet's own browser.
*
* @param walletBrowserUrl the wallet's in-app-browser template, from
* [FireblocksHeadlessConnect.Wallet.inAppBrowser].
*
* The resulting [WalletConnection] carries [WalletConnection.walletBrowserUrl]
* and [WalletConnection.walletKey] so later sign/send calls can be routed
* back into the same browser — the account lives with that injected provider
* and nowhere else.
*/
fun connect(
context: Context,
hostedPageUrl: String,
scheme: String,
walletBrowserUrl: String,
walletKey: String,
chain: String = "evm",
environmentId: String? = null,
onResult: (FireblocksConnectResult) -> Unit,
) {
val nonce = randomNonce()
val pageUrl = buildPageUrl(
hostedPageUrl, scheme, nonce, environmentId,
mapOf("wallet" to walletKey, "chain" to chain),
)
arm(Pending.Connect(nonce, walletKey, walletBrowserUrl, chain, onResult)) {
onResult(FireblocksConnectResult.Cancelled)
}
if (!open(context, walletBrowserUrl, pageUrl)) {
clear()
onResult(FireblocksConnectResult.Error("could not open the wallet app — is it installed?"))
}
}
/**
* Sign [message] with a wallet connected through [connect].
*
* [expectedAddress] is REQUIRED and checked by the page itself: it connects
* again inside the wallet's browser before signing, and without this it
* would sign with whatever account happens to connect.
*/
fun signMessage(
context: Context,
hostedPageUrl: String,
scheme: String,
walletBrowserUrl: String,
walletKey: String,
message: String,
expectedAddress: String,
environmentId: String? = null,
onResult: (FireblocksSignResult) -> Unit,
) {
val nonce = randomNonce()
val pageUrl = buildPageUrl(
hostedPageUrl, scheme, nonce, environmentId,
mapOf(
"intent" to "signMessage",
"walletKey" to walletKey,
"message" to message,
"expectedAddress" to expectedAddress,
),
)
arm(Pending.Sign(nonce, onResult)) { onResult(FireblocksSignResult.Cancelled) }
if (!open(context, walletBrowserUrl, pageUrl)) {
clear()
onResult(FireblocksSignResult.Error("could not open the wallet app — is it installed?"))
}
}
/**
* Send an EVM transaction with a wallet connected through [connect].
* [to] / [chainId] / [value] / [data] / [gasLimit] are `0x`-prefixed hex,
* same contract as [FireblocksConnect.sendTransaction]. The wallet signs AND
* broadcasts: a success is already on-chain.
*/
fun sendTransaction(
context: Context,
hostedPageUrl: String,
scheme: String,
walletBrowserUrl: String,
walletKey: String,
to: String,
chainId: String,
expectedAddress: String,
value: String = "0x0",
data: String = "0x",
gasLimit: String? = null,
environmentId: String? = null,
onResult: (FireblocksSendResult) -> Unit,
) {
val nonce = randomNonce()
val params = buildMap {
put("intent", "sendTx")
put("walletKey", walletKey)
put("to", to)
put("value", value)
put("data", data)
put("chainId", chainId)
put("expectedAddress", expectedAddress)
gasLimit?.takeIf { it.isNotBlank() }?.let { put("gasLimit", it) }
}
val pageUrl = buildPageUrl(hostedPageUrl, scheme, nonce, environmentId, params)
arm(Pending.Send(nonce, onResult)) { onResult(FireblocksSendResult.Cancelled) }
if (!open(context, walletBrowserUrl, pageUrl)) {
clear()
onResult(FireblocksSendResult.Error("could not open the wallet app — is it installed?"))
}
}
/** Abandon the in-flight request (e.g. the user navigated away in the app). */
fun cancel() {
val inFlight = pending ?: return
clear()
when (inFlight) {
is Pending.Connect -> inFlight.onResult(FireblocksConnectResult.Cancelled)
is Pending.Sign -> inFlight.onResult(FireblocksSignResult.Cancelled)
is Pending.Send -> inFlight.onResult(FireblocksSendResult.Cancelled)
}
}
/**
* Called by [FireblocksRedirectActivity]. Returns `true` when the URL was
* this flow's callback (consumed, whether or not a request was waiting), so
* the activity can fall through to the Custom Tab flows for anything else.
*/
internal fun handleRedirect(uri: Uri): Boolean {
if (!uri.host.equals(CALLBACK_HOST, ignoreCase = true)) return false
val inFlight = pending ?: run {
Log.d(TAG, "callback with no request in flight: $uri")
return true
}
// A stale callback (an old page re-opened in the wallet's browser) must
// not resolve the request that IS in flight: drop it and keep waiting,
// rather than failing the live request with a nonce mismatch.
if (uri.getQueryParameter("nonce") != inFlight.nonce) {
Log.d(TAG, "dropped callback with mismatched nonce")
return true
}
clear()
when (inFlight) {
is Pending.Connect -> {
val address = uri.getQueryParameter("address").orEmpty()
if (address.isEmpty()) {
inFlight.onResult(FireblocksConnectResult.Error("malformed result"))
} else {
inFlight.onResult(
FireblocksConnectResult.Success(
WalletConnection(
address = address,
chain = uri.getQueryParameter("chain") ?: inFlight.chain,
walletName = uri.getQueryParameter("walletName").orEmpty(),
walletImage = uri.getQueryParameter("walletImage").orEmpty(),
sessionId = "",
walletKey = inFlight.walletKey,
walletBrowserUrl = inFlight.walletBrowserUrl,
)
)
)
}
}
is Pending.Sign -> {
if (uri.getQueryParameter("error") == "1") {
val message = uri.getQueryParameter("message").orEmpty()
inFlight.onResult(
FireblocksSignResult.Error(
message.ifEmpty { "sign failed" },
uri.getQueryParameter("code") ?: "unknown",
)
)
} else {
val signature = uri.getQueryParameter("signature").orEmpty()
if (signature.isEmpty()) {
inFlight.onResult(FireblocksSignResult.Error("malformed result"))
} else {
inFlight.onResult(FireblocksSignResult.Success(signature))
}
}
}
is Pending.Send -> {
if (uri.getQueryParameter("error") == "1") {
val message = uri.getQueryParameter("message").orEmpty()
inFlight.onResult(
FireblocksSendResult.Error(
message.ifEmpty { "send failed" },
uri.getQueryParameter("code") ?: "unknown",
)
)
} else {
val txHash = uri.getQueryParameter("txHash").orEmpty()
if (txHash.isEmpty()) {
inFlight.onResult(FireblocksSendResult.Error("malformed result"))
} else {
inFlight.onResult(FireblocksSendResult.Success(txHash))
}
}
}
}
return true
}
// ── Private ──────────────────────────────────────────────────────────────
/** Replace whatever was waiting (see the object's docs) and arm the timeout. */
private fun arm(next: Pending, onTimeout: () -> Unit) {
cancel()
pending = next
val runnable = Runnable {
// Only if this very request is still the one waiting.
if (pending === next) {
clear()
onTimeout()
}
}
timeout = runnable
handler.postDelayed(runnable, TIMEOUT_MS)
}
private fun clear() {
pending = null
timeout?.let(handler::removeCallbacks)
timeout = null
}
/** Expand the template and hand it to the OS. `false` = nothing took it. */
private fun open(context: Context, walletBrowserUrl: String, pageUrl: String): Boolean {
// Some templates (Phantom's) use the placeholder more than once — as
// both the browse target and the `ref` — so replace every occurrence.
// Uri.encode matches JavaScript's encodeURIComponent allow-list, which
// is what the template expects.
val target = walletBrowserUrl.replace("{{encodedDappURI}}", Uri.encode(pageUrl))
return try {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(target))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
true
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "no app handled $target", e)
false
}
}
private fun buildPageUrl(
hostedPageUrl: String,
scheme: String,
nonce: String,
environmentId: String?,
params: Map<String, String>,
): String {
val builder = Uri.parse(hostedPageUrl).buildUpon()
params.forEach { (key, value) -> builder.appendQueryParameter(key, value) }
builder.appendQueryParameter("redirect_uri", "$scheme://$CALLBACK_HOST")
builder.appendQueryParameter("nonce", nonce)
// The page runs inside a wallet's WebView here — say so explicitly
// rather than leaving it to user-agent guessing (see `getEnvInfo` in
// `src/env.ts`): among other things this stops it offering a deeplink
// connector that would bounce out of that browser.
builder.appendQueryParameter("embedded", "1")
environmentId?.takeIf { it.isNotBlank() }
?.let { builder.appendQueryParameter("environmentId", it) }
return builder.build().toString()
}
/** Cryptographically-random nonce (CSRF correlation; not a secret). */
private fun randomNonce(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
}
Carry the two extra fields
Add the template toFireblocksHeadlessConnect.Wallet and remember it on the connection, or sign and send fall back to the engine.
data class Wallet(
// …
/** The wallet's own in-app-browser template, or null if it has none. */
val inAppBrowser: String? = null,
)
data class WalletConnection(
// …
/** Set when the connection was made inside the wallet's browser. */
val walletBrowserUrl: String? = null,
)
Register the callback host
Add a third host to theFireblocksRedirectActivity intent-filter and route it before the Custom Tab flows, which all share wallet-callback.
AndroidManifest.xml
<data android:scheme="myapp" android:host="wallet-browser" />
FireblocksRedirectActivity
intent?.data?.let { uri ->
if (!FireblocksHeadlessConnect.handleReturnURL(uri) &&
!FireblocksWalletBrowser.handleRedirect(uri)
) {
FireblocksConnect.handleRedirect(uri)
}
}
Offer the option only for Phantom
Do not derive EVM support from the presence of a template. Phantom’s template comes from its Sui wallet-book entry, so a template on its own says nothing about EVM. The evidence for Phantom specifically isphantomevm.injectedConfig.windowLocations: ["phantom.ethereum"], an EIP-1193 provider inside its browser.
MainActivity.kt
private const val WALLET_BROWSER_EVM_CHAIN = "evm-wallet-browser"
private fun offersWalletBrowserEvm(wallet: Wallet): Boolean =
wallet.key.lowercase() == "phantom" &&
wallet.inAppBrowser != null &&
!wallet.chains.contains("evm")
private fun pickerChains(wallet: Wallet): List<String> =
wallet.chains + if (offersWalletBrowserEvm(wallet)) listOf(WALLET_BROWSER_EVM_CHAIN) else emptyList()
Connect, sign, and send
MainActivity.kt
// connect: the synthetic picker value routes here
val template = wallet.inAppBrowser ?: return
FireblocksWalletBrowser.connect(
context = this,
hostedPageUrl = hostedPageUrl,
scheme = scheme,
walletBrowserUrl = template,
walletKey = wallet.key,
) { result ->
runOnUiThread {
when (result) {
is FireblocksConnectResult.Success -> showConnected(result.wallet)
is FireblocksConnectResult.Cancelled -> renderList()
is FireblocksConnectResult.Error -> fail(result.reason)
}
}
}
// sign and send: reopen the SAME browser
connection.walletBrowserUrl?.let { browserUrl ->
FireblocksWalletBrowser.signMessage(
context = this,
hostedPageUrl = hostedPageUrl,
scheme = scheme,
walletBrowserUrl = browserUrl,
walletKey = connection.walletKey!!,
message = "Hello from Android",
expectedAddress = connection.address, // the page refuses a mismatch
) { result -> /* Success / Cancelled / Error */ }
FireblocksWalletBrowser.sendTransaction(
context = this,
hostedPageUrl = hostedPageUrl,
scheme = scheme,
walletBrowserUrl = browserUrl,
walletKey = connection.walletKey!!,
to = "0xRecipientAddress",
chainId = "0x1",
expectedAddress = connection.address,
value = "0x2386f26fc10000", // 0.01 ETH, hex wei
) { result -> /* Success / Cancelled / Error */ }
}
What travels on the URL
| Parameter | Value |
|---|---|
wallet and chain=evm | connect, or intent=signMessage / intent=sendTx with walletKey |
redirect_uri | <scheme>://wallet-browser |
nonce | random per attempt, verified on return, mismatches dropped |
embedded=1 | the page is inside a wallet web view, so it must not offer a connector that bounces out of it |
environmentId | optional Dynamic environment ID |
address and chain (connect), signature (sign), or txHash (send), or error=1&code=&message=, always with the nonce echoed back. A send is already broadcast when the hash arrives. One request is in flight at a time, a new one supersedes the previous, and an abandoned one times out after five minutes.
Phantom pitfalls
- Keep the template on the connection. Sign and send must reopen the same browser, because the account exists nowhere else. Losing the stored template sends the request to the engine, which reports no wallet connected.
- Use a separate callback host.
wallet-callbackis claimed by the visible flow, so a link arriving from Phantom would be dropped or complete an unrelated request. - Watch where the template lands on Android. The template is an
httpsapp link and reaches Phantom only if its app links are verified. Otherwise Android can hand it to Chrome, where nothing is injected and the page correctly reports no EVM path. That message in a browser that is not Phantom means the hand-off went to the wrong app. - Offer the return anchor. The page renders a “Return to the app” link for browsers that ignore a programmatic redirect.
8. The bridge (for reference)
Identical to iOS.bridge messages
// web → app (connect) request-scoped messages carry requestId
ready engine initialized
wallets { wallets: […] } the wallet menu (live)
deeplink { requestId, url } app opens the wallet
opening { requestId } wallet opening (Phantom)
connected { requestId, address, chain, … } success
fallback { requestId, reason } can't go headless → visible flow
error { requestId, code, message } failed
event { requestId?, event, sessionId, t } diagnostic timeline
// web → app (sign)
signed { requestId, signature } message signed (hex string)
signFailed { requestId, code, message } sign failed
signedTx { requestId, signedTransaction, chain } tx signed
signTxFailed { requestId, code, message } tx sign failed
// app → web
window.headlessConnect.connect({ requestId, walletKey, chain })
window.headlessConnect.cancel(requestId)
window.headlessConnect.handleReturnURL(url) // redirect wallets
window.headlessConnect.sign({ requestId, message })
window.headlessConnect.signTx({ requestId, transaction })
Common pitfalls
- Do not call
webView.onPause()on the hiddenWebView. That suspends its relay socket. - Cancellation is not auto-detected by Custom Tabs. Treat resumed-with-no-result as cancelled, or hold the pending flow in a
ViewModel. - Test on a device with a wallet installed, not a bare emulator.
- Serve over HTTPS. The flow mints WalletConnect URIs via WebCrypto.