Skip to main content
This guide covers how to embed the bridge-connected WebView in your Android application, including Jetpack Compose and View-system integration patterns.

Jetpack Compose Integration

Unlike iOS, there is no prebuilt BridgeWebView composable. The Android pattern is three steps: construct a WebView inside an AndroidView factory, call host.attach(webView), then loadUrl(...). attach() configures the WebView internally (JavaScript, DOM storage, the bootstrap-injecting WebViewClient), so there is no separate setup call.

Attaching a WebView in Compose

Hold the BridgeHost in remember { } so it survives recomposition, attach it in the AndroidView factory, and detach in DisposableEffect.onDispose.
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.viewinterop.AndroidView
import com.gbg.gbgbridge.core.BridgeHost

@Composable
fun JourneyScreen(journeyUrl: String) {
  val host = remember {
    BridgeHost(hostVersion = "1.0.0").apply {
      documentCapture.handler = { request ->
        // Present your capture UI, then suspend until it calls complete(...)
        documentCapture.awaitCompletion()
      }
    }
  }

  AndroidView(
    factory = { context ->
      WebView(context).apply {
        host.attach(this)
        loadUrl(journeyUrl)
      }
    },
  )

  DisposableEffect(host) {
    onDispose { host.detach() }
  }
}
attach() runs BridgeWebViewConfigurator.configure() internally. Never call configure() yourself before attach() — attach() would simply re-run it and clobber whatever you set up.

Observing Bridge State

BridgeHost is not observable — there is no ObservableObject analogue, and reading properties like lastError does not trigger recomposition. To drive Compose UI from bridge activity, mirror state into Compose via a BridgeHostDelegate. (The typed capture slots are the exception: host.documentCapture.activeRequest is a StateFlow, so you can collectAsState() it directly.)
class JourneyBridge(hostVersion: String) : BridgeHostDelegate {
  var onErrorChanged: ((String?) -> Unit)? = null

  val host = BridgeHost(hostVersion = hostVersion).also { it.delegate = this }

  override fun onError(host: BridgeHost, error: Throwable) {
    onErrorChanged?.invoke(host.lastError ?: error.message)
  }
}

@Composable
fun JourneyScreen(journeyUrl: String) {
  val bridge = remember { JourneyBridge(hostVersion = "1.0.0") }
  var errorBanner by remember { mutableStateOf<String?>(null) }

  LaunchedEffect(bridge) {
    bridge.onErrorChanged = { errorBanner = it }
  }

  Box(modifier = Modifier.fillMaxSize()) {
    AndroidView(
      modifier = Modifier.fillMaxSize(),
      factory = { context ->
        WebView(context).apply {
          bridge.host.attach(this)
          loadUrl(journeyUrl)
        }
      },
    )

    // Show error banner when an error occurs
    errorBanner?.let { error ->
      Text(
        text = error,
        color = Color.White,
        modifier = Modifier
          .align(Alignment.TopCenter)
          .padding(16.dp)
          .background(MaterialTheme.colorScheme.error, MaterialTheme.shapes.small)
          .padding(horizontal = 12.dp, vertical = 8.dp),
      )
    }
  }

  DisposableEffect(bridge) {
    onDispose { bridge.host.detach() }
  }
}
BridgeHost.delegate is stored as a WeakReference — the host does not keep your delegate alive. An inline host.delegate = SomeDelegate() with no other strong reference silently stops firing after the next garbage collection. Hold the delegate as a property of a remembered object (as JourneyBridge does above) so it stays pinned for the lifetime of the screen.

Passing the Host to Child Views

BridgeHost is a plain class, so pass it to child composables as an ordinary parameter — no property-wrapper ceremony is needed. The flip side: properties like receivedMessages are immutable snapshots per read and won’t recompose your UI when they change, so mirror anything you want to display through the delegate. When rendering message lists, key items by correlationId (BridgeMessage has no id property on Android).
class JourneyBridge(hostVersion: String) : BridgeHostDelegate {
  var onMessageCountChanged: ((Int) -> Unit)? = null

  val host = BridgeHost(hostVersion = hostVersion).also { it.delegate = this }

  override fun onMessage(host: BridgeHost, message: BridgeMessage) {
    onMessageCountChanged?.invoke(host.receivedMessages.size)
  }
}

@Composable
fun JourneyContainer() {
  val bridge = remember { JourneyBridge(hostVersion = "1.0.0") }
  var messageCount by remember { mutableStateOf(0) }

  LaunchedEffect(bridge) {
    bridge.onMessageCountChanged = { messageCount = it }
  }

  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("Journey") },
        actions = { MessageCountBadge(count = messageCount) },
      )
    },
  ) { padding ->
    JourneyWebView(
      host = bridge.host,
      modifier = Modifier.padding(padding),
    )
  }
}

