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

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

View file

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

View file

@ -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() {
} }
} }
} }

View file

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

View file

@ -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 {
), ),
) )
} }

View file

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

View file

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

View file

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

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