Files
alfred-mobile/WEBSOCKET_INTEGRATION.md

578 lines
16 KiB
Markdown
Raw Normal View History

# 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>(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`:**
```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<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`:**
```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 <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!