Product pivot: defer Home Assistant; provider-based server architecture (Nextcloud default)

ISOLATE (nothing deleted):
- moved HA module (ha/, ui/devices/, HA client tests), e2e scripts, and HA
  docs under deferred/home-assistant/ with a README explaining status + how
  to revive; complete snapshot preserved on branch deferred/home-assistant

REMOVE FROM ACTIVE PRODUCT:
- HomeScreen: Devices card + route gone; MainActivity nav updated
- ShonarApplication: haRepository removed
- BuiltInSettings: Home Assistant category/settings removed from defaults
- SettingsManagerTest: secret tests rewritten around a user-created
  SECRET-type setting (no built-in secret ships)
- Manifest + URL-validation test fixture wording neutralized
- README/ROADMAP: HA marked deferred with pointer to preserved branch

ADD (design, per product direction):
- docs/server-providers.md: ShonarProvider interface, Room data model,
  auth ladder (OIDC/PKCE -> Nextcloud login-flow-v2 -> token paste),
  sync strategy, provider-selection UX (Nextcloud default; Start9/Umbrel
  as platform-probe + explicit service binding, never universal APIs;
  custom SHONAR server; local-only), TLS TOFU pinning policy,
  no-secret-logging rules, provider contract test strategy, phased plan P0-P7

VERIFY: 19 Android unit tests green, APK builds, on-device launch OK
(consent dialog renders; no Devices entry). Backend unchanged (26 tests).
This commit is contained in:
avi 2026-09-08 18:19:57 -05:00
commit 4eab1f11cf
22 changed files with 289 additions and 87 deletions

View file

@ -20,7 +20,7 @@
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).
address ranges (a user-configured self-hosted server on LAN).
TLS verification is NOT disabled anywhere. -->
<activity

View file

@ -12,7 +12,6 @@ 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
@ -29,8 +28,7 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background,
) {
NavHost(navController = nav, startDestination = "home") {
composable("home") { HomeScreen(onOpenDevices = { nav.navigate("devices") }, onOpenSettings = { nav.navigate("settings") }) }
composable("devices") { DevicesScreen() }
composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) }
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
}
}
@ -38,3 +36,4 @@ class MainActivity : ComponentActivity() {
}
}
}

View file

@ -1,7 +1,6 @@
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
@ -14,7 +13,4 @@ class ShonarApplication : Application() {
secureStore = SecureSettingsStore(this),
)
}
/** Single HA integration instance; the UI talks only to this. */
val haRepository: HaRepository by lazy { HaRepository(settingsManager) }
}

View file

