diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt new file mode 100644 index 000000000..4faf7791f --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt @@ -0,0 +1,129 @@ +package ai.openclaw.android.ui + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import ai.openclaw.android.MainViewModel + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + AndroidView( + modifier = modifier, + factory = { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } else { + disableForceDarkIfSupported(settings) + } + if (isDebuggable) { + Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") + } + isScrollContainer = true + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e( + "OpenClawWebView", + "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", + ) + } + + override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("OpenClawWebView", "onPageFinished: $url") + } + viewModel.canvas.onPageFinished() + } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "OpenClawWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "OpenClawWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + + val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } + addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) + viewModel.canvas.attach(this) + } + }, + ) +} + +private fun disableForceDarkIfSupported(settings: WebSettings) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return + @Suppress("DEPRECATION") + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) +} + +private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { + @JavascriptInterface + fun postMessage(payload: String?) { + val msg = payload?.trim().orEmpty() + if (msg.isEmpty()) return + onMessage(msg) + } + + companion object { + const val interfaceName: String = "openclawCanvasA2UIAction" + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt new file mode 100644 index 000000000..ae153ad9c --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -0,0 +1,356 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import ai.openclaw.android.MainViewModel + +private enum class HomeTab( + val label: String, + val icon: ImageVector, +) { + Connect(label = "Connect", icon = Icons.Default.CheckCircle), + Chat(label = "Chat", icon = Icons.Default.ChatBubble), + Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver), + Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare), + Settings(label = "Settings", icon = Icons.Default.Settings), +} + +private enum class StatusVisual { + Connected, + Connecting, + Warning, + Error, + Offline, +} + +@Composable +fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { + var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + + val statusVisual = + remember(statusText, isConnected) { + val lower = statusText.lowercase() + when { + isConnected -> StatusVisual.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting + lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning + lower.contains("error") || lower.contains("failed") -> StatusVisual.Error + else -> StatusVisual.Offline + } + } + + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopStatusBar( + statusText = statusText, + statusVisual = statusVisual, + ) + }, + bottomBar = { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .background(mobileBackgroundGradient), + ) { + when (activeTab) { + HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) + HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel) + HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Settings -> SettingsSheet(viewModel = viewModel) + } + } + } +} + +@Composable +private fun TopStatusBar( + statusText: String, + statusVisual: StatusVisual, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + + val (chipBg, chipDot, chipText, chipBorder) = + when (statusVisual) { + StatusVisual.Connected -> + listOf( + mobileSuccessSoft, + mobileSuccess, + mobileSuccess, + Color(0xFFCFEBD8), + ) + StatusVisual.Connecting -> + listOf( + mobileAccentSoft, + mobileAccent, + mobileAccent, + Color(0xFFD5E2FA), + ) + StatusVisual.Warning -> + listOf( + mobileWarningSoft, + mobileWarning, + mobileWarning, + Color(0xFFEED8B8), + ) + StatusVisual.Error -> + listOf( + mobileDangerSoft, + mobileDanger, + mobileDanger, + Color(0xFFF3C8C8), + ) + StatusVisual.Offline -> + listOf( + mobileSurface, + mobileTextTertiary, + mobileTextSecondary, + mobileBorder, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets), + color = Color.Transparent, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "OpenClaw", + style = mobileTitle2, + color = mobileText, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = chipBg, + border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.padding(top = 1.dp), + color = chipDot, + shape = RoundedCornerShape(999.dp), + ) { + Box(modifier = Modifier.padding(4.dp)) + } + Text( + text = statusText.trim().ifEmpty { "Offline" }, + style = mobileCaption1, + color = chipText, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun BottomTabBar( + activeTab: HomeTab, + onSelect: (HomeTab) -> Unit, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(safeInsets), + ) { + Surface( + modifier = Modifier.fillMaxWidth().offset(y = (-4).dp), + color = Color.White.copy(alpha = 0.97f), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeTab.entries.forEach { tab -> + val active = tab == activeTab + Surface( + onClick = { onSelect(tab) }, + modifier = Modifier.weight(1f).heightIn(min = 58.dp), + shape = RoundedCornerShape(16.dp), + color = if (active) mobileAccentSoft else Color.Transparent, + border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + tint = if (active) mobileAccent else mobileTextTertiary, + ) + Text( + text = tab.label, + color = if (active) mobileAccent else mobileTextSecondary, + style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium), + ) + } + } + } + } + } + } +} + +@Composable +private fun VoiceTabScreen(viewModel: MainViewModel) { + val context = LocalContext.current + val talkEnabled by viewModel.talkEnabled.collectAsState() + val talkStatusText by viewModel.talkStatusText.collectAsState() + val talkIsListening by viewModel.talkIsListening.collectAsState() + val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() + val seamColorArgb by viewModel.seamColorArgb.collectAsState() + + val seamColor = remember(seamColorArgb) { Color(seamColorArgb) } + + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + viewModel.setTalkEnabled(true) + } + } + + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) { + if (talkEnabled) { + TalkOrbOverlay( + seamColor = seamColor, + statusText = talkStatusText, + isListening = talkIsListening, + isSpeaking = talkIsSpeaking, + modifier = Modifier.align(Alignment.Center), + ) + } else { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("VOICE", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text("Talk Mode", style = mobileTitle1, color = mobileText) + Text( + "Enable voice controls and watch live listening/speaking state.", + style = mobileBody, + color = mobileTextSecondary, + ) + } + } + + Button( + onClick = { + if (talkEnabled) { + viewModel.setTalkEnabled(false) + return@Button + } + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (micOk) { + viewModel.setTalkEnabled(true) + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (talkEnabled) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Text( + if (talkEnabled) "Disable Talk Mode" else "Enable Talk Mode", + style = mobileHeadline.copy(fontWeight = FontWeight.Bold), + ) + } + } +} + +@Composable +private fun ScreenTabScreen(viewModel: MainViewModel) { + val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() + + Box(modifier = Modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt index 38440ac5a..e50a03cc5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt @@ -1,80 +1,14 @@ package ai.openclaw.android.ui -import android.annotation.SuppressLint -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Color -import android.util.Log -import android.view.View -import android.webkit.JavascriptInterface -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebSettings -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebViewClient -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -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.graphics.Color as ComposeColor -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import ai.openclaw.android.CameraHudKind import ai.openclaw.android.MainViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RootScreen(viewModel: MainViewModel) { - var sheet by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - val context = LocalContext.current val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() if (!onboardingCompleted) { @@ -82,355 +16,5 @@ fun RootScreen(viewModel: MainViewModel) { return } - val serverName by viewModel.serverName.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val cameraHud by viewModel.cameraHud.collectAsState() - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - val screenRecordActive by viewModel.screenRecordActive.collectAsState() - val isForeground by viewModel.isForeground.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) viewModel.setTalkEnabled(true) - } - val activity = - remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if (!isForeground) { - return@remember StatusActivity( - title = "Foreground required", - icon = Icons.Default.Report, - contentDescription = "Foreground required", - ) - } - - val lowerStatus = statusText.lowercase() - if (lowerStatus.contains("repair")) { - return@remember StatusActivity( - title = "Repairing…", - icon = Icons.Default.Refresh, - contentDescription = "Repairing", - ) - } - if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { - return@remember StatusActivity( - title = "Approval pending", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Approval pending", - ) - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if (screenRecordActive) { - return@remember StatusActivity( - title = "Recording screen…", - icon = Icons.AutoMirrored.Filled.ScreenShare, - contentDescription = "Recording screen", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - - cameraHud?.let { hud -> - return@remember when (hud.kind) { - CameraHudKind.Photo -> - StatusActivity( - title = hud.message, - icon = Icons.Default.PhotoCamera, - contentDescription = "Taking photo", - ) - CameraHudKind.Recording -> - StatusActivity( - title = hud.message, - icon = Icons.Default.FiberManualRecord, - contentDescription = "Recording", - tint = androidx.compose.ui.graphics.Color.Red, - ) - CameraHudKind.Success -> - StatusActivity( - title = hud.message, - icon = Icons.Default.CheckCircle, - contentDescription = "Capture finished", - ) - CameraHudKind.Error -> - StatusActivity( - title = hud.message, - icon = Icons.Default.Error, - contentDescription = "Capture failed", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - } - - if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { - return@remember StatusActivity( - title = "Mic permission", - icon = Icons.Default.Error, - contentDescription = "Mic permission required", - ) - } - if (voiceWakeStatusText == "Paused") { - val suffix = if (!isForeground) " (background)" else "" - return@remember StatusActivity( - title = "Voice Wake paused$suffix", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Voice Wake paused", - ) - } - - null - } - - val gatewayState = - remember(serverName, statusText) { - when { - serverName != null -> GatewayState.Connected - statusText.contains("connecting", ignoreCase = true) || - statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting - statusText.contains("error", ignoreCase = true) -> GatewayState.Error - else -> GatewayState.Disconnected - } - } - - val voiceEnabled = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - Box(modifier = Modifier.fillMaxSize()) { - CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - } - - // Camera flash must be in a Popup to render above the WebView. - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } - - // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { - StatusPill( - gateway = gatewayState, - voiceEnabled = voiceEnabled, - activity = activity, - onClick = { sheet = Sheet.Settings }, - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), - ) - } - - Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { - Column( - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.End, - ) { - OverlayIconButton( - onClick = { sheet = Sheet.Chat }, - icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, - ) - - // Talk mode gets a dedicated side bubble instead of burying it in settings. - val baseOverlay = overlayContainerColor() - val talkContainer = - lerp( - baseOverlay, - seamColor.copy(alpha = baseOverlay.alpha), - if (talkEnabled) 0.35f else 0.22f, - ) - val talkContent = if (talkEnabled) seamColor else overlayIconColor() - OverlayIconButton( - onClick = { - val next = !talkEnabled - if (next) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - containerColor = talkContainer, - contentColor = talkContent, - icon = { - Icon( - Icons.Default.RecordVoiceOver, - contentDescription = "Talk Mode", - ) - }, - ) - - OverlayIconButton( - onClick = { sheet = Sheet.Settings }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - ) - } - } - - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - ) - } - } - - val currentSheet = sheet - if (currentSheet != null) { - ModalBottomSheet( - onDismissRequest = { sheet = null }, - sheetState = sheetState, - ) { - when (currentSheet) { - Sheet.Chat -> ChatSheet(viewModel = viewModel) - Sheet.Settings -> SettingsSheet(viewModel = viewModel) - } - } - } -} - -private enum class Sheet { - Chat, - Settings, -} - -@Composable -private fun OverlayIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - containerColor: ComposeColor? = null, - contentColor: ComposeColor? = null, -) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(44.dp), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = containerColor ?: overlayContainerColor(), - contentColor = contentColor ?: overlayIconColor(), - ), - ) { - icon() - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 - AndroidView( - modifier = modifier, - factory = { - WebView(context).apply { - settings.javaScriptEnabled = true - // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) - } else { - disableForceDarkIfSupported(settings) - } - if (isDebuggable) { - Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") - } - isScrollContainer = true - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - isVerticalScrollBarEnabled = true - isHorizontalScrollBarEnabled = true - webViewClient = - object : WebViewClient() { - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") - } - - override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - errorResponse: WebResourceResponse, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e( - "OpenClawWebView", - "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", - ) - } - - override fun onPageFinished(view: WebView, url: String?) { - if (isDebuggable) { - Log.d("OpenClawWebView", "onPageFinished: $url") - } - viewModel.canvas.onPageFinished() - } - - override fun onRenderProcessGone( - view: WebView, - detail: android.webkit.RenderProcessGoneDetail, - ): Boolean { - if (isDebuggable) { - Log.e( - "OpenClawWebView", - "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", - ) - } - return true - } - } - webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (!isDebuggable) return false - val msg = consoleMessage ?: return false - Log.d( - "OpenClawWebView", - "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", - ) - return false - } - } - // Use default layer/background; avoid forcing a black fill over WebView content. - - val a2uiBridge = - CanvasA2UIActionBridge { payload -> - viewModel.handleCanvasA2UIActionFromWebView(payload) - } - addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) - viewModel.canvas.attach(this) - } - }, - ) -} - -private fun disableForceDarkIfSupported(settings: WebSettings) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return - @Suppress("DEPRECATION") - WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) -} - -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { - @JavascriptInterface - fun postMessage(payload: String?) { - val msg = payload?.trim().orEmpty() - if (msg.isEmpty()) return - onMessage(msg) - } - - companion object { - const val interfaceName: String = "openclawCanvasA2UIAction" - } + PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize()) }