From b466e3116f52c65768c2569aeeae30b70b47c3df Mon Sep 17 00:00:00 2001 From: Kohei Date: Wed, 18 Feb 2026 22:31:36 +0900 Subject: [PATCH] feat: persist WebSocket session across reconnections Android generates a stable client ID (UUID) stored in SharedPreferences and sends it as a query parameter on WS connect. The server uses this ID as the chat session key, so AI conversation context survives app restarts. On reconnection, the server closes the stale connection and replaces it with the new one. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/io/picoclaw/android/di/AppModule.kt | 9 ++++++++- .../picoclaw/android/core/data/remote/WebSocketClient.kt | 6 ++++-- pkg/channels/websocket.go | 9 ++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt index c2f8e27ed..3a0ff7059 100644 --- a/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt +++ b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt @@ -23,6 +23,7 @@ import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.dsl.module +import java.util.UUID import java.util.concurrent.TimeUnit val appModule = module { @@ -52,7 +53,13 @@ val appModule = module { } // WebSocketClient - single { WebSocketClient(get(), get()) } + single { + val prefs = androidContext().getSharedPreferences("picoclaw", android.content.Context.MODE_PRIVATE) + val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { + prefs.edit().putString("client_id", it).apply() + } + WebSocketClient(get(), get(), clientId) + } // ImageFileStorage single { ImageFileStorage(androidContext()) } diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt index eac1c0a5f..7df89a654 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt @@ -25,7 +25,8 @@ import kotlinx.serialization.json.Json class WebSocketClient( private val client: HttpClient, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val clientId: String ) { private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) @@ -47,7 +48,8 @@ class WebSocketClient( while (isActive) { try { _connectionState.value = ConnectionState.CONNECTING - client.webSocket(wsUrl) { + val url = "$wsUrl?client_id=$clientId" + client.webSocket(url) { session = this _connectionState.value = ConnectionState.CONNECTED retryDelay = INITIAL_DELAY diff --git a/pkg/channels/websocket.go b/pkg/channels/websocket.go index 1a50f6b16..8dfbcb8c3 100644 --- a/pkg/channels/websocket.go +++ b/pkg/channels/websocket.go @@ -166,7 +166,10 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) { return } - clientID := uuid.New().String() + clientID := r.URL.Query().Get("client_id") + if clientID == "" { + clientID = uuid.New().String() + } logger.InfoCF("websocket", "New WebSocket connection", map[string]interface{}{ "client_id": clientID, @@ -176,6 +179,10 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) { chatID := fmt.Sprintf("ws:%s", clientID) c.mu.Lock() + if oldConn, ok := c.chatConns[chatID]; ok { + delete(c.clients, oldConn) + oldConn.Close() + } c.clients[conn] = clientID c.chatConns[chatID] = conn c.mu.Unlock()