From 4d5402946df2d485a35ae0f53d2fe45667d3aa3f Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 16:25:25 -0500 Subject: [PATCH] Android: app shell + Custom Settings engine + Home Assistant integration Generic settings architecture (data-driven, no per-setting UI code): - SettingDefinition (id/name/description/category/type/default/min/max/ choices/editable/sensitive/requiresRestart/visibleIf) x 8 types: boolean/string/number/select/multi-select/color/url/secret - SettingsManager: validation, reset, custom CRUD, export/import (all-or-nothing with per-key rejection reasons; custom definitions travel in the export), secrets routed to EncryptedSharedPreferences (AndroidKeyStore master key) and excluded from export by default - Settings screen renders controls from the type; search; add/edit/delete dialogs for custom settings; import/export dialogs - Built-ins: General / Home Assistant / Appearance / Network / Advanced Home Assistant integration (official REST + WebSocket APIs only): - HomeAssistantClient: GET /api/, /api/states, /api/states/{id}, POST /api/services/{domain}/{service}, WS /api/websocket (auth -> subscribe_events state_changed) with exponential-backoff reconnect; safe HaError types that never contain the token - HaRepository: single service layer between UI and client; optimistic toggles reconciled by WS; poll fallback from refresh-interval setting - Devices screen: discovery list, search, live states, toggles (light/switch/fan/input_boolean/humidifier) - Cleartext permitted for LAN http:// URLs (same stance as the official HA companion app; TLS verification untouched); consent dialog on first launch; Record button present but inert until M4 (honest label) - Tests: 29 unit tests (19 settings incl. secret routing, import validation, persistence; 10 HA via MockWebServer incl. 401 handling, unreachable, WS handshake + event + auth_invalid). All green. Docs: docs/home-assistant.md (install, token creation, storage, troubleshooting, endpoint table); README + ROADMAP updated honestly (live e2e against a real HA server still in progress). --- README.md | 16 +- android/app/build.gradle.kts | 73 +++ android/app/src/main/AndroidManifest.xml | 36 ++ .../src/main/java/com/shonar/MainActivity.kt | 40 ++ .../main/java/com/shonar/ShonarApplication.kt | 20 + .../main/java/com/shonar/ha/HaRepository.kt | 194 +++++++ .../java/com/shonar/ha/HomeAssistantClient.kt | 260 +++++++++ .../java/com/shonar/ha/HomeAssistantEntity.kt | 37 ++ .../com/shonar/settings/BuiltInSettings.kt | 144 +++++ .../com/shonar/settings/SettingDefinition.kt | 37 ++ .../com/shonar/settings/SettingsManager.kt | 333 +++++++++++ .../java/com/shonar/settings/SettingsStore.kt | 110 ++++ .../com/shonar/ui/devices/DevicesScreen.kt | 166 ++++++ .../java/com/shonar/ui/devices/HaViewModel.kt | 55 ++ .../java/com/shonar/ui/home/HomeScreen.kt | 151 +++++ .../com/shonar/ui/settings/SettingsScreen.kt | 539 ++++++++++++++++++ .../shonar/ui/settings/SettingsViewModel.kt | 126 ++++ .../main/java/com/shonar/ui/theme/Theme.kt | 53 ++ .../res/drawable/ic_launcher_foreground.xml | 18 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + android/app/src/main/res/values/colors.xml | 3 + android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/themes.xml | 3 + .../main/res/xml/network_security_config.xml | 17 + .../com/shonar/HomeAssistantClientTest.kt | 207 +++++++ .../java/com/shonar/SettingsManagerTest.kt | 260 +++++++++ android/build.gradle.kts | 6 + android/gradle.properties | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 251 ++++++++ android/gradlew.bat | 94 +++ android/settings.gradle.kts | 17 + docs/ROADMAP.md | 2 + docs/home-assistant.md | 106 ++++ 35 files changed, 3389 insertions(+), 4 deletions(-) create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/shonar/MainActivity.kt create mode 100644 android/app/src/main/java/com/shonar/ShonarApplication.kt create mode 100644 android/app/src/main/java/com/shonar/ha/HaRepository.kt create mode 100644 android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt create mode 100644 android/app/src/main/java/com/shonar/ha/HomeAssistantEntity.kt create mode 100644 android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt create mode 100644 android/app/src/main/java/com/shonar/settings/SettingDefinition.kt create mode 100644 android/app/src/main/java/com/shonar/settings/SettingsManager.kt create mode 100644 android/app/src/main/java/com/shonar/settings/SettingsStore.kt create mode 100644 android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/devices/HaViewModel.kt create mode 100644 android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt create mode 100644 android/app/src/main/java/com/shonar/ui/theme/Theme.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt create mode 100644 android/app/src/test/java/com/shonar/SettingsManagerTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 docs/home-assistant.md diff --git a/README.md b/README.md index 043a97e..fc38bb2 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,18 @@ adb install app/build/outputs/apk/debug/app-debug.apk S.H.O.N.A.R. is developed in milestones; each merged milestone is tested and buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix. -**Current state:** M0–M1 complete — backend health, full auth (register, -login, rotating refresh tokens with reuse detection, logout, delete-account), -rate limiting, Alembic schema, Docker dev stack. Everything else is -in-progress or TODO as per the roadmap. +**Current state:** + +- Backend: M0–M2 complete — health, full auth (register, login, rotating + refresh tokens with reuse detection, logout, delete-account), rate + limiting, chunked resumable uploads, recordings CRUD, storage + abstraction, Postgres FTS schema, Docker dev stack. +- Android: app shell with a generic data-driven **Custom Settings** system + (8 value types, add/edit/delete/reset/search/export/import), and a full + **Home Assistant** integration — connect, test, entity discovery, live + WebSocket state updates, service calls (toggles), encrypted token + storage. See [docs/home-assistant.md](docs/home-assistant.md). +- Recording engine, SHONAR sync, playback, AI pipeline: TODO per roadmap. ## AI providers diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..18e4c90 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "com.shonar" + compileSdk = 35 + + defaultConfig { + applicationId = "com.shonar" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.4") + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + // persistence + implementation("androidx.datastore:datastore-preferences:1.1.1") + // keystore-backed secrets (Home Assistant tokens etc.) + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // networking (Home Assistant REST + WebSocket; later SHONAR API) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + // tests + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + androidTestImplementation(composeBom) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c20ddfd --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/shonar/MainActivity.kt b/android/app/src/main/java/com/shonar/MainActivity.kt new file mode 100644 index 0000000..11c257b --- /dev/null +++ b/android/app/src/main/java/com/shonar/MainActivity.kt @@ -0,0 +1,40 @@ +package com.shonar + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.shonar.ui.devices.DevicesScreen +import com.shonar.ui.home.HomeScreen +import com.shonar.ui.settings.SettingsScreen +import com.shonar.ui.theme.ShonarTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val app = application as ShonarApplication + setContent { + ShonarTheme { + val nav = rememberNavController() + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + NavHost(navController = nav, startDestination = "home") { + composable("home") { HomeScreen(onOpenDevices = { nav.navigate("devices") }, onOpenSettings = { nav.navigate("settings") }) } + composable("devices") { DevicesScreen() } + composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt new file mode 100644 index 0000000..e0eb418 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt @@ -0,0 +1,20 @@ +package com.shonar + +import android.app.Application +import com.shonar.ha.HaRepository +import com.shonar.settings.DataStoreSettingsStore +import com.shonar.settings.SecureSettingsStore +import com.shonar.settings.SettingsManager + +class ShonarApplication : Application() { + + val settingsManager: SettingsManager by lazy { + SettingsManager( + store = DataStoreSettingsStore(this), + secureStore = SecureSettingsStore(this), + ) + } + + /** Single HA integration instance; the UI talks only to this. */ + val haRepository: HaRepository by lazy { HaRepository(settingsManager) } +} diff --git a/android/app/src/main/java/com/shonar/ha/HaRepository.kt b/android/app/src/main/java/com/shonar/ha/HaRepository.kt new file mode 100644 index 0000000..ed8eca5 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ha/HaRepository.kt @@ -0,0 +1,194 @@ +package com.shonar.ha + +import com.shonar.settings.BuiltInSettings +import com.shonar.settings.SettingsManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class HaConnectionStatus { DISABLED, NOT_CONFIGURED, CONNECTING, CONNECTED, ERROR } + +data class HaSnapshot( + val status: HaConnectionStatus = HaConnectionStatus.DISABLED, + val entities: List = emptyList(), + val errorMessage: String? = null, + val lastUpdated: Long = 0L, +) + +/** + * The ONLY path between the UI and Home Assistant. Reads its configuration + * from the generic [SettingsManager] (url/token/enabled/refresh interval), + * exposes a state snapshot, and applies optimistic local updates on service + * calls so the UI feels instant; the WebSocket stream reconciles afterwards. + */ +class HaRepository( + private val settings: SettingsManager, +) { + private val snapshotFlow = MutableStateFlow(HaSnapshot()) + val snapshot: StateFlow = snapshotFlow.asStateFlow() + + @Volatile private var client: HomeAssistantClient? = null + @Volatile private var configFingerprint: String? = null + + /** (Re)build the client from current settings. Returns false if not usable. */ + private suspend fun clientOrNull(): HomeAssistantClient? { + if (!settings.bool(BuiltInSettings.HA_ENABLED)) { + snapshotFlow.value = snapshotFlow.value.copy(status = HaConnectionStatus.DISABLED) + return null + } + val url = settings.string(BuiltInSettings.HA_URL) + val token = settings.string(BuiltInSettings.HA_TOKEN) + if (url.isBlank() || token.isBlank()) { + snapshotFlow.value = snapshotFlow.value.copy( + status = HaConnectionStatus.NOT_CONFIGURED, + errorMessage = "Set the Home Assistant URL and token in Settings.", + ) + return null + } + val fingerprint = "$url|$token" + if (fingerprint != configFingerprint) { + client = HomeAssistantClient(url, token) + configFingerprint = fingerprint + } + return client + } + + /** Explicit "Test connection" from Settings. */ + suspend fun testConnection(): Result = withContext(Dispatchers.IO) { + val c = clientOrNull() ?: return@withContext Result.failure( + HaError.NotConfigured() + ) + runCatching { c.fetchConfig() } + .onSuccess { cfg -> + snapshotFlow.value = snapshotFlow.value.copy( + status = HaConnectionStatus.CONNECTED, errorMessage = null + ) + } + .onFailure { e -> + snapshotFlow.value = snapshotFlow.value.copy( + status = HaConnectionStatus.ERROR, + errorMessage = e.message, + ) + } + } + + suspend fun refreshEntities(): Result> = + withContext(Dispatchers.IO) { + val c = clientOrNull() ?: return@withContext Result.failure(HaError.NotConfigured()) + snapshotFlow.value = snapshotFlow.value.copy(status = HaConnectionStatus.CONNECTING) + runCatching { c.fetchStates() } + .onSuccess { list -> + snapshotFlow.value = HaSnapshot( + status = HaConnectionStatus.CONNECTED, + entities = list.sortedBy { it.label.lowercase() }, + lastUpdated = System.currentTimeMillis(), + ) + } + .onFailure { e -> + snapshotFlow.value = snapshotFlow.value.copy( + status = HaConnectionStatus.ERROR, + errorMessage = e.message, + ) + } + } + + /** + * Call a HA service. [entityId] is optional convenience: it is inserted + * as entity_id service data (the pattern almost all device services use). + */ + suspend fun callService( + domain: String, + service: String, + entityId: String? = null, + data: Map = emptyMap(), + ): Result = withContext(Dispatchers.IO) { + val c = clientOrNull() ?: return@withContext Result.failure(HaError.NotConfigured()) + val payload = buildMap { + putAll(data) + if (entityId != null) put("entity_id", entityId) + } + runCatching { c.callService(domain, service, payload) } + .onSuccess { + // Optimistic flip; WS/poll will reconcile the true value. + if (entityId != null && service in setOf("turn_on", "turn_off", "toggle")) { + val nowOn = service != "turn_off" + snapshotFlow.update { snap -> + snap.copy( + entities = snap.entities.map { + if (it.entityId == entityId) + it.copy(state = if (nowOn) "on" else "off") else it + } + ) + } + } + } + .onFailure { e -> + snapshotFlow.update { it.copy(errorMessage = e.message) } + } + } + + /** + * Keep entities fresh: WebSocket while healthy; if WS dies repeatedly the + * poll fallback below keeps the UI updated at the configured interval. + */ + fun startAutoRefresh(scope: kotlinx.coroutines.CoroutineScope) { + scope.launch { + settings.valuesChanged.collectLatest { + // settings changed: force reconnect/refresh + configFingerprint = null + refreshEntities() + } + } + scope.launch { + while (true) { + val interval = runCatching { + settings.double(BuiltInSettings.HA_REFRESH_INTERVAL).toLong() + }.getOrDefault(10L).coerceIn(1L, 300L) + if (settings.bool(BuiltInSettings.HA_ENABLED) && + snapshotFlow.value.status != HaConnectionStatus.CONNECTED + ) { + refreshEntities() + } + delay(interval * 1000) + } + } + } + + /** Live stream of changed entities (WS). Merge into the snapshot. */ + fun observeStateChanges(scope: kotlinx.coroutines.CoroutineScope) { + scope.launch { + while (true) { + val c = clientOrNull() ?: break + try { + c.stateChangeEvents().collect { changed -> + snapshotFlow.update { snap -> + if (snap.status != HaConnectionStatus.CONNECTED) snap + else snap.copy( + entities = snap.entities.map { + if (it.entityId == changed.entityId) changed else it + } + ) + } + } + } catch (_: Exception) { + delay(5_000) // WS reconnect backoff at repo level too + } + } + } + } + + private inline fun MutableStateFlow.update(block: (T) -> T) { + while (true) { + val prev = value + val next = block(prev) + if (compareAndSet(prev, next)) return + } + } +} + diff --git a/android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt b/android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt new file mode 100644 index 0000000..a7b7041 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt @@ -0,0 +1,260 @@ +package com.shonar.ha + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive +private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject + +/** Errors surfaced to the UI. Messages are safe: never contain the token. */ +sealed class HaError(message: String) : Exception(message) { + class NotConfigured : HaError("Home Assistant is not configured. Set the URL and token in Settings.") + class Unauthorized : HaError("Authentication failed. Check the long-lived access token in Settings.") + class Unreachable(val detail: String) : HaError("Cannot reach the Home Assistant server ($detail).") + class Unexpected(val code: Int) : HaError("Home Assistant returned an unexpected response (HTTP $code).") +} + +/** + * Minimal official-API client. REST under /api plus WebSocket under + * /api/websocket. Endpoints used: + * GET /api/ -> config (connection test) + * GET /api/states -> all entities + * GET /api/states/{entity_id} -> one entity + * POST /api/services/{domain}/{service} -> call service + * WS /api/websocket -> auth + subscribe_events(state_changed) + * No unofficial APIs, no wrappers. + */ +class HomeAssistantClient( + baseUrl: String, + private val token: String, + private val baseHttp: OkHttpClient = defaultHttp(), +) { + private val json = Json { ignoreUnknownKeys = true } + private val http = baseHttp.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** normalized without trailing slash */ + private val url: String = baseUrl.trim().trimEnd('/') + + private fun request(path: String, body: String? = null, post: Boolean = false): Request { + val builder = Request.Builder() + .url("$url$path") + .header("Authorization", "Bearer $token") // never logged + .header("Accept", "application/json") + if (post) { + builder.post((body ?: "{}").toRequestBody(JSON_TYPE)) + } + return builder.build() + } + + private fun guard(block: () -> T): T = + try { + block() + } catch (e: HaError) { + throw e + } catch (e: java.io.IOException) { + // IOException messages contain host/port only — no credentials. + throw HaError.Unreachable(e.message?.take(120) ?: "network error") + } + + /** Connection test: GET /api/ returns config when the token is valid. */ + fun fetchConfig(): HaConfig = guard { + http.newCall(request("/api/")).execute().use { resp -> + when (resp.code) { + 200 -> json.decodeFromString(resp.body?.string() ?: "{}") + 401, 403 -> throw HaError.Unauthorized() + else -> throw HaError.Unexpected(resp.code) + } + } + } + + fun fetchStates(): List = guard { + http.newCall(request("/api/states")).execute().use { resp -> + when (resp.code) { + 200 -> json.decodeFromString>(resp.body?.string() ?: "[]") + 401, 403 -> throw HaError.Unauthorized() + else -> throw HaError.Unexpected(resp.code) + } + } + } + + fun fetchState(entityId: String): HomeAssistantEntity = guard { + http.newCall(request("/api/states/$entityId")).execute().use { resp -> + when (resp.code) { + 200 -> json.decodeFromString(resp.body?.string() ?: "{}") + 401, 403 -> throw HaError.Unauthorized() + 404 -> throw HaError.Unexpected(404) + else -> throw HaError.Unexpected(resp.code) + } + } + } + + /** + * POST /api/services/{domain}/{service} with optional JSON data. + * Returns the list of affected entities on success. + */ + fun callService(domain: String, service: String, data: Map = emptyMap()): Unit = guard { + val payload = buildServiceJson(data).toString() + http.newCall(request("/api/services/$domain/$service", payload, post = true)).execute().use { resp -> + when (resp.code) { + in 200..299 -> Unit + 401, 403 -> throw HaError.Unauthorized() + else -> { + val detail = runCatching { + json.parseToJsonElement(resp.body?.string() ?: "") + .jsonObjectOrNull()?.get("message")?.jsonPrimitiveOrNull()?.content + }.getOrNull().orEmpty() + if (detail.contains("Unauthorized", ignoreCase = true)) throw HaError.Unauthorized() + throw HaError.Unexpected(resp.code) + } + } + } + } + + // --- WebSocket: live state_changed events --------------------------------- + + /** + * Live state updates over /api/websocket with auto-reconnect (exponential + * backoff up to 60s). Emits changed entities. Completes only when the + * collector is cancelled. + */ + fun stateChangeEvents(): Flow = callbackFlow { + val backoffMs = AtomicInteger(2_000) + var socket: WebSocket? = null + var closed = false + val msgId = AtomicInteger(1) + + fun wsUrl(): String = + url.replaceFirst("http", "ws") + "/api/websocket" + + fun connect() { + if (closed) return + socket = http.newWebSocket( + Request.Builder().url(wsUrl()).build(), + object : WebSocketListener() { + + override fun onOpen(webSocket: WebSocket, response: Response) { + // HA greets with auth_required; we answer with the token. + // (If the greeting was already consumed we send anyway — + // HA ignores stray auth messages before auth_ok.) + webSocket.send( + buildJsonObject { + put("type", "auth") + put("access_token", token) + }.toString() + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val obj = runCatching { json.parseToJsonElement(text).jsonObject } + .getOrNull() ?: return + val type = obj["type"]?.jsonPrimitiveOrNull()?.content + when (type) { + "auth_ok" -> { + backoffMs.set(2_000) + webSocket.send( + buildJsonObject { + put("id", msgId.getAndIncrement()) + put("type", "subscribe_events") + put("event_type", "state_changed") + }.toString() + ) + } + "auth_invalid" -> { + // Auth will never succeed: drop the socket + // hard, surface the error, stop reconnecting. + closed = true + webSocket.cancel() + close(HaError.Unauthorized()) + } + "event" -> { + val newState = obj["event"]?.jsonObjectOrNull() + ?.get("data")?.jsonObjectOrNull() + ?.get("new_state")?.toString() ?: return + decodeEntity(newState)?.let { trySend(it) } + } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + scheduleReconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + scheduleReconnect() + } + + private fun scheduleReconnect() { + if (closed) return + val delay = backoffMs.get().toLong() + backoffMs.set((backoffMs.get() * 2).coerceAtMost(60_000)) + RECONNECT_EXECUTOR.schedule({ if (!closed) connect() }, delay, TimeUnit.MILLISECONDS) + } + } + ) + } + + connect() + awaitClose { + closed = true + socket?.close(1000, "client gone") + } + } + + private fun decodeEntity(raw: String): HomeAssistantEntity? = runCatching { + json.decodeFromString(HomeAssistantEntity.serializer(), raw) + }.getOrNull() + + companion object { + private val JSON_TYPE = "application/json; charset=utf-8".toMediaType() + + /** Convert loose Kotlin service data to typed JSON (numbers stay + * numbers; HA service data is type-sensitive). */ + internal fun toJsonElement(v: Any?): JsonElement = when (v) { + null -> JsonNull + is JsonElement -> v + is Boolean -> JsonPrimitive(v) + is Number -> JsonPrimitive(v) + is String -> JsonPrimitive(v) + is Map<*, *> -> buildJsonObject { + v.forEach { (k, value) -> put(k.toString(), toJsonElement(value)) } + } + is Iterable<*> -> buildJsonArray { v.forEach { add(toJsonElement(it)) } } + else -> JsonPrimitive(v.toString()) + } + + internal fun buildServiceJson(data: Map): JsonObject = + toJsonElement(data) as JsonObject + + private val RECONNECT_EXECUTOR = + java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "ha-ws-reconnect").apply { isDaemon = true } + } + + /** Default client: TLS verification ON (no trust-all anywhere). */ + fun defaultHttp(): OkHttpClient = + OkHttpClient.Builder().build() + } +} diff --git a/android/app/src/main/java/com/shonar/ha/HomeAssistantEntity.kt b/android/app/src/main/java/com/shonar/ha/HomeAssistantEntity.kt new file mode 100644 index 0000000..bc1e585 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ha/HomeAssistantEntity.kt @@ -0,0 +1,37 @@ +package com.shonar.ha + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Home Assistant entity, as returned by GET /api/states (snake_case on the + * wire — mapped with @SerialName). + */ +@Serializable +data class HomeAssistantEntity( + @SerialName("entity_id") val entityId: String, + val state: String, + val attributes: Map = emptyMap(), + @SerialName("last_changed") val lastChanged: String? = null, + @SerialName("last_updated") val lastUpdated: String? = null, +) { + val domain: String get() = entityId.substringBefore('.', "") + + /** UI-friendly label from attributes.friendly_name when present. */ + val label: String + get() = (attributes["friendly_name"] + as? kotlinx.serialization.json.JsonPrimitive)?.content ?: entityId + + val isOn: Boolean + get() = state.lowercase() in ON_STATES + + companion object { + private val ON_STATES = setOf("on", "open", "active", "home", "true", "locked") + } +} + +@Serializable +data class HaConfig( + @SerialName("location_name") val locationName: String? = null, + val version: String? = null, +) diff --git a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt new file mode 100644 index 0000000..ff5229a --- /dev/null +++ b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt @@ -0,0 +1,144 @@ +package com.shonar.settings + +/** + * Built-in settings. These ship with the app; users may add more at runtime. + * Home Assistant connection settings live here like any other category — + * proving the generic architecture carries a real integration. + */ +object BuiltInSettings { + + const val CAT_GENERAL = "General" + const val CAT_HOME_ASSISTANT = "Home Assistant" + const val CAT_APPEARANCE = "Appearance" + const val CAT_NETWORK = "Network" + const val CAT_ADVANCED = "Advanced" + + // ids other code depends on (single source of truth) + const val HA_URL = "home_assistant_url" + const val HA_TOKEN = "home_assistant_token" + const val HA_ENABLED = "home_assistant_enabled" + const val HA_REFRESH_INTERVAL = "home_assistant_refresh_interval" + const val CONSENT = "consent_notice_seen" + + val all: List = listOf( + // --- Home Assistant --------------------------------------------- + SettingDefinition( + id = HA_ENABLED, + name = "Enable Home Assistant", + description = "Connect to a local Home Assistant server.", + category = CAT_HOME_ASSISTANT, + type = SettingType.BOOLEAN, + defaultJson = "false", + ), + SettingDefinition( + id = HA_URL, + name = "Home Assistant URL", + description = "URL of the local Home Assistant server.", + category = CAT_HOME_ASSISTANT, + type = SettingType.URL, + defaultJson = "\"" + "http://homeassistant.local:8123\"", + visibleIfSettingId = HA_ENABLED, + ), + SettingDefinition( + id = HA_TOKEN, + name = "Long-lived access token", + description = "Create one in Home Assistant: profile picture -> " + + "Security -> Long-lived access token. Stored encrypted on device.", + category = CAT_HOME_ASSISTANT, + type = SettingType.SECRET, + defaultJson = "\"\"", + sensitive = true, + visibleIfSettingId = HA_ENABLED, + ), + SettingDefinition( + id = HA_REFRESH_INTERVAL, + name = "State refresh interval", + description = "Fallback poll interval in seconds when the live " + + "WebSocket connection is down.", + category = CAT_HOME_ASSISTANT, + type = SettingType.NUMBER, + defaultJson = "10", + min = 1.0, + max = 300.0, + visibleIfSettingId = HA_ENABLED, + ), + // --- General ------------------------------------------------------- + SettingDefinition( + id = "default_recording_title_format", + name = "Default recording title", + description = "Used for new recordings until you rename them.", + category = CAT_GENERAL, + type = SettingType.SELECT, + choices = listOf("Date and time", "Recording ", "Untitled"), + defaultJson = "\"Date and time\"", + ), + SettingDefinition( + id = "consent_notice_seen", + name = "Recording consent notice acknowledged", + description = "You confirmed you will comply with local " + + "recording-consent laws.", + category = CAT_GENERAL, + type = SettingType.BOOLEAN, + defaultJson = "false", + editable = true, + ), + // --- Appearance ------------------------------------------------------- + SettingDefinition( + id = "theme_mode", + name = "Theme", + description = "Dark, light, or follow the system.", + category = CAT_APPEARANCE, + type = SettingType.SELECT, + choices = listOf("System", "Light", "Dark"), + defaultJson = "\"System\"", + ), + SettingDefinition( + id = "accent_color", + name = "Accent color", + category = CAT_APPEARANCE, + type = SettingType.COLOR, + defaultJson = "\"#4FD1C5\"", + ), + // --- Network ------------------------------------------------------------- + SettingDefinition( + id = "server_url", + name = "SHONAR server URL", + description = "Your self-hosted SHONAR backend.", + category = CAT_NETWORK, + type = SettingType.URL, + defaultJson = "\"\"", + ), + SettingDefinition( + id = "wifi_only_upload", + name = "Upload on Wi-Fi only", + category = CAT_NETWORK, + type = SettingType.BOOLEAN, + defaultJson = "true", + ), + SettingDefinition( + id = "charging_only_upload", + name = "Upload only while charging", + category = CAT_NETWORK, + type = SettingType.BOOLEAN, + defaultJson = "false", + ), + // --- Advanced ------------------------------------------------------------ + SettingDefinition( + id = "log_http_bodies", + name = "Log request/response bodies (debug)", + description = "Never enabled by default. May write sensitive data " + + "to logcat; for debugging only.", + category = CAT_ADVANCED, + type = SettingType.BOOLEAN, + defaultJson = "false", + ), + SettingDefinition( + id = "app_version", + name = "App version", + category = CAT_ADVANCED, + type = SettingType.STRING, + defaultJson = "\"0.1.0\"", + editable = false, + ), + ) +} diff --git a/android/app/src/main/java/com/shonar/settings/SettingDefinition.kt b/android/app/src/main/java/com/shonar/settings/SettingDefinition.kt new file mode 100644 index 0000000..275adbb --- /dev/null +++ b/android/app/src/main/java/com/shonar/settings/SettingDefinition.kt @@ -0,0 +1,37 @@ +package com.shonar.settings + +import kotlinx.serialization.Serializable + +/** All setting value types the app can render and validate. */ +@Serializable +enum class SettingType { + BOOLEAN, STRING, NUMBER, SELECT, MULTI_SELECT, COLOR, URL, SECRET +} + +/** + * A single settings entry. Definitions may come from the built-in registry + * or be created by the user at runtime (custom settings) — no code changes + * required to add one. + */ +@Serializable +data class SettingDefinition( + val id: String, + val name: String, + val description: String = "", + val category: String, + val type: SettingType, + val defaultJson: String, // value encoded as JSON per type + val min: Double? = null, // NUMBER + val max: Double? = null, // NUMBER + val choices: List = emptyList(), // SELECT / MULTI_SELECT + val editable: Boolean = true, // false = informational only + val sensitive: Boolean = false, // stored in secure storage, masked UI + val requiresRestart: Boolean = false, + /** Simple conditional visibility: id of another boolean setting. */ + val visibleIfSettingId: String? = null, + /** True when the user created this (vs built-in); user settings can be deleted. */ + val custom: Boolean = false, +) + +/** Validation failure with a user-facing message. */ +class SettingValidationException(message: String) : Exception(message) diff --git a/android/app/src/main/java/com/shonar/settings/SettingsManager.kt b/android/app/src/main/java/com/shonar/settings/SettingsManager.kt new file mode 100644 index 0000000..00c528b --- /dev/null +++ b/android/app/src/main/java/com/shonar/settings/SettingsManager.kt @@ -0,0 +1,333 @@ +package com.shonar.settings + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** Result of importing settings: applied ids + rejected entries with reasons. */ +data class ImportResult(val applied: List, val rejected: Map) + +/** + * Central settings engine. Definitions are data, not code: built-ins come + * from [BuiltInSettings], user-created definitions persist alongside values. + * + * Values are stored as JSON strings (per [SettingDefinition.type]). + * Sensitive values go to [secureStore]; everything else to [store]. + */ +class SettingsManager( + private val store: SettingsStore, + private val secureStore: SettingsStore, +) { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val definitionsFlow = MutableStateFlow>(emptyList()) + val definitions: StateFlow> = definitionsFlow.asStateFlow() + + /** Bumped on any value change so ViewModels can re-read. */ + private val valuesVersion = MutableStateFlow(0L) + val valuesChanged: StateFlow = valuesVersion.asStateFlow() + + private var loaded = false + + suspend fun ensureLoaded() { + if (loaded) return + loaded = true + val builtIns = BuiltInSettings.all + val customJson = store.getString(CUSTOM_DEFS_KEY) + val customs = customJson?.let { + runCatching { json.decodeFromString>(it) }.getOrDefault(emptyList()) + }.orEmpty() + // Built-ins win on id collision; custom defs can shadow defaults only + // for their own ids. + definitionsFlow.value = builtIns + customs.filter { c -> builtIns.none { it.id == c.id } } + } + + private fun storeFor(def: SettingDefinition): SettingsStore = + if (def.sensitive) secureStore else store + + suspend fun getString(id: String): String? { + val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return null + return storeFor(def).getString(valueKey(id)) + } + + /** Typed read with default fallback. */ + suspend fun value(id: String): JsonElement? { + val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return null + val raw = storeFor(def).getString(valueKey(id)) ?: return json.parseToJsonElement(def.defaultJson) + return runCatching { json.parseToJsonElement(raw) }.getOrElse { + json.parseToJsonElement(def.defaultJson) + } + } + + suspend fun bool(id: String): Boolean = + (value(id) as? kotlinx.serialization.json.JsonPrimitive)?.booleanOrNull ?: false + + suspend fun string(id: String): String = + (value(id) as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + + suspend fun double(id: String): Double = + (value(id) as? kotlinx.serialization.json.JsonPrimitive)?.doubleOrNull ?: 0.0 + + suspend fun stringList(id: String): List = + runCatching { value(id)?.jsonArray?.map { it.jsonPrimitive.content } }.getOrNull() + ?: emptyList() + + /** Validate + persist. Throws [SettingValidationException] on bad input. */ + suspend fun setValue(id: String, jsonValue: String) { + val def = definitionsFlow.value.firstOrNull { it.id == id } + ?: throw SettingValidationException("Unknown setting: $id") + if (!def.editable) throw SettingValidationException("This setting is not editable.") + validate(def, jsonValue) + storeFor(def).putString(valueKey(id), jsonValue) + valuesVersion.value += 1 + } + + suspend fun reset(id: String) { + val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return + if (!def.editable) throw SettingValidationException("This setting is not editable.") + storeFor(def).remove(valueKey(id)) + valuesVersion.value += 1 + } + + fun validate(def: SettingDefinition, jsonValue: String) { + val el = runCatching { json.parseToJsonElement(jsonValue) } + .getOrElse { throw SettingValidationException("Invalid value encoding.") } + val p = el as? kotlinx.serialization.json.JsonPrimitive + when (def.type) { + SettingType.BOOLEAN -> { + if (p == null || p.booleanOrNull == null) + throw SettingValidationException("Expected true or false.") + } + SettingType.STRING, SettingType.SECRET -> { + if (p == null || !p.isString) + throw SettingValidationException("Expected text.") + if (def.type == SettingType.SECRET && p.content.isBlank()) + throw SettingValidationException("Value cannot be empty.") + } + SettingType.NUMBER -> { + val d = p?.doubleOrNull + ?: throw SettingValidationException("Expected a number.") + if (def.min != null && d < def.min) + throw SettingValidationException("Must be at least ${fmt(def.min)}.") + if (def.max != null && d > def.max) + throw SettingValidationException("Must be at most ${fmt(def.max)}.") + } + SettingType.SELECT -> { + val v = p?.content ?: throw SettingValidationException("Expected text.") + if (v !in def.choices) + throw SettingValidationException("Choose one of: ${def.choices.joinToString()}") + } + SettingType.MULTI_SELECT -> { + val items = runCatching { el.jsonArray.map { it.jsonPrimitive.content } } + .getOrElse { throw SettingValidationException("Expected a list.") } + val bad = items.filter { it !in def.choices } + if (bad.isNotEmpty()) + throw SettingValidationException("Unknown options: ${bad.joinToString()}") + } + SettingType.COLOR -> { + val v = p?.content ?: throw SettingValidationException("Expected a color.") + if (!COLOR_REGEX.matches(v)) + throw SettingValidationException("Use #RRGGBB or #AARRGGBB.") + } + SettingType.URL -> { + val v = p?.content ?: throw SettingValidationException("Expected a URL.") + validateUrlOrThrow(v) + } + } + } + + // --- custom setting CRUD ------------------------------------------------ + + suspend fun addCustom(def: SettingDefinition) { + validateId(def.id) + if (definitionsFlow.value.any { it.id == def.id }) + throw SettingValidationException("A setting with id \"${def.id}\" already exists.") + if (def.name.isBlank()) throw SettingValidationException("Display name is required.") + // Default must validate against the definition itself. + validate(def, def.defaultJson) + val updated = definitionsFlow.value + def.copy(custom = true, sensitive = def.sensitive) + persistDefinitions(updated) + } + + suspend fun updateCustom(def: SettingDefinition) { + val existing = definitionsFlow.value.firstOrNull { it.id == def.id } + ?: throw SettingValidationException("Setting not found.") + if (!existing.custom) throw SettingValidationException("Built-in settings cannot be edited.") + validate(def, def.defaultJson) + val updated = definitionsFlow.value.map { + if (it.id == def.id) def.copy(custom = true) else it + } + persistDefinitions(updated) + // Current value may no longer pass the new definition; drop it if not. + currentRaw(def.id)?.let { raw -> + runCatching { validate(def, raw) }.onFailure { reset(def.id) } + } + } + + suspend fun deleteCustom(id: String) { + val def = definitionsFlow.value.firstOrNull { it.id == id } + ?: throw SettingValidationException("Setting not found.") + if (!def.custom) throw SettingValidationException("Built-in settings cannot be deleted.") + storeFor(def).remove(valueKey(id)) + persistDefinitions(definitionsFlow.value.filterNot { it.id == id }) + } + + private suspend fun currentRaw(id: String): String? { + val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return null + return storeFor(def).getString(valueKey(id)) + } + + private suspend fun persistDefinitions(all: List) { + val customs = all.filter { it.custom } + store.putString(CUSTOM_DEFS_KEY, json.encodeToString(customs)) + definitionsFlow.value = BuiltInSettings.all + + customs.filter { c -> BuiltInSettings.all.none { it.id == c.id } } + valuesVersion.value += 1 + } + + // --- export / import ----------------------------------------------------- + + /** JSON export. Sensitive values are included ONLY when asked. */ + suspend fun exportJson(includeSecrets: Boolean): String { + val settingsObj = buildJsonObject { + for (def in definitionsFlow.value) { + val raw = if (def.sensitive && !includeSecrets) null + else storeFor(def).getString(valueKey(def.id)) + // Export only values the user actually set (differ from the + // default). Untouched built-ins and empty secrets stay out. + if (raw != null && raw != def.defaultJson) { + runCatching { put(def.id, json.parseToJsonElement(raw)) } + } + } + } + val customs = definitionsFlow.value.filter { it.custom } + val obj = buildJsonObject { + put("schemaVersion", 1) + put("app", "SHONAR") + put("customDefinitions", json.encodeToJsonElement(customs)) + put("settings", settingsObj) + } + return obj.toString() + } + + /** + * Import validates every entry against known definitions BEFORE + * applying any of them (all-or-nothing). Unknown ids are rejected, not + * silently created. + */ + suspend fun importJson(text: String): ImportResult { + val root = runCatching { json.parseToJsonElement(text).jsonObject } + .getOrElse { throw SettingValidationException("Not a valid settings JSON file.") } + if (root["app"]?.jsonPrimitive?.content != "SHONAR") + throw SettingValidationException("Not a SHONAR settings file.") + val settingsObj = root["settings"]?.jsonObject + ?: throw SettingValidationException("Missing \"settings\" object.") + + // Custom definitions travel with the export so imports work on a + // fresh install. Validate them first; unknown/invalid ones are + // reported, and their values (if any) are rejected with them. + val pendingDefs = mutableListOf() + val defsById = definitionsFlow.value.associateBy { it.id }.toMutableMap() + val rejected = mutableMapOf() + root["customDefinitions"]?.let { el -> + val defs = runCatching { + json.decodeFromJsonElement>(el) + }.getOrElse { emptyList() } + for (d in defs) { + runCatching { + if (!ID_REGEX.matches(d.id)) { + throw SettingValidationException("bad id") + } + validate(d, d.defaultJson) + }.onFailure { rejected["def.${d.id}"] = it.message ?: "invalid definition" } + .onSuccess { + if (d.id !in defsById) { + pendingDefs += d.copy(custom = true) + defsById[d.id] = d.copy(custom = true) + } + } + } + } + + val pending = mutableListOf>() + for ((id, el) in settingsObj) { + val def = defsById[id] + if (def == null) { + rejected[id] = "Unknown setting id" + continue + } + if (!def.editable) { + rejected[id] = "Not editable" + continue + } + val raw = el.toString() + runCatching { validate(def, raw) } + .onFailure { rejected[id] = it.message ?: "Invalid" } + .onSuccess { pending += def to raw } + } + if (rejected.isEmpty()) { + if (pendingDefs.isNotEmpty()) { + val merged = definitionsFlow.value + pendingDefs + store.putString(CUSTOM_DEFS_KEY, + json.encodeToString(merged.filter { it.custom })) + definitionsFlow.value = BuiltInSettings.all + merged.filter { it.custom } + } + pending.forEach { (def, raw) -> storeFor(def).putString(valueKey(def.id), raw) } + valuesVersion.value += 1 + } + return ImportResult( + applied = if (rejected.isEmpty()) pending.map { it.first.id } else emptyList(), + rejected = rejected, + ) + } + + private fun valueKey(id: String) = "value.$id" + + private fun validateId(id: String) { + if (!ID_REGEX.matches(id)) + throw SettingValidationException( + "id must be lowercase letters, digits and underscores (max 64)." + ) + } + + private fun fmt(d: Double): String = + if (d == d.toLong().toDouble()) d.toLong().toString() else d.toString() + + companion object { + const val CUSTOM_DEFS_KEY = "custom_definitions" + val ID_REGEX = Regex("^[a-z0-9_]{1,64}$") + val COLOR_REGEX = Regex("^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") + + /** URL validation: http(s) only, parseable host, no credentials, + * optional port. Rejects javascript:, file:, etc. */ + fun validateUrlOrThrow(v: String) { + val trimmed = v.trim().trimEnd('/') + if (trimmed.isBlank()) throw SettingValidationException("URL cannot be empty.") + val uri = runCatching { java.net.URI(trimmed) } + .getOrElse { throw SettingValidationException("Not a valid URL.") } + if (uri.scheme != "http" && uri.scheme != "https") + throw SettingValidationException("Only http and https URLs are allowed.") + if (uri.host.isNullOrBlank()) + throw SettingValidationException("URL must include a host.") + if (!uri.userInfo.isNullOrBlank()) + throw SettingValidationException("URL credentials are not allowed; use the token field.") + if (uri.port != -1 && (uri.port < 1 || uri.port > 65535)) + throw SettingValidationException("Port out of range.") + } + } +} diff --git a/android/app/src/main/java/com/shonar/settings/SettingsStore.kt b/android/app/src/main/java/com/shonar/settings/SettingsStore.kt new file mode 100644 index 0000000..ea6fb41 --- /dev/null +++ b/android/app/src/main/java/com/shonar/settings/SettingsStore.kt @@ -0,0 +1,110 @@ +package com.shonar.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Key/value persistence abstraction. Two implementations: + * - [DataStoreSettingsStore] for ordinary settings + * - [SecureSettingsStore] (EncryptedSharedPreferences, AndroidKeyStore- + * backed) for anything marked sensitive. Secrets never touch the + * unencrypted store. + */ +interface SettingsStore { + suspend fun getString(key: String): String? + suspend fun putString(key: String, value: String) + suspend fun remove(key: String) + suspend fun keys(): Set + + /** Emits whenever any value changes (for reactive UI). */ + val changes: StateFlow +} + +private val Context.settingsDataStore: DataStore by preferencesDataStore( + name = "shonar_settings" +) + +class DataStoreSettingsStore(private val context: Context) : SettingsStore { + private val changeFlow = MutableStateFlow(0L) + override val changes: StateFlow = changeFlow.asStateFlow() + + private val store get() = context.settingsDataStore + + override suspend fun getString(key: String): String? = + store.data.first()[stringPreferencesKey(key)] + + override suspend fun putString(key: String, value: String) { + store.edit { it[stringPreferencesKey(key)] = value } + changeFlow.value += 1 + } + + override suspend fun remove(key: String) { + store.edit { it.remove(stringPreferencesKey(key)) } + changeFlow.value += 1 + } + + override suspend fun keys(): Set = + store.data.first().asMap().keys.map { it.name }.toSet() +} + +class SecureSettingsStore(context: Context) : SettingsStore { + private val prefs by lazy { + val masterKey = MasterKey.Builder(context, "shonar_secrets_master") + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "shonar_secrets", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private val changeFlow = MutableStateFlow(0L) + override val changes: StateFlow = changeFlow.asStateFlow() + + override suspend fun getString(key: String): String? = prefs.getString(key, null) + + override suspend fun putString(key: String, value: String) { + prefs.edit().putString(key, value).apply() + changeFlow.value += 1 + } + + override suspend fun remove(key: String) { + prefs.edit().remove(key).apply() + changeFlow.value += 1 + } + + override suspend fun keys(): Set = prefs.all.keys +} + +/** In-memory store: used by unit tests. */ +class InMemorySettingsStore : SettingsStore { + private val map = mutableMapOf() + private val changeFlow = MutableStateFlow(0L) + override val changes: StateFlow = changeFlow.asStateFlow() + + override suspend fun getString(key: String): String? = map[key] + override suspend fun putString(key: String, value: String) { + map[key] = value + changeFlow.value += 1 + } + + override suspend fun remove(key: String) { + map.remove(key) + changeFlow.value += 1 + } + + override suspend fun keys(): Set = map.keys.toSet() +} diff --git a/android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt b/android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt new file mode 100644 index 0000000..fe47c74 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt @@ -0,0 +1,166 @@ +package com.shonar.ui.devices + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.shonar.ShonarApplication +import com.shonar.ha.HaConnectionStatus +import com.shonar.ha.HomeAssistantEntity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DevicesScreen() { + val app = LocalContext.current.applicationContext as ShonarApplication + val vm = remember { HaViewModel(app) } + val state by vm.state.collectAsState() + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("Devices") }, + actions = { + IconButton(onClick = vm::refresh) { + Icon(Icons.Filled.Refresh, contentDescription = "Refresh entities") + } + }, + ) + + when (state.snapshot.status) { + HaConnectionStatus.DISABLED, HaConnectionStatus.NOT_CONFIGURED -> StatusMessage( + "Home Assistant is not configured.", + "Enable it and set the URL + long-lived access token in Settings.", + ) + + HaConnectionStatus.CONNECTING -> if (state.snapshot.entities.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { CircularProgressIndicator() } + } else { + EntityList(state, vm) + } + + HaConnectionStatus.ERROR -> StatusMessage( + "Connection problem", + state.snapshot.errorMessage ?: "Unknown error", + retry = vm::refresh, + ) + + HaConnectionStatus.CONNECTED -> EntityList(state, vm) + } + } +} + +@Composable +private fun StatusMessage(title: String, body: String, retry: (() -> Unit)? = null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (retry != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = retry) { Text("Retry") } + } + } +} + +@Composable +private fun EntityList(state: DevicesUiState, vm: HaViewModel) { + OutlinedTextField( + value = state.search, + onValueChange = vm::setSearch, + placeholder = { Text("Search entities") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + state.actionError?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (state.snapshot.entities.isEmpty()) { + StatusMessage("No entities", "Nothing matched. Use refresh to reload.") + return + } + LazyColumn( + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.snapshot.entities, key = { it.entityId }) { entity -> + EntityCard(entity = entity, onToggle = { vm.toggle(entity.entityId, entity.isOn) }) + } + } +} + +private val SWITCHABLE_DOMAINS = setOf("light", "switch", "fan", "input_boolean", "humidifier") + +@Composable +private fun EntityCard(entity: HomeAssistantEntity, onToggle: () -> Unit) { + val switchable = entity.domain in SWITCHABLE_DOMAINS + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(entity.label, style = MaterialTheme.typography.bodyLarge) + Text( + entity.entityId + " • " + entity.state, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (switchable) { + Switch(checked = entity.isOn, onCheckedChange = { onToggle() }) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ui/devices/HaViewModel.kt b/android/app/src/main/java/com/shonar/ui/devices/HaViewModel.kt new file mode 100644 index 0000000..98b732c --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/devices/HaViewModel.kt @@ -0,0 +1,55 @@ +package com.shonar.ui.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shonar.ShonarApplication +import com.shonar.ha.HaSnapshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class DevicesUiState( + val snapshot: HaSnapshot = HaSnapshot(), + val search: String = "", + val actionError: String? = null, +) + +/** UI-facing view over HaRepository; UI never touches the client directly. */ +class HaViewModel(private val app: ShonarApplication) : ViewModel() { + + private val repo = app.haRepository + private val searchFlow = MutableStateFlow("") + private val errorFlow = MutableStateFlow(null) + + val state: StateFlow = + combine(repo.snapshot, searchFlow, errorFlow) { snap, query, actionErr -> + val entities = if (query.isBlank()) snap.entities else snap.entities.filter { + it.label.contains(query, ignoreCase = true) || + it.entityId.contains(query, ignoreCase = true) + } + DevicesUiState(snap.copy(entities = entities), query, actionErr) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) + + init { + viewModelScope.launch { app.settingsManager.ensureLoaded() } + repo.startAutoRefresh(viewModelScope) + repo.observeStateChanges(viewModelScope) + viewModelScope.launch { repo.refreshEntities() } + } + + fun setSearch(q: String) { searchFlow.value = q } + fun refresh() { viewModelScope.launch { repo.refreshEntities() } } + + fun toggle(entityId: String, currentlyOn: Boolean) { + viewModelScope.launch { + repo.callService( + domain = entityId.substringBefore('.'), + service = if (currentlyOn) "turn_off" else "turn_on", + entityId = entityId, + ).onFailure { errorFlow.value = it.message } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt new file mode 100644 index 0000000..5500fd8 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -0,0 +1,151 @@ +package com.shonar.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DevicesOther +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.shonar.ShonarApplication +import com.shonar.settings.BuiltInSettings +import kotlinx.coroutines.launch + +/** + * Home: big record button (functional UI; recording engine lands in M4), + * quick entries for Devices + Settings, and the first-launch recording-consent + * notice which must be acknowledged before anything else. + */ +@Composable +fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) { + val app = LocalContext.current.applicationContext as ShonarApplication + val scope = rememberCoroutineScope() + var consentSeen by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + app.settingsManager.ensureLoaded() + consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) + } + + Scaffold( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { /* M4: start recording flow */ }, + icon = { Icon(Icons.Filled.Mic, contentDescription = null) }, + text = { Text("Record") }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("SHONAR", style = MaterialTheme.typography.headlineMedium) + Text( + "Self-hosted Oral Notes and Audio Recorder", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + QuickCard("Devices", "Home Assistant entities on your network", + Icons.Filled.DevicesOther, onOpenDevices) + QuickCard("Settings", "Server, sync, appearance, custom settings", + Icons.Filled.Settings, onOpenSettings) + Spacer(Modifier.height(16.dp)) + Text( + "Recording is coming in the next milestone. Nothing records today.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (consentSeen == false) { + ConsentDialog(onAcknowledge = { + scope.launch { + app.settingsManager.setValue(BuiltInSettings.CONSENT, "true") + } + consentSeen = true + }) + } +} + +@Composable +private fun ConsentDialog(onAcknowledge: () -> Unit) { + AlertDialog( + onDismissRequest = { }, + title = { Text("Record responsibly") }, + text = { + Text( + "Recording conversations may require consent where you live — " + + "many jurisdictions require everyone's permission, and " + + "recording conversations you are not part of is illegal " + + "almost everywhere. By continuing you confirm you will " + + "comply with your local recording-consent laws. " + + "SHONAR never records silently: an always-visible " + + "indicator and stop control are part of the design." + ) + }, + confirmButton = { TextButton(onClick = onAcknowledge) { Text("I understand") } }, + ) +} + +@Composable +private fun QuickCard( + title: String, + subtitle: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + Card(onClick = onClick, modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Row( + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp)) + Spacer(Modifier.size(16.dp)) + Column { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..4c596bc --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/settings/SettingsScreen.kt @@ -0,0 +1,539 @@ +package com.shonar.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.shonar.ShonarApplication +import com.shonar.settings.SettingDefinition +import com.shonar.settings.SettingType +import com.shonar.ui.theme.parseColor +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +/** + * Generic settings screen: every control is rendered from the setting's + * [SettingType] — no per-setting UI code. Users can add/edit/delete custom + * settings, reset to defaults, search, and export/import JSON. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val app = LocalContext.current.applicationContext as ShonarApplication + val vm = remember { SettingsViewModel(app) } + val state by vm.state.collectAsState() + + var showAddDialog by remember { mutableStateOf(false) } + var showImportDialog by remember { mutableStateOf(false) } + var exportText by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } + + Column(Modifier.fillMaxWidth()) { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { vm.export { exportText = it } }) { + Icon(Icons.Filled.Download, contentDescription = "Export settings") + } + IconButton(onClick = { showImportDialog = true }) { + Icon(Icons.Filled.Upload, contentDescription = "Import settings") + } + IconButton(onClick = { showAddDialog = true }) { + Icon(Icons.Filled.Add, contentDescription = "Add custom setting") + } + }, + ) + + state.message?.let { msg -> + Text( + msg, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + OutlinedTextField( + value = state.search, + onValueChange = vm::setSearch, + placeholder = { Text("Search settings") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + + LazyColumn(Modifier.fillMaxWidth()) { + state.categories.forEach { section -> + item { + Text( + section.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) + } + section.items.forEach { row -> + item(key = row.def.id) { + SettingRowView( + row = row, + onValueChange = { jsonValue -> vm.setValue(row.def.id, jsonValue) }, + onReset = { vm.reset(row.def.id) }, + onDelete = if (row.def.custom) ({ pendingDelete = row.def }) else null, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + item { Spacer(Modifier.height(32.dp)) } + } + } + + if (showAddDialog) { + AddCustomSettingDialog( + onDismiss = { showAddDialog = false }, + onAdd = { def -> vm.addCustom(def); showAddDialog = false }, + ) + } + + if (showImportDialog) { + TextImportDialog( + title = "Import settings JSON", + onDismiss = { showImportDialog = false }, + onConfirm = { text -> vm.import(text); showImportDialog = false }, + ) + } + + exportText?.let { text -> + TextImportDialog( + title = "Export (copy this JSON; secrets are not included)", + initial = text, + editable = false, + confirmLabel = "Close", + onDismiss = { exportText = null }, + onConfirm = { exportText = null }, + ) + } + + pendingDelete?.let { def -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("Delete \u201C${def.name}\u201D?") }, + text = { Text("This removes the custom setting and its value.") }, + confirmButton = { + TextButton(onClick = { vm.deleteCustom(def.id); pendingDelete = null }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun SettingRowView( + row: SettingsUiState.SettingRow, + onValueChange: (String) -> Unit, + onReset: () -> Unit, + onDelete: (() -> Unit)?, +) { + val def = row.def + Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(def.name, style = MaterialTheme.typography.bodyLarge) + if (def.description.isNotBlank()) { + Text( + def.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!def.editable) { + Text( + "read-only", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.Delete, + contentDescription = "Delete setting", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + Spacer(Modifier.height(6.dp)) + Control(def = def, valueJson = row.valueJson, onValueChange = onValueChange, onReset = onReset) + } +} + +/** Renders the correct control for each SettingType. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Control( + def: SettingDefinition, + valueJson: String, + onValueChange: (String) -> Unit, + onReset: () -> Unit, +) { + val json = remember { Json { ignoreUnknownKeys = true } } + val prim = runCatching { json.parseToJsonElement(valueJson).jsonPrimitive }.getOrNull() + + when (def.type) { + SettingType.BOOLEAN -> { + val v = prim?.booleanOrNull ?: false + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = v, + onCheckedChange = { onValueChange(if (it) "true" else "false") }, + enabled = def.editable, + ) + } + } + + SettingType.STRING, SettingType.URL -> { + var text by remember(valueJson) { mutableStateOf(prim?.content ?: "") } + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + enabled = def.editable, + isError = def.type == SettingType.URL && text.isNotBlank() && + runCatching { + com.shonar.settings.SettingsManager.validateUrlOrThrow(text) + }.isFailure, + supportingText = { + Row { + TextButton(onClick = onReset, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + Text("Reset to default") + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + if (text != (prim?.content ?: "")) { + // commit on change-complete via a small Apply button pattern: + TextButton(onClick = { onValueChange(encodeString(text)) }) { Text("Apply") } + } + } + + SettingType.SECRET -> { + var text by remember(valueJson) { mutableStateOf(prim?.content ?: "") } + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + enabled = def.editable, + visualTransformation = PasswordVisualTransformation(), + placeholder = { Text(if ((prim?.content ?: "").isBlank()) "not set" else "\u2022\u2022\u2022\u2022\u2022\u2022") }, + modifier = Modifier.fillMaxWidth(), + ) + Row { + TextButton(onClick = { onValueChange(encodeString(text)) }) { Text("Save") } + if ((prim?.content ?: "").isNotBlank()) { + TextButton(onClick = onReset) { Text("Clear") } + } + } + } + + SettingType.NUMBER -> { + val current = prim?.content?.toDoubleOrNull() ?: def.min ?: 0.0 + var text by remember(valueJson) { mutableStateOf(current.toString()) } + val lo = def.min ?: 0.0 + val hi = def.max ?: (lo + 100.0) + if (hi > lo) { + Slider( + value = current.toFloat().coerceIn(lo.toFloat(), hi.toFloat()), + onValueChange = { text = it.toString() }, + onValueChangeFinished = { onValueChange(text) }, + valueRange = lo.toFloat()..hi.toFloat(), + enabled = def.editable, + ) + } + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + enabled = def.editable, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + trailingIcon = { + TextButton(onClick = { onValueChange(text) }) { Text("Set") } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + SettingType.SELECT -> { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = prim?.content ?: "", + onValueChange = {}, + readOnly = true, + enabled = def.editable, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + def.choices.forEach { choice -> + DropdownMenuItem( + text = { Text(choice) }, + onClick = { + onValueChange(encodeString(choice)) + expanded = false + }, + ) + } + } + } + } + + SettingType.MULTI_SELECT -> { + val selected = runCatching { + json.parseToJsonElement(valueJson).jsonArray.map { it.jsonPrimitive.content } + }.getOrDefault(emptyList()) + def.choices.forEach { choice -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(enabled = def.editable) { + val next = if (choice in selected) selected - choice else selected + choice + onValueChange(encodeStringList(next)) + }, + ) { + Checkbox(checked = choice in selected, onCheckedChange = null, enabled = def.editable) + Text(choice, style = MaterialTheme.typography.bodyMedium) + } + } + } + + SettingType.COLOR -> { + val presets = listOf("#4FD1C5", "#2A9D94", "#6C9EF8", "#F6C44C", "#E76F51", "#A78BFA") + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + presets.forEach { hex -> + Box( + modifier = Modifier + .size(32.dp) + .background(parseColor(hex), CircleShape) + .border( + width = if (prim?.content == hex) 3.dp else 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = CircleShape, + ) + .clickable(enabled = def.editable) { onValueChange(encodeString(hex)) }, + ) + } + } + TextButton(onClick = onReset) { Text("Reset") } + } + } +} + +private fun encodeString(s: String): String = + JsonPrimitive(s).toString() + +private fun encodeStringList(items: List): String = + items.joinToString(prefix = "[", postfix = "]") { JsonPrimitive(it).toString() } + +/** Reusable JSON/text import dialog. */ +@Composable +fun TextImportDialog( + title: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, + initial: String = "", + editable: Boolean = true, + confirmLabel: String = "Import", +) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title, style = MaterialTheme.typography.titleMedium) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { if (editable) text = it }, + readOnly = !editable, + minLines = 6, + maxLines = 12, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(text) }, enabled = text.isNotBlank()) { + Text(confirmLabel) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** Dialog to create a custom setting definition. */ +@Composable +private fun AddCustomSettingDialog( + onDismiss: () -> Unit, + onAdd: (SettingDefinition) -> Unit, +) { + var id by remember { mutableStateOf("") } + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var category by remember { mutableStateOf("Custom") } + var type by remember { mutableStateOf(SettingType.STRING) } + var defaultText by remember { mutableStateOf("") } + var minText by remember { mutableStateOf("") } + var maxText by remember { mutableStateOf("") } + var choicesText by remember { mutableStateOf("") } + var sensitive by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New custom setting") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField(value = id, onValueChange = { id = it }, label = { Text("id (snake_case)") }, singleLine = true) + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Display name") }, singleLine = true) + OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text("Description") }, singleLine = true) + OutlinedTextField(value = category, onValueChange = { category = it }, label = { Text("Category") }, singleLine = true) + TypeDropdown(type) { type = it } + OutlinedTextField(value = defaultText, onValueChange = { defaultText = it }, label = { Text("Default value") }, singleLine = true) + if (type == SettingType.NUMBER) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = minText, onValueChange = { minText = it }, label = { Text("min") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedTextField(value = maxText, onValueChange = { maxText = it }, label = { Text("max") }, singleLine = true, modifier = Modifier.weight(1f)) + } + } + if (type == SettingType.SELECT || type == SettingType.MULTI_SELECT) { + OutlinedTextField(value = choicesText, onValueChange = { choicesText = it }, label = { Text("Choices (comma-separated)") }, singleLine = true) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = sensitive, onCheckedChange = { sensitive = it }) + Text("Sensitive (store encrypted)") + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } + }, + confirmButton = { + TextButton(onClick = { + val defId = id.trim().lowercase().replace(' ', '_') + if (defId.isBlank() || name.isBlank()) { + error = "id and display name are required" + return@TextButton + } + val defaultJson = when (type) { + SettingType.BOOLEAN -> + if (defaultText.trim().lowercase() in setOf("true", "on", "yes", "1")) "true" else "false" + SettingType.NUMBER -> + defaultText.trim().toDoubleOrNull()?.toString() + ?: run { error = "Default must be a number"; return@TextButton } + SettingType.MULTI_SELECT -> encodeStringList( + defaultText.split(",").map { it.trim() }.filter { it.isNotBlank() } + ) + else -> encodeString(defaultText) + } + val def = SettingDefinition( + id = defId, + name = name.trim(), + description = description.trim(), + category = category.trim().ifBlank { "Custom" }, + type = type, + defaultJson = defaultJson, + min = minText.trim().toDoubleOrNull(), + max = maxText.trim().toDoubleOrNull(), + choices = choicesText.split(",").map { it.trim() }.filter { it.isNotBlank() }, + sensitive = sensitive || type == SettingType.SECRET, + ) + onAdd(def) // SettingsManager re-validates and reports errors + }) { Text("Create") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TypeDropdown(value: SettingType, onSelect: (SettingType) -> Unit) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = value.name.lowercase().replace('_', ' '), + onValueChange = {}, + readOnly = true, + label = { Text("Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + SettingType.entries.forEach { t -> + DropdownMenuItem( + text = { Text(t.name.lowercase().replace('_', ' ')) }, + onClick = { onSelect(t); expanded = false }, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..c6f9f18 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt @@ -0,0 +1,126 @@ +package com.shonar.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shonar.ShonarApplication +import com.shonar.settings.SettingDefinition +import com.shonar.settings.SettingType +import com.shonar.settings.SettingValidationException +import com.shonar.settings.SettingsManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class SettingsUiState( + val categories: List = emptyList(), + val search: String = "", + val busy: Boolean = false, + val message: String? = null, +) { + data class CategorySection( + val name: String, + val items: List, + ) + data class SettingRow( + val def: SettingDefinition, + /** Displayable current value (secrets masked by the UI itself). */ + val valueJson: String, + ) +} + +class SettingsViewModel(private val app: ShonarApplication) : ViewModel() { + + val settings: SettingsManager = app.settingsManager + private val searchFlow = MutableStateFlow("") + private val messageFlow = MutableStateFlow(null) + + // combine's transform is suspend, so value reads stay coroutine-native + // (no runBlocking anywhere near the main thread). + val state: StateFlow = combine( + settings.definitions, + settings.valuesChanged, + searchFlow, + messageFlow, + ) { defs, _, query, message -> + val gateValue = mutableMapOf() + suspend fun gate(id: String): Boolean = + gateValue.getOrPut(id) { settings.bool(id) } + + // visibility rules: hide settings gated on a false boolean + val visible = defs.filter { def -> + val g = def.visibleIfSettingId ?: return@filter true + gate(g) + } + val filtered = if (query.isBlank()) visible else visible.filter { + it.name.contains(query, ignoreCase = true) || + it.id.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true) + } + val byCat = filtered.groupBy { it.category } + SettingsUiState( + categories = byCat.entries + .sortedBy { it.key } + .map { (cat, items) -> + SettingsUiState.CategorySection( + cat, + items.map { row -> + SettingsUiState.SettingRow( + def = row, + valueJson = settings.value(row.id)?.toString() ?: row.defaultJson, + ) + }, + ) + }, + search = query, + message = message, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) + + fun setSearch(q: String) { + searchFlow.value = q + } + + fun setValue(id: String, jsonValue: String) = viewModelScope.launch { + runCatching { settings.setValue(id, jsonValue) } + .onFailure { messageFlow.value = it.message ?: "Invalid value" } + } + + fun reset(id: String) = viewModelScope.launch { + runCatching { settings.reset(id) } + .onFailure { messageFlow.value = it.message } + } + + fun addCustom(def: SettingDefinition) = viewModelScope.launch { + runCatching { settings.addCustom(def) } + .onSuccess { messageFlow.value = "Added \u201C${def.name}\u201D" } + .onFailure { messageFlow.value = it.message } + } + + fun deleteCustom(id: String) = viewModelScope.launch { + runCatching { settings.deleteCustom(id) } + .onSuccess { messageFlow.value = "Setting deleted" } + .onFailure { messageFlow.value = it.message } + } + + fun export(onDone: (String) -> Unit) = viewModelScope.launch { + runCatching { settings.exportJson(includeSecrets = false) } + .onSuccess(onDone) + .onFailure { messageFlow.value = it.message } + } + + fun import(text: String) = viewModelScope.launch { + runCatching { settings.importJson(text) } + .onSuccess { res -> + messageFlow.value = if (res.rejected.isEmpty()) { + "Imported ${res.applied.size} settings" + } else { + "Import rejected: " + res.rejected.entries.joinToString { "${it.key}: ${it.value}" } + } + } + .onFailure { messageFlow.value = it.message } + } + +} diff --git a/android/app/src/main/java/com/shonar/ui/theme/Theme.kt b/android/app/src/main/java/com/shonar/ui/theme/Theme.kt new file mode 100644 index 0000000..4ceeefc --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/theme/Theme.kt @@ -0,0 +1,53 @@ +package com.shonar.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// SHONAR palette — deep-sea sonar: dark navy + teal ping. +private val Teal = Color(0xFF4FD1C5) +private val TealDark = Color(0xFF2A9D94) +private val DeepNavy = Color(0xFF0B1220) +private val NavySurface = Color(0xFF121C2E) +private val Ink = Color(0xFF10141A) + +private val DarkScheme = darkColorScheme( + primary = Teal, + onPrimary = Ink, + secondary = TealDark, + background = DeepNavy, + surface = NavySurface, + onBackground = Color(0xFFE6EDF3), + onSurface = Color(0xFFE6EDF3), +) + +private val LightScheme = lightColorScheme( + primary = TealDark, + onPrimary = Color.White, + secondary = Teal, +) + +@Composable +fun ShonarTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkScheme else LightScheme, + content = content, + ) +} + +/** Parse "#RRGGBB"/"#AARRGGBB" (validated by SettingsManager); fallback teal. */ +fun parseColor(hex: String, fallback: Color = Teal): Color { + val v = hex.removePrefix("#") + val l = v.toLongOrNull(16) ?: return fallback + return when (v.length) { + 6 -> Color(0xFF000000 or l) + 8 -> Color(l) + else -> fallback + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..25c5a70 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5101d8d --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #0B1220 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6d44739 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SHONAR + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..352cbf5 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + +