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