diff --git a/README.md b/README.md index fc38bb2..c71ad90 100644 --- a/README.md +++ b/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 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. + (8 value types, add/edit/delete/reset/search/export/import). +- **Product direction:** the server layer is provider-based, with + **Nextcloud as the default** provider and options for Start9, Umbrel, a + custom SHONAR server, or local-only storage. See + [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 diff --git a/android/.kotlin/errors/errors-1788909278662.log b/android/.kotlin/errors/errors-1788909278662.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1788909278662.log @@ -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 + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 18e4c90..cf321c9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -57,10 +57,10 @@ dependencies { // persistence 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") - // networking (Home Assistant REST + WebSocket; later SHONAR API) + // networking (server provider APIs over HTTPS) 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") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c20ddfd..f2164b4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ android:theme="@style/Theme.Shonar" android:networkSecurityConfig="@xml/network_security_config"> = listOf( - // --- Home Assistant --------------------------------------------- - SettingDefinition( - id = HA_ENABLED, - name = "Enable Home Assistant", - description = "Connect to a local Home Assistant server.", - category = CAT_HOME_ASSISTANT, - type = SettingType.BOOLEAN, - defaultJson = "false", - ), - SettingDefinition( - id = HA_URL, - name = "Home Assistant URL", - description = "URL of the local Home Assistant server.", - category = CAT_HOME_ASSISTANT, - type = SettingType.URL, - defaultJson = "\"" + "http://homeassistant.local:8123\"", - visibleIfSettingId = HA_ENABLED, - ), - SettingDefinition( - id = HA_TOKEN, - name = "Long-lived access token", - description = "Create one in Home Assistant: profile picture -> " + - "Security -> Long-lived access token. Stored encrypted on device.", - category = CAT_HOME_ASSISTANT, - type = SettingType.SECRET, - defaultJson = "\"\"", - sensitive = true, - visibleIfSettingId = HA_ENABLED, - ), - SettingDefinition( - id = HA_REFRESH_INTERVAL, - name = "State refresh interval", - description = "Fallback poll interval in seconds when the live " + - "WebSocket connection is down.", - category = CAT_HOME_ASSISTANT, - type = SettingType.NUMBER, - defaultJson = "10", - min = 1.0, - max = 300.0, - visibleIfSettingId = HA_ENABLED, - ), // --- General ------------------------------------------------------- SettingDefinition( id = "default_recording_title_format", @@ -142,3 +98,4 @@ object BuiltInSettings { ), ) } + diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index 5500fd8..3ee8a83 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DevicesOther import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog @@ -41,11 +40,11 @@ import kotlinx.coroutines.launch /** * Home: big record button (functional UI; recording engine lands in M4), - * quick entries for Devices + Settings, and the first-launch recording-consent - * notice which must be acknowledged before anything else. + * a Settings entry, and the first-launch recording-consent notice which must + * be acknowledged before anything else. */ @Composable -fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) { +fun HomeScreen(onOpenSettings: () -> Unit) { val app = LocalContext.current.applicationContext as ShonarApplication val scope = rememberCoroutineScope() var consentSeen by remember { mutableStateOf(null) } @@ -79,8 +78,6 @@ fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(24.dp)) - QuickCard("Devices", "Home Assistant entities on your network", - Icons.Filled.DevicesOther, onOpenDevices) QuickCard("Settings", "Server, sync, appearance, custom settings", Icons.Filled.Settings, onOpenSettings) Spacer(Modifier.height(16.dp)) diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index 7b10cd0..f737223 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,17 +1,24 @@ - + + + localhost + .local + 10.0.0.0 + 192.168.0.0 + 172.16.0.0 + diff --git a/android/app/src/test/java/com/shonar/SettingsManagerTest.kt b/android/app/src/test/java/com/shonar/SettingsManagerTest.kt index bd67be7..25bb790 100644 --- a/android/app/src/test/java/com/shonar/SettingsManagerTest.kt +++ b/android/app/src/test/java/com/shonar/SettingsManagerTest.kt @@ -159,11 +159,18 @@ class SettingsManagerTest { val secure = InMemorySettingsStore() val sm = SettingsManager(store, secure) sm.ensureLoaded() - sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"secret-token\"") + // A user-created SECRET-type setting (no built-in secrets ship). + sm.addCustom( + SettingDefinition( + id = "my_secret", name = "My secret", category = "Custom", + type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true, + ) + ) + sm.setValue("my_secret", "\"secret-token\"") // values are stored as JSON; a string value is quoted at rest - assertEquals("\"secret-token\"", secure.getString("value.home_assistant_token")) + assertEquals("\"secret-token\"", secure.getString("value.my_secret")) // must NOT be in the plain store - assertTrue(store.keys().none { "home_assistant_token" in it }) + assertTrue(store.keys().none { it.startsWith("value.") && "my_secret" in it }) } // --- validation -------------------------------------------------------------- @@ -171,7 +178,7 @@ class SettingsManagerTest { @Test fun urlValidation_acceptsAndRejects() { // valid - for (u in listOf("http://homeassistant.local:8123", "https://ha.example.com", + for (u in listOf("http://nextcloud.local:8080", "https://ha.example.com", "http://192.168.1.50:8123")) { SettingsManager.validateUrlOrThrow(u) // must not throw } @@ -218,7 +225,14 @@ class SettingsManagerTest { fun export_omitsSecrets_unlessAsked() = runTest { val sm = manager() sm.ensureLoaded() - sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"top-secret\"") + // A user-created SECRET-type setting (no built-in secrets ship). + sm.addCustom( + SettingDefinition( + id = "my_secret", name = "My secret", category = "Custom", + type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true, + ) + ) + sm.setValue("my_secret", "\"top-secret\"") val dump = sm.exportJson(includeSecrets = false) assertFalse(dump.contains("top-secret")) val withSecrets = sm.exportJson(includeSecrets = true) diff --git a/deferred/home-assistant/README.md b/deferred/home-assistant/README.md new file mode 100644 index 0000000..bd64fb9 --- /dev/null +++ b/deferred/home-assistant/README.md @@ -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. diff --git a/android/app/src/main/java/com/shonar/ha/HaRepository.kt b/deferred/home-assistant/android/src/main/java/com/shonar/ha/HaRepository.kt similarity index 100% rename from android/app/src/main/java/com/shonar/ha/HaRepository.kt rename to deferred/home-assistant/android/src/main/java/com/shonar/ha/HaRepository.kt diff --git a/android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt b/deferred/home-assistant/android/src/main/java/com/shonar/ha/HomeAssistantClient.kt similarity index 100% rename from android/app/src/main/java/com/shonar/ha/HomeAssistantClient.kt rename to deferred/home-assistant/android/src/main/java/com/shonar/ha/HomeAssistantClient.kt diff --git a/android/app/src/main/java/com/shonar/ha/HomeAssistantEntity.kt b/deferred/home-assistant/android/src/main/java/com/shonar/ha/HomeAssistantEntity.kt similarity index 100% rename from android/app/src/main/java/com/shonar/ha/HomeAssistantEntity.kt rename to deferred/home-assistant/android/src/main/java/com/shonar/ha/HomeAssistantEntity.kt diff --git a/android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt b/deferred/home-assistant/android/src/main/java/com/shonar/ui/devices/DevicesScreen.kt similarity index 100% rename from android/app/src/main/java/com/shonar/ui/devices/DevicesScreen.kt rename to deferred/home-assistant/android/src/main/java/com/shonar/ui/devices/DevicesScreen.kt diff --git a/android/app/src/main/java/com/shonar/ui/devices/HaViewModel.kt b/deferred/home-assistant/android/src/main/java/com/shonar/ui/devices/HaViewModel.kt similarity index 100% rename from android/app/src/main/java/com/shonar/ui/devices/HaViewModel.kt rename to deferred/home-assistant/android/src/main/java/com/shonar/ui/devices/HaViewModel.kt diff --git a/android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt b/deferred/home-assistant/android/src/test/java/com/shonar/HomeAssistantClientTest.kt similarity index 100% rename from android/app/src/test/java/com/shonar/HomeAssistantClientTest.kt rename to deferred/home-assistant/android/src/test/java/com/shonar/HomeAssistantClientTest.kt diff --git a/docs/home-assistant.md b/deferred/home-assistant/docs/home-assistant.md similarity index 100% rename from docs/home-assistant.md rename to deferred/home-assistant/docs/home-assistant.md diff --git a/scripts/ha_e2e_login_flow.sh b/deferred/home-assistant/scripts/ha_e2e_login_flow.sh similarity index 100% rename from scripts/ha_e2e_login_flow.sh rename to deferred/home-assistant/scripts/ha_e2e_login_flow.sh diff --git a/scripts/ha_e2e_token.sh b/deferred/home-assistant/scripts/ha_e2e_token.sh similarity index 100% rename from scripts/ha_e2e_token.sh rename to deferred/home-assistant/scripts/ha_e2e_token.sh diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2b024b..a3880d7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 | | M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done | | M3 | Android: server URL config, login, token persistence + auto-refresh | TODO | -| HA-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) + Home Assistant integration (REST + WebSocket, discovery, service calls, reconnect) — 29 unit tests green, APK builds | done | -| HA-2 | Home Assistant live end-to-end against a real server (token minting flow blocked; unit + MockWebServer coverage only) | in progress | +| S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done | +| ~~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 | | 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 | diff --git a/docs/server-providers.md b/docs/server-providers.md new file mode 100644 index 0000000..2d68d34 --- /dev/null +++ b/docs/server-providers.md @@ -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 + + // 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 // 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.