@ -1,194 +0,0 @@
package com.shonar.ha
import com.shonar.settings.BuiltInSettings
import com.shonar.settings.SettingsManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class HaConnectionStatus { DISABLED, NOT_CONFIGURED, CONNECTING, CONNECTED, ERROR }
data class HaSnapshot(
val status: HaConnectionStatus = HaConnectionStatus.DISABLED,
val entities: List<HomeAssistantEntity> = emptyList(),
val errorMessage: String? = null,
val lastUpdated: Long = 0L,
)
/**
* The ONLY path between the UI and Home Assistant. Reads its configuration
* from the generic [SettingsManager] (url/token/enabled/refresh interval),
* exposes a state snapshot, and applies optimistic local updates on service
* calls so the UI feels instant; the WebSocket stream reconciles afterwards.
*/
class HaRepository(
private val settings: SettingsManager,
) {
private val snapshotFlow = MutableStateFlow(HaSnapshot())
val snapshot: StateFlow<HaSnapshot> = snapshotFlow.asStateFlow()
@Volatile private var client: HomeAssistantClient? = null
@Volatile private var configFingerprint: String? = null
/** (Re)build the client from current settings. Returns false if not usable. */
private suspend fun clientOrNull(): HomeAssistantClient? {
if (!settings.bool(BuiltInSettings.HA_ENABLED)) {
snapshotFlow.value = snapshotFlow.value.copy(status = HaConnectionStatus.DISABLED)
return null
}
val url = settings.string(BuiltInSettings.HA_URL)
val token = settings.string(BuiltInSettings.HA_TOKEN)
if (url.isBlank() || token.isBlank()) {
snapshotFlow.value = snapshotFlow.value.copy(
status = HaConnectionStatus.NOT_CONFIGURED,
errorMessage = "Set the Home Assistant URL and token in Settings.",
)
return null
}
val fingerprint = "$url|$token"
if (fingerprint != configFingerprint) {
client = HomeAssistantClient(url, token)
configFingerprint = fingerprint
}
return client
}
/** Explicit "Test connection" from Settings. */
suspend fun testConnection(): Result<HaConfig> = withContext(Dispatchers.IO) {
val c = clientOrNull() ?: return@withContext Result.failure(
HaError.NotConfigured()
)
runCatching { c.fetchConfig() }
.onSuccess { cfg ->
snapshotFlow.value = snapshotFlow.value.copy(
status = HaConnectionStatus.CONNECTED, errorMessage = null
)
}
.onFailure { e ->
snapshotFlow.value = snapshotFlow.value.copy(
status = HaConnectionStatus.ERROR,
errorMessage = e.message,
)
}
}
suspend fun refreshEntities(): Result<List<HomeAssistantEntity>> =
withContext(Dispatchers.IO) {
val c = clientOrNull() ?: return@withContext Result.failure(HaError.NotConfigured())
snapshotFlow.value = snapshotFlow.value.copy(status = HaConnectionStatus.CONNECTING)
runCatching { c.fetchStates() }
.onSuccess { list ->
snapshotFlow.value = HaSnapshot(
status = HaConnectionStatus.CONNECTED,
entities = list.sortedBy { it.label.lowercase() },
lastUpdated = System.currentTimeMillis(),
)
}
.onFailure { e ->
snapshotFlow.value = snapshotFlow.value.copy(
status = HaConnectionStatus.ERROR,
errorMessage = e.message,
)
}
}
/**
* Call a HA service. [entityId] is optional convenience: it is inserted
* as entity_id service data (the pattern almost all device services use).
*/
suspend fun callService(
domain: String,
service: String,
entityId: String? = null,
data: Map<String, Any?> = emptyMap(),
): Result<Unit> = withContext(Dispatchers.IO) {
val c = clientOrNull() ?: return@withContext Result.failure(HaError.NotConfigured())
val payload = buildMap {
putAll(data)
if (entityId != null) put("entity_id", entityId)
}
runCatching { c.callService(domain, service, payload) }
.onSuccess {
// Optimistic flip; WS/poll will reconcile the true value.
if (entityId != null && service in setOf("turn_on", "turn_off", "toggle")) {
val nowOn = service != "turn_off"
snapshotFlow.update { snap ->
snap.copy(
entities = snap.entities.map {
if (it.entityId == entityId)
it.copy(state = if (nowOn) "on" else "off") else it
}
)
}
}
}
.onFailure { e ->
snapshotFlow.update { it.copy(errorMessage = e.message) }
}
}
/**
* Keep entities fresh: WebSocket while healthy; if WS dies repeatedly the
* poll fallback below keeps the UI updated at the configured interval.
*/
fun startAutoRefresh(scope: kotlinx.coroutines.CoroutineScope) {
scope.launch {
settings.valuesChanged.collectLatest {
// settings changed: force reconnect/refresh
configFingerprint = null
refreshEntities()
}
}
scope.launch {
while (true) {
val interval = runCatching {
settings.double(BuiltInSettings.HA_REFRESH_INTERVAL).toLong()
}.getOrDefault(10L).coerceIn(1L, 300L)
if (settings.bool(BuiltInSettings.HA_ENABLED) &&
snapshotFlow.value.status != HaConnectionStatus.CONNECTED
) {
refreshEntities()
}
delay(interval * 1000)
}
}
}
/** Live stream of changed entities (WS). Merge into the snapshot. */
fun observeStateChanges(scope: kotlinx.coroutines.CoroutineScope) {
scope.launch {
while (true) {
val c = clientOrNull() ?: break
try {
c.stateChangeEvents().collect { changed ->
snapshotFlow.update { snap ->
if (snap.status != HaConnectionStatus.CONNECTED) snap
else snap.copy(
entities = snap.entities.map {
if (it.entityId == changed.entityId) changed else it
}
)
}
}
} catch (_: Exception) {
delay(5_000) // WS reconnect backoff at repo level too
}
}
}
}
private inline fun <T> MutableStateFlow<T>.update(block: (T) -> T) {
while (true) {
val prev = value
val next = block(prev)
if (compareAndSet(prev, next)) return
}
}
}

