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:
parent
b1151c5ec8
commit
4d5402946d
35 changed files with 3389 additions and 4 deletions
73
android/app/build.gradle.kts
Normal file
73
android/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
36
android/app/src/main/AndroidManifest.xml
Normal file
36
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
40
android/app/src/main/java/com/shonar/MainActivity.kt
Normal file
40
android/app/src/main/java/com/shonar/MainActivity.kt
Normal 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() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
android/app/src/main/java/com/shonar/ShonarApplication.kt
Normal file
20
android/app/src/main/java/com/shonar/ShonarApplication.kt
Normal 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) }
|
||||
}
|
||||
194
android/app/src/main/java/com/shonar/ha/HaRepository.kt
Normal file
194
android/app/src/main/java/com/shonar/ha/HaRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
260
android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt
Normal file
260
android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
144
android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt
Normal file
144
android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt
Normal 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
333
android/app/src/main/java/com/shonar/settings/SettingsManager.kt
Normal file
333
android/app/src/main/java/com/shonar/settings/SettingsManager.kt
Normal 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
110
android/app/src/main/java/com/shonar/settings/SettingsStore.kt
Normal file
110
android/app/src/main/java/com/shonar/settings/SettingsStore.kt
Normal 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()
|
||||
}
|
||||
166
android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt
Normal file
166
android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt
Normal 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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
151
android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt
Normal file
151
android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
53
android/app/src/main/java/com/shonar/ui/theme/Theme.kt
Normal file
53
android/app/src/main/java/com/shonar/ui/theme/Theme.kt
Normal 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
|
||||
}
|
||||
}
|
||||
18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
18
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
3
android/app/src/main/res/values/colors.xml
Normal file
3
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<color name="ic_launcher_background">#0B1220</color>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
3
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">SHONAR</string>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<style name="Theme.Shonar" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
17
android/app/src/main/res/xml/network_security_config.xml
Normal file
17
android/app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||
207
android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt
Normal file
207
android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
260
android/app/src/test/java/com/shonar/SettingsManagerTest.kt
Normal file
260
android/app/src/test/java/com/shonar/SettingsManagerTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue