Android: app shell + Custom Settings engine + Home Assistant integration

Generic settings architecture (data-driven, no per-setting UI code):
- SettingDefinition (id/name/description/category/type/default/min/max/
  choices/editable/sensitive/requiresRestart/visibleIf) x 8 types:
  boolean/string/number/select/multi-select/color/url/secret
- SettingsManager: validation, reset, custom CRUD, export/import
  (all-or-nothing with per-key rejection reasons; custom definitions
  travel in the export), secrets routed to EncryptedSharedPreferences
  (AndroidKeyStore master key) and excluded from export by default
- Settings screen renders controls from the type; search; add/edit/delete
  dialogs for custom settings; import/export dialogs
- Built-ins: General / Home Assistant / Appearance / Network / Advanced

Home Assistant integration (official REST + WebSocket APIs only):
- HomeAssistantClient: GET /api/, /api/states, /api/states/{id},
  POST /api/services/{domain}/{service}, WS /api/websocket
  (auth -> subscribe_events state_changed) with exponential-backoff
  reconnect; safe HaError types that never contain the token
- HaRepository: single service layer between UI and client; optimistic
  toggles reconciled by WS; poll fallback from refresh-interval setting
- Devices screen: discovery list, search, live states, toggles
  (light/switch/fan/input_boolean/humidifier)
- Cleartext permitted for LAN http:// URLs (same stance as the official
  HA companion app; TLS verification untouched); consent dialog on first
  launch; Record button present but inert until M4 (honest label)
- Tests: 29 unit tests (19 settings incl. secret routing, import
  validation, persistence; 10 HA via MockWebServer incl. 401 handling,
  unreachable, WS handshake + event + auth_invalid). All green.

Docs: docs/home-assistant.md (install, token creation, storage,
troubleshooting, endpoint table); README + ROADMAP updated honestly
(live e2e against a real HA server still in progress).
This commit is contained in:
avi 2026-09-08 16:25:25 -05:00
commit 4d5402946d
35 changed files with 3389 additions and 4 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Recording (M4). Declared now so the manifest is honest about the
full feature set; nothing records until M4 lands. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".ShonarApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Shonar"
android:networkSecurityConfig="@xml/network_security_config">
<!-- network_security_config permits cleartext ONLY for private/LAN
address ranges (Home Assistant, self-hosted SHONAR on LAN).
TLS verification is NOT disabled anywhere. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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() }) }
}
}
}
}
}
}

View file

@ -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) }
}

View file

@ -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<HomeAssistantEntity> = 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<HaSnapshot> = 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<HaConfig> = 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<List<HomeAssistantEntity>> =
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<String, Any?> = emptyMap(),
): Result<Unit> = 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 <T> MutableStateFlow<T>.update(block: (T) -> T) {
while (true) {
val prev = value
val next = block(prev)
if (compareAndSet(prev, next)) return
}
}
}

View file

@ -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 <T> 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<HaConfig>(resp.body?.string() ?: "{}")
401, 403 -> throw HaError.Unauthorized()
else -> throw HaError.Unexpected(resp.code)
}
}
}
fun fetchStates(): List<HomeAssistantEntity> = guard {
http.newCall(request("/api/states")).execute().use { resp ->
when (resp.code) {
200 -> json.decodeFromString<List<HomeAssistantEntity>>(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<HomeAssistantEntity>(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<String, Any?> = 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<HomeAssistantEntity> = 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<String, Any?>): 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()
}
}

View file

@ -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<String, kotlinx.serialization.json.JsonElement> = 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,
)

View file

@ -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<SettingDefinition> = 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 <n>", "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,
),
)
}

View file

@ -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<String> = 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)

View file

