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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-27 15:22:09 +09:00
parent 908c1e051b
commit 790cd6a411
8 changed files with 116 additions and 11 deletions

View file

@ -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)
}

View file

@ -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()
}
}
}
}
}

View file

@ -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<GatewaySettingsStore> { 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<GatewaySettingsStore>().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()) }
}

View file

@ -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<GatewaySettings> =
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
}
}
}

View file

@ -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"
}

View file

@ -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()
}

View file

@ -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()) }
}

View file

@ -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