View file

@ -1,260 +0,0 @@
package com.shonar.ha
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
/** Errors surfaced to the UI. Messages are safe: never contain the token. */
sealed class HaError(message: String) : Exception(message) {
class NotConfigured : HaError("Home Assistant is not configured. Set the URL and token in Settings.")
class Unauthorized : HaError("Authentication failed. Check the long-lived access token in Settings.")
class Unreachable(val detail: String) : HaError("Cannot reach the Home Assistant server ($detail).")
class Unexpected(val code: Int) : HaError("Home Assistant returned an unexpected response (HTTP $code).")
}
/**
* Minimal official-API client. REST under /api plus WebSocket under
* /api/websocket. Endpoints used:
* GET /api/ -> config (connection test)
* GET /api/states -> all entities
* GET /api/states/{entity_id} -> one entity
* POST /api/services/{domain}/{service} -> call service
* WS /api/websocket -> auth + subscribe_events(state_changed)
* No unofficial APIs, no wrappers.
*/
class HomeAssistantClient(
baseUrl: String,
private val token: String,
private val baseHttp: OkHttpClient = defaultHttp(),
) {
private val json = Json { ignoreUnknownKeys = true }
private val http = baseHttp.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** normalized without trailing slash */
private val url: String = baseUrl.trim().trimEnd('/')
private fun request(path: String, body: String? = null, post: Boolean = false): Request {
val builder = Request.Builder()
.url("$url$path")
.header("Authorization", "Bearer $token") // never logged
.header("Accept", "application/json")
if (post) {
builder.post((body ?: "{}").toRequestBody(JSON_TYPE))
}
return builder.build()
}
private fun <T> guard(block: () -> T): T =
try {
block()
} catch (e: HaError) {
throw e
} catch (e: java.io.IOException) {
// IOException messages contain host/port only — no credentials.
throw HaError.Unreachable(e.message?.take(120) ?: "network error")
}
/** Connection test: GET /api/ returns config when the token is valid. */
fun fetchConfig(): HaConfig = guard {
http.newCall(request("/api/")).execute().use { resp ->
when (resp.code) {
200 -> json.decodeFromString<HaConfig>(resp.body?.string() ?: "{}")
401, 403 -> throw HaError.Unauthorized()
else -> throw HaError.Unexpected(resp.code)
}
}
}
fun fetchStates(): List<HomeAssistantEntity> = guard {
http.newCall(request("/api/states")).execute().use { resp ->
when (resp.code) {
200 -> json.decodeFromString<List<HomeAssistantEntity>>(resp.body?.string() ?: "[]")
401, 403 -> throw HaError.Unauthorized()
else -> throw HaError.Unexpected(resp.code)
}
}
}
fun fetchState(entityId: String): HomeAssistantEntity = guard {
http.newCall(request("/api/states/$entityId")).execute().use { resp ->
when (resp.code) {
200 -> json.decodeFromString<HomeAssistantEntity>(resp.body?.string() ?: "{}")
401, 403 -> throw HaError.Unauthorized()
404 -> throw HaError.Unexpected(404)
else -> throw HaError.Unexpected(resp.code)
}
}
}
/**
* POST /api/services/{domain}/{service} with optional JSON data.
* Returns the list of affected entities on success.
*/
fun callService(domain: String, service: String, data: Map<String, Any?> = emptyMap()): Unit = guard {
val payload = buildServiceJson(data).toString()
http.newCall(request("/api/services/$domain/$service", payload, post = true)).execute().use { resp ->
when (resp.code) {
in 200..299 -> Unit
401, 403 -> throw HaError.Unauthorized()
else -> {
val detail = runCatching {
json.parseToJsonElement(resp.body?.string() ?: "")
.jsonObjectOrNull()?.get("message")?.jsonPrimitiveOrNull()?.content
}.getOrNull().orEmpty()
if (detail.contains("Unauthorized", ignoreCase = true)) throw HaError.Unauthorized()
throw HaError.Unexpected(resp.code)
}
}
}
}
// --- WebSocket: live state_changed events ---------------------------------
/**
* Live state updates over /api/websocket with auto-reconnect (exponential
* backoff up to 60s). Emits changed entities. Completes only when the
* collector is cancelled.
*/
fun stateChangeEvents(): Flow<HomeAssistantEntity> = callbackFlow {
val backoffMs = AtomicInteger(2_000)
var socket: WebSocket? = null
var closed = false
val msgId = AtomicInteger(1)
fun wsUrl(): String =
url.replaceFirst("http", "ws") + "/api/websocket"
fun connect() {
if (closed) return
socket = http.newWebSocket(
Request.Builder().url(wsUrl()).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// HA greets with auth_required; we answer with the token.
// (If the greeting was already consumed we send anyway —
// HA ignores stray auth messages before auth_ok.)
webSocket.send(
buildJsonObject {
put("type", "auth")
put("access_token", token)
}.toString()
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val obj = runCatching { json.parseToJsonElement(text).jsonObject }
.getOrNull() ?: return
val type = obj["type"]?.jsonPrimitiveOrNull()?.content
when (type) {
"auth_ok" -> {
backoffMs.set(2_000)
webSocket.send(
buildJsonObject {
put("id", msgId.getAndIncrement())
put("type", "subscribe_events")
put("event_type", "state_changed")
}.toString()
)
}
"auth_invalid" -> {
// Auth will never succeed: drop the socket
// hard, surface the error, stop reconnecting.
closed = true
webSocket.cancel()
close(HaError.Unauthorized())
}
"event" -> {
val newState = obj["event"]?.jsonObjectOrNull()
?.get("data")?.jsonObjectOrNull()
?.get("new_state")?.toString() ?: return
decodeEntity(newState)?.let { trySend(it) }
}
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
scheduleReconnect()
}
private fun scheduleReconnect() {
if (closed) return
val delay = backoffMs.get().toLong()
backoffMs.set((backoffMs.get() * 2).coerceAtMost(60_000))
RECONNECT_EXECUTOR.schedule({ if (!closed) connect() }, delay, TimeUnit.MILLISECONDS)
}
}
)
}
connect()
awaitClose {
closed = true
socket?.close(1000, "client gone")
}
}
private fun decodeEntity(raw: String): HomeAssistantEntity? = runCatching {
json.decodeFromString(HomeAssistantEntity.serializer(), raw)
}.getOrNull()
companion object {
private val JSON_TYPE = "application/json; charset=utf-8".toMediaType()
/** Convert loose Kotlin service data to typed JSON (numbers stay
* numbers; HA service data is type-sensitive). */
internal fun toJsonElement(v: Any?): JsonElement = when (v) {
null -> JsonNull
is JsonElement -> v
is Boolean -> JsonPrimitive(v)
is Number -> JsonPrimitive(v)
is String -> JsonPrimitive(v)
is Map<*, *> -> buildJsonObject {
v.forEach { (k, value) -> put(k.toString(), toJsonElement(value)) }
}
is Iterable<*> -> buildJsonArray { v.forEach { add(toJsonElement(it)) } }
else -> JsonPrimitive(v.toString())
}
internal fun buildServiceJson(data: Map<String, Any?>): JsonObject =
toJsonElement(data) as JsonObject
private val RECONNECT_EXECUTOR =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "ha-ws-reconnect").apply { isDaemon = true }
}
/** Default client: TLS verification ON (no trust-all anywhere). */
fun defaultHttp(): OkHttpClient =
OkHttpClient.Builder().build()
}
}

