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://your-connect-page.example/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 in addition to FireblocksConnect.kt (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.
MainActivity.kt
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://connections.dynamic.dev/headless.html?returnScheme=myapp"
/** 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(ENGINE_URL)
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://connections.dynamic.dev/"
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. 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.