Skip to main content
This example demonstrates bidirectional messaging between the native Android host and the web journey: sending events to the web journey and handling incoming requests with capability handlers.

What this example demonstrates

  • Sending events from native to web
  • Declaring capture support via typed capability slots
  • Registering custom capabilities for non-camera actions
  • Responding with success, error, and cancellation statuses
  • Observing all bridge traffic via BridgeHostDelegate

Complete source

package com.example.twoway

import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.gbg.gbgbridge.capabilities.CaptureResult
import com.gbg.gbgbridge.core.BridgeHost
import com.gbg.gbgbridge.core.BridgeHostDelegate
import com.gbg.gbgbridge.models.BridgeErrorPayload
import com.gbg.gbgbridge.models.BridgeMessage
import com.gbg.gbgbridge.models.BridgeResponseStatus
import kotlinx.coroutines.delay
import kotlinx.serialization.json.JsonPrimitive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

private const val JOURNEY_URL = "https://journey.example.com"

// ---------------------------------------------------------------------------
// App Entry Point
// ---------------------------------------------------------------------------

class TwoWayActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      MaterialTheme {
        TwoWayJourneyScreen()
      }
    }
  }
}

// ---------------------------------------------------------------------------
// Bridge Controller
// ---------------------------------------------------------------------------

/**
 * Owns the BridgeHost and β€” crucially β€” a strong reference to the delegate.
 *
 * BridgeHost stores its delegate as a WeakReference: the host does NOT keep
 * the delegate alive. An inline `host.delegate = MessageLogger()` with no
 * other strong reference would be garbage-collected and silently stop firing.
 * Holding the logger as a property of this controller (which itself lives in
 * a Compose `remember { }`) keeps it alive for the host's lifetime.
 */
class BridgeController(hostVersion: String) {
  val logger = MessageLogger()
  val host = BridgeHost(hostVersion = hostVersion)

  init {
    // Declare document capture support via the typed slot
    host.documentCapture.handler = { request ->
      // Simulate a capture delay (the handler is a suspend lambda)
      delay(1_000)

      // Return a mock result
      val mockData = ByteArray(100) { 0xFF.toByte() }
      CaptureResult.Document(
        imageData = mockData,
        width = 1920,
        height = 1080,
        mimeType = "image/jpeg"
      )
    }

    // Register a custom capability for device info
    host.registerCustomCapability("device.info", version = "1.0") { request, responder ->
      responder.respond(
        status = BridgeResponseStatus.SUCCESS,
        data = mapOf(
          "manufacturer" to JsonPrimitive(Build.MANUFACTURER),
          "model" to JsonPrimitive(Build.MODEL),
          "systemName" to JsonPrimitive("Android"),
          "systemVersion" to JsonPrimitive(Build.VERSION.RELEASE),
          "sdkInt" to JsonPrimitive(Build.VERSION.SDK_INT)
        )
      )
    }

    // Set the delegate for logging. `logger` is a property of this
    // controller, so it stays strongly referenced β€” see the class KDoc.
    host.delegate = logger
  }

  fun sendReadyEvent() {
    host.sendEvent(
      "host.ready",
      data = mapOf(
        "timestamp" to JsonPrimitive(System.currentTimeMillis()),
        "version" to JsonPrimitive("1.0.0")
      )
    )
  }

  fun sendPingEvent() {
    host.sendEvent(
      "host.ping",
      data = mapOf("timestamp" to JsonPrimitive(System.currentTimeMillis()))
    )
  }
}

// ---------------------------------------------------------------------------
// Main Screen
// ---------------------------------------------------------------------------

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TwoWayJourneyScreen() {
  // `remember` keeps the controller (host + strongly held delegate) across
  // recompositions and releases it when the screen leaves composition.
  val controller = remember { BridgeController(hostVersion = "1.0.0") }

  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("Two-Way Bridge") },
        actions = {
          TextButton(onClick = { controller.sendPingEvent() }) {
            Text("Send Ping")
          }
        }
      )
    }
  ) { padding ->
    Column(
      modifier = Modifier
        .fillMaxSize()
        .padding(padding)
    ) {
      // Journey WebView
      AndroidView(
        modifier = Modifier.weight(1f),
        factory = { context ->
          WebView(context).also { webView ->
            controller.host.attach(webView)
            webView.loadUrl(JOURNEY_URL)

            // Send the initial event
            controller.sendReadyEvent()
          }
        }
      )

      // Message log (bottom panel)
      MessageLogPanel(
        messages = controller.logger.log,
        modifier = Modifier
          .fillMaxWidth()
          .height(200.dp)
      )
    }
  }

  DisposableEffect(Unit) {
    onDispose { controller.host.detach() }
  }
}