View file

@ -1,37 +0,0 @@
package com.shonar.ha
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Home Assistant entity, as returned by GET /api/states (snake_case on the
* wire — mapped with @SerialName).
*/
@Serializable
data class HomeAssistantEntity(
@SerialName("entity_id") val entityId: String,
val state: String,
val attributes: Map<String, kotlinx.serialization.json.JsonElement> = emptyMap(),
@SerialName("last_changed") val lastChanged: String? = null,
@SerialName("last_updated") val lastUpdated: String? = null,
) {
val domain: String get() = entityId.substringBefore('.', "")
/** UI-friendly label from attributes.friendly_name when present. */
val label: String
get() = (attributes["friendly_name"]
as? kotlinx.serialization.json.JsonPrimitive)?.content ?: entityId
val isOn: Boolean
get() = state.lowercase() in ON_STATES
companion object {
private val ON_STATES = setOf("on", "open", "active", "home", "true", "locked")
}
}
@Serializable
data class HaConfig(
@SerialName("location_name") val locationName: String? = null,
val version: String? = null,
)

View file

@ -2,66 +2,22 @@ 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.
*
* NOTE: Home Assistant settings were removed from the default configuration
* (product scope change) and preserved on branch `deferred/home-assistant`
* under `deferred/home-assistant/`.
*/
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",
@ -142,3 +98,4 @@ object BuiltInSettings {
),
)
}

