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.coroutines.android)
implementation(libs.datastore.preferences)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.icons.lucide)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
} }

View file

@ -1,18 +1,38 @@
package io.clawdroid package io.clawdroid
import android.app.Application 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.di.appModule
import io.clawdroid.receiver.NotificationHelper import io.clawdroid.receiver.NotificationHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
class ClawDroidApp : Application() { class ClawDroidApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startKoin { val koinApp = startKoin {
androidContext(this@ClawDroidApp) androidContext(this@ClawDroidApp)
modules(appModule) modules(appModule, configModule)
} }
NotificationHelper.createNotificationChannel(this) 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.ObserveMessagesUseCase
import io.clawdroid.core.domain.usecase.ObserveStatusUseCase import io.clawdroid.core.domain.usecase.ObserveStatusUseCase
import io.clawdroid.core.domain.usecase.SendMessageUseCase import io.clawdroid.core.domain.usecase.SendMessageUseCase
import io.clawdroid.backend.api.GatewaySettingsStore
import io.clawdroid.feature.chat.ChatViewModel import io.clawdroid.feature.chat.ChatViewModel
import io.clawdroid.feature.chat.SettingsViewModel import io.clawdroid.feature.chat.SettingsViewModel
import io.clawdroid.feature.chat.voice.SpeechRecognizerWrapper import io.clawdroid.feature.chat.voice.SpeechRecognizerWrapper
import io.clawdroid.feature.chat.voice.TextToSpeechWrapper import io.clawdroid.feature.chat.voice.TextToSpeechWrapper
import io.clawdroid.feature.chat.voice.CameraCaptureManager import io.clawdroid.feature.chat.voice.CameraCaptureManager
import io.clawdroid.feature.chat.voice.VoiceModeManager import io.clawdroid.feature.chat.voice.VoiceModeManager
import io.clawdroid.settings.AppSettingsViewModel
import io.clawdroid.settings.GatewaySettingsStoreImpl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -44,6 +47,9 @@ val appModule = module {
// CoroutineScope // CoroutineScope
single { CoroutineScope(SupervisorJob() + Dispatchers.IO) } single { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
// GatewaySettingsStore
single<GatewaySettingsStore> { GatewaySettingsStoreImpl(androidContext(), get()) }
// Room // Room
single { single {
Room.databaseBuilder( Room.databaseBuilder(
@ -72,7 +78,11 @@ val appModule = module {
val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also {
prefs.edit().putString("client_id", it).apply() 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 // ImageFileStorage
@ -124,4 +134,5 @@ val appModule = module {
// ViewModel // ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(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 wsPort: Int = 18793,
val httpPort: Int = 18790, val httpPort: Int = 18790,
val apiKey: String = "", 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 package io.clawdroid.backend.config
import io.clawdroid.backend.api.GatewaySettingsStore
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
@ -41,8 +43,12 @@ data class SaveConfigResult(
val error: String? = null, val error: String? = null,
) )
class ConfigApiClient : Closeable { class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable {
private val baseUrl: String get() = "http://127.0.0.1:18790" private val baseUrl: String
get() = settingsStore.settings.value.httpBaseUrl
private val apiKey: String
get() = settingsStore.settings.value.apiKey
private val client = HttpClient(OkHttp) { private val client = HttpClient(OkHttp) {
install(ContentNegotiation) { install(ContentNegotiation) {
@ -51,17 +57,22 @@ class ConfigApiClient : Closeable {
} }
suspend fun getSchema(): ConfigSchema { 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 { 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 { suspend fun saveConfig(config: JsonObject): SaveConfigResult {
return client.put("$baseUrl/api/config") { return client.put("$baseUrl/api/config") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(config) setBody(config)
if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey")
}.ensureSuccess().body() }.ensureSuccess().body()
} }

View file

@ -6,6 +6,6 @@ import org.koin.core.module.dsl.withOptions
import org.koin.dsl.module import org.koin.dsl.module
val configModule = module { val configModule = module {
single { ConfigApiClient() } withOptions { callbacks = Callbacks(onClose = { it?.close() }) } single { ConfigApiClient(get()) } withOptions { callbacks = Callbacks(onClose = { it?.close() }) }
viewModel { ConfigViewModel(get()) } viewModel { ConfigViewModel(get()) }
} }

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.net.URLEncoder
class WebSocketClient( class WebSocketClient(
private val client: HttpClient, private val client: HttpClient,
@ -40,7 +41,8 @@ class WebSocketClient(
private var connectJob: Job? = null private var connectJob: Job? = null
private val json = Json { ignoreUnknownKeys = true } 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() { fun connect() {
if (connectJob?.isActive == true) return if (connectJob?.isActive == true) return
@ -49,7 +51,12 @@ class WebSocketClient(
while (isActive) { while (isActive) {
try { try {
_connectionState.value = ConnectionState.CONNECTING _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) { client.webSocket(url) {
session = this session = this
_connectionState.value = ConnectionState.CONNECTED _connectionState.value = ConnectionState.CONNECTED