Files
alfred-mobile/WEBSOCKET_INTEGRATION.md
jknapp 6d4ae2e5c3 Initial commit: Alfred Mobile - AI Assistant Android App
- OAuth authentication via Authentik
- WebSocket connection to OpenClaw gateway
- Configurable gateway URL with first-run setup
- User preferences sync across devices
- Multi-user support with custom assistant names
- ElevenLabs TTS integration (local + remote)
- FCM push notifications for alarms
- Voice input via Google Speech API
- No hardcoded secrets or internal IPs in tracked files
2026-02-09 11:12:51 -08:00

16 KiB

WebSocket Integration - Connect to Alfred

After OAuth authentication, connect to Alfred via WebSocket.

Configuration

Already set in OAuthConfig.kt:

const val GATEWAY_URL = "wss://alfred-app.dnspegasus.net"

Step 1: Create WebSocket Client

app/src/main/java/com/example/alfredmobile/openclaw/OpenClawClient.kt:

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>(ConnectionState.Disconnected)
    val connectionState: StateFlow<ConnectionState> = _connectionState
    
    private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    val messages: StateFlow<List<ChatMessage>> = _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:

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<ConnectionState>?
        get() = client?.connectionState
    
    val messages: StateFlow<List<ChatMessage>>?
        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:

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:

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:

    ./gradlew assembleDebug
    adb install app/build/outputs/apk/debug/app-debug.apk
    
  2. Watch logs:

    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:

journalctl --user -u alfred-proxy.service -f

You should see:

[proxy] New connection from <ip>
[auth] Token validated for user: <email>
[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!