View file

@ -1,166 +0,0 @@
package com.shonar.ui.devices
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.shonar.ShonarApplication
import com.shonar.ha.HaConnectionStatus
import com.shonar.ha.HomeAssistantEntity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DevicesScreen() {
val app = LocalContext.current.applicationContext as ShonarApplication
val vm = remember { HaViewModel(app) }
val state by vm.state.collectAsState()
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Devices") },
actions = {
IconButton(onClick = vm::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh entities")
}
},
)
when (state.snapshot.status) {
HaConnectionStatus.DISABLED, HaConnectionStatus.NOT_CONFIGURED -> StatusMessage(
"Home Assistant is not configured.",
"Enable it and set the URL + long-lived access token in Settings.",
)
HaConnectionStatus.CONNECTING -> if (state.snapshot.entities.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { CircularProgressIndicator() }
} else {
EntityList(state, vm)
}
HaConnectionStatus.ERROR -> StatusMessage(
"Connection problem",
state.snapshot.errorMessage ?: "Unknown error",
retry = vm::refresh,
)
HaConnectionStatus.CONNECTED -> EntityList(state, vm)
}
}
}
@Composable
private fun StatusMessage(title: String, body: String, retry: (() -> Unit)? = null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (retry != null) {
Spacer(Modifier.height(16.dp))
Button(onClick = retry) { Text("Retry") }
}
}
}
@Composable
private fun EntityList(state: DevicesUiState, vm: HaViewModel) {
OutlinedTextField(
value = state.search,
onValueChange = vm::setSearch,
placeholder = { Text("Search entities") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
state.actionError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (state.snapshot.entities.isEmpty()) {
StatusMessage("No entities", "Nothing matched. Use refresh to reload.")
return
}
LazyColumn(
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.snapshot.entities, key = { it.entityId }) { entity ->
EntityCard(entity = entity, onToggle = { vm.toggle(entity.entityId, entity.isOn) })
}
}
}
private val SWITCHABLE_DOMAINS = setOf("light", "switch", "fan", "input_boolean", "humidifier")
@Composable
private fun EntityCard(entity: HomeAssistantEntity, onToggle: () -> Unit) {
val switchable = entity.domain in SWITCHABLE_DOMAINS
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(entity.label, style = MaterialTheme.typography.bodyLarge)
Text(
entity.entityId + " • " + entity.state,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (switchable) {
Switch(checked = entity.isOn, onCheckedChange = { onToggle() })
}
}
}
}