// ---------------------------------------------------------------------------
// Message Logger (Delegate)
// ---------------------------------------------------------------------------

/** Observes all bridge traffic and maintains a log for the debug UI. */
class MessageLogger : BridgeHostDelegate {
  data class LogEntry(
    val timestamp: Long,
    val direction: String,
    val type: String,
    val action: String
  )

  val log = mutableStateListOf<LogEntry>()

  override fun onMessage(host: BridgeHost, message: BridgeMessage) {
    log.add(
      LogEntry(
        timestamp = System.currentTimeMillis(),
        direction = "IN",
        type = message.type.name,
        action = message.payload.action
      )
    )
  }

  override fun onMessageSent(host: BridgeHost, message: BridgeMessage) {
    // Android-only callback: fires for every outbound message
    log.add(
      LogEntry(
        timestamp = System.currentTimeMillis(),
        direction = "OUT",
        type = message.type.name,
        action = message.payload.action
      )
    )
  }

  override fun onUnhandledRequest(host: BridgeHost, request: BridgeMessage) {
    log.add(
      LogEntry(
        timestamp = System.currentTimeMillis(),
        direction = "IN",
        type = "UNHANDLED",
        action = request.payload.action
      )
    )

    // Respond with unsupported for unknown actions
    host.respond(
      to = request.correlationId,
      status = BridgeResponseStatus.UNSUPPORTED,
      error = BridgeErrorPayload(
        code = "UNSUPPORTED",
        message = "Action '${request.payload.action}' is not supported",
        recoverable = false
      )
    )
  }

  override fun onError(host: BridgeHost, error: Throwable) {
    log.add(
      LogEntry(
        timestamp = System.currentTimeMillis(),
        direction = "IN",
        type = "ERROR",
        action = error.message ?: "unknown error"
      )
    )
  }
}

// ---------------------------------------------------------------------------
// Message Log Panel
// ---------------------------------------------------------------------------

@Composable
fun MessageLogPanel(
  messages: List<MessageLogger.LogEntry>,
  modifier: Modifier = Modifier
) {
  val listState = rememberLazyListState()
  val timeFormat = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) }

  // Auto-scroll to the newest entry
  LaunchedEffect(messages.size) {
    if (messages.isNotEmpty()) {
      listState.animateScrollToItem(messages.size - 1)
    }
  }

  Column(modifier = modifier) {
    HorizontalDivider()

    Row(
      modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 12.dp, vertical = 6.dp),
      horizontalArrangement = Arrangement.SpaceBetween
    ) {
      Text("Bridge Log", style = MaterialTheme.typography.labelMedium)
      Text(
        "${messages.size} messages",
        style = MaterialTheme.typography.labelSmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant
      )
    }

    if (messages.isEmpty()) {
      Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
      ) {
        Text(
          "No messages yet",
          style = MaterialTheme.typography.labelSmall,
          color = MaterialTheme.colorScheme.onSurfaceVariant
        )
      }
    } else {
      LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
        items(messages) { entry ->
          Row(
            modifier = Modifier
              .fillMaxWidth()
              .padding(horizontal = 12.dp, vertical = 2.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
          ) {
            Text(
              timeFormat.format(Date(entry.timestamp)),
              style = MaterialTheme.typography.labelSmall,
              fontFamily = FontFamily.Monospace,
              color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Text(
              entry.direction,
              style = MaterialTheme.typography.labelSmall,
              fontFamily = FontFamily.Monospace,
              color = if (entry.direction == "IN") {
                MaterialTheme.colorScheme.primary
              } else {
                MaterialTheme.colorScheme.tertiary
              }
            )
            Text(
              entry.type,
              style = MaterialTheme.typography.labelSmall,
              fontFamily = FontFamily.Monospace,
              color = MaterialTheme.colorScheme.secondary
            )
            Text(
              entry.action,
              style = MaterialTheme.typography.labelSmall,
              fontFamily = FontFamily.Monospace,
              maxLines = 1
            )
          }
        }
      }
    }
  }
}