@Composable
fun JourneyWebView(host: BridgeHost, modifier: Modifier = Modifier) {
  AndroidView(
    modifier = modifier,
    factory = { context ->
      WebView(context).apply {
        host.attach(this)
        loadUrl("https://journey.example.com")
      }
    },
  )
}

@Composable
fun MessageCountBadge(count: Int) {
  Text(
    text = "$count",
    color = Color.White,
    style = MaterialTheme.typography.labelSmall,
    modifier = Modifier
      .background(MaterialTheme.colorScheme.primary, CircleShape)
      .padding(6.dp),
  )
}

Dynamic URL Changes

There is no URL binding to update. The AndroidView factory runs once, so navigate to a new URL from the update block (which runs on each recomposition) by calling loadUrl(...) when your URL state changes. The bridge survives navigation — the bootstrap script is re-injected on every page load.
@Composable
fun DynamicJourney(host: BridgeHost) {
  var journeyUrl by remember { mutableStateOf("https://journey.example.com/step1") }

  Column {
    AndroidView(
      modifier = Modifier.weight(1f),
      factory = { context ->
        WebView(context).apply {
          host.attach(this)
          loadUrl(journeyUrl)
        }
      },
      update = { webView ->
        if (webView.url != journeyUrl) webView.loadUrl(journeyUrl)
      },
    )

    Button(onClick = { journeyUrl = "https://journey.example.com/step2" }) {
      Text("Next Step")
    }
  }
}

View System Integration

For Activity- or Fragment-based apps, the pattern is identical — only the WebView construction differs. There is no makeWebView factory on Android: create the WebView yourself (programmatically or inflated from XML), then call host.attach(webView) and loadUrl(...).

Attaching in an Activity

Hold the host as a property so it lives as long as the Activity, attach in onCreate, and tear down in onDestroy.
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import com.gbg.gbgbridge.core.BridgeHost

class JourneyActivity : AppCompatActivity() {
  private val host = BridgeHost(hostVersion = "1.0.0")
  private lateinit var webView: WebView

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    // Programmatic WebView; an XML alternative is
    // findViewById<WebView>(R.id.journey_web_view) after setContentView(R.layout.…)
    webView = WebView(this).apply {
      layoutParams = ViewGroup.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.MATCH_PARENT,
      )
    }
    setContentView(webView)

    host.attach(webView)
    webView.loadUrl("https://journey.example.com")
  }

  override fun onDestroy() {
    try {
      host.dispose()
    } catch (e: Exception) {
      // removeJavascriptInterface can throw on a WebView already in shutdown
      Log.w("JourneyActivity", "Bridge teardown failed", e)
    }
    super.onDestroy()
  }
}

What attach() Configures

attach() calls BridgeWebViewConfigurator.configure() internally: it enables javaScriptEnabled and domStorageEnabled, installs the bootstrap-injecting WebViewClient, and installs a plain WebChromeClient — unconditionally overwriting any clients already set on the WebView. Three practical consequences:
  1. Don’t call configure() separately before attach() — it would be re-run and clobbered by attach()’s own configure pass.
  2. Set a custom WebChromeClient after attach(), never before, or the plain one installed by configure() will overwrite yours.
  3. Other WebView settings are untouched. If you need additional settings (media playback, caching, etc.), apply them on the WebView yourself — attach() won’t reset them.
host.attach(webView)

// Custom WebChromeClient must come AFTER attach()
webView.webChromeClient = MyConsoleLoggingChromeClient()

webView.loadUrl(journeyUrl)

Lifecycle Considerations

The BridgeHost and the WebView have separate lifecycles, the delegate is weakly held, and handlers should be set up before content loads. Keep these in mind to avoid leaks, silent callback loss, and missed messages.

Host Lifetime

The BridgeHost should live at least as long as the WebView. In Compose, use remember { } (scoped to the screen) so the host isn’t recreated on recomposition. In the View system, hold a strong reference as an Activity property or in a ViewModel. Whatever owns the host should also strongly hold the delegate and any handler objects, since the host won’t keep them alive.

WebView Detachment

Calling detach() removes the JavaScript interface, cancels any in-flight typed-slot capture (via cancelIfBusy), and clears pendingRequests and receivedMessages — unlike iOS, which preserves the message buffers across detach. Clearing avoids ghost entries in Compose lists when you re-attach. detach() is idempotent and safe to call from onDispose. If you call respond(...) or sendEvent(...) while no WebView is attached, nothing throws: the host fires delegate.onMessageSent (so you can trace the intent) and then silently drops the message at the transport. This diverges from iOS, which records lastError = "WebView not attached" — on Android no lastError is set for this case. If you registered raw BridgeCapabilityHandlers that hold on to a pending BridgeResponder, cancel or respond to them yourself before detaching so the web journey isn’t left waiting on a response that will never arrive:
DisposableEffect(controller) {
  onDispose {
    try {
      // Resolve any in-flight capture handlers first (rotation, navigation away, …)
      controller.cancelActiveCaptures("Journey screen disposed")
    } finally {
      controller.host.detach()
    }
  }
}