View file

@ -1,55 +0,0 @@
package com.shonar.ui.devices
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shonar.ShonarApplication
import com.shonar.ha.HaSnapshot
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class DevicesUiState(
val snapshot: HaSnapshot = HaSnapshot(),
val search: String = "",
val actionError: String? = null,
)
/** UI-facing view over HaRepository; UI never touches the client directly. */
class HaViewModel(private val app: ShonarApplication) : ViewModel() {
private val repo = app.haRepository
private val searchFlow = MutableStateFlow("")
private val errorFlow = MutableStateFlow<String?>(null)
val state: StateFlow<DevicesUiState> =
combine(repo.snapshot, searchFlow, errorFlow) { snap, query, actionErr ->
val entities = if (query.isBlank()) snap.entities else snap.entities.filter {
it.label.contains(query, ignoreCase = true) ||
it.entityId.contains(query, ignoreCase = true)
}
DevicesUiState(snap.copy(entities = entities), query, actionErr)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState())
init {
viewModelScope.launch { app.settingsManager.ensureLoaded() }
repo.startAutoRefresh(viewModelScope)
repo.observeStateChanges(viewModelScope)
viewModelScope.launch { repo.refreshEntities() }
}
fun setSearch(q: String) { searchFlow.value = q }
fun refresh() { viewModelScope.launch { repo.refreshEntities() } }
fun toggle(entityId: String, currentlyOn: Boolean) {
viewModelScope.launch {
repo.callService(
domain = entityId.substringBefore('.'),
service = if (currentlyOn) "turn_off" else "turn_on",
entityId = entityId,
).onFailure { errorFlow.value = it.message }
}
}
}

View file

@ -11,7 +11,6 @@ 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
@ -41,11 +40,11 @@ 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.
* a Settings entry, and the first-launch recording-consent notice which must
* be acknowledged before anything else.
*/
@Composable
fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
fun HomeScreen(onOpenSettings: () -> Unit) {
val app = LocalContext.current.applicationContext as ShonarApplication
val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
@ -79,8 +78,6 @@ fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
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))

View file

@ -1,17 +1,24 @@
<?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.
Cleartext HTTP is permitted ONLY for local self-hosted servers reached on a
private network or localhost (a SHONAR provider, Nextcloud, etc.) that a
user explicitly configures. Android cannot restrict cleartext to IP ranges,
so this is paired with app-level URL validation that rejects cleartext for
non-private hosts, and with an explicit in-app warning whenever the
configured server URL is http://.
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.
TLS/certificate verification is NEVER disabled. Self-signed certificates on
LAN servers are handled by explicit user approval of the specific
certificate (trust-on-first-use, recorded in secure storage), never by a
global bypass.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">.local</domain>
<domain includeSubdomains="true">10.0.0.0</domain>
<domain includeSubdomains="true">192.168.0.0</domain>
<domain includeSubdomains="true">172.16.0.0</domain>
</domain-config>
</network-security-config>

View file

