578 lines
16 KiB
Markdown
578 lines
16 KiB
Markdown
|
|
# 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!
|