Re-attaching a WebView

If you need to replace the WebView (e.g., after a navigation reset), call detach() and then attach() with the new WebView. Inbound messages still in flight from the old attach session are identity-gated and dropped, so the new session starts clean.

Disposing the Host

dispose() is an Android-only addition (iOS relies on ARC for teardown). It performs a terminal teardown: detach plus cancellation of the typed capture slots’ coroutine scopes. After dispose(), state-mutating methods (attach, register, respond, sendEvent, and friends) throw IllegalStateException; detach(), dispose(), clearError(), the getters, and the delegate setter remain safe. Call it from a terminal lifecycle callback — Activity.onDestroy or ViewModel.onCleared — wrapped in try/catch with a log, since removeJavascriptInterface can throw on a WebView that is already shutting down.
class JourneyViewModel : ViewModel() {
  val host = BridgeHost(hostVersion = "1.0.0")

  override fun onCleared() {
    try {
      host.dispose()
    } catch (e: Exception) {
      Log.w("JourneyViewModel", "Bridge teardown failed", e)
    }
  }
}
Use detach() for a recoverable teardown (the screen may come back and re-attach) and dispose() only when the host will never be used again.

Setting Up Handlers

Set handlers on typed slots or register custom capabilities before the WebView loads content. If the web journey sends a request before a handler is available, the request goes to pendingRequests (and delegate.onUnhandledRequest fires) — you can still answer it later via the lookup overload of respond(...).
// Set up typed slots
host.documentCapture.handler = { request ->
  showCaptureUi()
  host.documentCapture.awaitCompletion()
}

// Register custom capabilities
host.registerCustomCapability("nfc.read") { request, responder ->
  // Handle the request, then call responder.respond(...)
}

// Then attach and load the journey
host.attach(webView)
webView.loadUrl(journeyUrl)

Custom WebViewClient

On Android, navigation policy lives in the WebViewClient — and the bridge depends on the one attach() installs, because it injects the bootstrap script on every page load. If you need custom navigation behavior (intercepting links, logging load errors, SSL handling), don’t replace the client wholesale: subclass BridgeWebViewConfigurator.BootstrapInjectingWebViewClient and pass your instance to attach(webView, client = ...). Override whatever callbacks you need; if you override onPageStarted, call super.onPageStarted(...) to keep the bootstrap injection. Two things to know when supplying your own client:
  • The bootstrapScript parameter on configure() is ignored when a client is passed — your subclass owns its script, so pass the script to the subclass constructor. The SDK’s default bootstrap literal is internal, so supply it yourself.
  • The bootstrap injects on the main frame only, on every page load.
import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import com.gbg.gbgbridge.webview.BridgeWebViewConfigurator

// The SDK's default bootstrap is internal — supply the literal yourself
// when installing a custom client.
private const val BOOTSTRAP_SCRIPT =
  "window.GBGBridge = window.GBGBridge || {}; " +
    "window.GBGBridge.receive = window.GBGBridge.receive || function(){};"

class JourneyWebViewClient(
  bootstrapScript: String,
) : BridgeWebViewConfigurator.BootstrapInjectingWebViewClient(bootstrapScript) {

  override fun shouldOverrideUrlLoading(
    view: WebView,
    request: WebResourceRequest,
  ): Boolean {
    // Custom navigation logic: open external links in the browser
    val url = request.url ?: return true
    if (url.host != "journey.example.com") {
      view.context.startActivity(Intent(Intent.ACTION_VIEW, url))
      return true
    }
    return false
  }
}

// Pass the client to attach() — not to a separate configure() call
host.attach(webView, client = JourneyWebViewClient(BOOTSTRAP_SCRIPT))
webView.loadUrl(journeyUrl)
Bootstrap timing diverges from iOS. iOS injects the bootstrap via WKUserScript at .atDocumentStart, guaranteed to run before any page JavaScript. Android injects it via evaluateJavascript from onPageStarted, which is best-effort: a <script> in the document head that synchronously calls window.GBGBridge.receive could race and find it undefined. Web code should not assume the bootstrap is present before its own head scripts run.
To debug a blank or broken journey page, call WebView.setWebContentsDebuggingEnabled(true) on debuggable builds (gate it on ApplicationInfo.FLAG_DEBUGGABLE so release builds never opt in) and inspect the WebView with full DevTools at chrome://inspect. Setting a WebChromeClient that overrides onConsoleMessage — after attach() — forwards the page’s console.* output to logcat.

Next Steps