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

# Connections on Android (headless)

> Render your own native wallet list with a hidden WebView engine. Connect, sign messages, and sign transactions. No wallet SDK in your app.

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

The same headless architecture as iOS: render your own native list, and drive a **hidden** `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](/docs/connections/android) is the recommended default.

<Note>
  **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.
</Note>

Copy [`FireblocksHeadlessConnect.kt`](https://github.com/dynamic-labs-oss/iframe-fb/blob/main/android/app/src/main/java/com/fireblocks/connect/FireblocksHeadlessConnect.kt) in addition to [`FireblocksConnect.kt`](https://github.com/dynamic-labs-oss/iframe-fb/blob/main/android/app/src/main/java/com/fireblocks/connect/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 (a `wallets` 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](/docs/connections/android). |
| `featured`      | `Boolean`                  | Show by default; the rest of the catalog rides along for search.                         |

## 2. Drop in FireblocksHeadlessConnect

Owns a hidden `WebView`, bridges to it (`addJavascriptInterface` + `evaluateJavascript`), opens the wallet deeplink it returns, and calls you back. Pre-warm it at launch.

```kotlin MainActivity.kt theme={"system"}
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 */ }
    }
}
```

<AccordionGroup>
  <Accordion title="View FireblocksHeadlessConnect.kt (copy-paste ready)">
    ````kotlin FireblocksHeadlessConnect.kt theme={"system"}
    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
        }
    }
    ````
  </Accordion>
</AccordionGroup>

## 3. Sign a message

The hosted engine supports `window.headlessConnect.sign`. After a successful connect, invoke `sign()` with any string. The wallet app prompts the user; the callback delivers a hex signature.

<Note>
  **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.
</Note>

```kotlin MainActivity.kt theme={"system"}
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 */ }
    }
}
```

Only available for wallets connected through the headless engine. Visible-flow wallets do not hold an open session.

## 4. Sign a transaction

Pass a serialized transaction. Signing only, no broadcast.

```kotlin MainActivity.kt (EVM) theme={"system"}
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 */ }
    }
}
```

```kotlin MainActivity.kt (Solana) theme={"system"}
// 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: a `phantom-headless` host on the same `FireblocksRedirectActivity` intent-filter, and a `<queries>` block so the app can open wallet deeplinks on Android 11+.

```xml AndroidManifest.xml theme={"system"}
<!-- 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>
```

Route the return activity to the engine first (for Phantom's redirect), then fall through to the visible flow:

```kotlin FireblocksRedirectActivity theme={"system"}
intent?.data?.let { uri ->
    if (!FireblocksHeadlessConnect.handleReturnURL(uri)) {
        FireblocksConnect.handleRedirect(uri)
    }
}
```

<Note>
  The hidden `WebView` needs the `INTERNET` permission. Do not call `webView.onPause()` on it. That suspends the relay socket.
</Note>

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

<AccordionGroup>
  <Accordion title="View MainActivity.kt (sample list UI)">
    ```kotlin MainActivity.kt theme={"system"}
    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"
    }
    ```
  </Accordion>
</AccordionGroup>

## 7. The bridge (for reference)

Identical to iOS.

```text bridge messages theme={"system"}
// 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 hidden `WebView`. 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.
