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:
parent
908c1e051b
commit
790cd6a411
8 changed files with 116 additions and 11 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue