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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-18 22:31:36 +09:00
parent 96f559abbb
commit b466e3116f
3 changed files with 20 additions and 4 deletions

View file

@ -23,6 +23,7 @@ import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
val appModule = module { val appModule = module {
@ -52,7 +53,13 @@ val appModule = module {
} }
// WebSocketClient // 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 // ImageFileStorage
single { ImageFileStorage(androidContext()) } single { ImageFileStorage(androidContext()) }

View file

@ -25,7 +25,8 @@ import kotlinx.serialization.json.Json
class WebSocketClient( class WebSocketClient(
private val client: HttpClient, private val client: HttpClient,
private val scope: CoroutineScope private val scope: CoroutineScope,
private val clientId: String
) { ) {
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
@ -47,7 +48,8 @@ class WebSocketClient(
while (isActive) { while (isActive) {
try { try {
_connectionState.value = ConnectionState.CONNECTING _connectionState.value = ConnectionState.CONNECTING
client.webSocket(wsUrl) { val url = "$wsUrl?client_id=$clientId"
client.webSocket(url) {
session = this session = this
_connectionState.value = ConnectionState.CONNECTED _connectionState.value = ConnectionState.CONNECTED
retryDelay = INITIAL_DELAY retryDelay = INITIAL_DELAY

View file

@ -166,7 +166,10 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
return 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{}{ logger.InfoCF("websocket", "New WebSocket connection", map[string]interface{}{
"client_id": clientID, "client_id": clientID,
@ -176,6 +179,10 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
chatID := fmt.Sprintf("ws:%s", clientID) chatID := fmt.Sprintf("ws:%s", clientID)
c.mu.Lock() c.mu.Lock()
if oldConn, ok := c.chatConns[chatID]; ok {
delete(c.clients, oldConn)
oldConn.Close()
}
c.clients[conn] = clientID c.clients[conn] = clientID
c.chatConns[chatID] = conn c.chatConns[chatID] = conn
c.mu.Unlock() c.mu.Unlock()