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 @@
+
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..7b10cd0
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt b/android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt
new file mode 100644
index 0000000..a3338a4
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt
@@ -0,0 +1,207 @@
+package com.shonar
+
+import com.shonar.ha.HaError
+import com.shonar.ha.HomeAssistantClient
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.RecordedRequest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Home Assistant is mocked with MockWebServer (REST + WS upgrade); no real
+ * server required.
+ */
+class HomeAssistantClientTest {
+
+ private lateinit var server: MockWebServer
+ private val token = "test-long-lived-token"
+
+ @Before fun setUp() { server = MockWebServer(); server.start() }
+ @After fun tearDown() { server.shutdown() }
+
+ private fun client() = HomeAssistantClient(server.url("/").toString().trimEnd('/'), token)
+
+ private fun json(body: String) = MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody(body)
+
+ // --- connection ---------------------------------------------------------
+
+ @Test
+ fun fetchConfig_success() {
+ server.enqueue(json("""{"location_name":"Home","version":"2025.1.0"}"""))
+ val cfg = client().fetchConfig()
+ assertEquals("Home", cfg.locationName)
+ val req = server.takeRequest()
+ assertEquals("/api/", req.path)
+ assertEquals("Bearer $token", req.getHeader("Authorization"))
+ }
+
+ @Test
+ fun fetchConfig_invalidToken() {
+ server.enqueue(MockResponse().setResponseCode(401))
+ var err: Throwable? = null
+ try { client().fetchConfig() } catch (e: Throwable) { err = e }
+ assertTrue(err is HaError.Unauthorized)
+ // error message must not contain the token
+ assertTrue(!(err?.message ?: "").contains(token))
+ }
+
+ @Test
+ fun unreachable_producesSafeError() {
+ server.shutdown() // port now refuses connections
+ var err: Throwable? = null
+ try { client().fetchConfig() } catch (e: Throwable) { err = e }
+ assertTrue("expected Unreachable, got ${err?.javaClass}", err is HaError.Unreachable)
+ assertTrue(!(err?.message ?: "").contains(token))
+ }
+
+ // --- entities -------------------------------------------------------------
+
+ @Test
+ fun fetchStates_parsesEntities() {
+ server.enqueue(
+ json(
+ """[
+ {"entity_id":"light.kitchen","state":"on",
+ "attributes":{"friendly_name":"Kitchen Light"}},
+ {"entity_id":"climate.bedroom","state":"heat",
+ "attributes":{}}
+ ]"""
+ )
+ )
+ val states = client().fetchStates()
+ assertEquals(2, states.size)
+ assertEquals("light.kitchen", states[0].entityId)
+ assertEquals("Kitchen Light", states[0].label)
+ assertEquals("light", states[0].domain)
+ assertTrue(states[0].isOn)
+ }
+
+ @Test
+ fun fetchState_single() {
+ server.enqueue(json("""{"entity_id":"sensor.temp","state":"21.5","attributes":{}}"""))
+ val e = client().fetchState("sensor.temp")
+ assertEquals("21.5", e.state)
+ assertEquals("/api/states/sensor.temp", server.takeRequest().path)
+ }
+
+ @Test
+ fun fetchState_missingEntity404() {
+ server.enqueue(MockResponse().setResponseCode(404))
+ var err: Throwable? = null
+ try { client().fetchState("light.gone") } catch (t: Throwable) { err = t }
+ assertTrue(err is HaError.Unexpected)
+ }
+
+ // --- service calls -----------------------------------------------------------
+
+ @Test
+ fun callService_postsCorrectEndpointAndBody() {
+ server.enqueue(json("""{"entity_id":["light.kitchen"]}"""))
+ client().callService("light", "turn_on", mapOf("entity_id" to "light.kitchen", "brightness" to 200))
+ val req = server.takeRequest()
+ assertEquals("POST", req.method)
+ assertEquals("/api/services/light/turn_on", req.path)
+ val body = req.body.readUtf8()
+ val obj = kotlinx.serialization.json.Json.parseToJsonElement(body).jsonObject
+ assertEquals("light.kitchen", obj["entity_id"]?.jsonPrimitive?.content)
+ // numbers must stay numbers for HA service data
+ assertEquals(200, (obj["brightness"] as JsonPrimitive).int)
+ assertEquals("Bearer $token", req.getHeader("Authorization"))
+ }
+
+ @Test
+ fun callService_unauthorized() {
+ server.enqueue(MockResponse().setResponseCode(401).setBody("""{"message":"Unauthorized"}"""))
+ var err: Throwable? = null
+ try { client().callService("light", "turn_on") } catch (t: Throwable) { err = t }
+ assertTrue(err is HaError.Unauthorized)
+ }
+
+ // --- WebSocket ------------------------------------------------------------------
+
+ @Test
+ fun websocket_authHandshakeAndSubscribe() {
+ val upgrade = MockResponse()
+ .withWebSocketUpgrade(
+ object : okhttp3.WebSocketListener() {
+ override fun onMessage(webSocket: okhttp3.WebSocket, text: String) {
+ val obj = kotlinx.serialization.json.Json.parseToJsonElement(text).jsonObject
+ when (obj["type"]?.jsonPrimitive?.content) {
+ "auth" -> {
+ assertEquals(token, obj["access_token"]?.jsonPrimitive?.content)
+ webSocket.send("""{"type":"auth_ok"}""")
+ }
+ "subscribe_events" -> {
+ // emit one state_changed event like HA does
+ webSocket.send(
+ """{"id":1,"type":"result","success":true,"result":[]}"""
+ )
+ webSocket.send(
+ """{"id":1,"type":"event","event":{"event_type":"state_changed",""" +
+ """"data":{"new_state":{"entity_id":"light.kitchen",""" +
+ """"state":"off","attributes":{}}}}}"""
+ )
+ }
+ }
+ }
+ }
+ )
+ server.enqueue(upgrade)
+
+ val received = java.util.concurrent.CountDownLatch(1)
+ var seen: com.shonar.ha.HomeAssistantEntity? = null
+ val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
+ val job = scope.launch {
+ client().stateChangeEvents().collect { e ->
+ seen = e
+ received.countDown()
+ }
+ }
+ assertTrue("no WS event received", received.await(5, java.util.concurrent.TimeUnit.SECONDS))
+ job.cancel()
+ assertEquals("light.kitchen", seen?.entityId)
+ assertEquals("off", seen?.state)
+ }
+
+ @Test
+ fun websocket_authInvalid_surfacesUnauthorized() {
+ val upgrade = MockResponse()
+ .withWebSocketUpgrade(
+ object : okhttp3.WebSocketListener() {
+ override fun onMessage(webSocket: okhttp3.WebSocket, text: String) {
+ webSocket.send("""{"type":"auth_invalid","message":"Invalid access token"}""")
+ }
+ }
+ )
+ server.enqueue(upgrade)
+ val failed = java.util.concurrent.CountDownLatch(1)
+ var err: Throwable? = null
+ val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
+ val job = scope.launch {
+ try {
+ client().stateChangeEvents().collect { }
+ } catch (t: Throwable) {
+ err = t
+ failed.countDown()
+ }
+ }
+ assertTrue("flow did not fail on auth_invalid", failed.await(5, java.util.concurrent.TimeUnit.SECONDS))
+ assertTrue(err is HaError.Unauthorized)
+ job.cancel()
+ }
+}
diff --git a/android/app/src/test/java/com/shonar/SettingsManagerTest.kt b/android/app/src/test/java/com/shonar/SettingsManagerTest.kt
new file mode 100644
index 0000000..bd67be7
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/SettingsManagerTest.kt
@@ -0,0 +1,260 @@
+package com.shonar
+
+import com.shonar.settings.InMemorySettingsStore
+import com.shonar.settings.SettingDefinition
+import com.shonar.settings.SettingType
+import com.shonar.settings.SettingValidationException
+import com.shonar.settings.SettingsManager
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SettingsManagerTest {
+
+ private fun manager() =
+ SettingsManager(InMemorySettingsStore(), InMemorySettingsStore())
+
+ private val customNumber = SettingDefinition(
+ id = "my_number",
+ name = "My number",
+ category = "Custom",
+ type = SettingType.NUMBER,
+ defaultJson = "5",
+ min = 1.0,
+ max = 10.0,
+ )
+
+ // --- creation ---------------------------------------------------------
+
+ @Test
+ fun addCustomSetting_persistsDefinitionAndValue() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.addCustom(customNumber)
+ assertTrue(sm.definitions.value.any { it.id == "my_number" })
+ assertEquals(5.0, sm.double("my_number"), 0.0001)
+ }
+
+ @Test
+ fun addCustomSetting_rejectsBadId() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ val bad = customNumber.copy(id = "Bad ID!")
+ var threw = false
+ try { sm.addCustom(bad) } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ assertFalse(sm.definitions.value.any { it.id == "Bad ID!" })
+ }
+
+ @Test
+ fun addCustomSetting_rejectsInvalidDefault() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ val bad = customNumber.copy(defaultJson = "\"abc\"")
+ var threw = false
+ try { sm.addCustom(bad) } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ @Test
+ fun addCustomSetting_rejectsDuplicateId() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.addCustom(customNumber)
+ var threw = false
+ try { sm.addCustom(customNumber) } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ // --- modification ------------------------------------------------------
+
+ @Test
+ fun setValue_validatesRange() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.addCustom(customNumber)
+ sm.setValue("my_number", "7")
+ assertEquals(7.0, sm.double("my_number"), 0.0001)
+ var threw = false
+ try { sm.setValue("my_number", "99") } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ assertEquals("still old value", 7.0, sm.double("my_number"), 0.0001)
+ }
+
+ @Test
+ fun setValue_selectRejectsUnknownChoice() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ var threw = false
+ try {
+ sm.setValue("theme_mode", "\"Neon\"")
+ } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ @Test
+ fun reset_restoresDefault() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.addCustom(customNumber)
+ sm.setValue("my_number", "9")
+ sm.reset("my_number")
+ assertEquals(5.0, sm.double("my_number"), 0.0001)
+ }
+
+ // --- deletion -----------------------------------------------------------
+
+ @Test
+ fun deleteCustom_removesDefinitionAndValue() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.addCustom(customNumber)
+ sm.setValue("my_number", "3")
+ sm.deleteCustom("my_number")
+ assertFalse(sm.definitions.value.any { it.id == "my_number" })
+ assertNull(sm.getString("my_number"))
+ }
+
+ @Test
+ fun deleteCustom_refusesBuiltIn() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ var threw = false
+ try { sm.deleteCustom("theme_mode") } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ @Test
+ fun builtin_notEditable_cannotSet() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ var threw = false
+ try { sm.setValue("app_version", "\"9.9\"") } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ // --- persistence -----------------------------------------------------------
+
+ @Test
+ fun customDefinitions_and_values_surviveReload() = runTest {
+ val store = InMemorySettingsStore()
+ val secure = InMemorySettingsStore()
+ val sm1 = SettingsManager(store, secure)
+ sm1.ensureLoaded()
+ sm1.addCustom(customNumber)
+ sm1.setValue("my_number", "6")
+
+ val sm2 = SettingsManager(store, secure)
+ sm2.ensureLoaded()
+ assertTrue(sm2.definitions.value.any { it.id == "my_number" })
+ assertEquals(6.0, sm2.double("my_number"), 0.0001)
+ }
+
+ @Test
+ fun sensitiveValues_goToSecureStore_only() = runTest {
+ val store = InMemorySettingsStore()
+ val secure = InMemorySettingsStore()
+ val sm = SettingsManager(store, secure)
+ sm.ensureLoaded()
+ sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"secret-token\"")
+ // values are stored as JSON; a string value is quoted at rest
+ assertEquals("\"secret-token\"", secure.getString("value.home_assistant_token"))
+ // must NOT be in the plain store
+ assertTrue(store.keys().none { "home_assistant_token" in it })
+ }
+
+ // --- validation --------------------------------------------------------------
+
+ @Test
+ fun urlValidation_acceptsAndRejects() {
+ // valid
+ for (u in listOf("http://homeassistant.local:8123", "https://ha.example.com",
+ "http://192.168.1.50:8123")) {
+ SettingsManager.validateUrlOrThrow(u) // must not throw
+ }
+ for (u in listOf("ftp://host", "javascript:alert(1)", "http://", "http://user:****@host",
+ "not a url", "http://host:99999")) {
+ var threw = false
+ try { SettingsManager.validateUrlOrThrow(u) }
+ catch (e: SettingValidationException) { threw = true }
+ assertTrue("should reject: $u", threw)
+ }
+ }
+
+ @Test
+ fun colorValidation() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.setValue("accent_color", "\"#FF8800\"")
+ var threw = false
+ try { sm.setValue("accent_color", "\"orange\"") }
+ catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ // --- export / import ------------------------------------------------------------
+
+ @Test
+ fun exportImport_roundtrip() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.addCustom(customNumber)
+ sm.setValue("my_number", "8")
+ sm.setValue("theme_mode", "\"Dark\"")
+ val dump = sm.exportJson(includeSecrets = false)
+
+ val sm2 = manager()
+ sm2.ensureLoaded()
+ val res = sm2.importJson(dump)
+ assertTrue(res.rejected.toString(), res.rejected.isEmpty())
+ assertEquals(8.0, sm2.double("my_number"), 0.0001)
+ assertEquals("Dark", sm2.string("theme_mode"))
+ }
+
+ @Test
+ fun export_omitsSecrets_unlessAsked() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"top-secret\"")
+ val dump = sm.exportJson(includeSecrets = false)
+ assertFalse(dump.contains("top-secret"))
+ val withSecrets = sm.exportJson(includeSecrets = true)
+ assertTrue(withSecrets.contains("top-secret"))
+ }
+
+ @Test
+ fun import_allOrNothing_withReasons() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ val text = """{"app":"SHONAR","schemaVersion":1,"settings":{"theme_mode":"Dark","nonexistent_setting":"x"}}"""
+ val res = sm.importJson(text)
+ assertTrue(res.applied.isEmpty())
+ assertEquals("Unknown setting id", res.rejected["nonexistent_setting"])
+ // nothing applied even for the valid one
+ assertEquals("System", sm.string("theme_mode"))
+ }
+
+ @Test
+ fun import_rejectsWrongApp() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ var threw = false
+ try {
+ sm.importJson("""{"app":"OTHER","settings":{}}""")
+ } catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+
+ @Test
+ fun import_rejectsMalformedJson() = runTest {
+ val sm = manager()
+ sm.ensureLoaded()
+ var threw = false
+ try { sm.importJson("not json at all") }
+ catch (e: SettingValidationException) { threw = true }
+ assertTrue(threw)
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..70dfb63
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..73403e4
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..de64818
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "SHONAR"
+include(":app")
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 65c7d1f..d2b024b 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -10,6 +10,8 @@ updated in the same commit as the work it describes.
| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done |
| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done |
| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO |
+| HA-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) + Home Assistant integration (REST + WebSocket, discovery, service calls, reconnect) — 29 unit tests green, APK builds | done |
+| HA-2 | Home Assistant live end-to-end against a real server (token minting flow blocked; unit + MockWebServer coverage only) | in progress |
| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO |
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO |
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO |
diff --git a/docs/home-assistant.md b/docs/home-assistant.md
new file mode 100644
index 0000000..18d8bc5
--- /dev/null
+++ b/docs/home-assistant.md
@@ -0,0 +1,106 @@
+# Home Assistant integration — guide
+
+SHONAR can talk to a **local** Home Assistant server over Home Assistant's
+official REST and WebSocket APIs. No cloud, no third-party wrappers, no
+telemetry — the app talks only to the URL you configure.
+
+## 1. Install Home Assistant locally
+
+Any of these works; see :
+
+- **Home Assistant OS** on a Raspberry Pi / VM (recommended)
+- **Container:** `docker run -d --name homeassistant --restart=unless-stopped \
+ -v ./config:/config -e TZ=UTC --network=host ghcr.io/home-assistant/home-assistant:stable`
+- **Core:** Python venv install (advanced)
+
+After first start the UI is at `http://:8123`.
+
+## 2. Create a long-lived access token
+
+Home Assistant requires a long-lived access token for third-party apps
+(SHONAR cannot mint one for you — that's an HA security design):
+
+1. Open the HA web UI and log in.
+2. Click your **profile picture** (bottom-left) → **Security**.
+3. Scroll to **Long-lived access tokens** → **Create Token**.
+4. Name it (e.g. `SHONAR`) and **copy the token immediately** — it is shown
+ once.
+
+## 3. Where to enter the URL and token
+
+SHONAR app → **Settings** → **Home Assistant** category:
+
+1. Toggle **Enable Home Assistant** on.
+2. **Home Assistant URL** — e.g. `http://homeassistant.local:8123` or
+ `http://192.168.1.50:8123`.
+3. **Long-lived access token** — paste the token. Stored encrypted on the
+ device (AndroidKeyStore-backed EncryptedSharedPreferences); never in
+ plaintext, never logged, never included in settings export unless you
+ explicitly request it.
+
+The **Devices** tab then shows your entities. The connection status appears
+there: not configured / connecting / connected / error (with a plain-language
+message you can act on).
+
+## 4. Test the connection
+
+- Settings → Home Assistant has the URL/token fields; the Devices screen
+ doubles as the live connection test — hit the refresh icon.
+- Or from a shell on the same network:
+ `curl -H "Authorization: Bearer " http://:8123/api/`
+
+## 5. How entity discovery works
+
+`GET /api/states` returns every entity; the app lists them with friendly
+names, current state, and a search box. Entities whose domain is switchable
+(`light`, `switch`, `fan`, `input_boolean`, `humidifier`) get a toggle that
+calls `POST /api/services//turn_on|turn_off` with `entity_id`.
+Toggles apply optimistically and are reconciled by the live stream.
+
+Live updates: a WebSocket to `/api/websocket` authenticates and subscribes to
+`state_changed` events. On disconnect it reconnects with exponential backoff
+(2s → 60s). A configurable poll fallback (`State refresh interval`, used when
+the WebSocket is down) keeps the list fresh.
+
+## 6. How custom settings work
+
+All settings are data-driven (`SettingDefinition`), not hard-coded UI. Each
+has an id, name, description, category, type (boolean/string/number/select/
+multi-select/color/url/secret), default, optional min/max/choices,
+editability, sensitivity, restart requirement, and conditional visibility.
+
+Settings → **Add (+)** creates a custom setting with any type; **delete**
+works on custom settings only (built-ins can be reset but not removed).
+Search filters across name/id/description. **Export** produces JSON with
+secrets omitted by default; **Import** validates every entry against known
+definitions and applies all-or-nothing, reporting per-key rejection reasons.
+
+## 7. How settings are stored
+
+| Kind | Location |
+|---|---|
+| Ordinary values + custom definitions | Jetpack DataStore (`shonar_settings`) |
+| Sensitive values (HA token, secret-type settings) | EncryptedSharedPreferences, AES256-GCM values / AES256-SIV keys, master key in AndroidKeyStore (never exportable) |
+
+## 8. Troubleshooting
+
+| Symptom | Likely cause / fix |
+|---|---|
+| "Authentication failed…" | Token expired/revoked/typo. Create a fresh long-lived access token (step 2). |
+| "Cannot reach the Home Assistant server" | Wrong URL/IP, phone on different network/VLAN, HA not running, or a firewall blocking 8123. Try the curl test above from the phone's network. |
+| Cleartext HTTP warning | The app permits cleartext HTTP (like the official Home Assistant Android app) because LAN servers are plain `http://` by default. Android cannot restrict cleartext to IP *ranges* — use `https://` for any non-LAN host; certificate verification is never disabled. |
+| Entities stale | WebSocket dropped; poll fallback updates after the configured interval; refresh icon forces a reload. |
+| mDNS name not resolving | Use the IP address instead of `homeassistant.local` (phone's mDNS may be limited by the router). |
+
+## 9. Endpoints used (official API only)
+
+| Method | Path | Purpose |
+|---|---|---|
+| GET | `/api/` | connection/config test |
+| GET | `/api/states` | entity discovery |
+| GET | `/api/states/{entity_id}` | single state |
+| POST | `/api/services/{domain}/{service}` | service/action calls |
+| WS | `/api/websocket` | `auth`, `subscribe_events(state_changed)` |
+
+References: REST ,
+WebSocket .