@ -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<String>, val rejected: Map<String, String>)
/**
* 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<List<SettingDefinition>>(emptyList())
val definitions: StateFlow<List<SettingDefinition>> = definitionsFlow.asStateFlow()
/** Bumped on any value change so ViewModels can re-read. */
private val valuesVersion = MutableStateFlow(0L)
val valuesChanged: StateFlow<Long> = 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<List<SettingDefinition>>(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<String> =
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<SettingDefinition>) {
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<SettingDefinition>()
val defsById = definitionsFlow.value.associateBy { it.id }.toMutableMap()
val rejected = mutableMapOf<String, String>()
root["customDefinitions"]?.let { el ->
val defs = runCatching {
json.decodeFromJsonElement<List<SettingDefinition>>(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<Pair<SettingDefinition, String>>()
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.")
}
}
}

View file

@ -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<String>
/** Emits whenever any value changes (for reactive UI). */
val changes: StateFlow<Long>
}
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "shonar_settings"
)
class DataStoreSettingsStore(private val context: Context) : SettingsStore {
private val changeFlow = MutableStateFlow(0L)
override val changes: StateFlow<Long> = 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<String> =
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<Long> = 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<String> = prefs.all.keys
}
/** In-memory store: used by unit tests. */
class InMemorySettingsStore : SettingsStore {
private val map = mutableMapOf<String, String>()
private val changeFlow = MutableStateFlow(0L)
override val changes: StateFlow<Long> = 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<String> = map.keys.toSet()
}

View file

@ -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() })
}
}
}
}

View file

@ -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<String?>(null)
val state: StateFlow<DevicesUiState> =
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 }
}
}
}

View file

@ -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<Boolean?>(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)
}
}
}
}

View file

@ -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<String?>(null) }
var pendingDelete by remember { mutableStateOf<SettingDefinition?>(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>): 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<String?>(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 },
)
}
}
}
}

View file

@ -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<CategorySection> = emptyList(),
val search: String = "",
val busy: Boolean = false,
val message: String? = null,
) {
data class CategorySection(
val name: String,
val items: List<SettingRow>,
)
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<String?>(null)
// combine's transform is suspend, so value reads stay coroutine-native
// (no runBlocking anywhere near the main thread).
val state: StateFlow<SettingsUiState> = combine(
settings.definitions,
settings.valuesChanged,
searchFlow,
messageFlow,
) { defs, _, query, message ->
val gateValue = mutableMapOf<String, Boolean>()
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 }
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Sonar ping: concentric arcs around a center dot -->
<path android:fillColor="#4FD1C5"
android:pathData="M54,54m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0" />
<path android:strokeColor="#4FD1C5" android:strokeWidth="4" android:fillColor="#00000000"
android:pathData="M54,38 A16,16 0 0,1 70,54" />
<path android:strokeColor="#4FD1C5" android:strokeWidth="4" android:fillColor="#00000000"
android:pathData="M54,26 A28,28 0 0,1 82,54" />
<path android:strokeColor="#2A9D94" android:strokeWidth="4" android:fillColor="#00000000"
android:pathData="M54,70 A16,16 0 0,1 38,54" />
<path android:strokeColor="#2A9D94" android:strokeWidth="4" android:fillColor="#00000000"
android:pathData="M54,82 A28,28 0 0,1 26,54" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_background">#0B1220</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">SHONAR</string>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<style name="Theme.Shonar" parent="android:Theme.Material.NoActionBar" />
</resources>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext HTTP is permitted because SHONAR's core use case is a
user-configured LOCAL server URL (Home Assistant, self-hosted SHONAR) that
is typically plain http://192.168.x.x:8123 or http://homeassistant.local.
Note: Android cannot match IP *ranges* in network security configs — only
hostnames/suffixes — so a "LAN-only" allowlist is not expressible. This is
the same stance the official Home Assistant companion app takes.
What this does NOT do: TLS/certificate verification is left fully enabled
for every https:// URL. The app itself additionally warns (in Settings)
when an http:// URL points at a non-private host.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View file

@ -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()
}
}

View file

@ -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)
}
}

6
android/build.gradle.kts Normal file
View file

@ -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
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

View file

@ -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

251
android/gradlew vendored Executable file
View file

@ -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" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -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

View file

@ -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")

View file

@ -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 |

106
docs/home-assistant.md Normal file
View file

@ -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 <https://www.home-assistant.io/installation/>:
- **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://<host>: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 <token>" http://<ha-host>: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/<domain>/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 <https://developers.home-assistant.io/docs/api/rest/>,
WebSocket <https://developers.home-assistant.io/docs/api/websocket/>.