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
)
}
}
}
}
}
}