Code explanation

The sections below cover what each part of the app does, in roughly the order they’re exercised at runtime.

Outgoing events (native β†’ web)

// Send an event when the host is ready
host.sendEvent(
  "host.ready",
  data = mapOf(
    "timestamp" to JsonPrimitive(System.currentTimeMillis()),
    "version" to JsonPrimitive("1.0.0")
  )
)
The web journey receives this via window.GBGBridge.receive() β€” the same function on both platforms β€” and can use the timestamp and version information to initialize its state. Event data is a Map<String, JsonElement>; build values with JsonPrimitive(...) or buildJsonObject { } from kotlinx.serialization.
An event sent before the journey page has loaded won’t be observed by the web side β€” the bootstrap that installs window.GBGBridge.receive is injected as the page starts loading. The delegate still records the send intent via onMessageSent, which fires for every outbound message before transport.

Incoming requests (web β†’ native)

The web journey sends requests to the native host via window.GBGBridge.postMessage(jsonString) β€” the JavaScript interface the SDK installs under the name GBGBridge. This is the load-bearing platform difference: on iOS the channel is window.webkit.messageHandlers.gbgBridge. Web code targeting both platforms should feature-detect:
// Web journey code β€” works on both platforms
const json = JSON.stringify(message);
if (window.GBGBridge?.postMessage) {
  window.GBGBridge.postMessage(json);                       // Android
} else if (window.webkit?.messageHandlers?.gbgBridge) {
  window.webkit.messageHandlers.gbgBridge.postMessage(json); // iOS
}
When the web journey sends a camera.document.capture request, the typed slot handler runs:
Web Journey                    BridgeHost              documentCapture slot
     β”‚                              β”‚                         β”‚
     β”œβ”€β”€ request: camera.document  ─►│                        β”‚
     β”‚   .capture                   β”œβ”€β”€ suspend handler ─────►│
     β”‚                              β”‚                         β”‚ (simulate capture)
     β”‚                              │◄── CaptureResult ────────
     β”‚                              β”‚    (auto-encoded)       β”‚
     │◄── response: success ─────────                         β”‚
     β”‚   { imageBase64, imageWidth, β”‚                         β”‚
     β”‚     imageHeight, mimeType }  β”‚                         β”‚
The CaptureResult is automatically encoded to the bridge protocol format β€” no manual JsonElement map construction needed. The typed slot handler is a suspend lambda running on the main dispatcher, so you can delay, hop dispatchers for heavy work, or suspend on awaitCompletion() while a capture UI is showing. The device.info custom capability shows the other path: registerCustomCapability takes a synchronous handler (BridgeMessage, BridgeResponder) -> Unit. The example responds inline; for asynchronous work, retain the responder, do the work, hop back to the main thread, and call responder.respond(...) exactly once.

Holding the delegate

BridgeHost.delegate is backed by a WeakReference β€” the host never keeps your delegate alive. This is why the example routes everything through a BridgeController held in remember { }: the controller strongly holds the MessageLogger, so it survives garbage collection. A naive port that writes host.delegate = MessageLogger() with no other reference compiles and works β€” until the next GC, after which callbacks silently stop firing. The logger also implements onMessageSent, an Android-only delegate method with no iOS equivalent: it fires for every outbound message (before transport), which is why the log panel shows OUT entries without any extra wiring.

Unhandled Requests

The MessageLogger delegate catches requests with no registered handler and responds with UNSUPPORTED. This prevents the web journey from waiting indefinitely for a response. Unhandled requests are appended to host.pendingRequests before onUnhandledRequest fires, so the lookup overload respond(to = correlationId, status, ...) finds and removes the matching pending request. You don’t have to respond inside the callback β€” you can let requests accumulate in pendingRequests and respond later (for example, after showing UI), using the same lookup overload. If no pending request matches the correlation ID, the call silently no-ops.

Running This Example

  1. Add GBGBridge to your module: implementation("com.gbg:gbgbridge-sdk:0.1.0-alpha01").
  2. Create a new empty Compose Activity project and register TwoWayActivity as the launcher activity in your manifest, alongside <uses-permission android:name="android.permission.INTERNET" />.
  3. Replace the generated activity code with the code above.
  4. Update JOURNEY_URL to point to your web journey.
  5. Build and run.

Next Steps