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

# Two-Way Communication

> Bidirectional messaging between your app and the web journey.

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

```kotlin expandable theme={null}
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)

```kotlin theme={null}
// 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`.

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

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

```javascript theme={null}
// 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

* [Advanced Integration](/docs/go-v2/developer-integration/sdks/android/examples/advanced-integration) — Lifecycle management, custom configuration, and capability checks
* [Messaging Guide](/docs/go-v2/developer-integration/sdks/android/messaging) — Deep dive on message patterns
