# WebSocket Integration - Connect to Alfred After OAuth authentication, connect to Alfred via WebSocket. ## Configuration Already set in `OAuthConfig.kt`: ```kotlin const val GATEWAY_URL = "wss://alfred-app.dnspegasus.net" ``` --- ## Step 1: Create WebSocket Client **`app/src/main/java/com/example/alfredmobile/openclaw/OpenClawClient.kt`:** ```kotlin package com.example.alfredmobile.openclaw import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import okhttp3.* import org.json.JSONObject import java.util.concurrent.TimeUnit class OpenClawClient( private val gatewayUrl: String, private val accessToken: String ) { companion object { private const val TAG = "OpenClawClient" } private val client = OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) // No timeout for WebSocket .build() private var webSocket: WebSocket? = null private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages /** * Connect to Alfred gateway */ fun connect() { Log.d(TAG, "Connecting to: $gatewayUrl") val request = Request.Builder() .url(gatewayUrl) .addHeader("Authorization", "Bearer $accessToken") .build() webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { Log.d(TAG, "WebSocket opened") _connectionState.value = ConnectionState.Connected } override fun onMessage(webSocket: WebSocket, text: String) { Log.d(TAG, "Received: $text") handleMessage(text) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket closing: $code $reason") _connectionState.value = ConnectionState.Disconnecting } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket closed: $code $reason") _connectionState.value = ConnectionState.Disconnected } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket error", t) _connectionState.value = ConnectionState.Error(t.message ?: "Unknown error") } }) } /** * Disconnect from Alfred */ fun disconnect() { Log.d(TAG, "Disconnecting...") webSocket?.close(1000, "User disconnect") webSocket = null } /** * Send a message to Alfred */ fun sendMessage(text: String) { if (_connectionState.value != ConnectionState.Connected) { Log.w(TAG, "Not connected, cannot send message") return } // Add user message to UI immediately val userMessage = ChatMessage( role = "user", content = text, timestamp = System.currentTimeMillis() ) _messages.value = _messages.value + userMessage // TODO: Send to OpenClaw gateway // For now, send a request ID to track the response val requestId = System.currentTimeMillis().toString() val payload = JSONObject().apply { put("type", "req") put("id", requestId) put("method", "chat.send") put("params", JSONObject().apply { put("message", text) put("sessionKey", "main") }) } Log.d(TAG, "Sending: $payload") webSocket?.send(payload.toString()) } /** * Handle incoming WebSocket messages */ private fun handleMessage(text: String) { try { val json = JSONObject(text) val type = json.optString("type") when (type) { "event" -> handleEvent(json) "res" -> handleResponse(json) else -> Log.d(TAG, "Unknown message type: $type") } } catch (e: Exception) { Log.e(TAG, "Error parsing message", e) } } /** * Handle event messages (chat updates, tool calls, etc.) */ private fun handleEvent(json: JSONObject) { val event = json.optString("event") val payload = json.optJSONObject("payload") Log.d(TAG, "Event: $event") when (event) { "chat" -> { // Chat message from assistant payload?.let { handleChatEvent(it) } } "connect.challenge" -> { // OpenClaw wants us to send connect message sendConnectMessage() } else -> { Log.d(TAG, "Unhandled event: $event") } } } /** * Handle response messages */ private fun handleResponse(json: JSONObject) { val id = json.optString("id") val ok = json.optBoolean("ok") Log.d(TAG, "Response for $id: ok=$ok") if (!ok) { val error = json.optJSONObject("error") Log.e(TAG, "Request failed: ${error?.optString("message")}") } } /** * Handle chat event */ private fun handleChatEvent(payload: JSONObject) { val role = payload.optString("role", "assistant") val content = payload.optString("text", "") if (content.isNotEmpty()) { val message = ChatMessage( role = role, content = content, timestamp = System.currentTimeMillis() ) _messages.value = _messages.value + message } } /** * Send the initial connect message to OpenClaw */ private fun sendConnectMessage() { val connectPayload = JSONObject().apply { put("type", "req") put("id", "connect-${System.currentTimeMillis()}") put("method", "connect") put("params", JSONObject().apply { put("minProtocol", 3) put("maxProtocol", 3) put("client", JSONObject().apply { put("id", "openclaw-android") put("version", "1.0.0") put("platform", "android") put("mode", "webchat") }) put("role", "operator") put("scopes", org.json.JSONArray().apply { put("operator.admin") }) put("caps", org.json.JSONArray()) put("auth", JSONObject()) // Token injected by proxy put("userAgent", "AlfredMobile/1.0") }) } Log.d(TAG, "Sending connect message") webSocket?.send(connectPayload.toString()) } } /** * Connection states */ sealed class ConnectionState { object Disconnected : ConnectionState() object Connecting : ConnectionState() object Connected : ConnectionState() object Disconnecting : ConnectionState() data class Error(val message: String) : ConnectionState() } /** * Chat message model */ data class ChatMessage( val role: String, val content: String, val timestamp: Long ) ``` --- ## Step 2: Create Chat ViewModel **`app/src/main/java/com/example/alfredmobile/ui/ChatViewModel.kt`:** ```kotlin package com.example.alfredmobile.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.alfredmobile.auth.AuthManager import com.example.alfredmobile.auth.OAuthConfig import com.example.alfredmobile.openclaw.ChatMessage import com.example.alfredmobile.openclaw.ConnectionState import com.example.alfredmobile.openclaw.OpenClawClient import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class ChatViewModel( private val authManager: AuthManager ) : ViewModel() { private var client: OpenClawClient? = null val connectionState: StateFlow? get() = client?.connectionState val messages: StateFlow>? get() = client?.messages /** * Initialize and connect to Alfred */ fun connect() { val accessToken = authManager.getAccessToken() if (accessToken == null) { // Not logged in return } client = OpenClawClient( gatewayUrl = OAuthConfig.GATEWAY_URL, accessToken = accessToken ) client?.connect() } /** * Send a message to Alfred */ fun sendMessage(text: String) { client?.sendMessage(text) } /** * Disconnect from Alfred */ fun disconnect() { client?.disconnect() } override fun onCleared() { super.onCleared() disconnect() } } ``` --- ## Step 3: Update MainScreen with Chat UI **`app/src/main/java/com/example/alfredmobile/ui/MainScreen.kt`:** ```kotlin package com.example.alfredmobile.ui import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Send import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.example.alfredmobile.openclaw.ChatMessage import com.example.alfredmobile.openclaw.ConnectionState @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( viewModel: ChatViewModel, onLogout: () -> Unit ) { val messages by viewModel.messages?.collectAsState() ?: remember { mutableStateOf(emptyList()) } val connectionState by viewModel.connectionState?.collectAsState() ?: remember { mutableStateOf(ConnectionState.Disconnected) } var messageText by remember { mutableStateOf("") } // Connect when screen appears LaunchedEffect(Unit) { viewModel.connect() } Scaffold( topBar = { TopAppBar( title = { Text("🤵 Alfred") }, actions = { // Connection status indicator when (connectionState) { is ConnectionState.Connected -> { Text( "●", color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(end = 16.dp) ) } is ConnectionState.Error -> { Text( "●", color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(end = 16.dp) ) } else -> { Text( "○", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 16.dp) ) } } TextButton(onClick = onLogout) { Text("Logout") } } ) }, bottomBar = { Row( modifier = Modifier .fillMaxWidth() .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = messageText, onValueChange = { messageText = it }, modifier = Modifier.weight(1f), placeholder = { Text("Message Alfred...") }, maxLines = 3 ) Spacer(modifier = Modifier.width(8.dp)) IconButton( onClick = { if (messageText.isNotBlank()) { viewModel.sendMessage(messageText) messageText = "" } }, enabled = connectionState is ConnectionState.Connected ) { Icon(Icons.Default.Send, contentDescription = "Send") } } } ) { padding -> LazyColumn( modifier = Modifier .fillMaxSize() .padding(padding) .padding(horizontal = 16.dp), reverseLayout = true ) { items(messages.reversed()) { message -> ChatMessageBubble(message) Spacer(modifier = Modifier.height(8.dp)) } } } } @Composable fun ChatMessageBubble(message: ChatMessage) { val isUser = message.role == "user" Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start ) { Card( colors = CardDefaults.cardColors( containerColor = if (isUser) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant } ), modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = message.content, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium ) } } } ``` --- ## Step 4: Update MainActivity to Use ViewModel **Update `MainActivity.kt`:** ```kotlin package com.example.alfredmobile.ui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.* import androidx.lifecycle.viewmodel.compose.viewModel import com.example.alfredmobile.auth.AuthManager import com.example.alfredmobile.ui.theme.AlfredMobileTheme class MainActivity : ComponentActivity() { private lateinit var authManager: AuthManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) authManager = AuthManager(this) setContent { AlfredMobileTheme { var isLoggedIn by remember { mutableStateOf(authManager.isLoggedIn()) } if (isLoggedIn) { // Create ViewModel with AuthManager val viewModel = ChatViewModel(authManager) MainScreen( viewModel = viewModel, onLogout = { authManager.logout() viewModel.disconnect() isLoggedIn = false } ) } else { LoginScreen( onLoginClick = { authManager.startLogin(this) } ) } } } } } ``` --- ## Testing 1. **Build and install:** ```bash ./gradlew assembleDebug adb install app/build/outputs/apk/debug/app-debug.apk ``` 2. **Watch logs:** ```bash adb logcat | grep -E "OpenClawClient|ChatViewModel" ``` 3. **Test flow:** - Login → Should connect to Alfred - See connection indicator turn blue - Send a message - Receive response from Alfred --- ## Proxy Logs Monitor the proxy to see connections: ```bash journalctl --user -u alfred-proxy.service -f ``` You should see: ``` [proxy] New connection from [auth] Token validated for user: [proxy] Connected to OpenClaw ``` --- ## Next Features - Voice input (use Android Speech Recognizer) - Lists, timers, notes - Push notifications - Offline queue See the coding sub-agent's work in `~/.openclaw/workspace/alfred-mobile/` for the full app structure!