@ -1,207 +0,0 @@
package com.shonar
import com.shonar.ha.HaError
import com.shonar.ha.HomeAssistantClient
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Home Assistant is mocked with MockWebServer (REST + WS upgrade); no real
* server required.
*/
class HomeAssistantClientTest {
private lateinit var server: MockWebServer
private val token = "test-long-lived-token"
@Before fun setUp() { server = MockWebServer(); server.start() }
@After fun tearDown() { server.shutdown() }
private fun client() = HomeAssistantClient(server.url("/").toString().trimEnd('/'), token)
private fun json(body: String) = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(body)
// --- connection ---------------------------------------------------------
@Test
fun fetchConfig_success() {
server.enqueue(json("""{"location_name":"Home","version":"2025.1.0"}"""))
val cfg = client().fetchConfig()
assertEquals("Home", cfg.locationName)
val req = server.takeRequest()
assertEquals("/api/", req.path)
assertEquals("Bearer $token", req.getHeader("Authorization"))
}
@Test
fun fetchConfig_invalidToken() {
server.enqueue(MockResponse().setResponseCode(401))
var err: Throwable? = null
try { client().fetchConfig() } catch (e: Throwable) { err = e }
assertTrue(err is HaError.Unauthorized)
// error message must not contain the token
assertTrue(!(err?.message ?: "").contains(token))
}
@Test
fun unreachable_producesSafeError() {
server.shutdown() // port now refuses connections
var err: Throwable? = null
try { client().fetchConfig() } catch (e: Throwable) { err = e }
assertTrue("expected Unreachable, got ${err?.javaClass}", err is HaError.Unreachable)
assertTrue(!(err?.message ?: "").contains(token))
}
// --- entities -------------------------------------------------------------
@Test
fun fetchStates_parsesEntities() {
server.enqueue(
json(
"""[
{"entity_id":"light.kitchen","state":"on",
"attributes":{"friendly_name":"Kitchen Light"}},
{"entity_id":"climate.bedroom","state":"heat",
"attributes":{}}
]"""
)
)
val states = client().fetchStates()
assertEquals(2, states.size)
assertEquals("light.kitchen", states[0].entityId)
assertEquals("Kitchen Light", states[0].label)
assertEquals("light", states[0].domain)
assertTrue(states[0].isOn)
}
@Test
fun fetchState_single() {
server.enqueue(json("""{"entity_id":"sensor.temp","state":"21.5","attributes":{}}"""))
val e = client().fetchState("sensor.temp")
assertEquals("21.5", e.state)
assertEquals("/api/states/sensor.temp", server.takeRequest().path)
}
@Test
fun fetchState_missingEntity404() {
server.enqueue(MockResponse().setResponseCode(404))
var err: Throwable? = null
try { client().fetchState("light.gone") } catch (t: Throwable) { err = t }
assertTrue(err is HaError.Unexpected)
}
// --- service calls -----------------------------------------------------------
@Test
fun callService_postsCorrectEndpointAndBody() {
server.enqueue(json("""{"entity_id":["light.kitchen"]}"""))
client().callService("light", "turn_on", mapOf("entity_id" to "light.kitchen", "brightness" to 200))
val req = server.takeRequest()
assertEquals("POST", req.method)
assertEquals("/api/services/light/turn_on", req.path)
val body = req.body.readUtf8()
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body).jsonObject
assertEquals("light.kitchen", obj["entity_id"]?.jsonPrimitive?.content)
// numbers must stay numbers for HA service data
assertEquals(200, (obj["brightness"] as JsonPrimitive).int)
assertEquals("Bearer $token", req.getHeader("Authorization"))
}
@Test
fun callService_unauthorized() {
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"message":"Unauthorized"}"""))
var err: Throwable? = null
try { client().callService("light", "turn_on") } catch (t: Throwable) { err = t }
assertTrue(err is HaError.Unauthorized)
}
// --- WebSocket ------------------------------------------------------------------
@Test
fun websocket_authHandshakeAndSubscribe() {
val upgrade = MockResponse()
.withWebSocketUpgrade(
object : okhttp3.WebSocketListener() {
override fun onMessage(webSocket: okhttp3.WebSocket, text: String) {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(text).jsonObject
when (obj["type"]?.jsonPrimitive?.content) {
"auth" -> {
assertEquals(token, obj["access_token"]?.jsonPrimitive?.content)
webSocket.send("""{"type":"auth_ok"}""")
}
"subscribe_events" -> {
// emit one state_changed event like HA does
webSocket.send(
"""{"id":1,"type":"result","success":true,"result":[]}"""
)
webSocket.send(
"""{"id":1,"type":"event","event":{"event_type":"state_changed",""" +
""""data":{"new_state":{"entity_id":"light.kitchen",""" +
""""state":"off","attributes":{}}}}}"""
)
}
}
}
}
)
server.enqueue(upgrade)
val received = java.util.concurrent.CountDownLatch(1)
var seen: com.shonar.ha.HomeAssistantEntity? = null
val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
val job = scope.launch {
client().stateChangeEvents().collect { e ->
seen = e
received.countDown()
}
}
assertTrue("no WS event received", received.await(5, java.util.concurrent.TimeUnit.SECONDS))
job.cancel()
assertEquals("light.kitchen", seen?.entityId)
assertEquals("off", seen?.state)
}
@Test
fun websocket_authInvalid_surfacesUnauthorized() {
val upgrade = MockResponse()
.withWebSocketUpgrade(
object : okhttp3.WebSocketListener() {
override fun onMessage(webSocket: okhttp3.WebSocket, text: String) {
webSocket.send("""{"type":"auth_invalid","message":"Invalid access token"}""")
}
}
)
server.enqueue(upgrade)
val failed = java.util.concurrent.CountDownLatch(1)
var err: Throwable? = null
val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
val job = scope.launch {
try {
client().stateChangeEvents().collect { }
} catch (t: Throwable) {
err = t
failed.countDown()
}
}
assertTrue("flow did not fail on auth_invalid", failed.await(5, java.util.concurrent.TimeUnit.SECONDS))
assertTrue(err is HaError.Unauthorized)
job.cancel()
}
}

View file

@ -159,11 +159,18 @@ class SettingsManagerTest {
val secure = InMemorySettingsStore()
val sm = SettingsManager(store, secure)
sm.ensureLoaded()
sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"secret-token\"")
// A user-created SECRET-type setting (no built-in secrets ship).
sm.addCustom(
SettingDefinition(
id = "my_secret", name = "My secret", category = "Custom",
type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true,
)
)
sm.setValue("my_secret", "\"secret-token\"")
// values are stored as JSON; a string value is quoted at rest
assertEquals("\"secret-token\"", secure.getString("value.home_assistant_token"))
assertEquals("\"secret-token\"", secure.getString("value.my_secret"))
// must NOT be in the plain store
assertTrue(store.keys().none { "home_assistant_token" in it })
assertTrue(store.keys().none { it.startsWith("value.") && "my_secret" in it })
}
// --- validation --------------------------------------------------------------
@ -171,7 +178,7 @@ class SettingsManagerTest {
@Test
fun urlValidation_acceptsAndRejects() {
// valid
for (u in listOf("http://homeassistant.local:8123", "https://ha.example.com",
for (u in listOf("http://nextcloud.local:8080", "https://ha.example.com",
"http://192.168.1.50:8123")) {
SettingsManager.validateUrlOrThrow(u) // must not throw
}
@ -218,7 +225,14 @@ class SettingsManagerTest {
fun export_omitsSecrets_unlessAsked() = runTest {
val sm = manager()
sm.ensureLoaded()
sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"top-secret\"")
// A user-created SECRET-type setting (no built-in secrets ship).
sm.addCustom(
SettingDefinition(
id = "my_secret", name = "My secret", category = "Custom",
type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true,
)
)
sm.setValue("my_secret", "\"top-secret\"")
val dump = sm.exportJson(includeSecrets = false)
assertFalse(dump.contains("top-secret"))
val withSecrets = sm.exportJson(includeSecrets = true)