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