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

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

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

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

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

View file

@ -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

View 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

View file

@ -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")

View file

@ -20,7 +20,7 @@
android:theme="@style/Theme.Shonar"
android:networkSecurityConfig="@xml/network_security_config">
<!-- network_security_config permits cleartext ONLY for private/LAN
address ranges (Home Assistant, self-hosted SHONAR on LAN).
address ranges (a user-configured self-hosted server on LAN).
TLS verification is NOT disabled anywhere. -->
<activity

View file

@ -12,7 +12,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.shonar.ui.devices.DevicesScreen
import com.shonar.ui.home.HomeScreen
import com.shonar.ui.settings.SettingsScreen
import com.shonar.ui.theme.ShonarTheme
@ -29,8 +28,7 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background,
) {
NavHost(navController = nav, startDestination = "home") {
composable("home") { HomeScreen(onOpenDevices = { nav.navigate("devices") }, onOpenSettings = { nav.navigate("settings") }) }
composable("devices") { DevicesScreen() }
composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) }
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
}
}
@ -38,3 +36,4 @@ class MainActivity : ComponentActivity() {
}
}
}

View file

@ -1,7 +1,6 @@
package com.shonar
import android.app.Application
import com.shonar.ha.HaRepository
import com.shonar.settings.DataStoreSettingsStore
import com.shonar.settings.SecureSettingsStore
import com.shonar.settings.SettingsManager
@ -14,7 +13,4 @@ class ShonarApplication : Application() {
secureStore = SecureSettingsStore(this),
)
}
/** Single HA integration instance; the UI talks only to this. */
val haRepository: HaRepository by lazy { HaRepository(settingsManager) }
}

View file

@ -2,66 +2,22 @@ package com.shonar.settings
/**
* Built-in settings. These ship with the app; users may add more at runtime.
* Home Assistant connection settings live here like any other category —
* proving the generic architecture carries a real integration.
*
* NOTE: Home Assistant settings were removed from the default configuration
* (product scope change) and preserved on branch `deferred/home-assistant`
* under `deferred/home-assistant/`.
*/
object BuiltInSettings {
const val CAT_GENERAL = "General"
const val CAT_HOME_ASSISTANT = "Home Assistant"
const val CAT_APPEARANCE = "Appearance"
const val CAT_NETWORK = "Network"
const val CAT_ADVANCED = "Advanced"
// ids other code depends on (single source of truth)
const val HA_URL = "home_assistant_url"
const val HA_TOKEN = "home_assistant_token"
const val HA_ENABLED = "home_assistant_enabled"
const val HA_REFRESH_INTERVAL = "home_assistant_refresh_interval"
const val CONSENT = "consent_notice_seen"
val all: List<SettingDefinition> = listOf(
// --- Home Assistant ---------------------------------------------
SettingDefinition(
id = HA_ENABLED,
name = "Enable Home Assistant",
description = "Connect to a local Home Assistant server.",
category = CAT_HOME_ASSISTANT,
type = SettingType.BOOLEAN,
defaultJson = "false",
),
SettingDefinition(
id = HA_URL,
name = "Home Assistant URL",
description = "URL of the local Home Assistant server.",
category = CAT_HOME_ASSISTANT,
type = SettingType.URL,
defaultJson = "\"" + "http://homeassistant.local:8123\"",
visibleIfSettingId = HA_ENABLED,
),
SettingDefinition(
id = HA_TOKEN,
name = "Long-lived access token",
description = "Create one in Home Assistant: profile picture -> " +
"Security -> Long-lived access token. Stored encrypted on device.",
category = CAT_HOME_ASSISTANT,
type = SettingType.SECRET,
defaultJson = "\"\"",
sensitive = true,
visibleIfSettingId = HA_ENABLED,
),
SettingDefinition(
id = HA_REFRESH_INTERVAL,
name = "State refresh interval",
description = "Fallback poll interval in seconds when the live " +
"WebSocket connection is down.",
category = CAT_HOME_ASSISTANT,
type = SettingType.NUMBER,
defaultJson = "10",
min = 1.0,
max = 300.0,
visibleIfSettingId = HA_ENABLED,
),
// --- General -------------------------------------------------------
SettingDefinition(
id = "default_recording_title_format",
@ -142,3 +98,4 @@ object BuiltInSettings {
),
)
}

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DevicesOther
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
@ -41,11 +40,11 @@ import kotlinx.coroutines.launch
/**
* Home: big record button (functional UI; recording engine lands in M4),
* quick entries for Devices + Settings, and the first-launch recording-consent
* notice which must be acknowledged before anything else.
* a Settings entry, and the first-launch recording-consent notice which must
* be acknowledged before anything else.
*/
@Composable
fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
fun HomeScreen(onOpenSettings: () -> Unit) {
val app = LocalContext.current.applicationContext as ShonarApplication
val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
@ -79,8 +78,6 @@ fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
QuickCard("Devices", "Home Assistant entities on your network",
Icons.Filled.DevicesOther, onOpenDevices)
QuickCard("Settings", "Server, sync, appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
Spacer(Modifier.height(16.dp))

View file

@ -1,17 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext HTTP is permitted because SHONAR's core use case is a
user-configured LOCAL server URL (Home Assistant, self-hosted SHONAR) that
is typically plain http://192.168.x.x:8123 or http://homeassistant.local.
Cleartext HTTP is permitted ONLY for local self-hosted servers reached on a
private network or localhost (a SHONAR provider, Nextcloud, etc.) that a
user explicitly configures. Android cannot restrict cleartext to IP ranges,
so this is paired with app-level URL validation that rejects cleartext for
non-private hosts, and with an explicit in-app warning whenever the
configured server URL is http://.
Note: Android cannot match IP *ranges* in network security configs — only
hostnames/suffixes — so a "LAN-only" allowlist is not expressible. This is
the same stance the official Home Assistant companion app takes.
What this does NOT do: TLS/certificate verification is left fully enabled
for every https:// URL. The app itself additionally warns (in Settings)
when an http:// URL points at a non-private host.
TLS/certificate verification is NEVER disabled. Self-signed certificates on
LAN servers are handled by explicit user approval of the specific
certificate (trust-on-first-use, recorded in secure storage), never by a
global bypass.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">.local</domain>
<domain includeSubdomains="true">10.0.0.0</domain>
<domain includeSubdomains="true">192.168.0.0</domain>
<domain includeSubdomains="true">172.16.0.0</domain>
</domain-config>
</network-security-config>

View file

@ -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)

View 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.

View file

@ -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 |

192
docs/server-providers.md Normal file
View 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.