import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
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.Spacer
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.gbg.gbgbridge.capabilities.CameraDetector
import com.gbg.gbgbridge.capabilities.CaptureResult
import com.gbg.gbgbridge.capabilities.PermissionState
import com.gbg.gbgbridge.core.BridgeCapabilityInfo
import com.gbg.gbgbridge.core.BridgeConfiguration
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.BridgeMessageType
import com.gbg.gbgbridge.models.BridgeResponseStatus
import com.gbg.gbgbridge.webview.BridgeWebViewConfigurator
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.io.ByteArrayOutputStream
private const val TAG = "AdvancedJourney"
// ── Custom bootstrap script ──
//
// The SDK's default bootstrap plus an app-specific marker the page can probe.
// Hosts that install a custom WebViewClient own this literal: the subclass
// constructor receives it, and the first two statements MUST be preserved or
// the web side has no receive() entry point.
private const val CUSTOM_BOOTSTRAP: String =
"window.GBGBridge = window.GBGBridge || {}; " +
"window.GBGBridge.receive = window.GBGBridge.receive || function(){}; " +
"window.__advancedHostReady = true;"
// ── App entry point ──
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
JourneyLauncherScreen()
}
}
}
}
// ── Journey configuration ──
/** Centralized configuration for the identity verification journey. */
data class JourneyConfig(
val url: String,
val requiredCapabilities: List<String>,
val hostVersion: String,
) {
companion object {
val documentAndSelfie = JourneyConfig(
url = "https://journey.example.com/document-selfie",
requiredCapabilities = listOf("camera.document", "camera.selfie"),
hostVersion = "1.0.0",
)
val documentOnly = JourneyConfig(
url = "https://journey.example.com/document",
requiredCapabilities = listOf("camera.document"),
hostVersion = "1.0.0",
)
}
}
// ── Device capability detection ──
class DeviceCapabilities(val cameraResult: CameraDetector.Result) {
companion object {
fun detect(context: Context): DeviceCapabilities =
DeviceCapabilities(cameraResult = CameraDetector.check(context))
}
fun isSupported(capability: String): Boolean = when (capability) {
"camera.document", "camera.selfie" -> cameraResult.hardwareAvailable
"location.read" -> true // permission can be requested at runtime
else -> false
}
}
// ── Journey launcher (pre-launch validation) ──
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JourneyLauncherScreen() {
val context = LocalContext.current
val deviceCapabilities = remember { DeviceCapabilities.detect(context) }
var selectedJourney by remember { mutableStateOf(JourneyConfig.documentAndSelfie) }
var showJourney by remember { mutableStateOf(false) }
var showCapabilityWarning by remember { mutableStateOf(false) }
fun missingCapabilities(journey: JourneyConfig): List<String> =
journey.requiredCapabilities.filterNot { deviceCapabilities.isSupported(it) }
fun attemptLaunch(journey: JourneyConfig) {
selectedJourney = journey
if (missingCapabilities(journey).isEmpty()) {
showJourney = true
} else {
showCapabilityWarning = true
}
}
if (showJourney) {
JourneyContainerScreen(
config = selectedJourney,
onDismiss = { showJourney = false },
)
return
}
Scaffold(topBar = { TopAppBar(title = { Text("Verification") }) }) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Identity Verification", style = MaterialTheme.typography.titleLarge)
JourneyOptionCard(
title = "Document + Selfie Verification",
subtitle = "Document capture and selfie match",
requirements = JourneyConfig.documentAndSelfie.requiredCapabilities,
deviceCapabilities = deviceCapabilities,
onClick = { attemptLaunch(JourneyConfig.documentAndSelfie) },
)
JourneyOptionCard(
title = "Document Verification",
subtitle = "Document capture only",
requirements = JourneyConfig.documentOnly.requiredCapabilities,
deviceCapabilities = deviceCapabilities,
onClick = { attemptLaunch(JourneyConfig.documentOnly) },
)
DeviceCapabilitySummary(capabilities = deviceCapabilities)
}
}
if (showCapabilityWarning) {
val missing = missingCapabilities(selectedJourney)
AlertDialog(
onDismissRequest = { showCapabilityWarning = false },
title = { Text("Capability Warning") },
text = {
Text(
"This journey requires ${missing.joinToString(", ")} which " +
"${if (missing.size == 1) "is" else "are"} not available on this " +
"device. The journey may not complete successfully.",
)
},
confirmButton = {
TextButton(
onClick = {
showCapabilityWarning = false
showJourney = true
},
) { Text("Continue Anyway") }
},
dismissButton = {
TextButton(onClick = { showCapabilityWarning = false }) { Text("Cancel") }
},
)
}
}
// ── Journey state ──
sealed interface JourneyState {
data object Loading : JourneyState
data object Active : JourneyState
data class Completed(val status: String, val instanceId: String?) : JourneyState
data class Error(val message: String) : JourneyState
}
// ── Journey container (full integration) ──
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun JourneyContainerScreen(
config: JourneyConfig,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
// The controller strongly holds the delegate: BridgeHost.delegate is a
// WeakReference, so an inline delegate with no other reference would
// silently stop firing after the next GC.
val controller = remember(config) {
BridgeController(config = config, appContext = context.applicationContext)
}
var journeyState by remember { mutableStateOf<JourneyState>(JourneyState.Loading) }
var bridgeError by remember { mutableStateOf<String?>(null) }
var webViewGeneration by remember { mutableIntStateOf(0) }
// Route delegate callbacks into Compose state.
LaunchedEffect(controller) {
controller.delegate.onJourneyEvent = { state -> journeyState = state }
controller.delegate.onBridgeError = { message -> bridgeError = message }
}
// Ask for the camera permission once if it has never been determined, then
// push the result into the typed slots so capability.query reflects it.
val requestCameraPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> controller.onCameraPermissionResult(granted) }
LaunchedEffect(controller) {
if (controller.cameraAvailable &&
controller.host.documentCapture.permissionState == PermissionState.NOT_DETERMINED
) {
requestCameraPermission.launch(Manifest.permission.CAMERA)
}
}
// Forward background/foreground transitions to the web journey so it can
// pause timers, save state, or handle session timeouts.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, controller) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> controller.host.sendEvent("host.background")
Lifecycle.Event.ON_START -> controller.host.sendEvent("host.foreground")
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Terminal teardown. dispose() detaches, cancels the typed slots' coroutine
// scopes, and locks the host — state-mutating calls throw afterwards.
// removeJavascriptInterface can throw on a WebView that is already shutting
// down, so wrap defensively and log.
DisposableEffect(controller) {
onDispose {
try {
controller.host.dispose()
} catch (e: Exception) {
Log.w(TAG, "Bridge teardown failed", e)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verification") },
actions = {
TextButton(
onClick = {
controller.host.sendEvent(
"journey.cancel",
data = mapOf("reason" to JsonPrimitive("user_dismissed")),
)
onDismiss()
},
) { Text("Cancel") }
},
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
// key() forces a brand-new WebView when webViewGeneration changes — the
// retry path below. detach() before attach() is idempotent (a no-op on
// the first build) and clears the message buffers, so the new attach
// session starts clean.
key(webViewGeneration) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
controller.host.detach()
controller.host.attach(
webView = this,
client = JourneyWebViewClient(
bootstrapScript = CUSTOM_BOOTSTRAP,
allowedHosts = setOf("journey.example.com"),
onPageReady = {
if (journeyState == JourneyState.Loading) {
journeyState = JourneyState.Active
}
},
onLoadError = { message -> journeyState = JourneyState.Error(message) },
),
)
loadUrl(config.url)
}
},
)
}
when (val state = journeyState) {
JourneyState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
is JourneyState.Error -> JourneyErrorOverlay(
message = state.message,
onRetry = {
controller.host.clearError()
bridgeError = null
journeyState = JourneyState.Loading
webViewGeneration++ // swap in a fresh WebView
},
)
is JourneyState.Completed -> JourneyCompleteOverlay(result = state, onDone = onDismiss)
JourneyState.Active -> Unit
}
bridgeError?.let { message ->
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
) {
Text(
text = message,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
// Typed-slot capture UI. activeRequest is a StateFlow the SDK sets while a
// capture is in flight; observing it is the Compose-idiomatic way to know
// when to present a capture surface.
val documentRequest by controller.host.documentCapture.activeRequest.collectAsState()
val selfieRequest by controller.host.selfieCapture.activeRequest.collectAsState()
if (documentRequest != null) {
CaptureSheet(
title = "Document capture",
onComplete = {
val png = placeholderImage(Bitmap.CompressFormat.PNG)
controller.host.documentCapture.complete(
CaptureResult.Document(imageData = png, width = 640, height = 400),
)
},
onCancel = { controller.host.documentCapture.cancelIfBusy("User dismissed capture") },
)
}
if (selfieRequest != null) {
CaptureSheet(
title = "Selfie capture",
onComplete = {
val jpeg = placeholderImage(Bitmap.CompressFormat.JPEG)
controller.host.selfieCapture.complete(
CaptureResult.Selfie(
previewImageData = jpeg,
width = 640,
height = 400,
// Production selfie flows receive these blobs from the capture
// vendor — placeholders keep this example self-contained.
encryptedBlob = jpeg,
unencryptedBlob = jpeg,
),
)
},
onCancel = { controller.host.selfieCapture.cancelIfBusy("User dismissed capture") },
)
}
}
// ── Bridge controller (full configuration) ──
private class BridgeController(
config: JourneyConfig,
private val appContext: Context,
) {
val cameraAvailable: Boolean = CameraDetector.check(appContext).hardwareAvailable
// Strongly held — see the note at the remember { } site above.
val delegate = JourneyDelegate()
// Construction-time baseline. BridgeConfiguration snapshots this map
// defensively; it is what capability.query reports when no
// capabilitiesProvider is supplied.
private val staticCapabilities: Map<String, BridgeCapabilityInfo> = mapOf(
"camera.document" to BridgeCapabilityInfo(supported = cameraAvailable, version = "1.0"),
"camera.selfie" to BridgeCapabilityInfo(supported = cameraAvailable, version = "1.0"),
)
val host: BridgeHost = BridgeHost(
configuration = BridgeConfiguration(
hostVersion = config.hostVersion,
// Static capability map — the baseline declaration.
capabilities = staticCapabilities,
// Custom bootstrap. NOTE: because attach() receives a custom
// WebViewClient in this app, the client's constructor script is the one
// injected; this field applies when attach() is called without a
// client. Sharing one constant keeps the two in sync.
bootstrapScript = CUSTOM_BOOTSTRAP,
// Android-only message-level origin gate. Inbound messages are dropped
// (lastError + delegate.onError with a SecurityException) unless the
// WebView's main-frame origin matches an entry. Validated at
// construction: an empty list or a malformed entry throws
// IllegalArgumentException.
allowedOrigins = listOf("https://journey.example.com"),
),
// Android-only: re-evaluated on every capability read, replacing both the
// static map above and iOS's mutable `capabilities` property. Return the
// complete picture — typed slots with a handler still win at their id.
capabilitiesProvider = { staticCapabilities + liveCapabilities() },
)
init {
host.delegate = delegate
if (cameraAvailable) {
// Typed slots: setting a handler declares the capability supported and
// takes precedence over static/provider entries for camera.document and
// camera.selfie. Each handler suspends until the capture UI calls
// complete() or cancelIfBusy().
host.documentCapture.handler = { _ -> host.documentCapture.awaitCompletion() }
host.selfieCapture.handler = { _ -> host.selfieCapture.awaitCompletion() }
val camera = CameraDetector.check(appContext)
host.documentCapture.permissionState = camera.permissionState
host.selfieCapture.permissionState = camera.permissionState
}
// Custom capability — the manual counterpart to typed slots. It
// auto-appears in capability.query with supported = true; the provider
// above overrides its entry with live permission state (explicit
// configuration is authoritative over runtime registrations).
host.registerCustomCapability("location.read", version = "1.0") { _, responder ->
if (!locationPermitted()) {
responder.respond(
status = BridgeResponseStatus.ERROR,
error = BridgeErrorPayload(
code = "LOCATION_PERMISSION_DENIED",
message = "Location permission has not been granted.",
recoverable = true,
),
)
} else {
// In a real app, read a coarse location fix here.
responder.respond(
status = BridgeResponseStatus.SUCCESS,
data = mapOf(
"latitude" to JsonPrimitive(51.5072),
"longitude" to JsonPrimitive(-0.1276),
),
)
}
}
}
/** Called from the runtime-permission launcher result. */
fun onCameraPermissionResult(granted: Boolean) {
// CameraDetector can only report GRANTED vs NOT_DETERMINED — after our
// own permission flow we know the real answer, so set the richer state.
val state = if (granted) PermissionState.GRANTED else PermissionState.DENIED
host.documentCapture.permissionState = state
host.selfieCapture.permissionState = state
}
private fun liveCapabilities(): Map<String, BridgeCapabilityInfo> = mapOf(
"location.read" to BridgeCapabilityInfo(
supported = true,
version = "1.0",
permissionState = (
if (locationPermitted()) PermissionState.GRANTED else PermissionState.NOT_DETERMINED
).wireValue,
),
)
private fun locationPermitted(): Boolean =
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
}
// ── Journey delegate (message observation and routing) ──
private class JourneyDelegate : BridgeHostDelegate {
/** Journey lifecycle changes derived from terminal bridge EVENTs. */
var onJourneyEvent: ((JourneyState) -> Unit)? = null
/** Bridge-level failures surfaced for an error banner. */
var onBridgeError: ((String) -> Unit)? = null
override fun onMessage(host: BridgeHost, message: BridgeMessage) {
// The web journey reports terminal states as one-way EVENTs. Events never
// enter the request dispatch path, so the delegate is the only place to
// observe them.
if (message.type != BridgeMessageType.EVENT) return
val data = message.payload.data
val instanceId = data?.get("instanceId")?.jsonPrimitive?.contentOrNull
when (message.payload.action) {
"journey.completed" ->
onJourneyEvent?.invoke(JourneyState.Completed(status = "completed", instanceId = instanceId))
"journey.abandoned" ->
onJourneyEvent?.invoke(JourneyState.Completed(status = "abandoned", instanceId = instanceId))
"journey.failed" ->
onJourneyEvent?.invoke(JourneyState.Error("The verification journey reported a failure."))
}
}
override fun onMessageSent(host: BridgeHost, message: BridgeMessage) {
// Android-only outbound hook: fires for every message the host sends,
// before transport — including sends attempted while detached.
Log.d(TAG, "TO WEB ${message.payload.action} ${message.correlationId}")
}
override fun onUnhandledRequest(host: BridgeHost, request: BridgeMessage) {
// The request is already parked in host.pendingRequests; the lookup
// respond overload finds and removes it.
host.respond(
to = request.correlationId,
status = BridgeResponseStatus.UNSUPPORTED,
error = BridgeErrorPayload(
code = "UNSUPPORTED_ACTION",
message = "Action '${request.payload.action}' is not supported by this host",
recoverable = false,
),
)
}
override fun onError(host: BridgeHost, error: Throwable) {
// Fires for decode failures, handler exceptions, encode failures, and
// allowedOrigins rejections. lastError is read-only on Android — the SDK
// writes it; the host surfaces it and resets it via clearError().
Log.e(TAG, "Bridge error", error)
onBridgeError?.invoke(host.lastError ?: error.message ?: "Unknown bridge error")
}
}
// ── Custom WebViewClient (navigation policy + error logging) ──
private class JourneyWebViewClient(
bootstrapScript: String,
private val allowedHosts: Set<String>,
private val onPageReady: () -> Unit,
private val onLoadError: (String) -> Unit,
) : BridgeWebViewConfigurator.BootstrapInjectingWebViewClient(bootstrapScript) {
// Navigation policy: keep the WebView pinned to the journey origin. This
// is the primary gate — the message-level allowedOrigins check in
// BridgeConfiguration layers on top of it.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val host = request.url.host ?: return true
if (host in allowedHosts) return false // load in the WebView
Log.w(TAG, "Blocked navigation to ${request.url}")
return true // block (or hand off to an external browser via an Intent)
}
// The base class injects the bridge bootstrap in onPageStarted. We don't
// override it here; if you do, call super.onPageStarted or the bridge never
// initializes.
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
onPageReady()
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
super.onReceivedError(view, request, error)
if (request.isForMainFrame) {
Log.e(TAG, "Load error ${error.errorCode} (${error.description}) for ${request.url}")
onLoadError("The journey failed to load (${error.description}).")
}
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse,
) {
super.onReceivedHttpError(view, request, errorResponse)
if (request.isForMainFrame) {
Log.e(TAG, "HTTP ${errorResponse.statusCode} for ${request.url}")
onLoadError("The journey failed to load (HTTP ${errorResponse.statusCode}).")
}
}
}
// ── Supporting composables ──
@Composable
private fun JourneyOptionCard(
title: String,
subtitle: String,
requirements: List<String>,
deviceCapabilities: DeviceCapabilities,
onClick: () -> Unit,
) {
val allMet = requirements.all { deviceCapabilities.isSupported(it) }
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(subtitle, style = MaterialTheme.typography.bodySmall)
}
Text(if (allMet) "Ready" else "Limited", style = MaterialTheme.typography.labelMedium)
}
}
}
@Composable
private fun DeviceCapabilitySummary(capabilities: DeviceCapabilities) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Device capabilities", style = MaterialTheme.typography.labelMedium)
Text(
"Camera: ${if (capabilities.cameraResult.hardwareAvailable) "available" else "not available"}" +
" · Permission: ${capabilities.cameraResult.permissionState.wireValue}",
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun CaptureSheet(
title: String,
onComplete: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(title) },
text = {
Text(
"Production apps present a CameraX preview or photo picker here — " +
"see the capture screens guide. This example completes the slot " +
"with a placeholder image.",
)
},
confirmButton = { TextButton(onClick = onComplete) { Text("Use placeholder image") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun JourneyErrorOverlay(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Verification Error", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(message, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) { Text("Retry") }
}
}
@Composable
private fun JourneyCompleteOverlay(result: JourneyState.Completed, onDone: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (result.status == "completed") "Verification Complete" else "Verification Ended",
style = MaterialTheme.typography.titleMedium,
)
result.instanceId?.let {
Spacer(Modifier.height(8.dp))
Text("Instance: $it", style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(16.dp))
Button(onClick = onDone) { Text("Done") }
}
}
// ── Helpers ──
private fun placeholderImage(format: Bitmap.CompressFormat): ByteArray {
val bitmap = Bitmap.createBitmap(640, 400, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.DKGRAY)
return ByteArrayOutputStream().use { out ->
bitmap.compress(format, 90, out)
out.toByteArray()
}
}