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
16
README.md
16
README.md
|
|
@ -57,10 +57,18 @@ adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
S.H.O.N.A.R. is developed in milestones; each merged milestone is tested and
|
S.H.O.N.A.R. is developed in milestones; each merged milestone is tested and
|
||||||
buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix.
|
buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix.
|
||||||
|
|
||||||
**Current state:** M0–M1 complete — backend health, full auth (register,
|
**Current state:**
|
||||||
login, rotating refresh tokens with reuse detection, logout, delete-account),
|
|
||||||
rate limiting, Alembic schema, Docker dev stack. Everything else is
|
- Backend: M0–M2 complete — health, full auth (register, login, rotating
|
||||||
in-progress or TODO as per the roadmap.
|
refresh tokens with reuse detection, logout, delete-account), rate
|
||||||
|
limiting, chunked resumable uploads, recordings CRUD, storage
|
||||||
|
abstraction, Postgres FTS schema, Docker dev stack.
|
||||||
|
- Android: app shell with a generic data-driven **Custom Settings** system
|
||||||
|
(8 value types, add/edit/delete/reset/search/export/import), and a full
|
||||||
|
**Home Assistant** integration — connect, test, entity discovery, live
|
||||||
|
WebSocket state updates, service calls (toggles), encrypted token
|
||||||
|
storage. See [docs/home-assistant.md](docs/home-assistant.md).
|
||||||
|
- Recording engine, SHONAR sync, playback, AI pipeline: TODO per roadmap.
|
||||||
|
|
||||||
## AI providers
|
## AI providers
|
||||||
|
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
android/build.gradle.kts
Normal file
6
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
android/gradlew
vendored
Executable file
251
android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
android/gradlew.bat
vendored
Normal file
94
android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
17
android/settings.gradle.kts
Normal file
17
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "SHONAR"
|
||||||
|
include(":app")
|
||||||
|
|
@ -10,6 +10,8 @@ updated in the same commit as the work it describes.
|
||||||
| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done |
|
| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done |
|
||||||
| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done |
|
| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done |
|
||||||
| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO |
|
| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO |
|
||||||
|
| HA-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) + Home Assistant integration (REST + WebSocket, discovery, service calls, reconnect) — 29 unit tests green, APK builds | done |
|
||||||
|
| HA-2 | Home Assistant live end-to-end against a real server (token minting flow blocked; unit + MockWebServer coverage only) | in progress |
|
||||||
| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO |
|
| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO |
|
||||||
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO |
|
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO |
|
||||||
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO |
|
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO |
|
||||||
|
|
|
||||||
106
docs/home-assistant.md
Normal file
106
docs/home-assistant.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Home Assistant integration — guide
|
||||||
|
|
||||||
|
SHONAR can talk to a **local** Home Assistant server over Home Assistant's
|
||||||
|
official REST and WebSocket APIs. No cloud, no third-party wrappers, no
|
||||||
|
telemetry — the app talks only to the URL you configure.
|
||||||
|
|
||||||
|
## 1. Install Home Assistant locally
|
||||||
|
|
||||||
|
Any of these works; see <https://www.home-assistant.io/installation/>:
|
||||||
|
|
||||||
|
- **Home Assistant OS** on a Raspberry Pi / VM (recommended)
|
||||||
|
- **Container:** `docker run -d --name homeassistant --restart=unless-stopped \
|
||||||
|
-v ./config:/config -e TZ=UTC --network=host ghcr.io/home-assistant/home-assistant:stable`
|
||||||
|
- **Core:** Python venv install (advanced)
|
||||||
|
|
||||||
|
After first start the UI is at `http://<host>:8123`.
|
||||||
|
|
||||||
|
## 2. Create a long-lived access token
|
||||||
|
|
||||||
|
Home Assistant requires a long-lived access token for third-party apps
|
||||||
|
(SHONAR cannot mint one for you — that's an HA security design):
|
||||||
|
|
||||||
|
1. Open the HA web UI and log in.
|
||||||
|
2. Click your **profile picture** (bottom-left) → **Security**.
|
||||||
|
3. Scroll to **Long-lived access tokens** → **Create Token**.
|
||||||
|
4. Name it (e.g. `SHONAR`) and **copy the token immediately** — it is shown
|
||||||
|
once.
|
||||||
|
|
||||||
|
## 3. Where to enter the URL and token
|
||||||
|
|
||||||
|
SHONAR app → **Settings** → **Home Assistant** category:
|
||||||
|
|
||||||
|
1. Toggle **Enable Home Assistant** on.
|
||||||
|
2. **Home Assistant URL** — e.g. `http://homeassistant.local:8123` or
|
||||||
|
`http://192.168.1.50:8123`.
|
||||||
|
3. **Long-lived access token** — paste the token. Stored encrypted on the
|
||||||
|
device (AndroidKeyStore-backed EncryptedSharedPreferences); never in
|
||||||
|
plaintext, never logged, never included in settings export unless you
|
||||||
|
explicitly request it.
|
||||||
|
|
||||||
|
The **Devices** tab then shows your entities. The connection status appears
|
||||||
|
there: not configured / connecting / connected / error (with a plain-language
|
||||||
|
message you can act on).
|
||||||
|
|
||||||
|
## 4. Test the connection
|
||||||
|
|
||||||
|
- Settings → Home Assistant has the URL/token fields; the Devices screen
|
||||||
|
doubles as the live connection test — hit the refresh icon.
|
||||||
|
- Or from a shell on the same network:
|
||||||
|
`curl -H "Authorization: Bearer <token>" http://<ha-host>:8123/api/`
|
||||||
|
|
||||||
|
## 5. How entity discovery works
|
||||||
|
|
||||||
|
`GET /api/states` returns every entity; the app lists them with friendly
|
||||||
|
names, current state, and a search box. Entities whose domain is switchable
|
||||||
|
(`light`, `switch`, `fan`, `input_boolean`, `humidifier`) get a toggle that
|
||||||
|
calls `POST /api/services/<domain>/turn_on|turn_off` with `entity_id`.
|
||||||
|
Toggles apply optimistically and are reconciled by the live stream.
|
||||||
|
|
||||||
|
Live updates: a WebSocket to `/api/websocket` authenticates and subscribes to
|
||||||
|
`state_changed` events. On disconnect it reconnects with exponential backoff
|
||||||
|
(2s → 60s). A configurable poll fallback (`State refresh interval`, used when
|
||||||
|
the WebSocket is down) keeps the list fresh.
|
||||||
|
|
||||||
|
## 6. How custom settings work
|
||||||
|
|
||||||
|
All settings are data-driven (`SettingDefinition`), not hard-coded UI. Each
|
||||||
|
has an id, name, description, category, type (boolean/string/number/select/
|
||||||
|
multi-select/color/url/secret), default, optional min/max/choices,
|
||||||
|
editability, sensitivity, restart requirement, and conditional visibility.
|
||||||
|
|
||||||
|
Settings → **Add (+)** creates a custom setting with any type; **delete**
|
||||||
|
works on custom settings only (built-ins can be reset but not removed).
|
||||||
|
Search filters across name/id/description. **Export** produces JSON with
|
||||||
|
secrets omitted by default; **Import** validates every entry against known
|
||||||
|
definitions and applies all-or-nothing, reporting per-key rejection reasons.
|
||||||
|
|
||||||
|
## 7. How settings are stored
|
||||||
|
|
||||||
|
| Kind | Location |
|
||||||
|
|---|---|
|
||||||
|
| Ordinary values + custom definitions | Jetpack DataStore (`shonar_settings`) |
|
||||||
|
| Sensitive values (HA token, secret-type settings) | EncryptedSharedPreferences, AES256-GCM values / AES256-SIV keys, master key in AndroidKeyStore (never exportable) |
|
||||||
|
|
||||||
|
## 8. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause / fix |
|
||||||
|
|---|---|
|
||||||
|
| "Authentication failed…" | Token expired/revoked/typo. Create a fresh long-lived access token (step 2). |
|
||||||
|
| "Cannot reach the Home Assistant server" | Wrong URL/IP, phone on different network/VLAN, HA not running, or a firewall blocking 8123. Try the curl test above from the phone's network. |
|
||||||
|
| Cleartext HTTP warning | The app permits cleartext HTTP (like the official Home Assistant Android app) because LAN servers are plain `http://` by default. Android cannot restrict cleartext to IP *ranges* — use `https://` for any non-LAN host; certificate verification is never disabled. |
|
||||||
|
| Entities stale | WebSocket dropped; poll fallback updates after the configured interval; refresh icon forces a reload. |
|
||||||
|
| mDNS name not resolving | Use the IP address instead of `homeassistant.local` (phone's mDNS may be limited by the router). |
|
||||||
|
|
||||||
|
## 9. Endpoints used (official API only)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/` | connection/config test |
|
||||||
|
| GET | `/api/states` | entity discovery |
|
||||||
|
| GET | `/api/states/{entity_id}` | single state |
|
||||||
|
| POST | `/api/services/{domain}/{service}` | service/action calls |
|
||||||
|
| WS | `/api/websocket` | `auth`, `subscribe_events(state_changed)` |
|
||||||
|
|
||||||
|
References: REST <https://developers.home-assistant.io/docs/api/rest/>,
|
||||||
|
WebSocket <https://developers.home-assistant.io/docs/api/websocket/>.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue