From 790cd6a411569faf02bb9013cce4b20267dec25b Mon Sep 17 00:00:00 2001 From: Kohei Date: Fri, 27 Feb 2026 15:22:09 +0900 Subject: [PATCH] feat: add GatewaySettingsStore and wire up dynamic connection settings (Step 7) Implement GatewaySettingsStoreImpl with DataStore persistence, add apiKey support to WebSocketClient and ConfigApiClient, and observe settings changes in ClawDroidApp to reconnect WS on config updates. Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle.kts | 4 ++ .../main/java/io/clawdroid/ClawDroidApp.kt | 24 ++++++++- .../main/java/io/clawdroid/di/AppModule.kt | 13 ++++- .../settings/GatewaySettingsStoreImpl.kt | 49 +++++++++++++++++++ .../clawdroid/backend/api/GatewaySettings.kt | 5 +- .../backend/config/ConfigApiClient.kt | 19 +++++-- .../clawdroid/backend/config/ConfigModule.kt | 2 +- .../core/data/remote/WebSocketClient.kt | 11 ++++- 8 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 151774ae6..d84906d3c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -87,7 +87,11 @@ dependencies { implementation(libs.coroutines.android) + implementation(libs.datastore.preferences) + implementation(libs.serialization.json) + implementation(libs.icons.lucide) + debugImplementation(libs.compose.ui.tooling) } diff --git a/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt b/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt index 2f4a8c157..af06aa2e4 100644 --- a/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt +++ b/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt @@ -1,18 +1,38 @@ package io.clawdroid import android.app.Application +import io.clawdroid.backend.api.GatewaySettingsStore +import io.clawdroid.backend.config.configModule +import io.clawdroid.core.data.remote.WebSocketClient import io.clawdroid.di.appModule import io.clawdroid.receiver.NotificationHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin class ClawDroidApp : Application() { override fun onCreate() { super.onCreate() - startKoin { + val koinApp = startKoin { androidContext(this@ClawDroidApp) - modules(appModule) + modules(appModule, configModule) } NotificationHelper.createNotificationChannel(this) + + val koin = koinApp.koin + val settingsStore: GatewaySettingsStore = koin.get() + val wsClient: WebSocketClient = koin.get() + val scope: CoroutineScope = koin.get() + scope.launch { + settingsStore.settings.collect { s -> + if (wsClient.wsUrl != s.wsUrl || wsClient.apiKey != s.apiKey) { + wsClient.disconnect() + wsClient.wsUrl = s.wsUrl + wsClient.apiKey = s.apiKey + wsClient.connect() + } + } + } } } diff --git a/android/app/src/main/java/io/clawdroid/di/AppModule.kt b/android/app/src/main/java/io/clawdroid/di/AppModule.kt index b3562cc21..996a92b4f 100644 --- a/android/app/src/main/java/io/clawdroid/di/AppModule.kt +++ b/android/app/src/main/java/io/clawdroid/di/AppModule.kt @@ -24,12 +24,15 @@ import io.clawdroid.core.domain.usecase.ObserveConnectionUseCase import io.clawdroid.core.domain.usecase.ObserveMessagesUseCase import io.clawdroid.core.domain.usecase.ObserveStatusUseCase import io.clawdroid.core.domain.usecase.SendMessageUseCase +import io.clawdroid.backend.api.GatewaySettingsStore import io.clawdroid.feature.chat.ChatViewModel import io.clawdroid.feature.chat.SettingsViewModel import io.clawdroid.feature.chat.voice.SpeechRecognizerWrapper import io.clawdroid.feature.chat.voice.TextToSpeechWrapper import io.clawdroid.feature.chat.voice.CameraCaptureManager import io.clawdroid.feature.chat.voice.VoiceModeManager +import io.clawdroid.settings.AppSettingsViewModel +import io.clawdroid.settings.GatewaySettingsStoreImpl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -44,6 +47,9 @@ val appModule = module { // CoroutineScope single { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + // GatewaySettingsStore + single { GatewaySettingsStoreImpl(androidContext(), get()) } + // Room single { Room.databaseBuilder( @@ -72,7 +78,11 @@ val appModule = module { val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { prefs.edit().putString("client_id", it).apply() } - WebSocketClient(get(), get(), clientId) + val gwSettings = get().settings.value + WebSocketClient(get(), get(), clientId).apply { + wsUrl = gwSettings.wsUrl + apiKey = gwSettings.apiKey + } } // ImageFileStorage @@ -124,4 +134,5 @@ val appModule = module { // ViewModel viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) } + viewModel { AppSettingsViewModel(get()) } } diff --git a/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt b/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt new file mode 100644 index 000000000..93255cec8 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt @@ -0,0 +1,49 @@ +package io.clawdroid.settings + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import io.clawdroid.backend.api.GatewaySettings +import io.clawdroid.backend.api.GatewaySettingsStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +private val Context.gatewayDataStore by preferencesDataStore(name = "gateway_settings") + +class GatewaySettingsStoreImpl( + private val context: Context, + scope: CoroutineScope, +) : GatewaySettingsStore { + + private object Keys { + val WS_PORT = intPreferencesKey("ws_port") + val HTTP_PORT = intPreferencesKey("http_port") + val API_KEY = stringPreferencesKey("api_key") + } + + override val settings: StateFlow = + context.gatewayDataStore.data.map { prefs -> + GatewaySettings( + wsPort = prefs[Keys.WS_PORT] ?: DEFAULT.wsPort, + httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort, + apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey, + ) + }.stateIn(scope, SharingStarted.Eagerly, DEFAULT) + + companion object { + private val DEFAULT = GatewaySettings() + } + + override suspend fun update(settings: GatewaySettings) { + context.gatewayDataStore.edit { prefs -> + prefs[Keys.WS_PORT] = settings.wsPort + prefs[Keys.HTTP_PORT] = settings.httpPort + prefs[Keys.API_KEY] = settings.apiKey + } + } +} diff --git a/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt b/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt index 3626af99d..610e6040d 100644 --- a/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt +++ b/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt @@ -4,4 +4,7 @@ data class GatewaySettings( val wsPort: Int = 18793, val httpPort: Int = 18790, val apiKey: String = "", -) +) { + val wsUrl: String get() = "ws://127.0.0.1:$wsPort/ws" + val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort" +} diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt index 809cb944b..375bd817f 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt @@ -1,10 +1,12 @@ package io.clawdroid.backend.config +import io.clawdroid.backend.api.GatewaySettingsStore import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse @@ -41,8 +43,12 @@ data class SaveConfigResult( val error: String? = null, ) -class ConfigApiClient : Closeable { - private val baseUrl: String get() = "http://127.0.0.1:18790" +class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable { + private val baseUrl: String + get() = settingsStore.settings.value.httpBaseUrl + + private val apiKey: String + get() = settingsStore.settings.value.apiKey private val client = HttpClient(OkHttp) { install(ContentNegotiation) { @@ -51,17 +57,22 @@ class ConfigApiClient : Closeable { } suspend fun getSchema(): ConfigSchema { - return client.get("$baseUrl/api/config/schema").ensureSuccess().body() + return client.get("$baseUrl/api/config/schema") { + if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey") + }.ensureSuccess().body() } suspend fun getConfig(): JsonObject { - return client.get("$baseUrl/api/config").ensureSuccess().body() + return client.get("$baseUrl/api/config") { + if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey") + }.ensureSuccess().body() } suspend fun saveConfig(config: JsonObject): SaveConfigResult { return client.put("$baseUrl/api/config") { contentType(ContentType.Application.Json) setBody(config) + if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey") }.ensureSuccess().body() } diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt index 584d50a4f..7720cce6b 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt @@ -6,6 +6,6 @@ import org.koin.core.module.dsl.withOptions import org.koin.dsl.module val configModule = module { - single { ConfigApiClient() } withOptions { callbacks = Callbacks(onClose = { it?.close() }) } + single { ConfigApiClient(get()) } withOptions { callbacks = Callbacks(onClose = { it?.close() }) } viewModel { ConfigViewModel(get()) } } diff --git a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt index d35b7d9c5..65c512cf3 100644 --- a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt +++ b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.net.URLEncoder class WebSocketClient( private val client: HttpClient, @@ -40,7 +41,8 @@ class WebSocketClient( private var connectJob: Job? = null private val json = Json { ignoreUnknownKeys = true } - var wsUrl: String = "ws://127.0.0.1:18793/ws" + @Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws" + @Volatile var apiKey: String = "" fun connect() { if (connectJob?.isActive == true) return @@ -49,7 +51,12 @@ class WebSocketClient( while (isActive) { try { _connectionState.value = ConnectionState.CONNECTING - val url = "$wsUrl?client_id=$clientId&client_type=$clientType" + val currentWsUrl = wsUrl + val currentApiKey = apiKey + val url = buildString { + append("$currentWsUrl?client_id=$clientId&client_type=$clientType") + if (currentApiKey.isNotEmpty()) append("&api_key=${URLEncoder.encode(currentApiKey, "UTF-8")}") + } client.webSocket(url) { session = this _connectionState.value = ConnectionState.CONNECTED