Product pivot: defer Home Assistant; provider-based server architecture (Nextcloud default)
ISOLATE (nothing deleted): - moved HA module (ha/, ui/devices/, HA client tests), e2e scripts, and HA docs under deferred/home-assistant/ with a README explaining status + how to revive; complete snapshot preserved on branch deferred/home-assistant REMOVE FROM ACTIVE PRODUCT: - HomeScreen: Devices card + route gone; MainActivity nav updated - ShonarApplication: haRepository removed - BuiltInSettings: Home Assistant category/settings removed from defaults - SettingsManagerTest: secret tests rewritten around a user-created SECRET-type setting (no built-in secret ships) - Manifest + URL-validation test fixture wording neutralized - README/ROADMAP: HA marked deferred with pointer to preserved branch ADD (design, per product direction): - docs/server-providers.md: ShonarProvider interface, Room data model, auth ladder (OIDC/PKCE -> Nextcloud login-flow-v2 -> token paste), sync strategy, provider-selection UX (Nextcloud default; Start9/Umbrel as platform-probe + explicit service binding, never universal APIs; custom SHONAR server; local-only), TLS TOFU pinning policy, no-secret-logging rules, provider contract test strategy, phased plan P0-P7 VERIFY: 19 Android unit tests green, APK builds, on-device launch OK (consent dialog renders; no Devices entry). Backend unchanged (26 tests).
This commit is contained in:
parent
978ca908e1
commit
4eab1f11cf
22 changed files with 289 additions and 87 deletions
15
README.md
15
README.md
|
|
@ -64,11 +64,16 @@ buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix.
|
||||||
limiting, chunked resumable uploads, recordings CRUD, storage
|
limiting, chunked resumable uploads, recordings CRUD, storage
|
||||||
abstraction, Postgres FTS schema, Docker dev stack.
|
abstraction, Postgres FTS schema, Docker dev stack.
|
||||||
- Android: app shell with a generic data-driven **Custom Settings** system
|
- Android: app shell with a generic data-driven **Custom Settings** system
|
||||||
(8 value types, add/edit/delete/reset/search/export/import), and a full
|
(8 value types, add/edit/delete/reset/search/export/import).
|
||||||
**Home Assistant** integration — connect, test, entity discovery, live
|
- **Product direction:** the server layer is provider-based, with
|
||||||
WebSocket state updates, service calls (toggles), encrypted token
|
**Nextcloud as the default** provider and options for Start9, Umbrel, a
|
||||||
storage. See [docs/home-assistant.md](docs/home-assistant.md).
|
custom SHONAR server, or local-only storage. See
|
||||||
- Recording engine, SHONAR sync, playback, AI pipeline: TODO per roadmap.
|
[docs/server-providers.md](docs/server-providers.md) for the interface,
|
||||||
|
data model, auth flow, and phased plan.
|
||||||
|
- Home Assistant is **not** part of the initial product. Prior work is
|
||||||
|
preserved but disabled under [`deferred/home-assistant/`](deferred/home-assistant/README.md)
|
||||||
|
and on branch `deferred/home-assistant`.
|
||||||
|
- Recording engine, provider sync, playback, AI pipeline: TODO per roadmap.
|
||||||
|
|
||||||
## AI providers
|
## AI providers
|
||||||
|
|
||||||
|
|
|
||||||
4
android/.kotlin/errors/errors-1788909278662.log
Normal file
4
android/.kotlin/errors/errors-1788909278662.log
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
|
|
@ -57,10 +57,10 @@ dependencies {
|
||||||
|
|
||||||
// persistence
|
// persistence
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
// keystore-backed secrets (Home Assistant tokens etc.)
|
// keystore-backed secrets (server provider credentials)
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
// networking (Home Assistant REST + WebSocket; later SHONAR API)
|
// networking (server provider APIs over HTTPS)
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
android:theme="@style/Theme.Shonar"
|
android:theme="@style/Theme.Shonar"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<!-- network_security_config permits cleartext ONLY for private/LAN
|
<!-- network_security_config permits cleartext ONLY for private/LAN
|
||||||
address ranges (Home Assistant, self-hosted SHONAR on LAN).
|
address ranges (a user-configured self-hosted server on LAN).
|
||||||
TLS verification is NOT disabled anywhere. -->
|
TLS verification is NOT disabled anywhere. -->
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.shonar.ui.devices.DevicesScreen
|
|
||||||
import com.shonar.ui.home.HomeScreen
|
import com.shonar.ui.home.HomeScreen
|
||||||
import com.shonar.ui.settings.SettingsScreen
|
import com.shonar.ui.settings.SettingsScreen
|
||||||
import com.shonar.ui.theme.ShonarTheme
|
import com.shonar.ui.theme.ShonarTheme
|
||||||
|
|
@ -29,8 +28,7 @@ class MainActivity : ComponentActivity() {
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
NavHost(navController = nav, startDestination = "home") {
|
NavHost(navController = nav, startDestination = "home") {
|
||||||
composable("home") { HomeScreen(onOpenDevices = { nav.navigate("devices") }, onOpenSettings = { nav.navigate("settings") }) }
|
composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) }
|
||||||
composable("devices") { DevicesScreen() }
|
|
||||||
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -38,3 +36,4 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.shonar
|
package com.shonar
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.shonar.ha.HaRepository
|
|
||||||
import com.shonar.settings.DataStoreSettingsStore
|
import com.shonar.settings.DataStoreSettingsStore
|
||||||
import com.shonar.settings.SecureSettingsStore
|
import com.shonar.settings.SecureSettingsStore
|
||||||
import com.shonar.settings.SettingsManager
|
import com.shonar.settings.SettingsManager
|
||||||
|
|
@ -14,7 +13,4 @@ class ShonarApplication : Application() {
|
||||||
secureStore = SecureSettingsStore(this),
|
secureStore = SecureSettingsStore(this),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single HA integration instance; the UI talks only to this. */
|
|
||||||
val haRepository: HaRepository by lazy { HaRepository(settingsManager) }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,66 +2,22 @@ package com.shonar.settings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in settings. These ship with the app; users may add more at runtime.
|
* Built-in settings. These ship with the app; users may add more at runtime.
|
||||||
* Home Assistant connection settings live here like any other category —
|
*
|
||||||
* proving the generic architecture carries a real integration.
|
* NOTE: Home Assistant settings were removed from the default configuration
|
||||||
|
* (product scope change) and preserved on branch `deferred/home-assistant`
|
||||||
|
* under `deferred/home-assistant/`.
|
||||||
*/
|
*/
|
||||||
object BuiltInSettings {
|
object BuiltInSettings {
|
||||||
|
|
||||||
const val CAT_GENERAL = "General"
|
const val CAT_GENERAL = "General"
|
||||||
const val CAT_HOME_ASSISTANT = "Home Assistant"
|
|
||||||
const val CAT_APPEARANCE = "Appearance"
|
const val CAT_APPEARANCE = "Appearance"
|
||||||
const val CAT_NETWORK = "Network"
|
const val CAT_NETWORK = "Network"
|
||||||
const val CAT_ADVANCED = "Advanced"
|
const val CAT_ADVANCED = "Advanced"
|
||||||
|
|
||||||
// ids other code depends on (single source of truth)
|
// 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"
|
const val CONSENT = "consent_notice_seen"
|
||||||
|
|
||||||
val all: List<SettingDefinition> = listOf(
|
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 -------------------------------------------------------
|
// --- General -------------------------------------------------------
|
||||||
SettingDefinition(
|
SettingDefinition(
|
||||||
id = "default_recording_title_format",
|
id = "default_recording_title_format",
|
||||||
|
|
@ -142,3 +98,4 @@ object BuiltInSettings {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -41,11 +40,11 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: big record button (functional UI; recording engine lands in M4),
|
* Home: big record button (functional UI; recording engine lands in M4),
|
||||||
* quick entries for Devices + Settings, and the first-launch recording-consent
|
* a Settings entry, and the first-launch recording-consent notice which must
|
||||||
* notice which must be acknowledged before anything else.
|
* be acknowledged before anything else.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
|
fun HomeScreen(onOpenSettings: () -> Unit) {
|
||||||
val app = LocalContext.current.applicationContext as ShonarApplication
|
val app = LocalContext.current.applicationContext as ShonarApplication
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
|
@ -79,8 +78,6 @@ fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
QuickCard("Devices", "Home Assistant entities on your network",
|
|
||||||
Icons.Filled.DevicesOther, onOpenDevices)
|
|
||||||
QuickCard("Settings", "Server, sync, appearance, custom settings",
|
QuickCard("Settings", "Server, sync, appearance, custom settings",
|
||||||
Icons.Filled.Settings, onOpenSettings)
|
Icons.Filled.Settings, onOpenSettings)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
Cleartext HTTP is permitted because SHONAR's core use case is a
|
Cleartext HTTP is permitted ONLY for local self-hosted servers reached on a
|
||||||
user-configured LOCAL server URL (Home Assistant, self-hosted SHONAR) that
|
private network or localhost (a SHONAR provider, Nextcloud, etc.) that a
|
||||||
is typically plain http://192.168.x.x:8123 or http://homeassistant.local.
|
user explicitly configures. Android cannot restrict cleartext to IP ranges,
|
||||||
|
so this is paired with app-level URL validation that rejects cleartext for
|
||||||
|
non-private hosts, and with an explicit in-app warning whenever the
|
||||||
|
configured server URL is http://.
|
||||||
|
|
||||||
Note: Android cannot match IP *ranges* in network security configs — only
|
TLS/certificate verification is NEVER disabled. Self-signed certificates on
|
||||||
hostnames/suffixes — so a "LAN-only" allowlist is not expressible. This is
|
LAN servers are handled by explicit user approval of the specific
|
||||||
the same stance the official Home Assistant companion app takes.
|
certificate (trust-on-first-use, recorded in secure storage), never by a
|
||||||
|
global bypass.
|
||||||
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>
|
<network-security-config>
|
||||||
<base-config cleartextTrafficPermitted="true" />
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">.local</domain>
|
||||||
|
<domain includeSubdomains="true">10.0.0.0</domain>
|
||||||
|
<domain includeSubdomains="true">192.168.0.0</domain>
|
||||||
|
<domain includeSubdomains="true">172.16.0.0</domain>
|
||||||
|
</domain-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,18 @@ class SettingsManagerTest {
|
||||||
val secure = InMemorySettingsStore()
|
val secure = InMemorySettingsStore()
|
||||||
val sm = SettingsManager(store, secure)
|
val sm = SettingsManager(store, secure)
|
||||||
sm.ensureLoaded()
|
sm.ensureLoaded()
|
||||||
sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"secret-token\"")
|
// A user-created SECRET-type setting (no built-in secrets ship).
|
||||||
|
sm.addCustom(
|
||||||
|
SettingDefinition(
|
||||||
|
id = "my_secret", name = "My secret", category = "Custom",
|
||||||
|
type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sm.setValue("my_secret", "\"secret-token\"")
|
||||||
// values are stored as JSON; a string value is quoted at rest
|
// values are stored as JSON; a string value is quoted at rest
|
||||||
assertEquals("\"secret-token\"", secure.getString("value.home_assistant_token"))
|
assertEquals("\"secret-token\"", secure.getString("value.my_secret"))
|
||||||
// must NOT be in the plain store
|
// must NOT be in the plain store
|
||||||
assertTrue(store.keys().none { "home_assistant_token" in it })
|
assertTrue(store.keys().none { it.startsWith("value.") && "my_secret" in it })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- validation --------------------------------------------------------------
|
// --- validation --------------------------------------------------------------
|
||||||
|
|
@ -171,7 +178,7 @@ class SettingsManagerTest {
|
||||||
@Test
|
@Test
|
||||||
fun urlValidation_acceptsAndRejects() {
|
fun urlValidation_acceptsAndRejects() {
|
||||||
// valid
|
// valid
|
||||||
for (u in listOf("http://homeassistant.local:8123", "https://ha.example.com",
|
for (u in listOf("http://nextcloud.local:8080", "https://ha.example.com",
|
||||||
"http://192.168.1.50:8123")) {
|
"http://192.168.1.50:8123")) {
|
||||||
SettingsManager.validateUrlOrThrow(u) // must not throw
|
SettingsManager.validateUrlOrThrow(u) // must not throw
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +225,14 @@ class SettingsManagerTest {
|
||||||
fun export_omitsSecrets_unlessAsked() = runTest {
|
fun export_omitsSecrets_unlessAsked() = runTest {
|
||||||
val sm = manager()
|
val sm = manager()
|
||||||
sm.ensureLoaded()
|
sm.ensureLoaded()
|
||||||
sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"top-secret\"")
|
// A user-created SECRET-type setting (no built-in secrets ship).
|
||||||
|
sm.addCustom(
|
||||||
|
SettingDefinition(
|
||||||
|
id = "my_secret", name = "My secret", category = "Custom",
|
||||||
|
type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sm.setValue("my_secret", "\"top-secret\"")
|
||||||
val dump = sm.exportJson(includeSecrets = false)
|
val dump = sm.exportJson(includeSecrets = false)
|
||||||
assertFalse(dump.contains("top-secret"))
|
assertFalse(dump.contains("top-secret"))
|
||||||
val withSecrets = sm.exportJson(includeSecrets = true)
|
val withSecrets = sm.exportJson(includeSecrets = true)
|
||||||
|
|
|
||||||
31
deferred/home-assistant/README.md
Normal file
31
deferred/home-assistant/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# DEFERRED: Home Assistant integration
|
||||||
|
|
||||||
|
**Status: deferred — not part of SHONAR's initial product scope.**
|
||||||
|
|
||||||
|
Home Assistant must not be part of SHONAR's server, storage, authentication,
|
||||||
|
onboarding, or default feature set. This directory preserves the work that
|
||||||
|
was built (and unit-tested) before the product direction changed, for
|
||||||
|
possible future integration. It is excluded from the app build, the default
|
||||||
|
settings, the onboarding flow, and the active test plan.
|
||||||
|
|
||||||
|
**A complete, compilable copy of this integration (module `com.shonar.ha`,
|
||||||
|
Devices screen, e2e scripts, docs) is preserved on git branch
|
||||||
|
`deferred/home-assistant`.**
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `android/src/main/java/com/shonar/ha/` | `HomeAssistantClient` (official REST + WebSocket APIs), `HaRepository`, `HomeAssistantEntity` |
|
||||||
|
| `android/src/main/java/com/shonar/ui/devices/` | Devices screen + `HaViewModel` (entity discovery, toggles, live updates) |
|
||||||
|
| `android/src/test/java/com/shonar/HomeAssistantClientTest.kt` | 10 MockWebServer tests (401, unreachable, service calls, WS handshake/auth_invalid) |
|
||||||
|
| `scripts/ha_e2e_token.sh`, `scripts/ha_e2e_login_flow.sh` | Local-HA e2e auth scripts (official login_flow API, leak-safe) |
|
||||||
|
| `docs/home-assistant.md` | Setup/token/troubleshooting guide |
|
||||||
|
|
||||||
|
## To revive
|
||||||
|
|
||||||
|
1. `git checkout deferred/home-assistant` (or copy these files back).
|
||||||
|
2. Re-add the `HA_*` built-in settings and the `home_assistant` category.
|
||||||
|
3. Re-wire `ShonarApplication.haRepository`, the `devices` nav route, and the
|
||||||
|
Devices quick-card.
|
||||||
|
4. Re-enable the test and run the e2e scripts against a local HA instance.
|
||||||
|
|
@ -10,8 +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 |
|
| S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done |
|
||||||
| HA-2 | Home Assistant live end-to-end against a real server (token minting flow blocked; unit + MockWebServer coverage only) | in progress |
|
| ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** |
|
||||||
| 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 |
|
||||||
|
|
|
||||||
192
docs/server-providers.md
Normal file
192
docs/server-providers.md
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Server provider architecture
|
||||||
|
|
||||||
|
SHONAR is an open-source mobile AI app for recording, transcribing,
|
||||||
|
summarizing, and searching voice notes and conversations. Where your data
|
||||||
|
lives is a **provider** decision, hard-coded nowhere in the app.
|
||||||
|
|
||||||
|
**Default provider: Nextcloud.** Alternatives: Start9, Umbrel, custom SHONAR
|
||||||
|
server, or local-only storage. Home Assistant is explicitly out of initial
|
||||||
|
scope (see `deferred/home-assistant/README.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Provider interface
|
||||||
|
|
||||||
|
One Kotlin interface (`com.shonar.provider`) — nothing else in the app talks
|
||||||
|
to a server directly:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface ShonarProvider {
|
||||||
|
val descriptor: ProviderDescriptor // id, display name, capabilities
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
suspend fun probe(baseUrl: ServerUrl): ProbeResult // server reachable & is a SHONAR service?
|
||||||
|
suspend fun connect(credential: ProviderCredential): Unit // validate + persist (secure store)
|
||||||
|
suspend fun reconnect(): AuthState
|
||||||
|
suspend fun disconnect(revokeOnServer: Boolean): Unit
|
||||||
|
suspend fun deleteAccountAndData(): Unit
|
||||||
|
|
||||||
|
// storage (opaque keys — provider maps to its own layout)
|
||||||
|
suspend fun upload(recording: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef
|
||||||
|
suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit): Unit
|
||||||
|
suspend fun delete(ref: RemoteRef): Unit
|
||||||
|
suspend fun list(cursor: PageCursor?): Page<RemoteRecording>
|
||||||
|
|
||||||
|
// syncable sidecars (transcript/summary JSON, metadata)
|
||||||
|
suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray): Unit
|
||||||
|
suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray?
|
||||||
|
|
||||||
|
// status shown to the user
|
||||||
|
suspend fun storageLocationSummary(): StorageLocation // "Where is my data?" screen
|
||||||
|
val authState: StateFlow<AuthState> // CONNECTED / EXPIRED / REVOKED / OFFLINE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProviderRegistry` maps `ProviderDescriptor.id -> factory`. The UI, sync
|
||||||
|
engine, and database reference **providers by id only**. `LocalOnlyProvider`
|
||||||
|
is a first-class implementation (no network at all), so the app never has a
|
||||||
|
"no provider" special case.
|
||||||
|
|
||||||
|
## 2. Data model (Room)
|
||||||
|
|
||||||
|
- `recording(id UUID, title, createdAt, durationMs, localFile, mime, tags,
|
||||||
|
notes, originProviderId, remoteRef, syncState, downloadState)`
|
||||||
|
- `syncState`: `LOCAL_ONLY → QUEUED → UPLOADING → UPLOADED → SYNCED`, plus
|
||||||
|
`ERROR(retryAt, reasonCode)`. Pausing/canceling is a state, not a job kill.
|
||||||
|
- `provider(id, descriptorId, baseUrl, authKind, credentialAlias,
|
||||||
|
certPin?, lastSeenAt, enabled)` — one row per configured provider; at most
|
||||||
|
one `enabled` (the active one). Credential **alias** only; secrets live in
|
||||||
|
Android Keystore-backed storage, never in Room.
|
||||||
|
- `sidecar(recordingId, kind ∈ {TRANSCRIPT, SUMMARY, ACTION_ITEMS, KEYWORDS,
|
||||||
|
NOTES}, contentHash, updatedAt)` — synced independently of audio.
|
||||||
|
- Switching providers never touches `recording.localFile`; `remoteRef` +
|
||||||
|
`originProviderId` change only, so **provider switch is lossless**.
|
||||||
|
|
||||||
|
## 3. Authentication flow
|
||||||
|
|
||||||
|
Safest-compatible-first ladder, decided per probe:
|
||||||
|
|
||||||
|
1. **OAuth 2 / OIDC** (Nextcloud supports it on many hosts) — authorization
|
||||||
|
code + PKCE via Custom Tabs. App never sees the password.
|
||||||
|
2. **Nextcloud "Login flow v2"** (`/index.php/login/v2`): server-owned
|
||||||
|
browser consent screen, returns app password + endpoint. This is the
|
||||||
|
default for vanilla Nextcloud and is the recommended path.
|
||||||
|
3. **API token paste** (user already created an app password) — fallback.
|
||||||
|
4. **Custom SHONAR server**: SHONAR's own OAuth2-compatible API (backend
|
||||||
|
already has rotating refresh tokens, M1).
|
||||||
|
|
||||||
|
Rules: the user's *normal* account password is never requested, typed into
|
||||||
|
an app screen, or stored. Tokens go to Keystore-backed storage. Token
|
||||||
|
expiry → `EXPIRED` state with a one-tap re-auth that reuses the same flow.
|
||||||
|
`disconnect(revokeOnServer=true)` calls the provider's revoke endpoint (e.g.
|
||||||
|
Nextcloud `DELETE /index.php/core/auth/` via the app-password self-delete
|
||||||
|
endpoint) before clearing local state. Account deletion goes through the
|
||||||
|
provider API first, local wipe second.
|
||||||
|
|
||||||
|
## 4. TLS policy
|
||||||
|
|
||||||
|
- HTTPS required for any non-private host (validated in `ServerUrl`: scheme,
|
||||||
|
host, no credentials in URL, no path traversal; private ranges per RFC
|
||||||
|
1918/loopback allowed for LAN servers).
|
||||||
|
- **Never** a global cert bypass. Self-signed LAN certs handled as explicit
|
||||||
|
**trust-on-first-use**: connection attempt fails → user sees the cert
|
||||||
|
fingerprint + hostname and must approve → pin stored (SPKI SHA-256) in
|
||||||
|
secure storage → OkHttp pinned to that cert for that host only.
|
||||||
|
- Cleartext HTTP permitted only for private/LAN hosts the user typed, and
|
||||||
|
the UI shows a visible warning banner on `http://` servers.
|
||||||
|
- Logging: OkHttp logging interceptor is **off by default** and, when a
|
||||||
|
debug setting is enabled, redacts `Authorization`, `Cookie`, tokens, and
|
||||||
|
bodies. Audio bytes and transcripts are never logged in any mode.
|
||||||
|
|
||||||
|
## 5. Provider selection UX (onboarding + settings)
|
||||||
|
|
||||||
|
1. First launch → consent notice (already shipped) → **Choose where your
|
||||||
|
recordings live**:
|
||||||
|
- **Nextcloud** *(default, marked Recommended)* — URL → probe → pick auth
|
||||||
|
method → browser consent → connected.
|
||||||
|
- **Start9** — URL → probe → the app discovers which SHONAR-compatible
|
||||||
|
service is running (Server APIs are startOS app-specific; there is no
|
||||||
|
universal Start9 storage API) → the user **identifies/confirms the
|
||||||
|
service** from the probe result → that service's provider is bound.
|
||||||
|
- **Umbrel** — same pattern as Start9 (Umbrel apps each expose their own
|
||||||
|
API; probe + explicit service selection, never a universal assumption).
|
||||||
|
- **Custom server** — URL of a SHONAR backend (`/api/v1/healthz` +
|
||||||
|
`/api/v1/provider-info` probe identifies it as SHONAR-compatible).
|
||||||
|
- **Local only** — nothing leaves the phone; sync features hidden,
|
||||||
|
everything else fully functional.
|
||||||
|
2. The chosen provider is shown persistently on Home ("Stored: Nextcloud at
|
||||||
|
cloud.example.com"), and Settings → Server shows the full
|
||||||
|
**StorageLocation** screen: which provider, URL, what is synced vs local,
|
||||||
|
account, disconnect, delete account & data.
|
||||||
|
3. Switching provider: re-run selection; existing local recordings queue
|
||||||
|
against the new provider; old remote refs are kept in history until the
|
||||||
|
user chooses to migrate or forget them.
|
||||||
|
|
||||||
|
## 6. Nextcloud provider (implement first)
|
||||||
|
|
||||||
|
- **APIs** (official, supported): WebDAV `PROPFIND`/`GET`/`PUT`/`MOVE`/`DELETE`
|
||||||
|
under `/remote.php/dav/files/{user}/SHONAR/…`; login flow v2;
|
||||||
|
`OCS /ocs/v2.php/cloud/user` for identity/quota. No proprietary calls.
|
||||||
|
- **Layout**: `SHONAR/audio/{yyyy}/{recording-uuid}.m4a`,
|
||||||
|
`SHONAR/sidecars/{uuid}/transcript.json` etc. Remote ref = the DAV path
|
||||||
|
plus etag; originals are immutable (versions handled by Nextcloud's own
|
||||||
|
versioning, never overwritten by processing artifacts).
|
||||||
|
- **Chunked/resumable**: Nextcloud chunked-upload protocol
|
||||||
|
(`/remote.php/dav/uploads/…`) for large recordings; WorkManager drives it
|
||||||
|
with the same pause/resume/retry semantics already built for the SHONAR
|
||||||
|
backend (M2 upload sessions map 1:1 onto chunked DAV uploads).
|
||||||
|
- Probe: `GET /.well-known/webfinger` + `PROPFIND` depth 0 on the SHONAR
|
||||||
|
folder to verify endpoint + auth before first write.
|
||||||
|
- Auth per §3 (login flow v2 default). Quota surfaced in the StorageLocation
|
||||||
|
screen from OCS.
|
||||||
|
|
||||||
|
## 7. Start9 / Umbrel / custom adapters
|
||||||
|
|
||||||
|
- **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is
|
||||||
|
"platform probe + service binding": a `PlatformProbe` (Start9: Server API
|
||||||
|
over its RPC; Umbrel: its app manifest endpoints, where available)
|
||||||
|
enumerates *running apps*, and each app that embeds a **SHONAR-compatible
|
||||||
|
service** (the same `/api/v1/provider-info` handshake) is offered to the
|
||||||
|
user for explicit selection. Once selected, that binding reuses the
|
||||||
|
custom-SHONAR-server provider. If no SHONAR-compatible service is found,
|
||||||
|
the UI says so plainly and offers local-only or custom URL — we do **not**
|
||||||
|
invent universal storage semantics for these platforms.
|
||||||
|
- **Custom server** = the SHONAR FastAPI backend in this repo (already
|
||||||
|
M0–M2): auth, chunked uploads, recordings CRUD, FTS. `provider-info`
|
||||||
|
returns `{kind: "shonar", version, capabilities[]}` so the app can feature
|
||||||
|
-gate (e.g. server-side transcription available?).
|
||||||
|
- All three share the `ShonarProvider` contract; only discovery differs.
|
||||||
|
|
||||||
|
## 8. Test strategy
|
||||||
|
|
||||||
|
- **Provider contract suite** (shared, data-driven): every implementation
|
||||||
|
(`LocalOnly`, `CustomShonar`, `Nextcloud`) runs the same test list against
|
||||||
|
MockWebServer / fake FS — upload/resume/pause/retry, sidecars, delete,
|
||||||
|
auth-state transitions (CONNECTED→EXPIRED→re-auth→CONNECTED), revocation,
|
||||||
|
cert-pin TOFU, URL validation rejects (cleartext public host, credentials
|
||||||
|
in URL, traversal).
|
||||||
|
- **Nextcloud-specific**: login-flow v2 handshake, DAV path layout, chunked
|
||||||
|
upload protocol, quota parsing — against recorded MockWebServer fixtures.
|
||||||
|
- **Leak tests**: assert no credential, token, audio byte, or transcript
|
||||||
|
string ever appears in app logs (logcat capture under debug setting
|
||||||
|
enabled + redaction assertions) and in Room tables.
|
||||||
|
- **Local-only invariant**: device in airplane mode → full record/playback/
|
||||||
|
search functionality passes.
|
||||||
|
- Backend keeps its pytest suite (26 green as of M2). Android unit tests
|
||||||
|
keep 19 settings tests green; provider tests added per phase.
|
||||||
|
|
||||||
|
## 9. Phased plan
|
||||||
|
|
||||||
|
| Phase | Deliverable | Verify |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green |
|
||||||
|
| P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) |
|
||||||
|
| P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device |
|
||||||
|
| P3 | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | backend + app integration |
|
||||||
|
| P4 | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking | contract suite + a real Nextcloud instance |
|
||||||
|
| P5 | TLS TOFU pinning + redaction/logging + leak tests | cert fixtures |
|
||||||
|
| P6 | Platform probes for Start9/Umbrel + service-binding UX | probe fakes |
|
||||||
|
| P7 | Provider switching w/ migration prompts, account deletion, revocation | e2e |
|
||||||
|
|
||||||
|
Recording engine (M4) and playback (M6) proceed independently on top of the
|
||||||
|
same Room model; provider work is orthogonal.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue