diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cddbcef..2ee6c1f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,4 @@ jobs:
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- # Skipped until the Android project lands (M3).
- run: ./gradlew test assembleDebug --no-daemon
- continue-on-error: ${{ !hashFiles('android/**/build.gradle.kts') }}
diff --git a/.gitignore b/.gitignore
index ffde9f1..f174b13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ data/
# --- Android / Gradle ---
android/.gradle/
+android/.kotlin/
android/build/
android/app/build/
*.apk
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da7c87f..879c659 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,10 +18,8 @@ Thanks for your interest in improving S.H.O.N.A.R.!
```bash
# Backend
+./scripts/dev_bootstrap.sh
cd backend
-uv venv .venv && uv pip install -e ".[dev]"
-docker compose -f ../deploy/docker-compose.dev.yml up -d
-.venv/bin/alembic upgrade head
.venv/bin/uvicorn shonar.main:app --reload
.venv/bin/pytest && .venv/bin/ruff check .
diff --git a/README.md b/README.md
index c71ad90..4d86d7d 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,11 @@ buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix.
- 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.
+- Provider sync and AI pipeline remain TODO; local recording, playback, and
+ library storage are implemented in the Android app. The custom SHONAR
+ server provider (login, token persistence + auto-refresh, chunked
+ upload/download) is implemented with provider-selection login UI;
+ on-device verification pending, WorkManager sync driver still TODO.
## AI providers
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index cf321c9..ad7b917 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
@@ -48,6 +49,9 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.4")
+ implementation("androidx.room:room-runtime:2.6.1")
+ implementation("androidx.room:room-ktx:2.6.1")
+ kapt("androidx.room:room-compiler:2.6.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
@@ -62,12 +66,22 @@ dependencies {
// networking (server provider APIs over HTTPS)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ // background sync driver (M5)
+ implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// tests
testImplementation("junit:junit:4.13.2")
+ // Real org.json for JVM unit tests: the android.jar stubs throw
+ // ("Method ... not mocked"), which would kill any MockWebServer
+ // dispatcher or provider JSON parsing under test.
+ testImplementation("org.json:json:20240303")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+ // In-test certificate authority for the P5 TOFU tests (self-signed
+ // fixtures generated at runtime, no checked-in keys).
+ testImplementation("com.squareup.okhttp3:okhttp-tls:4.12.0")
+ testImplementation("androidx.room:room-testing:2.6.1")
androidTestImplementation(composeBom)
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f2164b4..ef485e9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,8 +4,6 @@
-
@@ -32,5 +30,9 @@
+
diff --git a/android/app/src/main/java/com/shonar/MainActivity.kt b/android/app/src/main/java/com/shonar/MainActivity.kt
index 9ee0703..d279e4d 100644
--- a/android/app/src/main/java/com/shonar/MainActivity.kt
+++ b/android/app/src/main/java/com/shonar/MainActivity.kt
@@ -10,16 +10,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
-import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.shonar.ui.home.HomeScreen
+import com.shonar.ui.provider.ProviderSelectionScreen
+import com.shonar.ui.provider.StorageScreen
import com.shonar.ui.settings.SettingsScreen
import com.shonar.ui.theme.ShonarTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val app = application as ShonarApplication
setContent {
ShonarTheme {
val nav = rememberNavController()
@@ -28,7 +28,24 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background,
) {
NavHost(navController = nav, startDestination = "home") {
- composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) }
+ composable("home") {
+ HomeScreen(
+ onOpenSettings = { nav.navigate("settings") },
+ onOpenStorage = { nav.navigate("storage") },
+ )
+ }
+ composable("storage") {
+ StorageScreen(
+ onBack = { nav.popBackStack() },
+ onSwitchProvider = { nav.navigate("provider") },
+ )
+ }
+ composable("provider") {
+ ProviderSelectionScreen(
+ onDone = { nav.popBackStack() },
+ onBack = { nav.popBackStack() },
+ )
+ }
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
}
}
@@ -36,4 +53,3 @@ class MainActivity : ComponentActivity() {
}
}
}
-
diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt
index 4773c1b..6f81b1c 100644
--- a/android/app/src/main/java/com/shonar/ShonarApplication.kt
+++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt
@@ -1,16 +1,113 @@
package com.shonar
import android.app.Application
+import androidx.room.Room
+import com.shonar.recording.RecordingRepository
+import com.shonar.recording.ShonarDatabase
import com.shonar.settings.DataStoreSettingsStore
import com.shonar.settings.SecureSettingsStore
import com.shonar.settings.SettingsManager
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
class ShonarApplication : Application() {
+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ val database: ShonarDatabase by lazy {
+ Room.databaseBuilder(this, ShonarDatabase::class.java, "shonar.db")
+ .addMigrations(
+ com.shonar.recording.MIGRATION_1_2,
+ com.shonar.recording.MIGRATION_2_3,
+ )
+ .build()
+ }
+
+ val recordingRepository: RecordingRepository by lazy {
+ RecordingRepository(this, database.recordingDao())
+ }
+
+ val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) }
+
val settingsManager: SettingsManager by lazy {
SettingsManager(
store = DataStoreSettingsStore(this),
- secureStore = SecureSettingsStore(this),
+ secureStore = secureStore,
+ )
+ }
+
+ /** P5: trust-on-first-use pins (secure store) + in-memory cache. */
+ val tofu: com.shonar.provider.TofuManager by lazy {
+ com.shonar.provider.TofuManager(
+ com.shonar.provider.TofuStore(secureStore)
+ )
+ }
+
+ /**
+ * HTTP debug logging follows the `log_http_bodies` setting. Interceptors
+ * run on OkHttp threads that cannot suspend, so the flag is cached here
+ * and refreshed whenever settings change — toggling needs no restart.
+ */
+ private val bodyLogging = AtomicBoolean(false)
+
+ /** Server providers (P1 local-only, P3 custom SHONAR, P4 Nextcloud + sync folder). */
+ val providerRegistry: com.shonar.provider.ProviderRegistry by lazy {
+ com.shonar.provider.ProviderRegistry.withDefaults(
+ appFilesDir = filesDir,
+ secureStore = secureStore,
+ plainStore = DataStoreSettingsStore(this),
+ tlsPolicy = com.shonar.provider.TlsPolicy(
+ tofu = tofu,
+ bodiesEnabled = bodyLogging::get,
+ ),
+ )
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ appScope.launch {
+ settingsManager.ensureLoaded()
+ // TOFU pins must be in cache before any TLS handshake needs them.
+ tofu.refresh()
+ syncBodyLoggingFlag()
+ scheduleSync()
+ settingsManager.valuesChanged.collect {
+ syncBodyLoggingFlag()
+ scheduleSync()
+ }
+ }
+ }
+
+ /** M5: steady-state periodic drain plus an immediate drain at startup
+ * and whenever sync constraints change. Re-running on unrelated
+ * settings edits only refreshes the periodic schedule (cheap UPDATE),
+ * never a drain. */
+ private var lastSyncFlags: Pair? = null
+
+ private suspend fun scheduleSync() {
+ val flags = wifiOnly() to chargingOnly()
+ com.shonar.recording.SyncScheduler.ensurePeriodic(this, flags.first, flags.second)
+ if (lastSyncFlags == null || lastSyncFlags != flags) {
+ com.shonar.recording.SyncScheduler.requestNow(this, flags.first, flags.second)
+ }
+ lastSyncFlags = flags
+ }
+
+ private suspend fun wifiOnly(): Boolean = runCatching {
+ settingsManager.bool(com.shonar.settings.BuiltInSettings.WIFI_ONLY_UPLOAD)
+ }.getOrDefault(true)
+
+ private suspend fun chargingOnly(): Boolean = runCatching {
+ settingsManager.bool(com.shonar.settings.BuiltInSettings.CHARGING_ONLY_UPLOAD)
+ }.getOrDefault(false)
+
+ private suspend fun syncBodyLoggingFlag() {
+ bodyLogging.set(
+ runCatching { settingsManager.bool("log_http_bodies") }.getOrDefault(false)
)
}
}
diff --git a/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt
new file mode 100644
index 0000000..a9d9624
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt
@@ -0,0 +1,573 @@
+package com.shonar.provider
+
+import java.io.File
+import java.security.MessageDigest
+import java.time.Instant
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+
+/**
+ * P3: SHONAR-backend provider (this repo's FastAPI server, M1 auth + M2
+ * uploads). Speaks only documented endpoints:
+ *
+ * - GET /api/v1/provider-info (probe; no credentials)
+ * - POST /api/v1/auth/login {email, password, device_name, platform}
+ * - POST /api/v1/auth/refresh {refresh_token} (rotating; reuse-detected)
+ * - POST /api/v1/auth/logout {refresh_token}
+ * - GET /api/v1/auth/me (token validation)
+ * - POST /api/v1/auth/delete-account {password} (via [deleteAccount])
+ * - POST /api/v1/uploads (create session)
+ * - GET /api/v1/uploads/{id} (resume: received indexes)
+ * - PUT /api/v1/uploads/{id}/chunks/{n} (+ X-Chunk-Sha256)
+ * - POST /api/v1/uploads/{id}/finalize
+ * - GET /api/v1/recordings?limit&offset&sort&order
+ * - GET /api/v1/recordings/{id}/audio
+ * - DELETE /api/v1/recordings/{id}?purge=true
+ *
+ * Token discipline: the backend rotates refresh tokens with reuse
+ * detection, so the stored pair is overwritten on every login AND every
+ * refresh, and concurrent 401s serialize on [refreshMutex] — two parallel
+ * refreshes would look like token reuse and burn the whole family.
+ *
+ * Sidecars: the backend has no sidecar endpoints until M7 (transcripts),
+ * so transcript/summary JSON is cached in app-private storage keyed by the
+ * remote recording id. Same layout as LocalOnlyProvider, so the M7
+ * migration is a file walk, not a format change.
+ *
+ * Cancellation safety: a cancelled upload leaves an open server session
+ * with some chunks stored — invisible until finalize, resumable via the
+ * status endpoint, and idempotent per draft id through
+ * `client_recording_id`. Nothing half-visible ever appears in listings.
+ */
+class CustomShonarProvider(
+ private val auth: ShonarAuthStore,
+ private val sidecarRoot: File,
+ private val client: OkHttpClient = defaultClient(),
+ private val handshake: ShonarHandshake = ShonarHandshake(),
+) : ShonarProvider {
+
+ override val descriptor = ProviderDescriptor(
+ id = ProviderRegistry.CUSTOM_SHONAR_ID,
+ displayName = "Custom SHONAR server",
+ capabilities = setOf(
+ ProviderDescriptor.Capability.CHUNKED_UPLOAD,
+ ProviderDescriptor.Capability.ACCOUNT_DELETION,
+ ),
+ )
+
+ private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
+ override val authState: StateFlow = _authState
+
+ /** Base origin, e.g. https://shonar.example.com. Set by connect/reconnect. */
+ private var origin: String? = null
+
+ private val refreshMutex = Mutex()
+
+ // ---- lifecycle ---------------------------------------------------------
+
+ override suspend fun probe(baseUrl: ServerUrl): ProbeResult = handshake.probe(baseUrl)
+
+ override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
+ when (credential) {
+ is ProviderCredential.ShonarLogin -> login(
+ credential.serverUrl.origin, credential.email, credential.password
+ )
+ is ProviderCredential.OAuthTokens -> resumeWithTokens(credential)
+ else -> throw ProviderError.InvalidUrl(
+ "Custom SHONAR server needs an email + password login"
+ )
+ }
+ }
+
+ override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
+ val saved = auth.load()
+ if (saved == null || saved.refreshToken.isBlank()) {
+ _authState.value = AuthState.DISCONNECTED
+ return@withContext _authState.value
+ }
+ origin = saved.baseUrl
+ val base = saved.baseUrl
+ try {
+ executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp ->
+ if (resp.code != 200) throw ProviderError.AuthExpired()
+ }
+ _authState.value = AuthState.CONNECTED
+ } catch (e: ProviderError.AuthExpired) {
+ // Refresh already failed inside executeAuthed: tokens are dead.
+ auth.clearTokens()
+ _authState.value = AuthState.EXPIRED
+ } catch (e: ProviderError) {
+ _authState.value = AuthState.OFFLINE
+ }
+ _authState.value
+ }
+
+ override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) {
+ if (revokeOnServer) {
+ // Best effort: local state is cleared even if revoke fails.
+ runCatching {
+ val saved = auth.load()
+ if (saved != null && saved.refreshToken.isNotBlank()) {
+ postUnauthed(
+ saved.baseUrl, "/api/v1/auth/logout",
+ """{"refresh_token":${jsonStr(saved.refreshToken)}}""",
+ ).close()
+ }
+ }
+ }
+ auth.clearTokens()
+ _authState.value = AuthState.DISCONNECTED
+ }
+
+ override suspend fun deleteAccountAndData() {
+ // No password is available here (never stored), so server-side
+ // account purge needs the explicit [deleteAccount] call below.
+ // This path revokes the session and wipes everything local.
+ withContext(Dispatchers.IO) {
+ disconnect(revokeOnServer = true)
+ sidecarRoot.deleteRecursively()
+ }
+ }
+
+ /**
+ * Full server-side account purge (backend: 30-day grace, then hard
+ * delete). Needs the password because it is never stored — call this
+ * from a confirmation screen that asks for it once.
+ */
+ suspend fun deleteAccount(password: String) = withContext(Dispatchers.IO) {
+ val base = origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected()
+ executeAuthed(base) { token ->
+ post(base, "/api/v1/auth/delete-account", token,
+ """{"password":${jsonStr(password)}}""")
+ }.use { resp ->
+ if (resp.code != 202 && resp.code != 204) {
+ throw ProviderError.Transient("Account deletion refused (HTTP ${resp.code})")
+ }
+ }
+ auth.clearAll()
+ origin = null
+ sidecarRoot.deleteRecursively()
+ _authState.value = AuthState.DISCONNECTED
+ }
+
+ // ---- storage -----------------------------------------------------------
+
+ override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
+ withContext(Dispatchers.IO) {
+ ensureConnected()
+ val base = currentOrigin()
+ val total = draft.sizeBytes.coerceAtLeast(1)
+
+ val (sessionId, chunkSize) = createSession(base, draft)
+ val received = uploadStatus(base, sessionId).toMutableSet()
+
+ var sent = 0L
+ // Account progress for already-present chunks so resume continues
+ // the bar instead of restarting it.
+ var idx = 0
+ val chunkCount = ((draft.sizeBytes + chunkSize - 1) / chunkSize).toInt().coerceAtLeast(1)
+ var resumedBytes = 0L
+ // Estimate resumed bytes from the received set (last chunk may be short).
+ for (i in received) {
+ resumedBytes += if (i < chunkCount - 1) chunkSize.toLong()
+ else (draft.sizeBytes - chunkSize * (chunkCount - 1)).coerceAtLeast(0)
+ }
+ sent = resumedBytes
+ if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
+
+ while (idx < chunkCount) {
+ // Cooperative cancellation between chunks; a cancelled
+ // upload leaves a resumable server session, never a
+ // half-visible object.
+ ensureActive()
+ if (idx !in received) {
+ val slice = readSlice(draft.sourceFile, idx.toLong() * chunkSize, chunkSize)
+ putChunk(base, sessionId, idx, slice)
+ sent += slice.size
+ onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
+ }
+ idx++
+ }
+
+ val recordingId = finalize(base, sessionId, draft)
+ onProgress(1f)
+ RemoteRef(ProviderRegistry.CUSTOM_SHONAR_ID, recordingId, etag = null, sizeBytes = draft.sizeBytes)
+ }
+
+ override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
+ withContext(Dispatchers.IO) {
+ ensureConnected()
+ val base = currentOrigin()
+ executeAuthed(base) { token -> get(base, "/api/v1/recordings/${ref.key}/audio", token) }.use { resp ->
+ when (resp.code) {
+ 200 -> {
+ val body = resp.body ?: throw ProviderError.Transient("Empty download response")
+ val total = body.contentLength().takeIf { it > 0 } ?: -1
+ dest.parentFile?.mkdirs()
+ body.byteStream().use { input ->
+ dest.outputStream().use { output ->
+ val buf = ByteArray(64 * 1024)
+ var written = 0L
+ var lastReported = -1f
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ output.write(buf, 0, n)
+ written += n
+ if (total > 0) {
+ val p = (written.toFloat() / total).coerceIn(0f, 1f)
+ if (p > lastReported) {
+ onProgress(p)
+ lastReported = p
+ }
+ }
+ }
+ }
+ }
+ onProgress(1f)
+ }
+ 404 -> throw ProviderError.NotFound(ref.key)
+ else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})")
+ }
+ }
+ }
+
+ override suspend fun delete(ref: RemoteRef) {
+ withContext(Dispatchers.IO) {
+ ensureConnected()
+ val base = currentOrigin()
+ executeAuthed(base) { token ->
+ Request.Builder().url("$base/api/v1/recordings/${ref.key}?purge=true")
+ .delete().header("Authorization", "Bearer $token").build()
+ }.use { resp ->
+ when (resp.code) {
+ 204, 200 -> {
+ sidecarDir(ref).deleteRecursively()
+ }
+ 404 -> throw ProviderError.NotFound(ref.key)
+ else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})")
+ }
+ }
+ }
+ }
+
+ override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) {
+ ensureConnected()
+ val base = currentOrigin()
+ val offset = cursor?.toIntOrNull()?.coerceAtLeast(0) ?: 0
+ val limit = 200
+ executeAuthed(base) { token ->
+ get(base, "/api/v1/recordings?limit=$limit&offset=$offset&sort=recorded_at&order=desc", token)
+ }.use { resp ->
+ if (resp.code != 200) throw ProviderError.Transient("Listing failed (HTTP ${resp.code})")
+ val root = org.json.JSONObject(resp.body?.string().orEmpty())
+ val total = root.optInt("total", 0)
+ val items = root.optJSONArray("items") ?: org.json.JSONArray()
+ val out = mutableListOf()
+ for (i in 0 until items.length()) {
+ parseRecording(items.getJSONObject(i))?.let { out += it }
+ }
+ val next = if (offset + limit < total) (offset + limit).toString() else null
+ Page(out, next)
+ }
+ }
+
+ // ---- sidecars (local cache until the backend gains endpoints in M7) ----
+
+ private fun sidecarDir(ref: RemoteRef) = File(sidecarRoot, ref.key)
+
+ override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
+ withContext(Dispatchers.IO) {
+ val f = File(sidecarDir(ref), kind.fileName)
+ f.parentFile?.mkdirs()
+ f.writeBytes(bytes)
+ }
+
+ override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
+ withContext(Dispatchers.IO) {
+ val f = File(sidecarDir(ref), kind.fileName)
+ if (f.exists()) f.readBytes() else null
+ }
+
+ // ---- status ------------------------------------------------------------
+
+ override suspend fun storageLocationSummary(): StorageLocation =
+ withContext(Dispatchers.IO) {
+ ensureConnected()
+ val base = currentOrigin()
+ val host = runCatching { java.net.URI(base).host }.getOrNull() ?: base
+ val email = auth.load()?.email.orEmpty()
+ var count = 0
+ var bytes = 0L
+ var cursor: String? = null
+ do {
+ val page = list(cursor)
+ count += page.items.size
+ bytes += page.items.sumOf { it.ref.sizeBytes }
+ cursor = page.nextCursor
+ } while (cursor != null)
+ StorageLocation(
+ headline = "Custom SHONAR server at $host",
+ detail = if (email.isBlank()) "$count recordings synced."
+ else "Signed in as $email · $count recordings synced.",
+ syncedCount = count,
+ localOnlyCount = 0,
+ bytesUsed = bytes,
+ )
+ }
+
+ // ---- internals ---------------------------------------------------------
+
+ private fun ensureConnected() {
+ if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
+ }
+
+ private suspend fun currentOrigin(): String =
+ origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected()
+
+ private suspend fun login(base: String, email: String, password: String) {
+ val body = """{"email":${jsonStr(email)},"password":${jsonStr(password)},""" +
+ """"device_name":"SHONAR Android","platform":"android"}"""
+ postUnauthed(base, "/api/v1/auth/login", body).use { resp ->
+ when (resp.code) {
+ 200 -> {
+ persistSession(base, email, org.json.JSONObject(resp.body?.string().orEmpty()))
+ origin = base
+ _authState.value = AuthState.CONNECTED
+ }
+ 401 -> throw ProviderError.Transient("Invalid email or password")
+ else -> throw ProviderError.Transient("Login failed (HTTP ${resp.code})")
+ }
+ }
+ }
+
+ private suspend fun resumeWithTokens(credential: ProviderCredential.OAuthTokens) {
+ val base = origin ?: auth.load()?.baseUrl
+ ?: throw ProviderError.InvalidUrl("No SHONAR server configured yet")
+ origin = base
+ auth.save(
+ ShonarSession(
+ baseUrl = base,
+ email = credential.accountLabel,
+ accessToken = credential.accessToken,
+ refreshToken = credential.refreshToken.orEmpty(),
+ expiresAtEpochSec = credential.expiresAtEpochSec,
+ deviceId = null,
+ )
+ )
+ executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp ->
+ if (resp.code != 200) throw ProviderError.AuthExpired()
+ }
+ _authState.value = AuthState.CONNECTED
+ }
+
+ private suspend fun persistSession(base: String, email: String, json: org.json.JSONObject) {
+ val nowSec = Instant.now().epochSecond
+ auth.save(
+ ShonarSession(
+ baseUrl = base,
+ email = email,
+ accessToken = json.getString("access_token"),
+ refreshToken = json.getString("refresh_token"),
+ expiresAtEpochSec = nowSec + json.optInt("expires_in", 900),
+ deviceId = json.optString("device_id", null).takeUnless { it.isNullOrBlank() },
+ )
+ )
+ }
+
+ /** Single-flight refresh; concurrent 401s must not double-refresh. */
+ private suspend fun refreshLocked(failedAccess: String): String = refreshMutex.withLock {
+ val current = auth.load() ?: throw ProviderError.NotConnected()
+ // A peer already refreshed while we queued — reuse its tokens.
+ if (current.accessToken != failedAccess && current.accessToken.isNotBlank()) {
+ return@withLock current.accessToken
+ }
+ if (current.refreshToken.isBlank()) {
+ _authState.value = AuthState.EXPIRED
+ throw ProviderError.AuthExpired()
+ }
+ val body = """{"refresh_token":${jsonStr(current.refreshToken)}}"""
+ try {
+ postUnauthed(current.baseUrl, "/api/v1/auth/refresh", body).use { resp ->
+ if (resp.code != 200) {
+ // Reuse detected or revoked family: stored tokens are dead.
+ auth.clearTokens()
+ _authState.value = AuthState.EXPIRED
+ throw ProviderError.AuthExpired()
+ }
+ persistSession(current.baseUrl, current.email, org.json.JSONObject(resp.body?.string().orEmpty()))
+ _authState.value = AuthState.CONNECTED
+ return@withLock auth.load()?.accessToken ?: throw ProviderError.AuthExpired()
+ }
+ } catch (e: ProviderError) {
+ throw e
+ } catch (e: Exception) {
+ throw ProviderError.Transient("Token refresh failed (${e.javaClass.simpleName})")
+ }
+ }
+
+ /** Execute an authenticated request; one transparent refresh+retry on 401. */
+ private suspend fun executeAuthed(
+ base: String,
+ build: (access: String) -> Request,
+ ): Response = withContext(Dispatchers.IO) {
+ val session = auth.load() ?: throw ProviderError.NotConnected()
+ val first = client.newCall(build(session.accessToken)).execute()
+ if (first.code != 401) return@withContext first
+ first.close()
+ val fresh = refreshLocked(session.accessToken)
+ val retry = client.newCall(build(fresh)).execute()
+ if (retry.code == 401) {
+ retry.close()
+ _authState.value = AuthState.EXPIRED
+ throw ProviderError.AuthExpired()
+ }
+ retry
+ }
+
+ private fun get(base: String, path: String, access: String): Request =
+ Request.Builder().url(base + path).get()
+ .header("Authorization", "Bearer $access").build()
+
+ private fun post(base: String, path: String, access: String, json: String): Request =
+ Request.Builder().url(base + path)
+ .post(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
+ .header("Authorization", "Bearer $access").build()
+
+ private fun postUnauthed(base: String, path: String, json: String): Response {
+ val req = Request.Builder().url(base + path)
+ .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build()
+ return client.newCall(req).execute()
+ }
+
+ // Upload-session flow returns Triple(sessionId, chunkSize, declaredMime echo not needed).
+ private suspend fun createSession(base: String, draft: RecordingDraft): Pair {
+ val body = """{"declared_mime_type":${jsonStr(draft.mime)},""" +
+ """"declared_size_bytes":${draft.sizeBytes},""" +
+ """"title":${jsonStr(draft.title)},""" +
+ """"client_recording_id":${jsonStr(draft.id)}}"""
+ executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp ->
+ when (resp.code) {
+ 201 -> {
+ val json = org.json.JSONObject(resp.body?.string().orEmpty())
+ return json.getString("id") to json.optInt("chunk_size_bytes", 16 * 1024 * 1024)
+ }
+ 413 -> throw ProviderError.Transient("Recording exceeds the server size limit")
+ 415 -> throw ProviderError.Transient("Audio type not accepted by the server")
+ else -> throw ProviderError.Transient("Upload rejected (HTTP ${resp.code})")
+ }
+ }
+ }
+
+ private suspend fun uploadStatus(base: String, sessionId: String): Set {
+ executeAuthed(base) { token -> get(base, "/api/v1/uploads/$sessionId", token) }.use { resp ->
+ if (resp.code != 200) throw ProviderError.Transient("Upload status failed (HTTP ${resp.code})")
+ val arr = org.json.JSONObject(resp.body?.string().orEmpty())
+ .optJSONArray("received_chunk_indexes") ?: return emptySet()
+ return (0 until arr.length()).map { arr.getInt(it) }.toSet()
+ }
+ }
+
+ private suspend fun putChunk(base: String, sessionId: String, index: Int, bytes: ByteArray) {
+ val digest = MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
+ val req = { token: String ->
+ Request.Builder().url("$base/api/v1/uploads/$sessionId/chunks/$index")
+ .put(bytes.toRequestBody("application/octet-stream".toMediaType()))
+ .header("Authorization", "Bearer $token")
+ .header("X-Chunk-Sha256", digest).build()
+ }
+ executeAuthed(base, req).use { resp ->
+ if (resp.code != 201) throw ProviderError.Transient("Chunk $index rejected (HTTP ${resp.code})")
+ }
+ }
+
+ private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String {
+ val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString()
+ val body = """{"recorded_at":${jsonStr(recordedAt)},""" +
+ """"duration_seconds":${draft.durationMs / 1000.0}}"""
+ executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) }
+ .use { resp ->
+ if (resp.code != 201) {
+ throw ProviderError.Transient("Upload finalize failed (HTTP ${resp.code})")
+ }
+ return org.json.JSONObject(resp.body?.string().orEmpty()).getString("id")
+ }
+ }
+
+ private fun parseRecording(json: org.json.JSONObject): RemoteRecording? {
+ if (!json.optBoolean("has_audio", false)) return null
+ val id = json.optString("id", "")
+ if (id.isBlank()) return null
+ val createdAt = runCatching {
+ Instant.parse(json.getString("recorded_at")).toEpochMilli()
+ }.getOrDefault(0L)
+ return RemoteRecording(
+ ref = RemoteRef(
+ providerId = ProviderRegistry.CUSTOM_SHONAR_ID,
+ key = id,
+ etag = null,
+ sizeBytes = json.optLong("size_bytes", 0),
+ ),
+ title = json.optString("title", id),
+ createdAtEpochMs = createdAt,
+ durationMs = (json.optDouble("duration_seconds", 0.0) * 1000).toLong(),
+ mime = json.optString("mime_type", null) ?: "application/octet-stream",
+ )
+ }
+
+ companion object {
+ fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
+ // No logging interceptor by policy (docs/server-providers.md §4):
+ // bodies would carry audio bytes and transcripts.
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ /** JSON string literal with escaping; never pass secrets to message strings. */
+ internal fun jsonStr(raw: String): String = buildString {
+ append('"')
+ for (c in raw) when (c) {
+ '"' -> append("\\\"")
+ '\\' -> append("\\\\")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ else -> if (c < ' ') append("\\u%04x".format(c.code)) else append(c)
+ }
+ append('"')
+ }
+
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
+
+ private fun readSlice(file: File, offset: Long, max: Int): ByteArray {
+ file.inputStream().use { input ->
+ var skipped = 0L
+ while (skipped < offset) {
+ val n = input.skip(offset - skipped)
+ if (n <= 0) break
+ skipped += n
+ }
+ val buf = ByteArray(max)
+ var read = 0
+ while (read < max) {
+ val n = input.read(buf, read, max - read)
+ if (n < 0) break
+ read += n
+ }
+ return if (read == max) buf else buf.copyOf(read)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt
new file mode 100644
index 0000000..b4269c8
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt
@@ -0,0 +1,160 @@
+package com.shonar.provider
+
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
+
+/**
+ * P4: sync-folder provider — "bring your own sync". The app reads and
+ * writes plain files under a user-chosen directory; an external tool
+ * (Syncthing, the Nextcloud desktop client, rsync, …) moves those bytes
+ * between devices. The app never talks to a network for this provider —
+ * provable by construction (no HTTP imports in this file).
+ *
+ * The on-disk layout is identical to [LocalOnlyProvider] (`audio/{id}`,
+ * `sidecars/…`), so switching between local-only and a sync folder is a
+ * copy, not a migration, and anything already syncing the folder picks
+ * the recordings up with no special handling.
+ *
+ * Two deliberate differences from local-only:
+ * - [connect] validates the directory (must exist, be a directory, be
+ * readable AND writable) and refuses anything else. A typo must be an
+ * error, never a silently created folder somewhere surprising. Paths
+ * escaping via `..` are rejected for the same reason.
+ * - [deleteAccountAndData] NEVER deletes the folder's contents. That
+ * directory belongs to the user and their sync tool, not to the app —
+ * forgetting the path is the whole operation.
+ */
+class FolderSyncProvider(
+ private val pathStore: com.shonar.settings.SettingsStore =
+ com.shonar.settings.InMemorySettingsStore(),
+) : ShonarProvider {
+
+ override val descriptor = ProviderDescriptor(
+ id = ID,
+ displayName = "Sync folder",
+ capabilities = setOf(), // the sync tool owns the protocol, not us
+ )
+
+ private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
+ override val authState: StateFlow = _authState
+
+ private suspend fun storedRoot(): File? {
+ val raw = pathStore.getString(KEY_ROOT) ?: return null
+ return File(raw)
+ }
+
+ private fun checkDir(dir: File): File {
+ if (".." in dir.path.split(File.separatorChar)) {
+ throw ProviderError.InvalidUrl("Folder path must not contain '..'")
+ }
+ if (!dir.exists()) throw ProviderError.InvalidUrl(
+ "Folder does not exist: ${dir.path}. Create it (or let Syncthing create it) first."
+ )
+ if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}")
+ if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl(
+ "Folder is not readable and writable: ${dir.path}"
+ )
+ return dir
+ }
+
+ override suspend fun probe(baseUrl: ServerUrl): ProbeResult =
+ ProbeResult.Incompatible // no server involved — nothing to probe
+
+ override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
+ val folder = credential as? ProviderCredential.FolderPath
+ ?: throw ProviderError.InvalidUrl(
+ "Sync folder needs a folder path to sync through"
+ )
+ val dir = checkDir(File(folder.path))
+ pathStore.putString(KEY_ROOT, dir.canonicalPath)
+ _authState.value = AuthState.CONNECTED
+ }
+
+ override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
+ val root = storedRoot()
+ if (root == null) {
+ _authState.value = AuthState.DISCONNECTED
+ return@withContext _authState.value
+ }
+ runCatching { checkDir(root) }
+ .onSuccess { _authState.value = AuthState.CONNECTED }
+ .onFailure { _authState.value = AuthState.DISCONNECTED }
+ _authState.value
+ }
+
+ override suspend fun disconnect(revokeOnServer: Boolean) {
+ // Nothing remote to revoke; the path is kept so reconnect is one tap.
+ _authState.value = AuthState.DISCONNECTED
+ }
+
+ override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) {
+ // Forget the folder. The files stay — they belong to the user and
+ // their sync tool, and deleting someone's Syncthing folder because
+ // they tapped "disconnect" would be unforgivable.
+ pathStore.remove(KEY_ROOT)
+ _authState.value = AuthState.DISCONNECTED
+ }
+
+ // ---- storage: delegate with rewritten identity --------------------------
+
+ private suspend fun root(): File {
+ if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
+ return storedRoot()?.let { checkDir(it) } ?: throw ProviderError.NotConnected()
+ }
+
+ override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
+ withContext(Dispatchers.IO) {
+ val ref = LocalOnlyProvider(root()).upload(draft, onProgress)
+ ref.copy(providerId = ID)
+ }
+
+ override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
+ withContext(Dispatchers.IO) {
+ LocalOnlyProvider(root()).download(ref.copy(providerId = LocalOnlyProvider.ID), dest, onProgress)
+ }
+
+ override suspend fun delete(ref: RemoteRef) = withContext(Dispatchers.IO) {
+ LocalOnlyProvider(root()).delete(ref.copy(providerId = LocalOnlyProvider.ID))
+ }
+
+ override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) {
+ val page = LocalOnlyProvider(root()).list(cursor)
+ Page(
+ page.items.map { it.copy(ref = it.ref.copy(providerId = ID)) },
+ page.nextCursor,
+ )
+ }
+
+ override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
+ withContext(Dispatchers.IO) {
+ LocalOnlyProvider(root())
+ .putSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind, bytes)
+ }
+
+ override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
+ withContext(Dispatchers.IO) {
+ LocalOnlyProvider(root())
+ .getSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind)
+ }
+
+ override suspend fun storageLocationSummary(): StorageLocation =
+ withContext(Dispatchers.IO) {
+ val r = root()
+ val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
+ StorageLocation(
+ headline = "Sync folder",
+ detail = "${r.path} · ${audio.size} recordings — synced by your sync tool, not by this app.",
+ syncedCount = 0,
+ localOnlyCount = audio.size,
+ bytesUsed = audio.sumOf { it.length() },
+ )
+ }
+
+ companion object {
+ const val ID = "sync-folder"
+ const val KEY_ROOT = "provider.sync-folder.root"
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt b/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt
new file mode 100644
index 0000000..9750e75
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt
@@ -0,0 +1,157 @@
+package com.shonar.provider
+
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+
+/**
+ * Nextcloud server identification + login flow v2 (docs/server-providers.md
+ * §3, §6). No credentials are ever sent here: [startLogin] is anonymous,
+ * [poll] carries only the one-time poll token.
+ *
+ * Login flow v2 (official, supported):
+ * 1. POST {base}/index.php/login/v2 -> {poll:{token,endpoint}, login:url}
+ * 2. user approves {login} in a browser (server-owned consent screen)
+ * 3. POST {poll.endpoint} {"token":...} -> 404 while pending,
+ * 200 {server, loginName, appPassword} once approved
+ */
+class NextcloudAuth(
+ private val client: OkHttpClient = defaultClient(),
+ private val tofu: TofuManager? = null,
+) {
+
+ /** Is there a Nextcloud at [url]? Read-only, no credentials. */
+ suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
+ val req = Request.Builder().url(url.origin + "/status.php").get().build()
+ try {
+ client.newCall(req).execute().use { resp ->
+ if (resp.code != 200) return@withContext ProbeResult.Incompatible
+ val body = JSONObject(resp.body?.string().orEmpty())
+ // status.php: {productname, versionstring, ...}. Some forks
+ // report "Nextcloud" with different casing — accept any.
+ val product = body.optString("productname", "")
+ if (!product.equals("nextcloud", ignoreCase = true)) {
+ return@withContext ProbeResult.Incompatible
+ }
+ ProbeResult.Compatible(
+ descriptor = ProviderDescriptor(
+ id = ProviderRegistry.NEXTCLOUD_ID,
+ displayName = "Nextcloud",
+ isDefault = true,
+ capabilities = setOf(
+ ProviderDescriptor.Capability.CHUNKED_UPLOAD,
+ ProviderDescriptor.Capability.QUOTA_INFO,
+ ),
+ ),
+ serverName = product,
+ version = body.optString("versionstring", "unknown"),
+ )
+ }
+ } catch (e: javax.net.ssl.SSLHandshakeException) {
+ ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown")
+ } catch (e: javax.net.ssl.SSLPeerUnverifiedException) {
+ ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown")
+ } catch (e: Exception) {
+ ProbeResult.NetworkError(e.javaClass.simpleName)
+ }
+ }
+
+ /** Begins login flow v2. Returns the browser URL + poll handle. */
+ suspend fun startLogin(url: ServerUrl): LoginFlowSession = withContext(Dispatchers.IO) {
+ val req = Request.Builder().url(url.origin + "/index.php/login/v2")
+ .post(ByteArray(0).toRequestBody(null)).build()
+ try {
+ client.newCall(req).execute().use { resp ->
+ if (resp.code != 200) {
+ throw ProviderError.Transient(
+ "Server refused the login request (HTTP ${resp.code}). " +
+ "Is this a Nextcloud?"
+ )
+ }
+ val body = JSONObject(resp.body?.string().orEmpty())
+ val poll = body.optJSONObject("poll")
+ val login = body.optString("login", "")
+ val token = poll?.optString("token", "").orEmpty()
+ val endpoint = poll?.optString("endpoint", "").orEmpty()
+ if (login.isBlank() || token.isBlank() || endpoint.isBlank()) {
+ throw ProviderError.Transient("Server gave an incomplete login response")
+ }
+ LoginFlowSession(
+ baseUrl = url.origin,
+ loginUrl = login,
+ pollToken = token,
+ pollEndpoint = endpoint,
+ )
+ }
+ } catch (e: ProviderError) {
+ throw e
+ } catch (e: Exception) {
+ throw ProviderError.Transient("Could not reach the server (${e.javaClass.simpleName})")
+ }
+ }
+
+ /** One poll attempt. Call repeatedly until [PollResult.Approved]. */
+ suspend fun poll(flow: LoginFlowSession): PollResult = withContext(Dispatchers.IO) {
+ val json = """{"token":"${flow.pollToken}"}"""
+ val req = Request.Builder().url(flow.pollEndpoint)
+ .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build()
+ try {
+ client.newCall(req).execute().use { resp ->
+ when (resp.code) {
+ 200 -> {
+ val body = JSONObject(resp.body?.string().orEmpty())
+ val server = body.optString("server", flow.baseUrl)
+ val name = body.optString("loginName", "")
+ val pass = body.optString("appPassword", "")
+ if (name.isBlank() || pass.isBlank()) {
+ return@withContext PollResult.Failed("Server approved but sent no credentials")
+ }
+ PollResult.Approved(
+ ProviderCredential.AppPassword(
+ accountLabel = "$name@${baseHost(server)}",
+ loginUrl = server,
+ user = name,
+ password = pass,
+ )
+ )
+ }
+ 404 -> PollResult.Pending
+ else -> PollResult.Failed("Login poll failed (HTTP ${resp.code})")
+ }
+ }
+ } catch (e: Exception) {
+ PollResult.Failed("Login poll failed (${e.javaClass.simpleName})")
+ }
+ }
+
+ companion object {
+ fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .followRedirects(false)
+ .build()
+
+ private fun baseHost(server: String): String =
+ runCatching { java.net.URI(server).host }.getOrNull() ?: server
+ }
+}
+
+/** Browser URL + one-time poll handle from [NextcloudAuth.startLogin]. */
+data class LoginFlowSession(
+ val baseUrl: String,
+ val loginUrl: String,
+ val pollToken: String,
+ val pollEndpoint: String,
+)
+
+/** One poll attempt's outcome. The token itself never appears in messages. */
+sealed class PollResult {
+ data object Pending : PollResult()
+ data class Approved(val credential: ProviderCredential.AppPassword) : PollResult()
+ data class Failed(val reason: String) : PollResult()
+}
diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt b/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt
new file mode 100644
index 0000000..572cd45
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt
@@ -0,0 +1,55 @@
+package com.shonar.provider
+
+import com.shonar.settings.SettingsStore
+
+/**
+ * Secure persistence for the Nextcloud session: server origin, DAV user id,
+ * display name, and the app password from login flow v2. The user's *normal*
+ * account password is never requested, typed, or stored — only the
+ * server-issued app password lives here (docs/server-providers.md §3).
+ */
+class NextcloudAuthStore(private val secure: SettingsStore) {
+
+ suspend fun save(session: NcSession) {
+ secure.putString(KEY_BASE_URL, session.baseUrl)
+ secure.putString(KEY_USER_ID, session.userId)
+ secure.putString(KEY_USERNAME, session.username)
+ secure.putString(KEY_APP_PASSWORD, session.appPassword)
+ }
+
+ suspend fun load(): NcSession? {
+ val base = secure.getString(KEY_BASE_URL) ?: return null
+ val pass = secure.getString(KEY_APP_PASSWORD) ?: return null
+ return NcSession(
+ baseUrl = base,
+ userId = secure.getString(KEY_USER_ID).orEmpty(),
+ username = secure.getString(KEY_USERNAME).orEmpty(),
+ appPassword = pass,
+ )
+ }
+
+ suspend fun clear() {
+ secure.remove(KEY_BASE_URL)
+ secure.remove(KEY_USER_ID)
+ secure.remove(KEY_USERNAME)
+ secure.remove(KEY_APP_PASSWORD)
+ }
+
+ companion object {
+ private const val PREFIX = "provider.nextcloud."
+ const val KEY_BASE_URL = PREFIX + "base_url"
+ const val KEY_USER_ID = PREFIX + "user_id"
+ const val KEY_USERNAME = PREFIX + "username"
+ const val KEY_APP_PASSWORD = PREFIX + "app_password"
+ }
+}
+
+/** Authenticated Nextcloud session. Never logged (see toString). */
+data class NcSession(
+ val baseUrl: String,
+ val userId: String, // DAV path user (OCS `id`, not the display name)
+ val username: String, // human hint only
+ val appPassword: String,
+) {
+ override fun toString(): String = "NcSession(server=$baseUrl, user=$username, [redacted])"
+}
diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt b/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt
new file mode 100644
index 0000000..5ab2e9a
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt
@@ -0,0 +1,644 @@
+package com.shonar.provider
+
+import java.io.File
+import java.time.format.DateTimeFormatter
+import java.util.UUID
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONObject
+
+/**
+ * P4: Nextcloud provider — the product default. Talks only official,
+ * supported endpoints (docs/server-providers.md §6):
+ *
+ * - GET {base}/status.php (probe)
+ * - login flow v2 (see [NextcloudAuth])
+ * - GET {base}/ocs/v2.php/cloud/user (identity + quota)
+ * - DELETE {base}/ocs/v2.php/core/apppassword (revoke own app password)
+ * - WebDAV {base}/remote.php/dav/files/{user}/… (PROPFIND/GET/PUT/MKCOL/MOVE/DELETE)
+ * - Chunked upload v2 {base}/remote.php/dav/uploads/{user}/{transfer}/
+ * (MKCOL, PUT chunks 00001..N, MOVE {transfer}/.file -> destination)
+ *
+ * Layout: `SHONAR/audio/{uuid}.m4a`, `SHONAR/sidecars/{uuid}/{kind}.json`.
+ * Originals are never overwritten by processing artifacts — uploads with
+ * the same draft id MOVE onto the same key (idempotent replace).
+ *
+ * Chunk naming follows the developer manual: chunks are numbered 1..10000
+ * and assembled in name order, so names are zero-padded to 5 digits
+ * ("00001".."10000") to keep lexical order == numeric order. Chunk size
+ * defaults to 16 MiB — the server requires 5 MiB..5 GiB per chunk (last
+ * chunk exempt), so never lower the default for production use; the
+ * constructor parameter exists for tests only.
+ *
+ * Transfer ids are deterministic per recording (`shonar-{draft.id}`), so a
+ * killed upload resumes by PROPFIND-ing the transfer folder and skipping
+ * present chunks — including across process restarts.
+ */
+class NextcloudProvider(
+ private val auth: NextcloudAuthStore,
+ private val client: OkHttpClient = NextcloudAuth.defaultClient(),
+ private val loginFlow: NextcloudAuth = NextcloudAuth(client),
+ private val chunkSizeBytes: Long = 16 * 1024 * 1024,
+) : ShonarProvider {
+
+ override val descriptor = ProviderDescriptor(
+ id = ProviderRegistry.NEXTCLOUD_ID,
+ displayName = "Nextcloud",
+ isDefault = true,
+ capabilities = setOf(
+ ProviderDescriptor.Capability.CHUNKED_UPLOAD,
+ ProviderDescriptor.Capability.QUOTA_INFO,
+ ),
+ )
+
+ private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
+ override val authState: StateFlow = _authState
+
+ // ---- lifecycle ---------------------------------------------------------
+
+ override suspend fun probe(baseUrl: ServerUrl): ProbeResult = loginFlow.probe(baseUrl)
+
+ override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
+ val app = credential as? ProviderCredential.AppPassword
+ ?: throw ProviderError.InvalidUrl(
+ "Nextcloud connects with an app password from the browser login flow"
+ )
+ val origin = ServerUrl.parse(app.loginUrl).getOrNull()?.origin
+ ?: throw ProviderError.InvalidUrl("Not a valid server URL")
+ // Validate before persisting: a wrong password must not overwrite a
+ // working session.
+ val userId = try {
+ ocsUserId(origin, app.user, app.password)
+ } catch (e: ProviderError.AuthExpired) {
+ throw ProviderError.Transient(
+ "Nextcloud rejected the login — approve it in the browser again"
+ )
+ }
+ auth.save(
+ NcSession(
+ baseUrl = origin,
+ userId = userId,
+ username = app.user,
+ appPassword = app.password,
+ )
+ )
+ _authState.value = AuthState.CONNECTED
+ }
+
+ override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
+ val saved = auth.load()
+ if (saved == null || saved.appPassword.isBlank() || saved.userId.isBlank()) {
+ _authState.value = AuthState.DISCONNECTED
+ return@withContext _authState.value
+ }
+ try {
+ ocsUserId(saved.baseUrl, saved.userId, saved.appPassword)
+ _authState.value = AuthState.CONNECTED
+ } catch (e: ProviderError.AuthExpired) {
+ _authState.value = AuthState.EXPIRED
+ } catch (e: ProviderError) {
+ _authState.value = AuthState.OFFLINE
+ }
+ _authState.value
+ }
+
+ override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) {
+ if (revokeOnServer) {
+ // Best effort: local state is cleared even if revoke fails.
+ runCatching {
+ val saved = auth.load()
+ if (saved != null && saved.appPassword.isNotBlank()) {
+ dav(saved, "DELETE", ocsPath("/core/apppassword"), null).close()
+ }
+ }
+ }
+ auth.clear()
+ _authState.value = AuthState.DISCONNECTED
+ }
+
+ /**
+ * Revokes the app password and forgets the session. There is no API for
+ * deleting the whole Nextcloud account — that stays a manual step on the
+ * server, and the message says so.
+ */
+ override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) {
+ disconnect(revokeOnServer = true)
+ }
+
+ // ---- storage -----------------------------------------------------------
+
+ override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
+ withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ val key = "SHONAR/audio/${draft.id}${extFor(draft.mime)}"
+ val total = draft.sizeBytes.coerceAtLeast(1)
+ ensureDir(session, "SHONAR")
+ ensureDir(session, "SHONAR/audio")
+
+ val transfer = "shonar-${draft.id}"
+ mkcol(session, transfer)
+ val received = transferChunks(session, transfer)
+ val chunkCount = ((draft.sizeBytes + chunkSizeBytes - 1) / chunkSizeBytes)
+ .toInt().coerceAtLeast(1)
+ var sent = 0L
+ for (i in received) {
+ sent += if (i < chunkCount) chunkSizeBytes else 0L
+ }
+ sent = sent.coerceAtMost(draft.sizeBytes)
+ if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
+
+ var idx = 1
+ while (idx <= chunkCount) {
+ ensureActive()
+ if (idx !in received) {
+ val slice = readSlice(draft.sourceFile, (idx - 1) * chunkSizeBytes, chunkSizeBytes)
+ putChunk(session, transfer, idx, slice, draft.sizeBytes, key)
+ sent += slice.size
+ onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
+ }
+ idx++
+ }
+ assemble(session, transfer, key, draft)
+ // Best-effort cleanup of the transfer folder; the server also
+ // expires stale upload dirs on its own.
+ runCatching {
+ dav(session, "DELETE", uploadsPath(session, transfer) + "/", null).close()
+ }
+ onProgress(1f)
+ RemoteRef(ProviderRegistry.NEXTCLOUD_ID, key, etag = null, sizeBytes = draft.sizeBytes)
+ }
+
+ override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
+ withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ dav(session, "GET", filesPath(session, ref.key), null).use { resp ->
+ when (resp.code) {
+ 200 -> {
+ val body = resp.body ?: throw ProviderError.Transient("Empty download response")
+ val total = body.contentLength().takeIf { it > 0 } ?: -1
+ dest.parentFile?.mkdirs()
+ body.byteStream().use { input ->
+ dest.outputStream().use { output ->
+ val buf = ByteArray(64 * 1024)
+ var written = 0L
+ var last = -1f
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ output.write(buf, 0, n)
+ written += n
+ if (total > 0) {
+ val p = (written.toFloat() / total).coerceIn(0f, 1f)
+ if (p > last) {
+ onProgress(p)
+ last = p
+ }
+ }
+ }
+ }
+ }
+ onProgress(1f)
+ }
+ 401 -> throw ProviderError.AuthExpired()
+ 404 -> throw ProviderError.NotFound(ref.key)
+ else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})")
+ }
+ }
+ }
+
+ override suspend fun delete(ref: RemoteRef) {
+ withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ dav(session, "DELETE", filesPath(session, ref.key), null).use { resp ->
+ when (resp.code) {
+ 200, 201, 204 -> Unit
+ 401 -> throw ProviderError.AuthExpired()
+ 404 -> throw ProviderError.NotFound(ref.key)
+ else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})")
+ }
+ }
+ // Sidecars go with the recording; ignore failures (may not exist).
+ runCatching {
+ dav(session, "DELETE", filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/"), null)
+ .close()
+ }
+ }
+ }
+
+ override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ // Single page: a PROPFIND Depth:1 returns the whole folder. Paging
+ // stays null until a library outgrows one response.
+ if (cursor != null) return@withContext Page(emptyList(), null)
+ val items = propfind(session, filesPath(session, "SHONAR/audio/"), depth = "1")
+ .filter { it.isFile && it.relativePath != "SHONAR/audio/" && !it.relativePath.removePrefix("SHONAR/audio/").contains('/') }
+ .map { e ->
+ val name = e.relativePath.removePrefix("SHONAR/audio/")
+ RemoteRecording(
+ ref = RemoteRef(ProviderRegistry.NEXTCLOUD_ID, e.relativePath, e.etag, e.size),
+ title = name.substringBeforeLast('.'),
+ createdAtEpochMs = e.lastModified,
+ durationMs = 0, // duration is tracked locally, not over DAV
+ mime = e.contentType ?: "application/octet-stream",
+ )
+ }
+ Page(items, nextCursor = null)
+ }
+
+ // ---- sidecars: real remote files under SHONAR/sidecars/{uuid}/ --------
+
+ override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
+ withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ val dir = "SHONAR/sidecars/${uuidForKey(ref.key)}"
+ ensureDir(session, "SHONAR/sidecars")
+ ensureDir(session, dir)
+ dav(
+ session, "PUT", filesPath(session, "$dir/${kind.fileName}"),
+ bytes.toRequestBody("application/json; charset=utf-8".toMediaType()),
+ sensitiveBody = true, // transcripts are never logged, in any mode
+ ).use { resp ->
+ if (resp.code !in 200..201 && resp.code != 204) {
+ throw mapError(resp.code, "Sidecar upload failed")
+ }
+ }
+ }
+
+ override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
+ withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ dav(
+ session, "GET",
+ filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/${kind.fileName}"), null,
+ sensitiveBody = true, // transcripts are never logged, in any mode
+ ).use { resp ->
+ when (resp.code) {
+ 200 -> resp.body?.bytes()
+ 401 -> throw ProviderError.AuthExpired()
+ 404 -> null
+ else -> throw mapError(resp.code, "Sidecar download failed")
+ }
+ }
+ }
+
+ // ---- status ------------------------------------------------------------
+
+ override suspend fun storageLocationSummary(): StorageLocation =
+ withContext(Dispatchers.IO) {
+ val session = connectedSession()
+ val quota = ocsQuota(session)
+ var count = 0
+ var bytes = 0L
+ // list() is single-page for now; keep the loop for when it pages.
+ var cursor: String? = null
+ do {
+ val page = list(cursor)
+ count += page.items.size
+ bytes += page.items.sumOf { it.ref.sizeBytes }
+ cursor = page.nextCursor
+ } while (cursor != null)
+ val host = runCatching { java.net.URI(session.baseUrl).host }.getOrNull()
+ ?: session.baseUrl
+ StorageLocation(
+ headline = "Nextcloud at $host",
+ detail = "${session.username} · $count recordings synced" +
+ (quota?.let { " · ${formatBytes(it.free)} free of ${formatBytes(it.total)}" } ?: ""),
+ syncedCount = count,
+ localOnlyCount = 0,
+ bytesUsed = bytes,
+ )
+ }
+
+ // ---- HTTP + DAV plumbing -----------------------------------------------
+
+ private suspend fun connectedSession(): NcSession {
+ if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
+ return auth.load()?.takeIf { it.appPassword.isNotBlank() && it.userId.isNotBlank() }
+ ?: throw ProviderError.NotConnected()
+ }
+
+ private fun basic(session: NcSession): String {
+ val raw = "${session.userId}:${session.appPassword}".toByteArray(Charsets.UTF_8)
+ return "Basic " + java.util.Base64.getEncoder().encodeToString(raw)
+ }
+
+ /** Raw DAV/OCS call. Caller closes the response. [path] starts with '/'. */
+ private fun dav(
+ session: NcSession,
+ method: String,
+ path: String,
+ body: okhttp3.RequestBody?,
+ sensitiveBody: Boolean = false,
+ ): Response {
+ val builder = Request.Builder().url(session.baseUrl + path)
+ .header("Authorization", basic(session))
+ if (sensitiveBody) builder.header(RedactingLogger.SENSITIVE_BODY, "1")
+ if (method == "GET") builder.get()
+ else builder.method(method, body)
+ if (path.startsWith("/ocs/")) {
+ builder.header("OCS-APIRequest", "true")
+ builder.header("Accept", "application/json")
+ }
+ return client.newCall(builder.build()).execute()
+ }
+
+ private fun filesPath(session: NcSession, relative: String): String {
+ val segs = relative.split('/').filter { it.isNotEmpty() }.joinToString("/") { enc(it) }
+ return "/remote.php/dav/files/${enc(session.userId)}/$segs"
+ }
+
+ private fun uploadsPath(session: NcSession, transfer: String): String =
+ "/remote.php/dav/uploads/${enc(session.userId)}/${enc(transfer)}"
+
+ private fun ocsPath(suffix: String): String = "/ocs/v2.php$suffix"
+
+ /** OCS identity check; returns the DAV user id or throws. */
+ private suspend fun ocsUserId(origin: String, user: String, appPassword: String): String =
+ withContext(Dispatchers.IO) {
+ val raw = "$user:$appPassword".toByteArray(Charsets.UTF_8)
+ val req = Request.Builder().url(origin + ocsPath("/cloud/user")).get()
+ .header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString(raw))
+ .header("OCS-APIRequest", "true")
+ .header("Accept", "application/json").build()
+ client.newCall(req).execute().use { resp ->
+ when (resp.code) {
+ 200 -> {
+ val data = JSONObject(resp.body?.string().orEmpty())
+ .optJSONObject("ocs")?.optJSONObject("data")
+ val id = data?.optString("id", "").orEmpty()
+ if (id.isBlank()) throw ProviderError.Transient("Server identity reply was empty")
+ id
+ }
+ 401 -> throw ProviderError.AuthExpired()
+ else -> throw ProviderError.Transient("Server identity check failed (HTTP ${resp.code})")
+ }
+ }
+ }
+
+ private data class Quota(val free: Long, val total: Long)
+
+ private suspend fun ocsQuota(session: NcSession): Quota? = withContext(Dispatchers.IO) {
+ // Quota is informational; never fail the summary over it.
+ runCatching {
+ dav(session, "GET", ocsPath("/cloud/user"), null).use { resp ->
+ if (resp.code != 200) return@runCatching null
+ val q = JSONObject(resp.body?.string().orEmpty())
+ .optJSONObject("ocs")?.optJSONObject("data")?.optJSONObject("quota")
+ ?: return@runCatching null
+ // Quota values may be numbers or numeric strings; total -3
+ // (or "unknown") means unlimited.
+ fun num(v: Any?): Long = when (v) {
+ is Number -> v.toLong()
+ is String -> v.toLongOrNull() ?: -3L
+ else -> -3L
+ }
+ val free = num(q.opt("free"))
+ val total = num(q.opt("total"))
+ if (total < 0) null else Quota(free.coerceAtLeast(0), total)
+ }
+ }.getOrNull()
+ }
+
+ /** MKCOL tolerant of "already exists". */
+ private suspend fun ensureDir(session: NcSession, relative: String) {
+ // Create level by level so a missing parent reads as progress, not 409.
+ val parts = relative.split('/').filter { it.isNotEmpty() }
+ var prefix = ""
+ for (part in parts) {
+ prefix = if (prefix.isEmpty()) part else "$prefix/$part"
+ dav(session, "MKCOL", filesPath(session, prefix), null).use { resp ->
+ if (resp.code == 401) throw ProviderError.AuthExpired()
+ if (resp.code != 201 && resp.code != 405) {
+ throw mapError(resp.code, "Could not create folder $prefix")
+ }
+ }
+ }
+ }
+
+ private suspend fun mkcol(session: NcSession, transfer: String) {
+ dav(session, "MKCOL", uploadsPath(session, transfer) + "/", null).use { resp ->
+ // 405: transfer folder from a previous attempt — resume into it.
+ if (resp.code == 401) throw ProviderError.AuthExpired()
+ if (resp.code != 201 && resp.code != 405) {
+ throw mapError(resp.code, "Could not start the upload")
+ }
+ }
+ }
+
+ /** Chunk names present in the transfer folder (resume). */
+ private suspend fun transferChunks(session: NcSession, transfer: String): Set {
+ val prefix = "/remote.php/dav/uploads/${session.userId}/"
+ val entries = propfind(session, uploadsPath(session, transfer) + "/", depth = "1", prefix = prefix)
+ return entries.mapNotNullTo(mutableSetOf()) { e ->
+ // Chunk names are "00001".. — compare by numeric value.
+ e.name.trimStart('0').ifEmpty { "0" }.toIntOrNull()
+ ?.takeIf { it in 1..10000 }
+ }
+ }
+
+ private suspend fun putChunk(
+ session: NcSession,
+ transfer: String,
+ index: Int, // 1-based
+ bytes: ByteArray,
+ totalBytes: Long,
+ destKey: String,
+ ) {
+ val name = "%05d".format(index)
+ val dest = session.baseUrl + filesPath(session, destKey)
+ val req = Request.Builder()
+ .url(session.baseUrl + uploadsPath(session, transfer) + "/" + name)
+ .put(bytes.toRequestBody("application/octet-stream".toMediaType()))
+ .header("Authorization", basic(session))
+ .header("OC-Total-Length", totalBytes.toString())
+ .header("Destination", dest).build()
+ client.newCall(req).execute().use { resp ->
+ when (resp.code) {
+ 200, 201, 204 -> Unit
+ 401 -> throw ProviderError.AuthExpired()
+ 507 -> throw ProviderError.QuotaExceeded()
+ else -> throw mapError(resp.code, "Chunk $index rejected")
+ }
+ }
+ }
+
+ private suspend fun assemble(
+ session: NcSession,
+ transfer: String,
+ destKey: String,
+ draft: RecordingDraft,
+ ) {
+ val dest = session.baseUrl + filesPath(session, destKey)
+ val req = Request.Builder()
+ .url(session.baseUrl + uploadsPath(session, transfer) + "/.file")
+ .method("MOVE", null)
+ .header("Authorization", basic(session))
+ .header("Destination", dest)
+ .header("Overwrite", "T")
+ .header("OC-Total-Length", draft.sizeBytes.toString())
+ // Server mtime = recording time, so listings sort by when it
+ // was recorded, not when it finished uploading.
+ .header("X-OC-Mtime", (draft.createdAtEpochMs / 1000).toString()).build()
+ client.newCall(req).execute().use { resp ->
+ when (resp.code) {
+ 200, 201, 204 -> Unit
+ 401 -> throw ProviderError.AuthExpired()
+ 404 -> throw ProviderError.Transient("Upload assembly failed — retry the upload")
+ 507 -> throw ProviderError.QuotaExceeded()
+ else -> throw mapError(resp.code, "Upload assembly failed")
+ }
+ }
+ }
+
+ internal data class DavEntry(
+ val relativePath: String, // relative to files/{user}/, decoded
+ val name: String,
+ val isFile: Boolean,
+ val size: Long,
+ val etag: String?,
+ val contentType: String?,
+ val lastModified: Long,
+ )
+
+ private suspend fun propfind(
+ session: NcSession,
+ path: String,
+ depth: String,
+ prefix: String = "/remote.php/dav/files/${session.userId}/",
+ ): List =
+ withContext(Dispatchers.IO) {
+ val body = """
+"""
+ val req = Request.Builder().url(session.baseUrl + path)
+ .method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
+ .header("Authorization", basic(session))
+ .header("Depth", depth).build()
+ client.newCall(req).execute().use { resp ->
+ when (resp.code) {
+ 200, 207 -> parseMultistatus(resp.body?.string().orEmpty(), prefix = prefix)
+ 401 -> throw ProviderError.AuthExpired()
+ 404 -> emptyList()
+ else -> throw mapError(resp.code, "Listing failed")
+ }
+ }
+ }
+
+ private fun mapError(code: Int, fallback: String): ProviderError = when (code) {
+ 401 -> ProviderError.AuthExpired()
+ 507 -> ProviderError.QuotaExceeded()
+ else -> ProviderError.Transient("$fallback (HTTP $code)")
+ }
+
+ companion object {
+ /** One path segment, percent-encoded (spaces as %20, not '+'). */
+ internal fun enc(segment: String): String =
+ java.net.URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
+
+ internal fun uuidForKey(key: String): String =
+ key.substringAfterLast('/').substringBeforeLast('.')
+
+ internal fun extFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) {
+ "audio/mp4", "audio/m4a" -> ".m4a"
+ "audio/aac" -> ".aac"
+ "audio/wav", "audio/x-wav" -> ".wav"
+ "audio/ogg", "audio/opus" -> ".ogg"
+ "audio/webm" -> ".webm"
+ "audio/mpeg" -> ".mp3"
+ else -> ".m4a" // the app records m4a; unknown mimes keep a playable suffix
+ }
+
+ internal fun formatBytes(n: Long): String {
+ if (n < 1024) return "$n B"
+ val units = arrayOf("KB", "MB", "GB", "TB")
+ var v = n.toDouble() / 1024
+ var u = 0
+ while (v >= 1024 && u < units.size - 1) {
+ v /= 1024
+ u++
+ }
+ return "%s %s".format(if (v >= 100) "%.0f" else "%.1f".format(v), units[u])
+ }
+
+ internal fun parseMultistatus(xml: String, prefix: String): List {
+ if (xml.isBlank()) return emptyList()
+ val out = mutableListOf()
+ try {
+ val factory = javax.xml.parsers.DocumentBuilderFactory.newInstance()
+ factory.isNamespaceAware = true
+ // Harden against XXE: multistatus docs never need doctypes.
+ runCatching {
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
+ }
+ val doc = factory.newDocumentBuilder()
+ .parse(java.io.ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8)))
+ val responses = doc.getElementsByTagNameNS("DAV:", "response")
+ for (i in 0 until responses.length) {
+ val el = responses.item(i) as? org.w3c.dom.Element ?: continue
+ fun text(tag: String): String? {
+ val nodes = el.getElementsByTagNameNS("DAV:", tag)
+ if (nodes.length == 0) return null
+ return nodes.item(0).textContent?.trim()?.takeIf { it.isNotEmpty() }
+ }
+ val href = text("href") ?: continue
+ val decoded = runCatching {
+ java.net.URLDecoder.decode(href, "UTF-8")
+ }.getOrNull() ?: href
+ // Strip scheme+host when the server returns absolute hrefs.
+ val pathOnly = runCatching { java.net.URI(decoded).path }.getOrNull() ?: decoded
+ val relative = pathOnly.removePrefix(prefix).trim('/')
+ val isCollection = runCatching {
+ val rt = el.getElementsByTagNameNS("DAV:", "resourcetype")
+ rt.length > 0 && (rt.item(0) as org.w3c.dom.Element)
+ .getElementsByTagNameNS("DAV:", "collection").length > 0
+ }.getOrDefault(false)
+ val lastMod = text("getlastmodified")?.let {
+ runCatching {
+ java.time.ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME)
+ .toInstant().toEpochMilli()
+ }.getOrNull()
+ } ?: 0L
+ out += DavEntry(
+ relativePath = relative,
+ name = relative.substringAfterLast('/'),
+ isFile = !isCollection,
+ size = text("getcontentlength")?.toLongOrNull() ?: 0L,
+ etag = text("getetag")?.trim('"'),
+ contentType = text("getcontenttype"),
+ lastModified = lastMod,
+ )
+ }
+ } catch (e: Exception) {
+ throw ProviderError.Transient("Could not read the server listing")
+ }
+ return out
+ }
+
+ private fun readSlice(file: File, offset: Long, max: Long): ByteArray {
+ file.inputStream().use { input ->
+ var skipped = 0L
+ while (skipped < offset) {
+ val n = input.skip(offset - skipped)
+ if (n <= 0) break
+ skipped += n
+ }
+ val cap = max.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
+ val buf = ByteArray(cap)
+ var read = 0
+ while (read < cap) {
+ val n = input.read(buf, read, cap - read)
+ if (n < 0) break
+ read += n
+ }
+ return if (read == cap) buf else buf.copyOf(read)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt
index d301e94..9cf7c9b 100644
--- a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt
+++ b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt
@@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.StateFlow
* user selected during setup; switching is just changing this id — the app
* never branches on concrete provider classes.
*/
-class ProviderRegistry(private val factories: Map ShonarProvider>) {
+class ProviderRegistry(
+ private val factories: Map ShonarProvider>,
+ /** Builds every provider HTTP client (TOFU trust + redacting logger). */
+ val tls: TlsPolicy = defaultTls(),
+) {
private val _activeId = MutableStateFlow(LocalOnlyProvider.ID)
val activeId: StateFlow = _activeId
@@ -37,14 +41,50 @@ class ProviderRegistry(private val factories: Map ShonarProvider>)
companion object {
const val NEXTCLOUD_ID = "nextcloud"
const val CUSTOM_SHONAR_ID = "custom-shonar"
+ const val SYNC_FOLDER_ID = FolderSyncProvider.ID
- /** Production factory map. P1 registers local-only; later phases add more. */
- fun withDefaults(appFilesDir: java.io.File): ProviderRegistry = ProviderRegistry(
- mapOf(
- LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) },
- // P3: CUSTOM_SHONAR_ID to { CustomShonarProvider(...) }
- // P4: NEXTCLOUD_ID to { NextcloudProvider(...) }
- ),
+ /** Behaviour-identical default: system trust, logging off, no pins. */
+ fun defaultTls(): TlsPolicy = TlsPolicy(
+ tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())),
)
+
+ /**
+ * Production factory map. P1 registered local-only, P3 the custom
+ * SHONAR server, P4 Nextcloud + the sync folder. Network providers
+ * are single shared instances (their sessions live in the secure
+ * store, not in the object) so connect/reconnect state survives
+ * `provider(id)` calls. P5: every HTTP client comes from [tlsPolicy].
+ */
+ fun withDefaults(
+ appFilesDir: java.io.File,
+ secureStore: com.shonar.settings.SettingsStore =
+ com.shonar.settings.InMemorySettingsStore(),
+ plainStore: com.shonar.settings.SettingsStore =
+ com.shonar.settings.InMemorySettingsStore(),
+ tlsPolicy: TlsPolicy = defaultTls(),
+ ): ProviderRegistry {
+ val custom = CustomShonarProvider(
+ auth = ShonarAuthStore(secureStore),
+ sidecarRoot = java.io.File(appFilesDir, "shonar-sidecars"),
+ client = tlsPolicy.apiClient(),
+ handshake = ShonarHandshake(tlsPolicy.probeClient(), tlsPolicy.tofu),
+ )
+ val nextcloud = NextcloudProvider(
+ auth = NextcloudAuthStore(secureStore),
+ client = tlsPolicy.nextcloudClient(),
+ loginFlow = NextcloudAuth(tlsPolicy.nextcloudClient(), tlsPolicy.tofu),
+ )
+ val folder = FolderSyncProvider(pathStore = plainStore)
+ return ProviderRegistry(
+ mapOf(
+ LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) },
+ CUSTOM_SHONAR_ID to { custom },
+ NEXTCLOUD_ID to { nextcloud },
+ SYNC_FOLDER_ID to { folder },
+ // P6: start9 / umbrel platform probes
+ ),
+ tls = tlsPolicy,
+ )
+ }
}
}
diff --git a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt
index 82774f0..d1a5e47 100644
--- a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt
+++ b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt
@@ -108,11 +108,38 @@ sealed class ProviderCredential {
override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])"
}
+ /**
+ * Custom SHONAR server login: email + password exchanged for a rotating
+ * token pair (backend M1). The password is held in memory only for the
+ * login call and never persisted — only the resulting tokens are stored
+ * (see ShonarAuthStore).
+ */
+ data class ShonarLogin(
+ override val accountLabel: String,
+ val serverUrl: ServerUrl,
+ val email: String,
+ val password: String,
+ ) : ProviderCredential() {
+ override fun toString(): String =
+ "ShonarLogin(account=$accountLabel, server=${serverUrl.origin}, [redacted])"
+ }
+
/** No credential needed (local-only provider). */
data object None : ProviderCredential() {
override val accountLabel: String get() = "local"
override fun toString(): String = "None"
}
+
+ /**
+ * Sync-folder provider: an absolute directory path owned by an external
+ * sync tool (Syncthing, the Nextcloud desktop client, rsync…). The path
+ * is not secret, but it is validated — connect refuses a missing or
+ * non-writable directory rather than creating whatever was typed.
+ */
+ data class FolderPath(
+ override val accountLabel: String,
+ val path: String,
+ ) : ProviderCredential()
}
/** Result of probing a base URL for a compatible service. */
diff --git a/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt b/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt
new file mode 100644
index 0000000..af028ec
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt
@@ -0,0 +1,83 @@
+package com.shonar.provider
+
+import com.shonar.settings.SettingsStore
+
+/**
+ * Secure persistence for the custom SHONAR server session.
+ *
+ * Stored (all in the Keystore-backed secure store, never in Room or logs):
+ * - base URL origin (e.g. https://shonar.example.com)
+ * - account email (hint only)
+ * - access token + expiry
+ * - refresh token + device id
+ *
+ * The user's password is NEVER stored here: it lives in memory for exactly
+ * one login call (see [ProviderCredential.ShonarLogin]).
+ *
+ * Rotation discipline (backend M1 has refresh reuse detection): every
+ * successful refresh overwrites the stored pair immediately, so a stored
+ * refresh token is always the newest one the server has issued.
+ */
+class ShonarAuthStore(private val secure: SettingsStore) {
+
+ suspend fun save(session: ShonarSession) {
+ secure.putString(KEY_BASE_URL, session.baseUrl)
+ secure.putString(KEY_EMAIL, session.email)
+ secure.putString(KEY_ACCESS, session.accessToken)
+ secure.putString(KEY_REFRESH, session.refreshToken)
+ secure.putString(KEY_EXPIRES_AT, session.expiresAtEpochSec.toString())
+ if (session.deviceId != null) secure.putString(KEY_DEVICE_ID, session.deviceId)
+ else secure.remove(KEY_DEVICE_ID)
+ }
+
+ suspend fun load(): ShonarSession? {
+ val baseUrl = secure.getString(KEY_BASE_URL) ?: return null
+ val access = secure.getString(KEY_ACCESS) ?: return null
+ val refresh = secure.getString(KEY_REFRESH) ?: return null
+ return ShonarSession(
+ baseUrl = baseUrl,
+ email = secure.getString(KEY_EMAIL).orEmpty(),
+ accessToken = access,
+ refreshToken = refresh,
+ expiresAtEpochSec = secure.getString(KEY_EXPIRES_AT)?.toLongOrNull(),
+ deviceId = secure.getString(KEY_DEVICE_ID),
+ )
+ }
+
+ /** Drop tokens but keep the URL + email so re-login is one step. */
+ suspend fun clearTokens() {
+ secure.remove(KEY_ACCESS)
+ secure.remove(KEY_REFRESH)
+ secure.remove(KEY_EXPIRES_AT)
+ secure.remove(KEY_DEVICE_ID)
+ }
+
+ /** Forget everything, including which server was configured. */
+ suspend fun clearAll() {
+ secure.remove(KEY_BASE_URL)
+ secure.remove(KEY_EMAIL)
+ clearTokens()
+ }
+
+ companion object {
+ private const val PREFIX = "provider.custom-shonar."
+ const val KEY_BASE_URL = PREFIX + "base_url"
+ const val KEY_EMAIL = PREFIX + "email"
+ const val KEY_ACCESS = PREFIX + "access_token"
+ const val KEY_REFRESH = PREFIX + "refresh_token"
+ const val KEY_EXPIRES_AT = PREFIX + "expires_at"
+ const val KEY_DEVICE_ID = PREFIX + "device_id"
+ }
+}
+
+/** In-memory session handed between login/refresh calls and the store. */
+data class ShonarSession(
+ val baseUrl: String,
+ val email: String,
+ val accessToken: String,
+ val refreshToken: String,
+ val expiresAtEpochSec: Long?,
+ val deviceId: String?,
+) {
+ override fun toString(): String = "ShonarSession(server=$baseUrl, account=$email, [redacted])"
+}
diff --git a/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt b/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt
new file mode 100644
index 0000000..522095a
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt
@@ -0,0 +1,91 @@
+package com.shonar.provider
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLHandshakeException
+
+/**
+ * Unauthenticated handshake against the SHONAR backend's
+ * GET /api/v1/provider-info. Used by the provider-selection screen and by
+ * Start9/Umbrel platform probes to identify SHONAR-compatible services.
+ *
+ * TLS policy (docs/server-providers.md §4):
+ * - full system verification by default, no bypass, ever.
+ * - a handshake failure yields ProbeResult.TlsFailure with the peer cert's
+ * SPKI SHA-256 (recorded by the TOFU trust manager) so the UI can run
+ * explicit trust-on-first-use approval; this client NEVER retries with
+ * verification disabled.
+ */
+class ShonarHandshake(
+ private val client: OkHttpClient = defaultClient(),
+ private val tofu: TofuManager? = null,
+) {
+
+ suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
+ val endpoint = url.origin + "/api/v1/provider-info"
+ val request = Request.Builder().url(endpoint).get().build()
+ try {
+ client.newCall(request).execute().use { resp ->
+ if (resp.code == 200) {
+ parseBody(resp.body?.string().orEmpty())
+ } else {
+ ProbeResult.Incompatible
+ }
+ }
+ } catch (e: SSLHandshakeException) {
+ ProbeResult.TlsFailure(fingerprintFor(url.host))
+ } catch (e: javax.net.ssl.SSLPeerUnverifiedException) {
+ // Hostname mismatch: strict verifier stays strict, but the UI
+ // should still say *why* instead of a generic network error.
+ ProbeResult.TlsFailure(fingerprintFor(url.host))
+ } catch (e: Exception) {
+ // message must stay generic: exception text can contain URLs but
+ // never credentials (this call sends no credentials at all)
+ ProbeResult.NetworkError(e.javaClass.simpleName)
+ }
+ }
+
+ private fun parseBody(json: String): ProbeResult = try {
+ val root = org.json.JSONObject(json)
+ if (root.optString("kind") != "shonar") {
+ ProbeResult.Incompatible
+ } else {
+ val caps = root.optJSONObject("capabilities") ?: org.json.JSONObject()
+ val set = mutableSetOf()
+ if (caps.optBoolean("chunked_upload")) set += ProviderDescriptor.Capability.CHUNKED_UPLOAD
+ if (caps.optBoolean("server_transcription")) set += ProviderDescriptor.Capability.SERVER_TRANSCRIPTION
+ if (caps.optBoolean("server_summary")) set += ProviderDescriptor.Capability.SERVER_SUMMARY
+ if (caps.optBoolean("account_deletion")) set += ProviderDescriptor.Capability.ACCOUNT_DELETION
+ ProbeResult.Compatible(
+ descriptor = ProviderDescriptor(
+ id = ProviderRegistry.CUSTOM_SHONAR_ID,
+ displayName = "Custom SHONAR server",
+ capabilities = set,
+ ),
+ serverName = root.optString("storage_backend", "server"),
+ version = root.optString("version", "unknown"),
+ )
+ }
+ } catch (e: Exception) {
+ ProbeResult.Incompatible
+ }
+
+ /**
+ * SPKI SHA-256 recorded by the TOFU trust manager during the failed
+ * handshake, or "unknown" when nothing was captured (plain HTTP,
+ * pre-handshake failure, or no TOFU manager wired).
+ */
+ private fun fingerprintFor(host: String): String =
+ tofu?.failureFor(host)?.spkiHex ?: "unknown"
+
+ companion object {
+ fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(15, TimeUnit.SECONDS)
+ .followRedirects(false) // do not silently follow redirects to other hosts
+ .build()
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/provider/SyncState.kt b/android/app/src/main/java/com/shonar/provider/SyncState.kt
new file mode 100644
index 0000000..02cda21
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/SyncState.kt
@@ -0,0 +1,42 @@
+package com.shonar.provider
+
+/**
+ * Sync lifecycle for one local recording against the active provider
+ * (docs/server-providers.md §2). P3 defines the vocabulary and the legal
+ * transitions; the WorkManager driver that moves recordings through it is
+ * M5 — until then uploads go through [ShonarProvider.upload] directly and
+ * land in UPLOADED.
+ *
+ * Pausing/canceling is a state, not a job kill: QUEUED and ERROR are stable
+ * resting states a later run resumes from.
+ */
+enum class SyncState {
+ LOCAL_ONLY, // provider is local-only, or user never enabled sync
+ QUEUED, // waiting for constraints (network, Wi-Fi-only, charging-only)
+ UPLOADING, // bytes in flight (resumable via the provider's session)
+ UPLOADED, // audio on the server; sidecars may still be pending
+ SYNCED, // audio + all sidecars confirmed server-side
+ ERROR, // failed; [SyncStatus.reasonCode] says why, [SyncStatus.retryAtEpochMs] when
+}
+
+data class SyncStatus(
+ val state: SyncState,
+ val retryAtEpochMs: Long? = null,
+ val reasonCode: String? = null,
+)
+
+/**
+ * Legal transitions. Anything not listed here is a programming error, not
+ * a state the UI should ever render.
+ */
+fun SyncStatus.canTransitionTo(next: SyncState): Boolean = when (state) {
+ SyncState.LOCAL_ONLY -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY
+ SyncState.QUEUED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY ||
+ next == SyncState.ERROR
+ SyncState.UPLOADING -> next == SyncState.UPLOADED || next == SyncState.QUEUED ||
+ next == SyncState.ERROR || next == SyncState.LOCAL_ONLY
+ SyncState.UPLOADED -> next == SyncState.SYNCED || next == SyncState.UPLOADING ||
+ next == SyncState.ERROR || next == SyncState.LOCAL_ONLY
+ SyncState.SYNCED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY
+ SyncState.ERROR -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY
+}
diff --git a/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt
new file mode 100644
index 0000000..f4d8178
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt
@@ -0,0 +1,186 @@
+package com.shonar.provider
+
+import java.util.concurrent.TimeUnit
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+
+/**
+ * P5: builds every provider HTTP client with the TOFU trust manager and the
+ * redacting logger (docs/server-providers.md §4). One place, so no call
+ * site can accidentally build a client that skips either.
+ */
+class TlsPolicy(
+ val tofu: TofuManager,
+ private val bodiesEnabled: () -> Boolean = { false },
+ private val sink: (String) -> Unit = { msg -> android.util.Log.d("ShonarNet", msg) },
+) {
+ fun newClient(
+ connectTimeoutS: Long,
+ readTimeoutS: Long,
+ writeTimeoutS: Long = readTimeoutS,
+ followRedirects: Boolean = true,
+ ): OkHttpClient {
+ val tm = tofu.trustManager
+ val sslContext = javax.net.ssl.SSLContext.getInstance("TLS")
+ sslContext.init(null, arrayOf(tm), null)
+ return OkHttpClient.Builder()
+ .sslSocketFactory(sslContext.socketFactory, tm)
+ .connectTimeout(connectTimeoutS, TimeUnit.SECONDS)
+ .readTimeout(readTimeoutS, TimeUnit.SECONDS)
+ .writeTimeout(writeTimeoutS, TimeUnit.SECONDS)
+ .followRedirects(followRedirects)
+ .addInterceptor(RedactingLogger(bodiesEnabled, sink))
+ .build()
+ }
+
+ /** P3-era API shape (15/60/60s). */
+ fun apiClient(): OkHttpClient = newClient(15, 60, 60)
+
+ /** Short probing shape (10/15s, no redirects). */
+ fun probeClient(): OkHttpClient = newClient(10, 15, 15, followRedirects = false)
+
+ /** Nextcloud shape (10/20/60s, no redirects). */
+ fun nextcloudClient(): OkHttpClient = newClient(10, 20, 60, followRedirects = false)
+}
+
+/**
+ * Logging is OFF by default; when the `log_http_bodies` debug setting is
+ * on, requests log in redacted form. Invariants (leak-tested):
+ * - Authorization / Cookie / Set-Cookie headers are never logged.
+ * - bodies log only for JSON/XML/text under [MAX_BODY] bytes; audio and
+ * other binary bodies never log.
+ * - token-shaped JSON values (`…token…`, `password`, `appPassword`) are
+ * masked, and Basic credentials are masked, even inside bodies.
+ */
+class RedactingLogger(
+ private val bodiesEnabled: () -> Boolean,
+ private val sink: (String) -> Unit,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val sensitive = chain.request().header(SENSITIVE_BODY) != null
+ val req = chain.request().newBuilder().removeHeader(SENSITIVE_BODY).build()
+ if (!bodiesEnabled()) return chain.proceed(req)
+ val t0 = System.currentTimeMillis()
+ val reqBody = req.body
+ val reqLen = reqBody?.contentLength()?.takeIf { it >= 0 }
+ sink("→ ${req.method} ${req.url.host}${req.url.encodedPath} body=${reqLen?.let { "$it B" } ?: "?"}")
+ for (i in 0 until req.headers.size) {
+ val name = req.headers.name(i)
+ sink(" $name: ${if (isSensitiveHeader(name)) "[redacted]" else req.headers.value(i)}")
+ }
+ if (sensitive) {
+ sink(" request-body: [sensitive, not logged]")
+ } else {
+ logRequestBody(reqBody?.contentType()?.toString(), reqBody)
+ }
+ try {
+ val resp = chain.proceed(req)
+ val ms = System.currentTimeMillis() - t0
+ sink("← ${resp.code} ${req.url.encodedPath} (${ms}ms)")
+ if (sensitive) {
+ sink(" response-body: [sensitive, not logged]")
+ return resp
+ }
+ val peek = resp.peekBody(MAX_BODY + 1)
+ logBody(
+ " response",
+ peek.contentType()?.toString(),
+ peek.bytes().toList(),
+ )
+ return resp
+ } catch (e: Exception) {
+ sink("✕ ${req.url.encodedPath} failed (${e.javaClass.simpleName})")
+ throw e
+ }
+ }
+
+ /**
+ * Request bodies need a size gate BEFORE reading: uploads are file
+ * bodies that must never be buffered just to log a prefix of them.
+ */
+ private fun logRequestBody(contentType: String?, body: okhttp3.RequestBody?) {
+ if (body == null) {
+ sink(" request-body: none")
+ return
+ }
+ val len = try {
+ body.contentLength()
+ } catch (e: Exception) {
+ -1L
+ }
+ if (len < 0 || len > MAX_BODY) {
+ val what = if (len < 0) "streaming" else "$len bytes"
+ sink(" request-body: [$what, not logged]")
+ return
+ }
+ val bytes = try {
+ val buf = okio.Buffer()
+ body.writeTo(buf)
+ buf.readByteArray().toList()
+ } catch (e: Exception) {
+ null
+ }
+ logBody(" request", contentType, bytes)
+ }
+
+ private fun logBody(prefix: String, contentType: String?, bytes: List?) {
+ if (bytes == null) {
+ sink("$prefix-body: none")
+ return
+ }
+ if (!isLoggableType(contentType)) {
+ sink("$prefix-body: [${bytes.size} bytes, not logged]")
+ return
+ }
+ if (bytes.size > MAX_BODY) {
+ sink("$prefix-body: [${bytes.size} bytes, over the $MAX_BODY B cap, not logged]")
+ return
+ }
+ sink("$prefix-body: ${redact(bytes.toByteArray().toString(Charsets.UTF_8))}")
+ }
+
+ companion object {
+ const val MAX_BODY: Long = 8192
+
+ /**
+ * Opt out of body logging per request (both directions). Providers
+ * set this on calls whose bodies are transcripts or other sensitive
+ * payloads; the logger strips it before sending so it never reaches
+ * the wire.
+ */
+ const val SENSITIVE_BODY = "X-Shonar-Sensitive-Body"
+
+ internal fun isSensitiveHeader(name: String): Boolean = when (name.lowercase()) {
+ "authorization", "cookie", "set-cookie", "x-chunk-sha256" -> true
+ else -> false
+ }
+
+ internal fun isLoggableType(contentType: String?): Boolean {
+ if (contentType == null) return false
+ val t = contentType.lowercase().substringBefore(';').trim()
+ return t.startsWith("application/json") || t.endsWith("+json") ||
+ t.startsWith("text/") || t.endsWith("+xml") ||
+ t == "application/xml" || t == "application/x-www-form-urlencoded"
+ }
+
+ private val SECRET_JSON = Regex(
+ """"[^"]*(token|password|secret)[^"]*"\s*:\s*"[^"]*"""",
+ RegexOption.IGNORE_CASE,
+ )
+ private val BASIC = Regex("""Basic\s+[A-Za-z0-9+/=]{8,}""")
+ private val BEARER = Regex("""Bearer\s+[A-Za-z0-9\-_.~+/=]{8,}""")
+
+ /** Mask token-shaped values; safe to run on any text. */
+ internal fun redact(text: String): String {
+ var out = SECRET_JSON.replace(text) { m ->
+ val key = m.value.substringBefore(':')
+ """$key:"***""""
+ }
+ out = BASIC.replace(out, "Basic [redacted]")
+ out = BEARER.replace(out, "Bearer [redacted]")
+ return out
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/provider/TofuTrust.kt b/android/app/src/main/java/com/shonar/provider/TofuTrust.kt
new file mode 100644
index 0000000..f684b10
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/provider/TofuTrust.kt
@@ -0,0 +1,287 @@
+package com.shonar.provider
+
+import com.shonar.settings.SettingsStore
+import java.net.Socket
+import java.security.MessageDigest
+import java.security.cert.CertificateException
+import java.security.cert.X509Certificate
+import java.util.concurrent.ConcurrentHashMap
+import javax.net.ssl.SSLEngine
+import javax.net.ssl.SSLSession
+import javax.net.ssl.X509ExtendedTrustManager
+import javax.net.ssl.X509TrustManager
+
+/**
+ * P5: trust-on-first-use (TOFU) for self-signed LAN servers
+ * (docs/server-providers.md §4).
+ *
+ * Policy, enforced by construction:
+ * - system CAs are always tried first; TOFU pins are a fallback, never a
+ * replacement. A host that later gets a real certificate just works.
+ * - pins are per-host: an approved cert for `nas.local` is trusted ONLY
+ * when `nas.local` presents it. Byte-equality on the leaf DER, so a
+ * rotation needs a fresh approval (correct TOFU semantics).
+ * - nothing is ever trusted silently: the first failure only RECORDS the
+ * chain, and trust requires an explicit [TofuManager.approve] call from
+ * a UI that showed the fingerprint.
+ * - the normal TLS hostname verifier stays strict; TOFU covers unknown
+ * CAs, not name mismatches.
+ */
+class TofuStore(private val secure: SettingsStore) {
+
+ suspend fun addPin(host: String, derBase64: String) {
+ val pins = pins(host).toMutableSet()
+ pins += derBase64
+ secure.putString(pinKey(host), org.json.JSONArray(pins.toList()).toString())
+ val hosts = hosts().toMutableSet()
+ if (hosts.add(host.lowercase())) {
+ secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString())
+ }
+ }
+
+ suspend fun pins(host: String): Set {
+ val raw = secure.getString(pinKey(host.lowercase())) ?: return emptySet()
+ return runCatching {
+ val arr = org.json.JSONArray(raw)
+ (0 until arr.length()).map { arr.getString(it) }.toSet()
+ }.getOrDefault(emptySet())
+ }
+
+ suspend fun hosts(): Set {
+ val raw = secure.getString(KEY_HOSTS) ?: return emptySet()
+ return runCatching {
+ val arr = org.json.JSONArray(raw)
+ (0 until arr.length()).map { arr.getString(it) }.toSet()
+ }.getOrDefault(emptySet())
+ }
+
+ suspend fun removeHost(host: String) {
+ secure.remove(pinKey(host.lowercase()))
+ val hosts = hosts().toMutableSet()
+ if (hosts.remove(host.lowercase())) {
+ secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString())
+ }
+ }
+
+ companion object {
+ private const val PREFIX = "tofu."
+ const val KEY_HOSTS = PREFIX + "hosts"
+ fun pinKey(host: String): String = PREFIX + "pins." + host.lowercase()
+ }
+}
+
+/** A recorded untrusted chain, shown to the user for approval. */
+data class TofuFailure(
+ val host: String,
+ /** SPKI SHA-256, browser-style `AB:CD:…` uppercase hex. */
+ val spkiHex: String,
+ val subject: String,
+ val issuer: String,
+ val validFrom: String,
+ val validUntil: String,
+ val leafDer: ByteArray,
+ val recordedAtMs: Long = System.currentTimeMillis(),
+) {
+ override fun toString(): String =
+ "TofuFailure(host=$host, spki=$spkiHex, subject=$subject)"
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is TofuFailure) return false
+ return host == other.host && spkiHex == other.spkiHex &&
+ leafDer.contentEquals(other.leafDer)
+ }
+
+ override fun hashCode(): Int = 31 * host.hashCode() + spkiHex.hashCode()
+}
+
+/**
+ * Thrown (as a [CertificateException], so it propagates through the TLS
+ * stack untouched) when a chain is neither system-trusted nor pinned.
+ * Carries no secrets — DNs and fingerprints are public cert contents.
+ */
+class TofuUntrustedException(
+ val failure: TofuFailure,
+ message: String = "Untrusted certificate for ${failure.host} (SPKI ${failure.spkiHex})",
+) : CertificateException(message)
+
+/**
+ * System-first trust manager with per-host TOFU fallback. The pin cache is
+ * in-memory (handshakes run on TLS threads that cannot suspend); it is
+ * filled by [TofuManager.refresh] at startup and kept in sync by
+ * approve/forget.
+ */
+class TofuTrustManager(
+ private val system: X509TrustManager,
+ private val manager: TofuManager,
+) : X509ExtendedTrustManager() {
+
+ override fun checkClientTrusted(chain: Array, authType: String) {
+ system.checkClientTrusted(chain, authType)
+ }
+
+ // Client-auth paths: this app is always the TLS client, so these only
+ // need to exist (newer JDKs/Android declare them abstract). Delegate to
+ // the 2-arg system check.
+ override fun checkClientTrusted(
+ chain: Array, authType: String, socket: Socket,
+ ) {
+ system.checkClientTrusted(chain, authType)
+ }
+
+ override fun checkClientTrusted(
+ chain: Array, authType: String, engine: SSLEngine,
+ ) {
+ system.checkClientTrusted(chain, authType)
+ }
+
+ override fun checkServerTrusted(chain: Array, authType: String) {
+ checkServerTrusted(chain, authType, host = null)
+ }
+
+ override fun checkServerTrusted(
+ chain: Array, authType: String, socket: Socket,
+ ) {
+ val host = runCatching {
+ (socket as? javax.net.ssl.SSLSocket)?.handshakeSession?.peerHost
+ }.getOrNull()
+ checkServerTrusted(chain, authType, host = host)
+ }
+
+ override fun checkServerTrusted(
+ chain: Array, authType: String, engine: SSLEngine,
+ ) {
+ val host = runCatching {
+ (engine.session as? javax.net.ssl.ExtendedSSLSession)?.peerHost
+ }.getOrNull()
+ checkServerTrusted(chain, authType, host = host)
+ }
+
+ private fun checkServerTrusted(
+ chain: Array, authType: String, host: String?,
+ ) {
+ // pins are checked first so an approved self-signed cert keeps
+ // working even where a system path would also exist; system trust
+ // is the fallback that makes later real certs frictionless.
+ if (chain.isNotEmpty() && host != null && manager.isPinned(host, chain[0])) {
+ return
+ }
+ try {
+ system.checkServerTrusted(chain, authType)
+ } catch (e: CertificateException) {
+ val failure = TofuFailure(
+ host = (host ?: "").lowercase(),
+ spkiHex = spkiHex(chain[0]),
+ subject = chain[0].subjectX500Principal.name,
+ issuer = chain[0].issuerX500Principal.name,
+ validFrom = chain[0].notBefore.toString(),
+ validUntil = chain[0].notAfter.toString(),
+ leafDer = chain[0].encoded,
+ )
+ manager.record(failure)
+ throw TofuUntrustedException(failure)
+ }
+ }
+
+ override fun getAcceptedIssuers(): Array = system.acceptedIssuers
+
+ companion object {
+ /** SPKI SHA-256 as browser-style colon-separated uppercase hex. */
+ internal fun spkiHex(cert: X509Certificate): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ .digest(cert.publicKey.encoded)
+ return digest.joinToString(":") { "%02X".format(it) }
+ }
+ }
+}
+
+/**
+ * Owns the pin store, the in-memory cache the trust manager reads, and the
+ * recorded failures the approval UI consumes.
+ */
+class TofuManager(
+ private val store: TofuStore,
+ system: X509TrustManager = defaultSystemTrustManager(),
+) {
+ private val cache = ConcurrentHashMap>()
+ private val failures = ConcurrentHashMap()
+
+ val trustManager: TofuTrustManager by lazy { TofuTrustManager(system, this) }
+
+ /** Fill the cache from the store. Call at startup (and only there). */
+ suspend fun refresh() {
+ val fresh = mutableMapOf>()
+ for (host in store.hosts()) {
+ fresh[host.lowercase()] = store.pins(host)
+ }
+ cache.clear()
+ cache.putAll(fresh)
+ }
+
+ internal fun isPinned(host: String, leaf: X509Certificate): Boolean {
+ val pins = cache[host.lowercase()] ?: return false
+ val der = runCatching { leaf.encoded }.getOrNull() ?: return false
+ return pins.any { pin ->
+ runCatching {
+ MessageDigest.isEqual(
+ der,
+ java.util.Base64.getDecoder().decode(pin),
+ )
+ }.getOrDefault(false)
+ }
+ }
+
+ internal fun record(failure: TofuFailure) {
+ failures[failure.host] = failure
+ }
+
+ /** Most recent failure for [host], else a fresh hostless one, else null. */
+ fun failureFor(host: String): TofuFailure? {
+ val key = host.lowercase()
+ failures[key]?.let { return it }
+ val fallback = failures[""] ?: return null
+ // A hostless record only belongs to this probe if it just happened
+ // (single onboarding flow, no concurrency to speak of).
+ if (System.currentTimeMillis() - fallback.recordedAtMs > FRESH_MS) return null
+ return fallback
+ }
+
+ /**
+ * Trust [host]'s recorded leaf from now on. Returns false when there is
+ * nothing recorded (never invent trust).
+ */
+ suspend fun approve(host: String): Boolean {
+ val key = host.lowercase()
+ val failure = failureFor(key) ?: return false
+ val der = java.util.Base64.getEncoder().encodeToString(failure.leafDer)
+ store.addPin(key, der)
+ cache[key] = store.pins(key)
+ failures.remove(key)
+ failures.remove("")
+ return true
+ }
+
+ suspend fun decline(host: String) {
+ failures.remove(host.lowercase())
+ failures.remove("")
+ }
+
+ suspend fun forget(host: String) {
+ store.removeHost(host)
+ cache.remove(host.lowercase())
+ }
+
+ suspend fun pinnedHosts(): Set = store.hosts()
+
+ companion object {
+ private const val FRESH_MS = 30_000L
+
+ fun defaultSystemTrustManager(): X509TrustManager {
+ val factory = javax.net.ssl.TrustManagerFactory.getInstance(
+ javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()
+ )
+ factory.init(null as java.security.KeyStore?)
+ return factory.trustManagers.filterIsInstance().first()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt b/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt
new file mode 100644
index 0000000..89541e2
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt
@@ -0,0 +1,89 @@
+package com.shonar.recording
+
+import com.shonar.provider.LocalOnlyProvider
+import com.shonar.provider.ProviderRegistry
+import com.shonar.provider.ShonarProvider
+import com.shonar.provider.SyncState
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+
+/**
+ * P7 foreground "migrate now" uploader. Deliberately bounded — this is NOT
+ * the M5 background driver:
+ *
+ * - runs while the user watches (progress callback), cancellable; the
+ * in-flight file finishes, files never started stay exactly as they
+ * were, and the rest can run later (or under M5).
+ * - one attempt per file, no retry, no network/constraint checks.
+ * - upload-only: the local file is the source of truth and is never
+ * modified; success overwrites the row's remote slot with the new
+ * provider, failure records ERROR + the provider's message and moves on.
+ * - local-only is rejected as a target: there is nothing to upload to.
+ * ("Keep everything local" is a migration *choice*, handled by leaving
+ * rows untouched — not by this runner.)
+ */
+class MigrationRunner(
+ private val slots: SyncSlots,
+ private val registry: ProviderRegistry,
+) {
+ data class Progress(val done: Int, val total: Int, val currentTitle: String)
+ data class Result(val uploaded: Int, val failed: List, val cancelled: Boolean)
+
+ suspend fun migrateAllTo(
+ providerId: String,
+ onProgress: (Progress) -> Unit = {},
+ ): Result {
+ require(providerId != LocalOnlyProvider.ID) { "cannot migrate to local-only" }
+ val target: ShonarProvider = registry.provider(providerId)
+ val rows = slots.rows()
+ var uploaded = 0
+ val failed = mutableListOf()
+ var cancelled = false
+ rows.forEachIndexed { index, row ->
+ // Cancellation lands here between files: counted, honest, resumable.
+ try {
+ currentCoroutineContext().ensureActive()
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ cancelled = true
+ return Result(uploaded, failed, cancelled)
+ }
+ onProgress(Progress(index, rows.size, row.title))
+ val draft = slots.draftFor(row)
+ if (draft == null) {
+ slots.markFailed(row, "local file is gone")
+ failed += row.title
+ return@forEachIndexed
+ }
+ stepToUploading(row)
+ try {
+ val ref = target.upload(draft) {}
+ slots.markUploaded(slots.rows().firstOrNull { it.id == row.id } ?: row, providerId, ref)
+ uploaded++
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ // Interrupted mid-file: leave the row UPLOADING so a later
+ // run retries it explicitly rather than assuming either
+ // outcome.
+ throw e
+ } catch (e: Exception) {
+ val fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
+ // Backoff included: the M5 drain picks ERROR rows up when due.
+ slots.markFailed(fresh, e.message ?: e.javaClass.simpleName)
+ failed += row.title
+ }
+ }
+ onProgress(Progress(rows.size, rows.size, ""))
+ return Result(uploaded, failed, cancelled)
+ }
+
+ /**
+ * Legal first steps toward UPLOADING from any resting state, per the
+ * transition table (shared with the M5 drain).
+ */
+ private suspend fun stepToUploading(row: RecordingEntity) {
+ var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
+ for (step in stepsToUploading(fresh.syncState)) {
+ slots.markState(fresh, step)
+ fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/recording/RecordingDao.kt b/android/app/src/main/java/com/shonar/recording/RecordingDao.kt
new file mode 100644
index 0000000..22a86ad
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/RecordingDao.kt
@@ -0,0 +1,29 @@
+package com.shonar.recording
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface RecordingDao {
+ @Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
+ suspend fun getAll(): List
+
+ @Query("SELECT * FROM recordings WHERE id = :id")
+ suspend fun getById(id: String): RecordingEntity?
+
+ @Insert
+ suspend fun insert(recording: RecordingEntity)
+
+ @Update
+ suspend fun update(recording: RecordingEntity)
+
+ @Delete
+ suspend fun delete(recording: RecordingEntity)
+}
diff --git a/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt b/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt
new file mode 100644
index 0000000..aa8ac3d
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt
@@ -0,0 +1,38 @@
+package com.shonar.recording
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.shonar.provider.SyncState
+
+/**
+ * Metadata for a completed local recording. Audio remains in app-private
+ * storage. P7: each row carries its provider slot —
+ *
+ * - [originProviderId] + [remoteKey]/[remoteEtag]/[remoteSizeBytes] name the
+ * server copy, if any. One slot is the whole history: switching providers
+ * never touches it until the user migrates (slot overwritten) or forgets
+ * (slot cleared). The local file is always the source of truth.
+ * - [syncState]/[syncReason] is the §2 lifecycle. Until the M5 background
+ * driver exists, the foreground migrator (P7) is the only writer besides
+ * these defaults.
+ */
+@Entity(tableName = "recordings")
+data class RecordingEntity(
+ @PrimaryKey val id: String,
+ val title: String,
+ val createdAtEpochMs: Long,
+ val durationMs: Long,
+ val filePath: String,
+ val mimeType: String,
+ val sizeBytes: Long,
+ val originProviderId: String = "local-only",
+ val remoteKey: String? = null,
+ val remoteEtag: String? = null,
+ val remoteSizeBytes: Long? = null,
+ val syncState: SyncState = SyncState.LOCAL_ONLY,
+ val syncReason: String? = null,
+ /** Consecutive failed attempts (M5 backoff). Reset on success/forget. */
+ val syncAttempts: Int = 0,
+ /** Next eligible retry, epoch ms. Null = due immediately. */
+ val syncRetryAtMs: Long? = null,
+)
diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt
new file mode 100644
index 0000000..822e0e3
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt
@@ -0,0 +1,76 @@
+package com.shonar.recording
+
+import android.content.Context
+import com.shonar.provider.RecordingDraft
+import com.shonar.provider.RemoteRef
+import com.shonar.provider.SyncState
+import kotlinx.coroutines.flow.Flow
+import java.io.File
+import java.text.DateFormat
+import java.util.Date
+import java.util.UUID
+
+class RecordingRepository(
+ private val context: Context,
+ private val dao: RecordingDao,
+) {
+ private val slots = SyncSlots(dao)
+ private val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() }
+ private val tempDir = File(recordingsDir, ".in-progress").apply { mkdirs() }
+
+ val recordings: Flow> = dao.observeAll()
+
+ fun newRecordingId(): String = UUID.randomUUID().toString()
+
+ fun tempFile(id: String): File = File(tempDir, "$id.m4a")
+
+ suspend fun finish(
+ id: String,
+ durationMs: Long,
+ tempFile: File,
+ createdAtEpochMs: Long = System.currentTimeMillis(),
+ ) {
+ val finalFile = File(recordingsDir, "$id.m4a")
+ if (!tempFile.renameTo(finalFile)) {
+ tempFile.copyTo(finalFile, overwrite = true)
+ tempFile.delete()
+ }
+ dao.insert(
+ RecordingEntity(
+ id = id,
+ title = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
+ .format(Date(createdAtEpochMs)),
+ createdAtEpochMs = createdAtEpochMs,
+ durationMs = durationMs.coerceAtLeast(1L),
+ filePath = finalFile.absolutePath,
+ mimeType = "audio/mp4",
+ sizeBytes = finalFile.length(),
+ ),
+ )
+ }
+
+ suspend fun delete(recording: RecordingEntity) {
+ val canonicalRoot = recordingsDir.canonicalFile
+ val canonicalFile = File(recording.filePath).canonicalFile
+ require(canonicalFile.toPath().startsWith(canonicalRoot.toPath())) { "Invalid recording path" }
+ canonicalFile.delete()
+ dao.delete(recording)
+ }
+
+ // ---- P7 provider slot (delegates to SyncSlots; Context-free logic) -----
+
+ /** Draft for upload, or null when the local file is gone. */
+ fun toDraft(entity: RecordingEntity): RecordingDraft? = slots.draftFor(entity)
+
+ suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) =
+ slots.markState(entity, state, reason)
+
+ suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) =
+ slots.markUploaded(entity, providerId, ref)
+
+ /**
+ * Forget every remote link (P7 "start fresh" choice). Files stay;
+ * nothing remote is touched — forgetting is metadata-only.
+ */
+ suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId)
+}
diff --git a/android/app/src/main/java/com/shonar/recording/RecordingService.kt b/android/app/src/main/java/com/shonar/recording/RecordingService.kt
new file mode 100644
index 0000000..3c1de60
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt
@@ -0,0 +1,242 @@
+package com.shonar.recording
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.media.MediaRecorder
+import android.os.Build
+import android.os.IBinder
+import android.os.SystemClock
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import com.shonar.MainActivity
+import com.shonar.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive
+import java.io.File
+
+data class RecordingSnapshot(
+ val phase: Phase = Phase.IDLE,
+ val recordingId: String? = null,
+ val elapsedMs: Long = 0,
+) {
+ enum class Phase { IDLE, RECORDING, PAUSED }
+}
+
+/** Owns microphone access and survives the activity leaving the foreground. */
+class RecordingService : Service() {
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var recorder: MediaRecorder? = null
+ private var currentId: String? = null
+ private var currentTempFile: File? = null
+ private var accumulatedMs = 0L
+ private var resumedAtElapsed = 0L
+ private var tickerJob: kotlinx.coroutines.Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> startRecording()
+ ACTION_PAUSE -> pauseRecording()
+ ACTION_RESUME -> resumeRecording()
+ ACTION_STOP -> stopRecording()
+ }
+ return START_NOT_STICKY
+ }
+
+ private fun startRecording() {
+ if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return
+ if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ stopSelf()
+ return
+ }
+
+ val app = application as com.shonar.ShonarApplication
+ val id = app.recordingRepository.newRecordingId()
+ val temp = app.recordingRepository.tempFile(id)
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ NOTIFICATION_ID,
+ notification("Recording in progress"),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification("Recording in progress"))
+ }
+ recorder = MediaRecorder().apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setOutputFile(temp.absolutePath)
+ prepare()
+ start()
+ }
+ currentId = id
+ currentTempFile = temp
+ resumedAtElapsed = SystemClock.elapsedRealtime()
+ accumulatedMs = 0L
+ updateSnapshot(RecordingSnapshot.Phase.RECORDING)
+ tickerJob = serviceScope.launch {
+ while (isActive) {
+ delay(500)
+ _snapshot.value = snapshot.value.copy(elapsedMs = elapsedMs())
+ }
+ }
+ } catch (_: Exception) {
+ recorder?.release()
+ recorder = null
+ temp.delete()
+ stopSelf()
+ }
+ }
+
+ private fun pauseRecording() {
+ if (snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
+ recorder?.pause()
+ accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed
+ updateSnapshot(RecordingSnapshot.Phase.PAUSED)
+ updateNotification("Recording paused")
+ }
+
+ private fun resumeRecording() {
+ if (snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return
+ recorder?.resume()
+ resumedAtElapsed = SystemClock.elapsedRealtime()
+ updateSnapshot(RecordingSnapshot.Phase.RECORDING)
+ updateNotification("Recording in progress")
+ }
+
+ private fun stopRecording() {
+ if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) {
+ stopSelf()
+ return
+ }
+ val id = currentId
+ val temp = currentTempFile
+ val duration = elapsedMs()
+ try {
+ recorder?.stop()
+ } catch (_: RuntimeException) {
+ temp?.delete()
+ }
+ recorder?.release()
+ recorder = null
+ currentId = null
+ currentTempFile = null
+ tickerJob?.cancel()
+ tickerJob = null
+ updateSnapshot(RecordingSnapshot.Phase.IDLE)
+ serviceScope.launch {
+ val app = application as com.shonar.ShonarApplication
+ if (id != null && temp != null && temp.exists() && temp.length() > 0) {
+ runCatching { app.recordingRepository.finish(id, duration, temp) }
+ .onFailure { temp.delete() }
+ }
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+
+ private fun elapsedMs(): Long = when (snapshot.value.phase) {
+ RecordingSnapshot.Phase.RECORDING ->
+ accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)
+ RecordingSnapshot.Phase.PAUSED -> accumulatedMs
+ RecordingSnapshot.Phase.IDLE -> 0L
+ }
+
+ private fun updateSnapshot(phase: RecordingSnapshot.Phase) {
+ _snapshot.value = RecordingSnapshot(phase, currentId, elapsedMs())
+ updateNotification(if (phase == RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress")
+ }
+
+ private fun updateNotification(text: String) {
+ if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) return
+ getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text))
+ }
+
+ private fun notification(text: String): Notification {
+ val open = PendingIntent.getActivity(
+ this, 1, Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val stop = PendingIntent.getService(
+ this, 2, Intent(this, RecordingService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle("SHONAR")
+ .setContentText(text)
+ .setOngoing(true)
+ .setContentIntent(open)
+ .addAction(0, "Stop", stop)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Recording", NotificationManager.IMPORTANCE_LOW),
+ )
+ }
+ }
+
+ override fun onDestroy() {
+ if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
+ recorder?.reset()
+ recorder?.release()
+ currentTempFile?.delete()
+ recorder = null
+ tickerJob?.cancel()
+ tickerJob = null
+ _snapshot.value = RecordingSnapshot()
+ }
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ private const val CHANNEL_ID = "recording"
+ private const val NOTIFICATION_ID = 1001
+ const val ACTION_START = "com.shonar.recording.START"
+ const val ACTION_PAUSE = "com.shonar.recording.PAUSE"
+ const val ACTION_RESUME = "com.shonar.recording.RESUME"
+ const val ACTION_STOP = "com.shonar.recording.STOP"
+
+ private val _snapshot = MutableStateFlow(RecordingSnapshot())
+ val snapshot: StateFlow = _snapshot.asStateFlow()
+
+ fun command(context: Context, action: String) {
+ val intent = Intent(context, RecordingService::class.java).setAction(action)
+ if (action == ACTION_START) {
+ androidx.core.content.ContextCompat.startForegroundService(context, intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt b/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt
new file mode 100644
index 0000000..9e4c82e
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt
@@ -0,0 +1,41 @@
+package com.shonar.recording
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+@Database(entities = [RecordingEntity::class], version = 3, exportSchema = false)
+@TypeConverters(SyncStateConverter::class)
+abstract class ShonarDatabase : RoomDatabase() {
+ abstract fun recordingDao(): RecordingDao
+}
+
+/**
+ * v1 -> v2 (P7): provider slot per recording. Existing rows predate sync
+ * tracking, so they read as never-synced locals — which is exactly what
+ * they are. NOT NULL columns carry defaults; no data moves.
+ */
+val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE recordings ADD COLUMN originProviderId TEXT NOT NULL DEFAULT 'local-only'")
+ db.execSQL("ALTER TABLE recordings ADD COLUMN remoteKey TEXT")
+ db.execSQL("ALTER TABLE recordings ADD COLUMN remoteEtag TEXT")
+ db.execSQL("ALTER TABLE recordings ADD COLUMN remoteSizeBytes INTEGER")
+ db.execSQL("ALTER TABLE recordings ADD COLUMN syncState TEXT NOT NULL DEFAULT 'LOCAL_ONLY'")
+ db.execSQL("ALTER TABLE recordings ADD COLUMN syncReason TEXT")
+ }
+}
+
+/**
+ * v2 -> v3 (M5): consecutive-failure backoff. attempts/retryAt default to
+ * "never failed, due immediately", which is exactly right for pre-existing
+ * ERROR rows left by P7 runs.
+ */
+val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE recordings ADD COLUMN syncAttempts INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("ALTER TABLE recordings ADD COLUMN syncRetryAtMs INTEGER")
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/recording/SyncDrain.kt b/android/app/src/main/java/com/shonar/recording/SyncDrain.kt
new file mode 100644
index 0000000..7b36682
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/SyncDrain.kt
@@ -0,0 +1,94 @@
+package com.shonar.recording
+
+import com.shonar.provider.LocalOnlyProvider
+import com.shonar.provider.ShonarProvider
+import com.shonar.provider.SyncState
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+
+/**
+ * M5 background drain engine. Context-free on purpose: the [SyncWorker]
+ * (Context-bound) builds the inputs, unit tests drive this directly.
+ *
+ * One pass over due rows — QUEUED, plus ERROR whose backoff expired (null
+ * retryAt = due immediately, which is how P7 leftovers read). SYNCED,
+ * UPLOADED and LOCAL_ONLY rows are never touched. Local-only target is a
+ * no-op. Failures record backoff via [SyncSlots.markFailed]; cancellation
+ * between files stops honestly, and a row interrupted mid-upload reverts
+ * to QUEUED so a later pass retries it explicitly.
+ */
+class SyncDrain(
+ private val slots: SyncSlots,
+ private val clock: () -> Long = System::currentTimeMillis,
+) {
+ /** Snapshot of device conditions, taken by the worker. Pure to evaluate. */
+ data class Gates(val online: Boolean, val unmetered: Boolean, val charging: Boolean)
+
+ data class Progress(val done: Int, val total: Int, val currentTitle: String)
+ data class Result(val uploaded: Int, val failed: List, val postponed: Boolean)
+
+ suspend fun drainOnce(
+ provider: ShonarProvider,
+ wifiOnly: Boolean,
+ chargingOnly: Boolean,
+ gates: Gates,
+ onProgress: (Progress) -> Unit = {},
+ ): Result {
+ if (provider.descriptor.id == LocalOnlyProvider.ID) {
+ return Result(0, emptyList(), postponed = false)
+ }
+ val now = clock()
+ val due = slots.rows().filter { row ->
+ row.syncState == SyncState.QUEUED ||
+ (row.syncState == SyncState.ERROR &&
+ (row.syncRetryAtMs == null || row.syncRetryAtMs <= now))
+ }
+ if (due.isEmpty()) return Result(0, emptyList(), postponed = false)
+ if (!gates.satisfiedBy(wifiOnly, chargingOnly)) {
+ return Result(0, emptyList(), postponed = true)
+ }
+ var uploaded = 0
+ val failed = mutableListOf()
+ due.forEachIndexed { index, row ->
+ try {
+ currentCoroutineContext().ensureActive()
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ return Result(uploaded, failed, postponed = false)
+ }
+ onProgress(Progress(index, due.size, row.title))
+ val draft = slots.draftFor(row)
+ if (draft == null) {
+ slots.markFailed(row, "local file is gone", now)
+ failed += row.title
+ return@forEachIndexed
+ }
+ var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
+ for (step in stepsToUploading(fresh.syncState)) {
+ slots.markState(fresh, step)
+ fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
+ }
+ try {
+ val ref = provider.upload(draft) {}
+ val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
+ slots.markUploaded(done, provider.descriptor.id, ref)
+ uploaded++
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ // Interrupted mid-file: revert so a later pass retries
+ // explicitly instead of assuming either outcome.
+ val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
+ slots.markState(done, SyncState.QUEUED)
+ throw e
+ } catch (e: Exception) {
+ val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
+ slots.markFailed(done, e.message ?: e.javaClass.simpleName, now)
+ failed += row.title
+ }
+ }
+ onProgress(Progress(due.size, due.size, ""))
+ return Result(uploaded, failed, postponed = false)
+ }
+}
+
+/** Pure gate evaluation, unit-tested. */
+internal fun SyncDrain.Gates.satisfiedBy(wifiOnly: Boolean, chargingOnly: Boolean): Boolean =
+ online && (!wifiOnly || unmetered) && (!chargingOnly || charging)
diff --git a/android/app/src/main/java/com/shonar/recording/SyncSlots.kt b/android/app/src/main/java/com/shonar/recording/SyncSlots.kt
new file mode 100644
index 0000000..67543ef
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/SyncSlots.kt
@@ -0,0 +1,116 @@
+package com.shonar.recording
+
+import com.shonar.provider.RecordingDraft
+import com.shonar.provider.RemoteRef
+import com.shonar.provider.SyncState
+import java.io.File
+
+/**
+ * P7 provider-slot bookkeeping over [RecordingDao]. Context-free on purpose:
+ * the repository (Context-bound) delegates here, and the migration runner
+ * and unit tests drive it directly.
+ *
+ * One slot is the whole history: switching providers never touches it until
+ * the user migrates (slot overwritten) or forgets (slot cleared). The local
+ * file is always the source of truth.
+ */
+class SyncSlots(private val dao: RecordingDao) {
+
+ suspend fun rows(): List = dao.getAll()
+
+ /** Draft for upload, or null when the local file is gone. */
+ fun draftFor(entity: RecordingEntity): RecordingDraft? {
+ val file = File(entity.filePath)
+ if (!file.isFile) return null
+ return RecordingDraft(
+ id = entity.id,
+ title = entity.title,
+ createdAtEpochMs = entity.createdAtEpochMs,
+ durationMs = entity.durationMs,
+ mime = entity.mimeType,
+ sourceFile = file,
+ sizeBytes = file.length(),
+ )
+ }
+
+ suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) {
+ dao.update(entity.copy(syncState = state, syncReason = reason))
+ }
+
+ suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) {
+ dao.update(
+ entity.copy(
+ originProviderId = providerId,
+ remoteKey = ref.key,
+ remoteEtag = ref.etag,
+ remoteSizeBytes = ref.sizeBytes,
+ syncState = SyncState.UPLOADED,
+ syncReason = null,
+ syncAttempts = 0,
+ syncRetryAtMs = null,
+ )
+ )
+ }
+
+ /**
+ * Record a failed attempt with exponential backoff. [nowMs] is injected
+ * (default wall-clock) so tests run on a fixed clock.
+ */
+ suspend fun markFailed(
+ entity: RecordingEntity,
+ reason: String,
+ nowMs: Long = System.currentTimeMillis(),
+ ) {
+ val attempts = (entity.syncAttempts + 1).coerceAtLeast(1)
+ dao.update(
+ entity.copy(
+ syncState = SyncState.ERROR,
+ syncReason = reason,
+ syncAttempts = attempts,
+ syncRetryAtMs = nowMs + backoffDelayMs(attempts),
+ )
+ )
+ }
+
+ /**
+ * Forget every remote link ("start fresh"). Files stay; rows read as
+ * never-synced under [newOriginId], ready for a later upload run.
+ * Nothing remote is touched — forgetting is metadata-only.
+ */
+ suspend fun forgetAllRemotes(newOriginId: String) { for (row in dao.getAll()) {
+ dao.update(
+ row.copy(
+ originProviderId = newOriginId,
+ remoteKey = null,
+ remoteEtag = null,
+ remoteSizeBytes = null,
+ syncState = SyncState.QUEUED,
+ syncReason = null,
+ syncAttempts = 0,
+ syncRetryAtMs = null,
+ )
+ )
+ }
+ }
+}
+
+/**
+ * Legal steps from any resting state toward UPLOADING, per the transition
+ * table. Shared by the P7 foreground migrator and the M5 background drain
+ * so both move rows through identical states.
+ */
+internal fun stepsToUploading(from: SyncState): List = when (from) {
+ SyncState.LOCAL_ONLY, SyncState.ERROR -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
+ // A leftover UPLOADING row (killed run) has no self-loop: detour via QUEUED.
+ SyncState.UPLOADING -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
+ else -> listOf(SyncState.UPLOADING)
+}
+
+/**
+ * Consecutive-failure backoff: 1m, 2m, 4m … capped at 1h. [attempt] is the
+ * 1-based count *including* the failure just recorded.
+ */
+internal fun backoffDelayMs(attempt: Int): Long {
+ val shift = (attempt.coerceAtLeast(1) - 1).coerceAtMost(6)
+ return (60_000L shl shift).coerceAtMost(3_600_000L)
+}
diff --git a/android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt b/android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt
new file mode 100644
index 0000000..381a75f
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt
@@ -0,0 +1,16 @@
+package com.shonar.recording
+
+import androidx.room.TypeConverter
+import com.shonar.provider.SyncState
+
+/** SyncState <-> String by enum name. Unknown names (future states from a
+ * newer app reading this DB are impossible — same app writes and reads) fall
+ * back to LOCAL_ONLY rather than crashing the library. */
+class SyncStateConverter {
+ @TypeConverter
+ fun toString(state: SyncState): String = state.name
+
+ @TypeConverter
+ fun toState(raw: String?): SyncState =
+ runCatching { SyncState.valueOf(raw!!) }.getOrDefault(SyncState.LOCAL_ONLY)
+}
diff --git a/android/app/src/main/java/com/shonar/recording/SyncWorker.kt b/android/app/src/main/java/com/shonar/recording/SyncWorker.kt
new file mode 100644
index 0000000..1553dd4
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/recording/SyncWorker.kt
@@ -0,0 +1,141 @@
+package com.shonar.recording
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.shonar.ShonarApplication
+import com.shonar.provider.AuthState
+import com.shonar.provider.LocalOnlyProvider
+import com.shonar.settings.BuiltInSettings
+import java.util.concurrent.TimeUnit
+
+/**
+ * M5 background sync driver. Thin by design: it resolves settings, the
+ * active provider, and live device gates, then hands one pass to the
+ * Context-free [SyncDrain] (which owns all state transitions and is where
+ * the unit tests live).
+ *
+ * Result contract:
+ * - success: the pass completed (per-file failures are recorded in the DB
+ * with backoff — retrying the whole worker immediately would hammer).
+ * - retry: transient only — offline at start, postponed by gates. Auth
+ * states needing the user (disconnected/expired/revoked) are failure.
+ * - cancellation propagates: the drain reverts the in-flight row to QUEUED.
+ */
+class SyncWorker(appContext: Context, params: WorkerParameters) :
+ CoroutineWorker(appContext, params) {
+
+ override suspend fun doWork(): Result {
+ val app = applicationContext as ShonarApplication
+ app.settingsManager.ensureLoaded()
+ val settings = app.settingsManager
+ val providerId = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
+ if (providerId == LocalOnlyProvider.ID) return Result.success()
+ val wifiOnly = settings.bool(BuiltInSettings.WIFI_ONLY_UPLOAD)
+ val chargingOnly = settings.bool(BuiltInSettings.CHARGING_ONLY_UPLOAD)
+
+ val gates = readGates(applicationContext)
+ val provider = try {
+ app.providerRegistry.provider(providerId)
+ } catch (e: Exception) {
+ return Result.failure()
+ }
+ val auth = try {
+ provider.reconnect()
+ } catch (e: Exception) {
+ return Result.retry()
+ }
+ when (auth) {
+ AuthState.DISCONNECTED, AuthState.EXPIRED, AuthState.REVOKED ->
+ return Result.failure()
+ AuthState.OFFLINE -> return Result.retry()
+ AuthState.CONNECTED -> Unit
+ }
+ val drain = SyncDrain(SyncSlots(app.database.recordingDao()))
+ val res = drain.drainOnce(provider, wifiOnly, chargingOnly, gates)
+ return if (res.postponed) Result.retry() else Result.success()
+ }
+
+ companion object {
+ /** Snapshot of device conditions for [SyncDrain.Gates]. */
+ internal fun readGates(context: Context): SyncDrain.Gates {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
+ as? android.net.ConnectivityManager
+ val caps = cm?.getNetworkCapabilities(cm.activeNetwork)
+ val online = caps?.hasCapability(
+ android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+ ) == true
+ val unmetered = caps?.hasCapability(
+ android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
+ ) == true
+ val bm = context.getSystemService(Context.BATTERY_SERVICE)
+ as? android.os.BatteryManager
+ return SyncDrain.Gates(
+ online = online,
+ unmetered = unmetered,
+ charging = bm?.isCharging == true,
+ )
+ }
+ }
+}
+
+/**
+ * WorkManager wiring: one-shot drains on demand, a periodic 15-minute
+ * drain (the platform minimum) while constraints hold, and pause.
+ * Constraint mapping is a pure function ([workConstraints]) so the
+ * settings-to-WorkManager translation is unit-tested.
+ */
+object SyncScheduler {
+ const val ONCE_NAME = "shonar-sync-drain-once"
+ const val PERIODIC_NAME = "shonar-sync-drain-periodic"
+
+ /** Pure settings -> constraints mapping. */
+ internal fun workConstraints(wifiOnly: Boolean, chargingOnly: Boolean): Constraints =
+ Constraints.Builder()
+ .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
+ .setRequiresCharging(chargingOnly)
+ .build()
+
+ /** Drain now (startup, after a switch, after settings change). */
+ fun requestNow(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
+ val req = OneTimeWorkRequestBuilder()
+ .setConstraints(workConstraints(wifiOnly, chargingOnly))
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
+ .addTag(ONCE_NAME)
+ .build()
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(ONCE_NAME, ExistingWorkPolicy.REPLACE, req)
+ }
+
+ /** Steady-state background drain. Idempotent; safe to call on every launch. */
+ fun ensurePeriodic(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
+ val req = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
+ .setConstraints(workConstraints(wifiOnly, chargingOnly))
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
+ .addTag(PERIODIC_NAME)
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ PERIODIC_NAME, ExistingPeriodicWorkPolicy.UPDATE, req
+ )
+ }
+
+ /** Pause: cancel scheduled work and revert in-flight rows to QUEUED. */
+ suspend fun pauseAll(context: Context, dao: RecordingDao) {
+ val wm = WorkManager.getInstance(context)
+ wm.cancelUniqueWork(ONCE_NAME)
+ wm.cancelUniqueWork(PERIODIC_NAME)
+ for (row in dao.getAll()) {
+ if (row.syncState == com.shonar.provider.SyncState.UPLOADING) {
+ dao.update(row.copy(syncState = com.shonar.provider.SyncState.QUEUED))
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt
index cbb7577..d88e5e0 100644
--- a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt
+++ b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt
@@ -16,6 +16,10 @@ object BuiltInSettings {
// ids other code depends on (single source of truth)
const val CONSENT = "consent_notice_seen"
+ const val PROVIDER_ID = "provider_id"
+ const val PROVIDER_URL = "provider_url"
+ const val WIFI_ONLY_UPLOAD = "wifi_only_upload"
+ const val CHARGING_ONLY_UPLOAD = "charging_only_upload"
val all: List = listOf(
// --- General -------------------------------------------------------
@@ -55,6 +59,24 @@ object BuiltInSettings {
type = SettingType.COLOR,
defaultJson = "\"#4FD1C5\"",
),
+ // --- Storage provider (selection persisted here; see ProviderRegistry) ---
+ SettingDefinition(
+ id = PROVIDER_ID,
+ name = "Storage provider",
+ description = "Where your recordings live.",
+ category = CAT_GENERAL,
+ type = SettingType.SELECT,
+ choices = listOf("local-only", "nextcloud", "custom-shonar", "sync-folder", "start9", "umbrel"),
+ defaultJson = "\"local-only\"",
+ ),
+ SettingDefinition(
+ id = PROVIDER_URL,
+ name = "Provider server URL",
+ description = "Server address for the selected provider (empty for local-only).",
+ category = CAT_GENERAL,
+ type = SettingType.URL,
+ defaultJson = "\"\"",
+ ),
// --- Network -------------------------------------------------------------
SettingDefinition(
id = "wifi_only_upload",
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 3ee8a83..d45deb8 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
@@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
@@ -34,32 +36,74 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import android.Manifest
+import android.content.pm.PackageManager
import com.shonar.ShonarApplication
import com.shonar.settings.BuiltInSettings
import kotlinx.coroutines.launch
+import androidx.lifecycle.viewmodel.compose.viewModel
/**
- * Home: big record button (functional UI; recording engine lands in M4),
+ * Home: offline recording, local library, provider selection, and settings.
* a Settings entry, and the first-launch recording-consent notice which must
* be acknowledged before anything else.
*/
@Composable
-fun HomeScreen(onOpenSettings: () -> Unit) {
- val app = LocalContext.current.applicationContext as ShonarApplication
+fun HomeScreen(
+ onOpenSettings: () -> Unit,
+ onOpenStorage: () -> Unit,
+) {
+ val context = LocalContext.current
+ val app = context.applicationContext as ShonarApplication
+ val vm: RecordingViewModel = viewModel(factory = RecordingViewModel.Factory)
+ val recorderState by vm.recorderState.collectAsState()
+ val recordings by vm.recordings.collectAsState()
+ val playingId by vm.playingId.collectAsState()
val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf(null) }
+ var storageHeadline by remember { mutableStateOf(null) }
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { granted -> if (granted) vm.start() }
+
+ fun startRecording() {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ vm.start()
+ } else {
+ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ }
LaunchedEffect(Unit) {
app.settingsManager.ensureLoaded()
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
+ val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID)
+ val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL)
+ storageHeadline = com.shonar.ui.provider.ProviderSelectionViewModel
+ .headlineFor(pid.ifBlank { "local-only" }, purl)
}
Scaffold(
floatingActionButton = {
ExtendedFloatingActionButton(
- onClick = { /* M4: start recording flow */ },
+ onClick = {
+ if (consentSeen == true) {
+ if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
+ startRecording()
+ } else {
+ vm.stop()
+ }
+ }
+ },
icon = { Icon(Icons.Filled.Mic, contentDescription = null) },
- text = { Text("Record") },
+ text = {
+ Text(if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) "Record" else "Stop")
+ },
)
},
) { padding ->
@@ -67,8 +111,9 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
modifier = Modifier
.fillMaxSize()
.padding(padding)
- .padding(24.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
@@ -77,15 +122,40 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ storageHeadline?.let { headline ->
+ StorageChip(headline = headline, onClick = onOpenStorage)
+ }
Spacer(Modifier.height(24.dp))
- QuickCard("Settings", "Server, sync, appearance, custom settings",
+ QuickCard("Choose storage", storageHeadline ?: "Where recordings live",
+ Icons.Filled.Cloud, onOpenStorage)
+ QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
- Spacer(Modifier.height(16.dp))
- Text(
- "Recording is coming in the next milestone. Nothing records today.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+ if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
+ RecordingStatusCard(
+ state = recorderState,
+ onPause = vm::pause,
+ onResume = vm::resume,
+ onStop = vm::stop,
+ )
+ }
+ if (recordings.isNotEmpty()) {
+ Text("Your recordings", style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.align(Alignment.Start))
+ recordings.forEach { recording ->
+ RecordingRow(
+ recording = recording,
+ playing = playingId == recording.id,
+ onPlay = { vm.togglePlayback(recording) },
+ onDelete = { vm.delete(recording) },
+ )
+ }
+ } else {
+ Text(
+ "Your recordings will appear here and stay on this device until you choose a provider.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
@@ -99,6 +169,86 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
}
}
+@Composable
+private fun RecordingStatusCard(
+ state: com.shonar.recording.RecordingSnapshot,
+ onPause: () -> Unit,
+ onResume: () -> Unit,
+ onStop: () -> Unit,
+) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress",
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(formatDuration(state.elapsedMs), style = MaterialTheme.typography.bodyLarge)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) onResume else onPause) {
+ Text(if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Resume" else "Pause")
+ }
+ TextButton(onClick = onStop) { Text("Finish") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RecordingRow(
+ recording: com.shonar.recording.RecordingEntity,
+ playing: Boolean,
+ onPlay: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Card(Modifier.fillMaxWidth()) {
+ Row(
+ Modifier.fillMaxWidth().padding(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(recording.title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "${formatDuration(recording.durationMs)} • ${formatBytes(recording.sizeBytes)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") }
+ TextButton(onClick = onDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) }
+ }
+ }
+}
+
+private fun formatDuration(durationMs: Long): String {
+ val totalSeconds = (durationMs / 1000).coerceAtLeast(0)
+ return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
+}
+
+private fun formatBytes(bytes: Long): String = when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0)
+ else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
+}
+
+/** Persistent "Stored: …" indicator — users always see where data goes. */
+@Composable
+private fun StorageChip(headline: String, onClick: () -> Unit) {
+ Card(onClick = onClick,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
+ Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.Cloud, contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer)
+ Spacer(Modifier.size(8.dp))
+ Text("Stored: $headline",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSecondaryContainer)
+ }
+ }
+}
+
@Composable
private fun ConsentDialog(onAcknowledge: () -> Unit) {
AlertDialog(
diff --git a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt
new file mode 100644
index 0000000..7387cf7
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt
@@ -0,0 +1,70 @@
+package com.shonar.ui.home
+
+import android.media.MediaPlayer
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.shonar.ShonarApplication
+import com.shonar.recording.RecordingEntity
+import com.shonar.recording.RecordingService
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
+ val recordings: StateFlow> = app.recordingRepository.recordings
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+ val recorderState = RecordingService.snapshot
+ private val _playingId = kotlinx.coroutines.flow.MutableStateFlow(null)
+ val playingId: StateFlow = _playingId
+ private var player: MediaPlayer? = null
+
+ fun start() = RecordingService.command(app, RecordingService.ACTION_START)
+ fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE)
+ fun resume() = RecordingService.command(app, RecordingService.ACTION_RESUME)
+ fun stop() = RecordingService.command(app, RecordingService.ACTION_STOP)
+
+ fun togglePlayback(recording: RecordingEntity) {
+ if (_playingId.value == recording.id) {
+ player?.let { if (it.isPlaying) it.pause() else it.start() }
+ return
+ }
+ player?.release()
+ player = runCatching {
+ MediaPlayer().apply {
+ setDataSource(recording.filePath)
+ setOnPreparedListener { it.start(); _playingId.value = recording.id }
+ setOnCompletionListener { _playingId.value = null; it.release(); player = null }
+ prepareAsync()
+ }
+ }.getOrNull()
+ }
+
+ fun delete(recording: RecordingEntity) {
+ if (_playingId.value == recording.id) stopPlayback()
+ viewModelScope.launch { app.recordingRepository.delete(recording) }
+ }
+
+ private fun stopPlayback() {
+ player?.release()
+ player = null
+ _playingId.value = null
+ }
+
+ override fun onCleared() {
+ stopPlayback()
+ super.onCleared()
+ }
+
+ companion object {
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
+ RecordingViewModel(app)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt
new file mode 100644
index 0000000..e5860ec
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt
@@ -0,0 +1,357 @@
+package com.shonar.ui.provider
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
+
+/**
+ * "Choose where your recordings live." One screen, every target.
+ * Unavailable providers are visible but disabled with an honest reason —
+ * never a fake-connected state.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProviderSelectionScreen(
+ onDone: () -> Unit,
+ onBack: () -> Unit,
+ vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory),
+) {
+ val state by vm.state.collectAsState()
+ val context = LocalContext.current
+ var selectedId by remember { mutableStateOf(null) }
+ var urlInput by remember { mutableStateOf("") }
+ var pathInput by remember { mutableStateOf("") }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Where your recordings live") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Text(
+ "You stay in control: recordings, transcripts, and summaries go where " +
+ "you choose. You can switch providers later without losing anything.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ items(vm.options) { opt ->
+ ProviderCard(
+ option = opt,
+ selected = selectedId == opt.id,
+ onSelect = {
+ selectedId = opt.id
+ if (!opt.available) {
+ // surface the honest reason immediately
+ vm.choose(opt, "")
+ }
+ },
+ )
+ }
+ val sel = vm.options.firstOrNull { it.id == selectedId }
+ if (sel != null && sel.available && sel.needsUrl) {
+ item {
+ OutlinedTextField(
+ value = urlInput,
+ onValueChange = { urlInput = it },
+ label = { Text("Server URL (https://…)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item {
+ Button(
+ onClick = { vm.choose(sel, urlInput) },
+ enabled = state !is SelectionUiState.Probing && urlInput.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Check server") }
+ }
+ }
+ if (sel != null && sel.available && sel.needsPath) {
+ item {
+ OutlinedTextField(
+ value = pathInput,
+ onValueChange = { pathInput = it },
+ label = { Text("Folder path (created by your sync tool)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item {
+ Button(
+ onClick = { vm.connectFolder(pathInput) },
+ enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Use this folder") }
+ }
+ }
+ if (state is SelectionUiState.NeedsNcApproval) {
+ item {
+ val approval = state as SelectionUiState.NeedsNcApproval
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "Approve SHONAR in your browser, then come back here. " +
+ "Nextcloud issues an app password — your normal password " +
+ "never touches this app.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Button(
+ onClick = {
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(approval.loginUrl))
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Open Nextcloud login") }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextButton(onClick = { vm.cancelFlow() }) { Text("Cancel") }
+ Spacer(Modifier.weight(1f))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
+ Spacer(Modifier.width(10.dp))
+ TextButton(onClick = { vm.pollNow() }) { Text("I've approved — check now") }
+ }
+ }
+ }
+ }
+ }
+ if (state is SelectionUiState.NeedsCredentials) {
+ item {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("Server email") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Server password") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = {
+ vm.login(
+ (state as SelectionUiState.NeedsCredentials).url,
+ email, password,
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Sign in") }
+ }
+ }
+ }
+ when (val s = state) {
+ is SelectionUiState.Probing -> item {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
+ Spacer(Modifier.width(10.dp))
+ Text("Checking ${s.url} …")
+ }
+ }
+ is SelectionUiState.Error -> item {
+ Text(s.message, color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium)
+ }
+ is SelectionUiState.NeedsTlsApproval -> item {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "The server at ${s.url} uses a certificate this app doesn't trust yet. " +
+ "Only approve if you run this server (or you verified the " +
+ "fingerprint with whoever does) — and only on a network you trust.",
+ color = MaterialTheme.colorScheme.error,
+ )
+ if (s.fingerprint != "unknown") {
+ Text(
+ "SHA-256: ${s.fingerprint}",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ if (s.details.isNotBlank()) {
+ Text(
+ s.details,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextButton(onClick = { vm.declineCert() }) { Text("Cancel") }
+ Spacer(Modifier.weight(1f))
+ Button(
+ onClick = { vm.approvePendingCert() },
+ enabled = s.fingerprint != "unknown",
+ ) { Text("Trust once") }
+ }
+ }
+ }
+ is SelectionUiState.Saved -> item {
+ Text(
+ "Connected: ${s.headline}",
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ is SelectionUiState.NeedsCredentials -> {}
+ is SelectionUiState.NeedsNcApproval -> {}
+ is SelectionUiState.NeedsMigration -> {}
+ is SelectionUiState.Migrating -> {}
+ is SelectionUiState.Idle -> {}
+ }
+ item {
+ Button(
+ onClick = onDone,
+ enabled = state !is SelectionUiState.Probing &&
+ state !is SelectionUiState.NeedsTlsApproval &&
+ state !is SelectionUiState.NeedsMigration &&
+ state !is SelectionUiState.Migrating,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(if (state is SelectionUiState.Saved) "Continue" else "Keep current setting") }
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+ }
+
+ val migration = state as? SelectionUiState.NeedsMigration
+ if (migration != null) {
+ AlertDialog(
+ onDismissRequest = { vm.keepAsIs() },
+ title = { Text("Move your library?") },
+ text = {
+ Text(
+ "You have ${migration.count} recordings. Upload them to the new " +
+ "provider now, keep them as they are, or forget old server links " +
+ "and start fresh? Your local files never move either way."
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { vm.migrateNow() },
+ enabled = migration.canUpload,
+ ) { Text("Upload ${migration.count} now") }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { vm.forgetLinks() }) { Text("Forget links") }
+ TextButton(onClick = { vm.keepAsIs() }) { Text("Keep as-is") }
+ }
+ },
+ )
+ }
+
+ val migrating = state as? SelectionUiState.Migrating
+ if (migrating != null) {
+ AlertDialog(
+ onDismissRequest = { },
+ title = { Text("Uploading ${migrating.done} of ${migrating.total}") },
+ text = {
+ Column {
+ LinearProgressIndicator(
+ progress = { (migrating.done.toFloat() / migrating.total.coerceAtLeast(1)) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ migrating.current.ifBlank { "Finishing…" },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { vm.cancelMigration() }) { Text("Cancel") }
+ },
+ )
+ }
+}
+
+@Composable
+private fun ProviderCard(option: ProviderOption, selected: Boolean, onSelect: () -> Unit) {
+ Card(onClick = onSelect) {
+ Row(
+ Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ RadioButton(selected = selected, onClick = onSelect)
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(option.name, style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold)
+ if (!option.available) {
+ Spacer(Modifier.width(8.dp))
+ Text(
+ "coming soon",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+ Text(option.blurb, style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt
new file mode 100644
index 0000000..a7898bc
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt
@@ -0,0 +1,564 @@
+package com.shonar.ui.provider
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.shonar.ShonarApplication
+import com.shonar.provider.ProbeResult
+import com.shonar.provider.ProviderError
+import com.shonar.provider.ProviderRegistry
+import com.shonar.provider.ServerUrl
+import com.shonar.provider.ShonarHandshake
+import com.shonar.settings.BuiltInSettings
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/** One card in the provider-selection list. */
+data class ProviderOption(
+ val id: String,
+ val name: String,
+ val blurb: String,
+ val needsUrl: Boolean,
+ val available: Boolean, // false -> disabled with "coming soon"
+ val needsPath: Boolean = false, // sync-folder style: a local directory path
+)
+
+sealed interface SelectionUiState {
+ data object Idle : SelectionUiState
+ data class Probing(val url: String) : SelectionUiState
+ data class NeedsCredentials(val url: String) : SelectionUiState
+ data class NeedsNcApproval(
+ val url: String,
+ val loginUrl: String,
+ val flow: com.shonar.provider.LoginFlowSession,
+ ) : SelectionUiState
+ data class NeedsTlsApproval(
+ val url: String,
+ val fingerprint: String,
+ val details: String = "",
+ ) : SelectionUiState
+ data class Error(val message: String) : SelectionUiState
+ data class Saved(val providerId: String, val headline: String) : SelectionUiState
+ /**
+ * P7: switching providers with a non-empty library asks what happens to
+ * it before anything is moved or forgotten.
+ */
+ data class NeedsMigration(
+ val providerId: String,
+ val count: Int,
+ val canUpload: Boolean,
+ ) : SelectionUiState
+ data class Migrating(val done: Int, val total: Int, val current: String) : SelectionUiState
+}
+
+class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
+
+ private val settings = app.settingsManager
+ private val tofu = app.tofu
+ private val handshake = ShonarHandshake(app.providerRegistry.tls.probeClient(), app.tofu)
+ private val ncAuth = com.shonar.provider.NextcloudAuth(
+ app.providerRegistry.tls.nextcloudClient(), app.tofu
+ )
+ private val customProvider: com.shonar.provider.ShonarProvider by lazy {
+ app.providerRegistry.provider(ProviderRegistry.CUSTOM_SHONAR_ID)
+ }
+ private val nextcloudProvider: com.shonar.provider.ShonarProvider by lazy {
+ app.providerRegistry.provider(ProviderRegistry.NEXTCLOUD_ID)
+ }
+ private val folderProvider: com.shonar.provider.ShonarProvider by lazy {
+ app.providerRegistry.provider(ProviderRegistry.SYNC_FOLDER_ID)
+ }
+ private var pollJob: kotlinx.coroutines.Job? = null
+ private var migrationJob: kotlinx.coroutines.Job? = null
+ private var migratingTo: String? = null
+ private var pendingOption: ProviderOption? = null
+ private var pendingUrl: String = ""
+ private var pendingMigration: SelectionUiState.NeedsMigration? = null
+
+ private val registry = app.providerRegistry
+ private val repository = app.recordingRepository
+ private val dao = app.database.recordingDao()
+
+ private val _state = MutableStateFlow(SelectionUiState.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ val options: List = listOf(
+ ProviderOption(
+ id = ProviderRegistry.NEXTCLOUD_ID,
+ name = "Nextcloud",
+ blurb = "Recommended. Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.",
+ needsUrl = true,
+ available = true, // P4
+ ),
+ ProviderOption(
+ id = ProviderRegistry.CUSTOM_SHONAR_ID,
+ name = "Custom SHONAR server",
+ blurb = "A SHONAR backend you run yourself (this project's server). Sign in with your server account.",
+ needsUrl = true,
+ available = true, // P3
+ ),
+ ProviderOption(
+ id = ProviderRegistry.SYNC_FOLDER_ID,
+ name = "Sync folder",
+ blurb = "A folder on this device kept in sync by Syncthing (or the Nextcloud " +
+ "desktop client, rsync…). The app writes plain files; your sync tool moves them.",
+ needsUrl = false,
+ available = true, // P4
+ needsPath = true,
+ ),
+ ProviderOption(
+ id = "start9",
+ name = "Start9 Server",
+ blurb = "A service on your Start9 box — enter its URL and the app detects " +
+ "Nextcloud or SHONAR automatically.",
+ needsUrl = true,
+ available = true, // P6a: generic hosted setup (no platform RPC)
+ ),
+ ProviderOption(
+ id = "umbrel",
+ name = "Umbrel",
+ blurb = "A service on your Umbrel — enter its URL and the app detects " +
+ "Nextcloud or SHONAR automatically.",
+ needsUrl = true,
+ available = true, // P6a: generic hosted setup (no platform RPC)
+ ),
+ ProviderOption(
+ id = com.shonar.provider.LocalOnlyProvider.ID,
+ name = "Local only",
+ blurb = "Nothing leaves this phone. Recording, playback, and search work fully offline.",
+ needsUrl = false,
+ available = true,
+ ),
+ )
+
+ fun choose(option: ProviderOption, rawUrl: String) {
+ viewModelScope.launch {
+ pollJob?.cancel()
+ migrationJob?.cancel()
+ if (!option.available) {
+ _state.value = SelectionUiState.Error("${option.name} support ships in an upcoming milestone.")
+ return@launch
+ }
+ if (!option.needsUrl) {
+ // Local-only saves straight away; the sync folder has its
+ // own path step (connectFolder).
+ if (option.needsPath) return@launch
+ runCatching { save(option.id, "") }
+ .onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") }
+ return@launch
+ }
+ val parsed = ServerUrl.parse(rawUrl)
+ parsed.exceptionOrNull()?.let {
+ _state.value = SelectionUiState.Error(it.message ?: "Invalid server URL")
+ return@launch
+ }
+ val url = parsed.getOrThrow()
+ pendingOption = option
+ pendingUrl = rawUrl
+ _state.value = SelectionUiState.Probing(rawUrl)
+ if (option.id == ProviderRegistry.NEXTCLOUD_ID) {
+ probeNextcloud(url, rawUrl)
+ return@launch
+ }
+ if (option.id == "start9" || option.id == "umbrel") {
+ probeHosted(url, rawUrl, option.name)
+ return@launch
+ }
+ when (val probe = handshake.probe(url)) {
+ is ProbeResult.Compatible ->
+ if (option.id == ProviderRegistry.CUSTOM_SHONAR_ID) {
+ // Probe proved the server; credentials come next (P3 login).
+ _state.value = SelectionUiState.NeedsCredentials(rawUrl)
+ } else {
+ save(option.id, rawUrl)
+ }
+ ProbeResult.Incompatible ->
+ _state.value = SelectionUiState.Error(
+ "No SHONAR-compatible service found at ${url.origin}. " +
+ "Check the URL, or choose Local only for now."
+ )
+ is ProbeResult.ServicesFound ->
+ _state.value = SelectionUiState.Error(
+ "Multiple services found — pick one: " + probe.services.joinToString { it.serviceName }
+ )
+ is ProbeResult.TlsFailure ->
+ _state.value = toTlsApproval(rawUrl, probe.fingerprintSha256)
+ is ProbeResult.NetworkError ->
+ _state.value = SelectionUiState.Error(
+ "Can't reach ${url.origin} (${probe.reason}). Check the address and your network."
+ )
+ }
+ }
+ }
+
+ /**
+ * P5 approval: the user saw the fingerprint and trusts this server.
+ * Pins the leaf cert for that host only, then retries the probe.
+ */
+ fun approvePendingCert() {
+ viewModelScope.launch {
+ val option = pendingOption ?: return@launch
+ val rawUrl = pendingUrl
+ if (rawUrl.isBlank()) return@launch
+ val host = ServerUrl.parse(rawUrl).getOrNull()?.host ?: return@launch
+ _state.value = SelectionUiState.Probing(rawUrl)
+ val ok = runCatching { tofu.approve(host) }.getOrDefault(false)
+ if (!ok) {
+ _state.value = SelectionUiState.Error(
+ "Nothing recorded for $host — try checking the server again."
+ )
+ return@launch
+ }
+ choose(option, rawUrl)
+ }
+ }
+
+ /** Walk away from the untrusted server; nothing is pinned. */
+ fun declineCert() {
+ viewModelScope.launch {
+ runCatching {
+ val host = ServerUrl.parse(pendingUrl).getOrNull()?.host ?: return@launch
+ tofu.decline(host)
+ }
+ _state.value = SelectionUiState.Idle
+ }
+ }
+
+ private fun toTlsApproval(rawUrl: String, fingerprint: String): SelectionUiState {
+ val host = ServerUrl.parse(rawUrl).getOrNull()?.host.orEmpty()
+ val failure = tofu.failureFor(host)
+ val details = failure?.let {
+ "Issued to: ${it.subject}\nIssued by: ${it.issuer}\nValid: ${it.validFrom} → ${it.validUntil}"
+ }.orEmpty()
+ return SelectionUiState.NeedsTlsApproval(rawUrl, fingerprint, details)
+ }
+
+ /**
+ * P3 login: exchange email + password for the rotating token pair.
+ * The password lives in memory for this call only — only the tokens
+ * reach the secure store.
+ */
+ fun login(rawUrl: String, email: String, password: String) {
+ viewModelScope.launch {
+ val parsed = ServerUrl.parse(rawUrl)
+ parsed.exceptionOrNull()?.let {
+ _state.value = SelectionUiState.Error(it.message ?: "Invalid server URL")
+ return@launch
+ }
+ if (email.isBlank() || password.isEmpty()) {
+ _state.value = SelectionUiState.Error("Enter your server email and password.")
+ return@launch
+ }
+ _state.value = SelectionUiState.Probing(rawUrl)
+ runCatching {
+ customProvider.connect(
+ com.shonar.provider.ProviderCredential.ShonarLogin(
+ accountLabel = email.trim(),
+ serverUrl = parsed.getOrThrow(),
+ email = email.trim(),
+ password = password,
+ )
+ )
+ }.onFailure {
+ _state.value = SelectionUiState.Error(
+ it.message ?: "Could not sign in to ${parsed.getOrThrow().origin}"
+ )
+ return@launch
+ }
+ save(ProviderRegistry.CUSTOM_SHONAR_ID, rawUrl)
+ }
+ }
+
+ /**
+ * P6a hosted setup: a Start9/Umbrel box (or anything else reachable) runs
+ * *some* service — probe for Nextcloud first, then SHONAR, and hand off
+ * to that provider's existing flow. The platform is an entry path, not a
+ * sync implementation: what gets persisted is the underlying protocol
+ * provider. No platform RPCs (deferred to P6b).
+ */
+ private suspend fun probeHosted(url: ServerUrl, rawUrl: String, platform: String) {
+ val nc = ncAuth.probe(url)
+ // A compatible Nextcloud answers fast; only probe SHONAR when it
+ // didn't match — but a TLS failure on either blocks everything.
+ val shonar = if (nc is ProbeResult.Compatible) nc else handshake.probe(url)
+ when (routeHosted(nc, shonar)) {
+ HostedRoute.NEXT_CLOUD -> {
+ runCatching { ncAuth.startLogin(url) }
+ .onSuccess {
+ _state.value = SelectionUiState.NeedsNcApproval(rawUrl, it.loginUrl, it)
+ startPolling()
+ }
+ .onFailure {
+ _state.value = SelectionUiState.Error(
+ it.message ?: "Could not start the Nextcloud login"
+ )
+ }
+ }
+ HostedRoute.SHONAR ->
+ _state.value = SelectionUiState.NeedsCredentials(rawUrl)
+ HostedRoute.TLS -> {
+ val fp = (nc as? ProbeResult.TlsFailure)?.fingerprintSha256
+ ?: (shonar as? ProbeResult.TlsFailure)?.fingerprintSha256
+ ?: "unknown"
+ _state.value = toTlsApproval(rawUrl, fp)
+ }
+ HostedRoute.NONE ->
+ _state.value = SelectionUiState.Error(
+ "No Nextcloud or SHONAR service found at ${url.origin} from $platform. " +
+ "Check the URL, or choose another option for now."
+ )
+ }
+ }
+ /**
+ * P4 Nextcloud: probe for status.php, then start login flow v2. The
+ * browser approval + polling continue in [NeedsNcApproval].
+ */
+ private suspend fun probeNextcloud(url: ServerUrl, rawUrl: String) {
+ when (val probe = ncAuth.probe(url)) {
+ is ProbeResult.Compatible -> {
+ runCatching { ncAuth.startLogin(url) }
+ .onSuccess {
+ _state.value = SelectionUiState.NeedsNcApproval(rawUrl, it.loginUrl, it)
+ startPolling()
+ }
+ .onFailure {
+ _state.value = SelectionUiState.Error(
+ it.message ?: "Could not start the Nextcloud login"
+ )
+ }
+ }
+ ProbeResult.Incompatible ->
+ _state.value = SelectionUiState.Error(
+ "That doesn't look like a Nextcloud (no status.php). " +
+ "Check the URL, or choose another option for now."
+ )
+ is ProbeResult.ServicesFound ->
+ _state.value = SelectionUiState.Error("Unexpected probe result — try again.")
+ is ProbeResult.TlsFailure ->
+ _state.value = toTlsApproval(rawUrl, probe.fingerprintSha256)
+ is ProbeResult.NetworkError ->
+ _state.value = SelectionUiState.Error(
+ "Can't reach ${url.origin} (${probe.reason}). Check the address and your network."
+ )
+ }
+ }
+
+ private fun startPolling() {
+ pollJob?.cancel()
+ pollJob = viewModelScope.launch {
+ while (true) {
+ kotlinx.coroutines.delay(2500)
+ val cur = _state.value as? SelectionUiState.NeedsNcApproval ?: break
+ if (!pollOnce(cur)) break
+ }
+ }
+ }
+
+ /** One poll attempt. Returns false when polling should stop. */
+ private suspend fun pollOnce(cur: SelectionUiState.NeedsNcApproval): Boolean {
+ return when (val r = ncAuth.poll(cur.flow)) {
+ is com.shonar.provider.PollResult.Approved -> {
+ runCatching { nextcloudProvider.connect(r.credential) }
+ .onSuccess { save(ProviderRegistry.NEXTCLOUD_ID, cur.url) }
+ .onFailure {
+ _state.value = SelectionUiState.Error(
+ it.message ?: "Could not connect to ${cur.url}"
+ )
+ }
+ false
+ }
+ is com.shonar.provider.PollResult.Failed -> {
+ _state.value = SelectionUiState.Error(r.reason)
+ false
+ }
+ com.shonar.provider.PollResult.Pending -> true
+ }
+ }
+
+ /** "I've approved — check now" button. */
+ fun pollNow() {
+ viewModelScope.launch {
+ val cur = _state.value as? SelectionUiState.NeedsNcApproval ?: return@launch
+ pollOnce(cur)
+ }
+ }
+
+ /** Leave the approval flow without disconnecting anything. */
+ fun cancelFlow() {
+ pollJob?.cancel()
+ _state.value = SelectionUiState.Idle
+ }
+
+ /**
+ * P4 sync folder: validate the directory and remember it. The folder
+ * must already exist (created by you or by Syncthing) — a typo is an
+ * error, never a silently created directory.
+ */
+ fun connectFolder(rawPath: String) {
+ viewModelScope.launch {
+ val path = rawPath.trim()
+ if (path.isEmpty()) {
+ _state.value = SelectionUiState.Error(
+ "Enter the folder path — e.g. the Syncthing folder for SHONAR."
+ )
+ return@launch
+ }
+ _state.value = SelectionUiState.Probing(path)
+ runCatching {
+ folderProvider.connect(
+ com.shonar.provider.ProviderCredential.FolderPath(
+ accountLabel = path,
+ path = path,
+ )
+ )
+ }.onFailure {
+ _state.value = SelectionUiState.Error(it.message ?: "Could not use $path")
+ return@launch
+ }
+ save(ProviderRegistry.SYNC_FOLDER_ID, "")
+ }
+ }
+
+ private suspend fun save(providerId: String, url: String) {
+ settings.ensureLoaded()
+ val previous = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
+ settings.setValue(BuiltInSettings.PROVIDER_ID, "\"" + providerId + "\"")
+ if (url.isBlank()) {
+ // An empty URL is represented by the setting's default, not by an
+ // invalid URL value. This also clears a previously configured
+ // remote provider when switching back to local-only storage.
+ settings.reset(BuiltInSettings.PROVIDER_URL)
+ } else {
+ settings.setValue(
+ BuiltInSettings.PROVIDER_URL,
+ "\"" + url.replace("\\", "\\\\").replace("\"", "\\\"") + "\"",
+ )
+ }
+ // P7: switching providers with a non-empty library asks what happens
+ // to it — nothing moves or is forgotten silently.
+ val count = runCatching { dao.getAll().size }.getOrDefault(0)
+ if (previous != providerId && count > 0) {
+ val ask = SelectionUiState.NeedsMigration(
+ providerId = providerId,
+ count = count,
+ canUpload = providerId != com.shonar.provider.LocalOnlyProvider.ID,
+ )
+ pendingMigration = ask
+ _state.value = ask
+ } else {
+ _state.value = SelectionUiState.Saved(providerId, headlineFor(providerId, url))
+ }
+ }
+
+ /** P7 "keep everything as it is": history stays, states stay. */
+ fun keepAsIs() {
+ viewModelScope.launch {
+ val ask = pendingMigration ?: return@launch
+ pendingMigration = null
+ _state.value = SelectionUiState.Saved(
+ ask.providerId, headlineFor(ask.providerId, pendingUrl)
+ )
+ }
+ }
+
+ /** P7 "start fresh": clear every remote link. Files stay; nothing remote
+ * is touched — forgetting is metadata-only. */
+ fun forgetLinks() {
+ viewModelScope.launch {
+ val ask = pendingMigration ?: return@launch
+ pendingMigration = null
+ runCatching { repository.forgetAllRemotes(ask.providerId) }
+ _state.value = SelectionUiState.Saved(
+ ask.providerId, headlineFor(ask.providerId, pendingUrl)
+ )
+ }
+ }
+
+ /**
+ * P7 "upload now": foreground migration to the new provider. Cancellable;
+ * already-finished files stay uploaded, the rest stay QUEUED for a later
+ * run. Failures are recorded per recording and reported, never retried
+ * here — retry policy is M5's job.
+ */
+ fun migrateNow() {
+ viewModelScope.launch {
+ val ask = pendingMigration ?: return@launch
+ pendingMigration = null
+ migrationJob?.cancel()
+ val runner = com.shonar.recording.MigrationRunner(
+ com.shonar.recording.SyncSlots(dao), registry
+ )
+ migratingTo = ask.providerId
+ migrationJob = viewModelScope.launch {
+ val res = runner.migrateAllTo(ask.providerId) { p ->
+ _state.value = SelectionUiState.Migrating(p.done, p.total, p.currentTitle)
+ }
+ migratingTo = null
+ _state.value = if (res.failed.isEmpty()) {
+ SelectionUiState.Saved(
+ ask.providerId, headlineFor(ask.providerId, pendingUrl)
+ )
+ } else {
+ SelectionUiState.Error(
+ "Uploaded ${res.uploaded} of ${res.uploaded + res.failed.size}. " +
+ "Failed (kept locally, retried by a future sync): " +
+ res.failed.take(3).joinToString(", ") +
+ if (res.failed.size > 3) ", …" else ""
+ )
+ }
+ }
+ }
+ }
+
+ /** Cancel a running migration. Finished files stay uploaded. */
+ fun cancelMigration() {
+ migrationJob?.cancel()
+ viewModelScope.launch {
+ // The runner reports cancellation itself by leaving Migrating;
+ // this is only the fallback if it somehow didn't.
+ kotlinx.coroutines.delay(100)
+ val target = migratingTo
+ if (_state.value is SelectionUiState.Migrating && target != null) {
+ migratingTo = null
+ _state.value = SelectionUiState.Saved(target, headlineFor(target, pendingUrl))
+ }
+ }
+ }
+
+ companion object {
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
+ ProviderSelectionViewModel(app)
+ }
+ }
+
+ fun headlineFor(providerId: String, url: String): String = when (providerId) {
+ com.shonar.provider.LocalOnlyProvider.ID -> "On this device only"
+ ProviderRegistry.SYNC_FOLDER_ID -> "Sync folder"
+ else -> {
+ val host = ServerUrl.parse(url).getOrNull()?.host
+ if (host.isNullOrBlank()) providerId else "$providerId at $host"
+ }
+ }
+ }
+}
+
+/** Where a hosted-service URL routes after probing. Pure; unit-tested. */
+enum class HostedRoute { NEXT_CLOUD, SHONAR, TLS, NONE }
+
+/**
+ * P6a routing: a compatible match wins on either probe; otherwise a TLS
+ * failure on either blocks everything (approval retries the whole flow);
+ * anything else means "nothing we speak lives here".
+ */
+fun routeHosted(nc: ProbeResult, shonar: ProbeResult): HostedRoute = when {
+ nc is ProbeResult.Compatible -> HostedRoute.NEXT_CLOUD
+ shonar is ProbeResult.Compatible -> HostedRoute.SHONAR
+ nc is ProbeResult.TlsFailure || shonar is ProbeResult.TlsFailure -> HostedRoute.TLS
+ else -> HostedRoute.NONE
+}
diff --git a/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt b/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt
new file mode 100644
index 0000000..3f0a7e6
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt
@@ -0,0 +1,250 @@
+package com.shonar.ui.provider
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.shonar.provider.AuthState
+
+/**
+ * P7 "Where is my data?": live provider summary plus the account
+ * lifecycle. Switching providers lives in the selection screen (the
+ * migration question is asked at the moment of switching).
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StorageScreen(
+ onBack: () -> Unit,
+ onSwitchProvider: () -> Unit,
+ vm: StorageViewModel = viewModel(factory = StorageViewModel.Factory),
+) {
+ val state by vm.state.collectAsState()
+
+ LaunchedEffect(Unit) { vm.refresh() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Where your recordings live") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Spacer(Modifier.height(4.dp))
+ when (val s = state) {
+ is StorageViewModel.UiState.Loading -> {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
+ Spacer(Modifier.width(10.dp))
+ Text("Loading storage info …")
+ }
+ }
+ is StorageViewModel.UiState.Error -> {
+ Text(s.message, color = MaterialTheme.colorScheme.error)
+ Button(onClick = onSwitchProvider, modifier = Modifier.fillMaxWidth()) {
+ Text("Choose a provider")
+ }
+ }
+ is StorageViewModel.UiState.ReadyState -> ReadyBody(
+ ready = s.ready,
+ onSwitchProvider = onSwitchProvider,
+ onReconnect = { vm.reconnect() },
+ onDisconnect = { vm.disconnect(it) },
+ onPauseSync = { vm.pauseSync() },
+ onAskDelete = { vm.askDelete() },
+ )
+ }
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+
+ val ready = (state as? StorageViewModel.UiState.ReadyState)?.ready
+ if (ready?.confirmDelete == true) {
+ DeleteDialog(
+ ready = ready,
+ onCancel = { vm.cancelDelete() },
+ onConfirm = { vm.confirmDelete(it) },
+ )
+ }
+}
+
+@Composable
+private fun ReadyBody(
+ ready: StorageViewModel.Ready,
+ onSwitchProvider: () -> Unit,
+ onReconnect: () -> Unit,
+ onDisconnect: (Boolean) -> Unit,
+ onPauseSync: () -> Unit,
+ onAskDelete: () -> Unit,
+) {
+ var revoke by remember(ready.providerId) { mutableStateOf(true) }
+
+ Text(
+ ready.displayName,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ "Status: ${authLabel(ready.auth)}",
+ color = if (ready.auth == AuthState.CONNECTED) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.error
+ },
+ )
+ ready.summary?.let {
+ Text(it.headline, style = MaterialTheme.typography.titleMedium)
+ Text(
+ it.detail,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ "Synced ${it.syncedCount} · local-only ${it.localOnlyCount}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ ready.summaryError?.let {
+ Text(it, style = MaterialTheme.typography.bodyMedium)
+ }
+ ready.busy?.let {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
+ Spacer(Modifier.width(10.dp))
+ Text(it)
+ }
+ }
+
+ val enabled = ready.busy == null
+ if (ready.auth != AuthState.CONNECTED) {
+ Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
+ Text("Reconnect")
+ }
+ }
+ Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
+ Text("Switch provider")
+ }
+ OutlinedButton(
+ onClick = onPauseSync,
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Pause sync") }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled)
+ Text("Also revoke on the server")
+ }
+ OutlinedButton(
+ onClick = { onDisconnect(revoke) },
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Disconnect") }
+ OutlinedButton(
+ onClick = onAskDelete,
+ enabled = enabled,
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Delete account & data") }
+}
+
+private fun authLabel(auth: AuthState): String = when (auth) {
+ AuthState.CONNECTED -> "connected"
+ AuthState.DISCONNECTED -> "disconnected"
+ AuthState.EXPIRED -> "expired — reconnect"
+ AuthState.REVOKED -> "revoked — connect again"
+ AuthState.OFFLINE -> "offline"
+}
+
+@Composable
+private fun DeleteDialog(
+ ready: StorageViewModel.Ready,
+ onCancel: () -> Unit,
+ onConfirm: (String) -> Unit,
+) {
+ var password by remember { mutableStateOf("") }
+ AlertDialog(
+ onDismissRequest = onCancel,
+ title = { Text("Delete account & data?") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ if (ready.needPassword) {
+ "This asks the server to purge the account (30-day grace), " +
+ "revokes this device, and forgets every remote link. " +
+ "Your local library files stay on this phone."
+ } else {
+ "This revokes access and forgets every remote link on " +
+ "${ready.displayName}. Your local library files stay " +
+ "on this phone; server copies are left alone."
+ }
+ )
+ if (ready.needPassword) {
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Server password") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ready.deleteError?.let {
+ Text(it, color = MaterialTheme.colorScheme.error)
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(password) },
+ enabled = !ready.needPassword || password.isNotEmpty(),
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ ),
+ ) { Text("Delete") }
+ },
+ dismissButton = {
+ TextButton(onClick = onCancel) { Text("Keep") }
+ },
+ )
+}
diff --git a/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt b/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt
new file mode 100644
index 0000000..4b748f8
--- /dev/null
+++ b/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt
@@ -0,0 +1,202 @@
+package com.shonar.ui.provider
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.shonar.ShonarApplication
+import com.shonar.provider.AuthState
+import com.shonar.provider.StorageLocation
+import com.shonar.settings.BuiltInSettings
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * P7 "Where is my data?" screen: the active provider's live summary plus
+ * the account lifecycle — reconnect, disconnect (optionally revoking
+ * server-side), and delete-account-and-data. Switching providers happens
+ * in the selection screen; the migration question is asked there, at the
+ * moment of switching.
+ */
+class StorageViewModel(private val app: ShonarApplication) : ViewModel() {
+
+ data class Ready(
+ val providerId: String,
+ val displayName: String,
+ val auth: AuthState,
+ val summary: StorageLocation?,
+ val summaryError: String?,
+ val busy: String? = null,
+ val confirmDelete: Boolean = false,
+ val needPassword: Boolean = false,
+ val deleteError: String? = null,
+ )
+
+ sealed interface UiState {
+ data object Loading : UiState
+ data class ReadyState(val ready: Ready) : UiState
+ data class Error(val message: String) : UiState
+ }
+
+ private val settings = app.settingsManager
+ private val registry = app.providerRegistry
+ private val repository = app.recordingRepository
+ private val dao = app.database.recordingDao()
+
+ private val _state = MutableStateFlow(UiState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+
+ fun refresh() {
+ viewModelScope.launch {
+ _state.value = UiState.Loading
+ _state.value = load()
+ }
+ }
+
+ private suspend fun load(): UiState {
+ settings.ensureLoaded()
+ val id = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
+ val provider = try {
+ registry.provider(id)
+ } catch (e: Exception) {
+ return UiState.Error("Unknown provider \"$id\" — pick one again.")
+ }
+ val auth = provider.authState.value
+ var summary: StorageLocation? = null
+ var summaryError: String? = null
+ try {
+ summary = provider.storageLocationSummary()
+ } catch (e: com.shonar.provider.ProviderError.NotConnected) {
+ summaryError = "Not connected — reconnect to see live numbers."
+ } catch (e: Exception) {
+ summaryError = e.message ?: "Could not load storage info."
+ }
+ return UiState.ReadyState(
+ Ready(
+ providerId = id,
+ displayName = provider.descriptor.displayName,
+ auth = auth,
+ summary = summary,
+ summaryError = summaryError,
+ )
+ )
+ }
+
+ fun reconnect() {
+ viewModelScope.launch {
+ val cur = currentReady() ?: return@launch
+ setBusy(cur, "Reconnecting…")
+ val provider = registry.provider(cur.providerId)
+ runCatching { provider.reconnect() }
+ _state.value = loadWith(cur)
+ }
+ }
+
+ fun disconnect(revokeOnServer: Boolean) {
+ viewModelScope.launch {
+ val cur = currentReady() ?: return@launch
+ setBusy(cur, "Disconnecting…")
+ runCatching { registry.provider(cur.providerId).disconnect(revokeOnServer) }
+ _state.value = loadWith(cur)
+ }
+ }
+
+ /**
+ * M5 pause: cancel scheduled sync work and revert in-flight rows to
+ * QUEUED. Pause is a resting state, not a job kill — the next drain
+ * (or a settings change) resumes cleanly.
+ */
+ fun pauseSync() {
+ viewModelScope.launch {
+ val cur = currentReady() ?: return@launch
+ setBusy(cur, "Pausing…")
+ runCatching {
+ com.shonar.recording.SyncScheduler.pauseAll(app.applicationContext, dao)
+ }
+ _state.value = loadWith(cur)
+ }
+ }
+
+ fun askDelete() {
+ viewModelScope.launch {
+ val cur = currentReady() ?: return@launch
+ val needsPassword = registry.provider(cur.providerId) is
+ com.shonar.provider.CustomShonarProvider
+ _state.value = UiState.ReadyState(
+ cur.copy(confirmDelete = true, needPassword = needsPassword, deleteError = null)
+ )
+ }
+ }
+
+ fun cancelDelete() {
+ viewModelScope.launch {
+ val cur = currentReady() ?: return@launch
+ _state.value = UiState.ReadyState(cur.copy(confirmDelete = false, deleteError = null))
+ }
+ }
+
+ /**
+ * Delete account & data on the active provider, then fall back to
+ * local-only with cleared remote links. Provider semantics differ and
+ * the confirm dialog says which apply; the local library files always
+ * stay — only the remote links are forgotten.
+ */
+ fun confirmDelete(password: String = "") {
+ viewModelScope.launch {
+ val cur = currentReady() ?: return@launch
+ setBusy(cur, "Deleting…")
+ val provider = registry.provider(cur.providerId)
+ val failure = runCatching {
+ val custom = provider as? com.shonar.provider.CustomShonarProvider
+ if (custom != null) {
+ if (password.isEmpty()) throw IllegalArgumentException("Password required")
+ custom.deleteAccount(password)
+ } else {
+ provider.deleteAccountAndData()
+ }
+ }.exceptionOrNull()
+ if (failure != null) {
+ _state.value = UiState.ReadyState(
+ cur.copy(
+ busy = null,
+ deleteError = failure.message ?: "Delete failed.",
+ )
+ )
+ return@launch
+ }
+ settings.ensureLoaded()
+ settings.setValue(BuiltInSettings.PROVIDER_ID, "\"local-only\"")
+ settings.reset(BuiltInSettings.PROVIDER_URL)
+ repository.forgetAllRemotes("local-only")
+ _state.value = load()
+ }
+ }
+
+ private suspend fun currentReady(): Ready? =
+ (_state.value as? UiState.ReadyState)?.ready
+
+ private suspend fun setBusy(cur: Ready, label: String) {
+ _state.value = UiState.ReadyState(cur.copy(busy = label))
+ }
+
+ private suspend fun loadWith(cur: Ready): UiState {
+ val fresh = load()
+ // Preserve an open confirm dialog across refreshes.
+ if (fresh is UiState.ReadyState && cur.confirmDelete) {
+ return UiState.ReadyState(fresh.ready.copy(confirmDelete = true))
+ }
+ return fresh
+ }
+
+ companion object {
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
+ StorageViewModel(app)
+ }
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt
new file mode 100644
index 0000000..4242708
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt
@@ -0,0 +1,199 @@
+package com.shonar.provider
+
+import java.io.File
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Auth lifecycle of CustomShonarProvider against the fake backend:
+ * persistence, transparent refresh, reuse-detection burn, revocation,
+ * restore, and purge. The storage happy path is covered by
+ * [CustomShonarProviderContractTest].
+ */
+class CustomShonarProviderAuthTest {
+
+ private val backend = FakeShonarBackend()
+ private lateinit var dir: File
+ private lateinit var store: com.shonar.settings.InMemorySettingsStore
+
+ @Before fun setUp() {
+ backend.start()
+ dir = File(System.getProperty("java.io.tmpdir"), "custom-auth-${System.nanoTime()}").apply { mkdirs() }
+ store = com.shonar.settings.InMemorySettingsStore()
+ }
+
+ @After fun tearDown() {
+ backend.stop()
+ dir.deleteRecursively()
+ }
+
+ private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow()
+
+ private fun loginCred() = ProviderCredential.ShonarLogin(
+ accountLabel = "test@example.com",
+ serverUrl = serverUrl(),
+ email = "test@example.com",
+ password = "correct-horse-battery",
+ )
+
+ private fun connectedProvider(): CustomShonarProvider = runBlocking {
+ val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
+ p.connect(loginCred())
+ p
+ }
+
+ @Test fun login_persistsSessionSecurely() = runBlocking {
+ connectedProvider()
+ val saved = ShonarAuthStore(store).load()
+ assertNotNull(saved)
+ assertEquals(backend.url, saved!!.baseUrl)
+ assertEquals("test@example.com", saved.email)
+ assertEquals(backend.currentAccess, saved.accessToken)
+ assertEquals(backend.currentRefresh, saved.refreshToken)
+ // password must never be persisted
+ assertFalse(store.keys().flatMap { listOf(store.getString(it).orEmpty()) }
+ .any { it.contains("correct-horse-battery") })
+ }
+
+ @Test fun staleAccessToken_triggersSingleRefreshAndRetry() = runBlocking {
+ val p = connectedProvider()
+ // Simulate an expired access token without touching the refresh token.
+ val saved = ShonarAuthStore(store).load()!!
+ ShonarAuthStore(store).save(saved.copy(accessToken = "access-stale"))
+ val before = backend.accessSeq
+
+ val page = p.list(null) // 401 -> refresh -> retry, transparently
+ assertTrue(page.items.isEmpty())
+ assertEquals(before + 1, backend.accessSeq)
+ assertEquals(backend.currentAccess, ShonarAuthStore(store).load()!!.accessToken)
+ assertEquals(AuthState.CONNECTED, p.authState.value)
+ }
+
+ @Test fun deadRefreshToken_burnsToExpired() = runBlocking {
+ val p = connectedProvider()
+ val saved = ShonarAuthStore(store).load()!!
+ // Both tokens unknown to the server (reuse-detection burn / revocation).
+ ShonarAuthStore(store).save(saved.copy(accessToken = "dead", refreshToken = "dead"))
+
+ try {
+ p.list(null)
+ fail("dead tokens must raise AuthExpired")
+ } catch (expected: ProviderError.AuthExpired) {
+ }
+ assertEquals(AuthState.EXPIRED, p.authState.value)
+ // Dead tokens are dropped; URL + email stay for one-step re-login.
+ val after = ShonarAuthStore(store).load()
+ assertNull(after?.accessToken?.takeIf { it.isNotBlank() })
+ }
+
+ @Test fun disconnect_revokesServerSideAndClearsTokens() = runBlocking {
+ val p = connectedProvider()
+ val refresh = ShonarAuthStore(store).load()!!.refreshToken
+ p.disconnect(revokeOnServer = true)
+ assertEquals(AuthState.DISCONNECTED, p.authState.value)
+ assertTrue("logout must carry the refresh token", backend.logouts.contains(refresh))
+ assertNull(ShonarAuthStore(store).load()?.refreshToken?.takeIf { it.isNotBlank() })
+ }
+
+ @Test fun disconnectWithoutRevoke_keepsServerSession() = runBlocking {
+ val p = connectedProvider()
+ p.disconnect(revokeOnServer = false)
+ assertEquals(AuthState.DISCONNECTED, p.authState.value)
+ assertTrue(backend.logouts.isEmpty())
+ }
+
+ @Test fun reconnect_restoresPersistedSession() = runBlocking {
+ connectedProvider()
+ // Fresh provider object, same secure store — like an app restart.
+ val p2 = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
+ assertEquals(AuthState.CONNECTED, p2.reconnect())
+ }
+
+ @Test fun reconnect_withNothingStored_staysDisconnected() = runBlocking {
+ val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
+ assertEquals(AuthState.DISCONNECTED, p.reconnect())
+ }
+
+ @Test fun deleteAccount_purgesServerSideAndWipesLocal() = runBlocking {
+ val p = connectedProvider()
+ p.deleteAccount("correct-horse-battery")
+ assertEquals(1, backend.deleteAccountCalls)
+ assertEquals(AuthState.DISCONNECTED, p.authState.value)
+ assertNull(ShonarAuthStore(store).load())
+ }
+
+ @Test fun operationsWhileDisconnected_throwNotConnected() = runBlocking {
+ val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
+ try {
+ p.list(null)
+ fail("must throw NotConnected")
+ } catch (expected: ProviderError.NotConnected) {
+ }
+ }
+}
+
+class ShonarAuthStoreTest {
+
+ private val store = com.shonar.settings.InMemorySettingsStore()
+ private val auth = ShonarAuthStore(store)
+
+ @Test fun save_load_roundtrip() = runBlocking {
+ auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 123L, "d1"))
+ val loaded = auth.load()
+ assertEquals("https://s.example.com", loaded!!.baseUrl)
+ assertEquals("a1", loaded.accessToken)
+ assertEquals("r1", loaded.refreshToken)
+ assertEquals("d1", loaded.deviceId)
+ }
+
+ @Test fun load_empty_isNull() = runBlocking {
+ assertNull(auth.load())
+ }
+
+ @Test fun clearTokens_keepsUrlAndEmail() = runBlocking {
+ auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null))
+ auth.clearTokens()
+ assertNull(auth.load()) // tokens gone -> no usable session
+ assertEquals("https://s.example.com", store.getString(ShonarAuthStore.KEY_BASE_URL))
+ assertEquals("u@x.com", store.getString(ShonarAuthStore.KEY_EMAIL))
+ }
+
+ @Test fun clearAll_forgetsEverything() = runBlocking {
+ auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null))
+ auth.clearAll()
+ assertNull(auth.load())
+ assertNull(store.getString(ShonarAuthStore.KEY_BASE_URL))
+ }
+}
+
+class SyncStateTest {
+
+ @Test fun queued_canStartUploading() {
+ assertTrue(SyncStatus(SyncState.QUEUED).canTransitionTo(SyncState.UPLOADING))
+ }
+
+ @Test fun uploading_canFinishOrFailButNotSkipToSynced() {
+ assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.UPLOADED))
+ assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.ERROR))
+ assertFalse(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.SYNCED))
+ }
+
+ @Test fun error_onlyRetriesOrGoesLocal() {
+ assertTrue(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.QUEUED))
+ assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.UPLOADING))
+ assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.SYNCED))
+ }
+
+ @Test fun synced_canReuploadAfterLocalEdit() {
+ assertTrue(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.UPLOADING))
+ assertFalse(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.QUEUED))
+ }
+}
diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt
new file mode 100644
index 0000000..7d7816d
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt
@@ -0,0 +1,315 @@
+package com.shonar.provider
+
+import java.io.File
+import java.util.UUID
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.RecordedRequest
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * In-memory fake of the SHONAR backend's M1 (auth) + M2 (uploads/recordings)
+ * endpoints. Response shapes mirror the real schemas
+ * (`backend/shonar/api/schemas_*.py`) so the contract suite below exercises
+ * the same JSON the app will meet in production:
+ *
+ * - rotating refresh tokens with reuse detection (old refresh dies on use)
+ * - chunk sessions with resume status + idempotent finalize per
+ * client_recording_id
+ */
+internal class FakeShonarBackend {
+
+ val server = MockWebServer()
+
+ var accessSeq = 0
+ var currentAccess = "access-0"
+ var currentRefresh = "refresh-0"
+
+ data class Session(
+ val id: String,
+ val declaredSize: Long,
+ val mime: String,
+ val title: String?,
+ val clientRecordingId: String?,
+ val chunks: MutableMap = mutableMapOf(),
+ )
+
+ data class Recording(
+ val id: String,
+ val title: String,
+ val mime: String,
+ val bytes: ByteArray,
+ val durationSeconds: Double,
+ )
+
+ val sessions = mutableMapOf()
+ val recordings = mutableMapOf() // id -> recording
+ val finalizedByClientId = mutableMapOf() // client id -> recording id
+ val logouts = mutableListOf()
+ var deleteAccountCalls = 0
+
+ val url: String get() = server.url("/").toString().removeSuffix("/")
+
+ fun start() {
+ server.dispatcher = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest): MockResponse =
+ handle(request)
+ }
+ server.start()
+ }
+
+ fun stop() = server.shutdown()
+
+ private fun authed(request: RecordedRequest): Boolean =
+ request.getHeader("Authorization") == "Bearer $currentAccess"
+
+ private fun handle(request: RecordedRequest): MockResponse {
+ val path = request.path?.substringBefore('?').orEmpty()
+ val method = request.method.orEmpty()
+ return when {
+ method == "GET" && path == "/api/v1/provider-info" -> json(200, JSONObject()
+ .put("kind", "shonar").put("version", "test")
+ .put("api_version", "v1")
+ .put("capabilities", JSONObject()
+ .put("chunked_upload", true).put("server_transcription", false)
+ .put("server_summary", false).put("account_deletion", true))
+ .put("storage_backend", "local"))
+
+ method == "POST" && path == "/api/v1/auth/login" -> {
+ val body = JSONObject(request.body.readUtf8())
+ if (body.optString("email") == "test@example.com" &&
+ body.optString("password") == "correct-horse-battery"
+ ) {
+ accessSeq++
+ currentAccess = "access-$accessSeq"
+ currentRefresh = "refresh-$accessSeq"
+ json(200, tokenPair())
+ } else {
+ json(401, JSONObject().put("detail", "Invalid credentials"))
+ }
+ }
+
+ method == "POST" && path == "/api/v1/auth/refresh" -> {
+ val body = JSONObject(request.body.readUtf8())
+ if (body.optString("refresh_token") == currentRefresh) {
+ accessSeq++
+ currentAccess = "access-$accessSeq"
+ currentRefresh = "refresh-$accessSeq"
+ json(200, tokenPair())
+ } else {
+ // reuse detection: unknown/rotated token burns the family
+ json(401, JSONObject().put("detail", "Invalid refresh token"))
+ }
+ }
+
+ method == "POST" && path == "/api/v1/auth/logout" -> {
+ logouts += JSONObject(request.body.readUtf8()).optString("refresh_token")
+ MockResponse().setResponseCode(204)
+ }
+
+ method == "POST" && path == "/api/v1/auth/delete-account" -> {
+ if (!authed(request)) return json(401, JSONObject().put("detail", "Nope"))
+ deleteAccountCalls++
+ json(202, JSONObject().put("detail", "scheduled"))
+ }
+
+ method == "GET" && path == "/api/v1/auth/me" ->
+ if (authed(request)) json(200, JSONObject()
+ .put("id", UUID.randomUUID().toString()).put("email", "test@example.com"))
+ else json(401, JSONObject().put("detail", "Invalid or expired token"))
+
+ method == "POST" && path == "/api/v1/uploads" -> {
+ if (!authed(request)) return unauthorized()
+ val body = JSONObject(request.body.readUtf8())
+ val id = UUID.randomUUID().toString()
+ sessions[id] = Session(
+ id = id,
+ declaredSize = body.getLong("declared_size_bytes"),
+ mime = body.getString("declared_mime_type"),
+ title = body.optString("title", null),
+ clientRecordingId = body.optString("client_recording_id", null)
+ .takeUnless { it.isNullOrBlank() },
+ )
+ json(201, JSONObject().put("id", id).put("status", "open")
+ .put("chunk_size_bytes", 64 * 1024)
+ .put("declared_mime_type", body.getString("declared_mime_type"))
+ .put("declared_size_bytes", body.getLong("declared_size_bytes")))
+ }
+
+ method == "GET" && path.startsWith("/api/v1/uploads/") -> {
+ if (!authed(request)) return unauthorized()
+ val id = path.removePrefix("/api/v1/uploads/")
+ val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone"))
+ json(200, JSONObject().put("id", id).put("status", "open")
+ .put("received_chunk_indexes", JSONArray(s.chunks.keys.sorted())))
+ }
+
+ method == "PUT" && "/chunks/" in path -> {
+ if (!authed(request)) return unauthorized()
+ val rest = path.removePrefix("/api/v1/uploads/") // {id}/chunks/{n}
+ val id = rest.substringBefore("/chunks/")
+ val idx = rest.substringAfter("/chunks/").toInt()
+ val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone"))
+ s.chunks[idx] = request.body.readByteArray()
+ json(201, JSONObject().put("chunk_index", idx)
+ .put("size_bytes", s.chunks[idx]!!.size))
+ }
+
+ method == "POST" && path.endsWith("/finalize") -> {
+ if (!authed(request)) return unauthorized()
+ val id = path.removePrefix("/api/v1/uploads/").removeSuffix("/finalize")
+ val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone"))
+ val total = s.chunks.toSortedMap().values.sumOf { it.size.toLong() }
+ if (total != s.declaredSize) {
+ return json(422, JSONObject().put("detail", "Size mismatch"))
+ }
+ val existing = s.clientRecordingId?.let { finalizedByClientId[it] }
+ if (existing != null) return json(201, recordingJson(recordings[existing]!!))
+ val assembled = s.chunks.toSortedMap().values
+ .fold(byteArrayOf()) { acc, b -> acc + b }
+ val body = JSONObject(request.body.readUtf8())
+ val rec = Recording(
+ id = UUID.randomUUID().toString(),
+ title = s.title ?: "Untitled recording",
+ mime = s.mime,
+ bytes = assembled,
+ durationSeconds = body.optDouble("duration_seconds", 0.0),
+ )
+ recordings[rec.id] = rec
+ s.clientRecordingId?.let { finalizedByClientId[it] = rec.id }
+ json(201, recordingJson(rec))
+ }
+
+ method == "GET" && path == "/api/v1/recordings" -> {
+ if (!authed(request)) return unauthorized()
+ val q = request.path?.substringAfter('?', "").orEmpty()
+ .split('&').associate {
+ val (k, v) = it.split('=', limit = 2) + ""
+ k to v
+ }
+ val limit = q["limit"]?.toIntOrNull() ?: 50
+ val offset = q["offset"]?.toIntOrNull() ?: 0
+ val all = recordings.values.sortedByDescending { it.id }
+ val page = all.drop(offset).take(limit)
+ json(200, JSONObject()
+ .put("items", JSONArray(page.map { recordingJson(it) }))
+ .put("total", all.size).put("limit", limit).put("offset", offset))
+ }
+
+ method == "GET" && path.endsWith("/audio") -> {
+ if (!authed(request)) return unauthorized()
+ val id = path.removePrefix("/api/v1/recordings/").removeSuffix("/audio")
+ val rec = recordings[id] ?: return json(404, JSONObject().put("detail", "gone"))
+ MockResponse().setResponseCode(200)
+ .setHeader("Content-Type", rec.mime)
+ .setBody(okio.Buffer().write(rec.bytes))
+ }
+
+ method == "DELETE" && path.startsWith("/api/v1/recordings/") -> {
+ if (!authed(request)) return unauthorized()
+ val id = path.removePrefix("/api/v1/recordings/")
+ if (recordings.remove(id) == null) return json(404, JSONObject().put("detail", "gone"))
+ finalizedByClientId.entries.removeIf { it.value == id }
+ MockResponse().setResponseCode(204)
+ }
+
+ else -> json(404, JSONObject().put("detail", "unknown $method $path"))
+ }
+ }
+
+ private fun unauthorized() = json(401, JSONObject().put("detail", "Invalid or expired token"))
+
+ private fun tokenPair() = JSONObject()
+ .put("access_token", currentAccess).put("token_type", "bearer")
+ .put("expires_in", 900).put("refresh_token", currentRefresh)
+
+ private fun recordingJson(r: Recording) = JSONObject()
+ .put("id", r.id).put("title", r.title)
+ .put("recorded_at", "2026-01-02T03:04:05Z")
+ .put("duration_seconds", r.durationSeconds)
+ .put("has_audio", true).put("mime_type", r.mime).put("size_bytes", r.bytes.size)
+
+ private fun json(code: Int, obj: JSONObject) = MockResponse()
+ .setResponseCode(code).setHeader("Content-Type", "application/json")
+ .setBody(obj.toString())
+}
+
+/** Runs the shared provider contract suite against CustomShonarProvider. */
+class CustomShonarProviderContractTest : ProviderContractTest() {
+
+ private val backend = FakeShonarBackend()
+ private lateinit var dir: File
+ private lateinit var store: com.shonar.settings.InMemorySettingsStore
+
+ @org.junit.Before fun setUp() {
+ backend.start()
+ dir = File(System.getProperty("java.io.tmpdir"), "custom-contract-${System.nanoTime()}").apply { mkdirs() }
+ store = com.shonar.settings.InMemorySettingsStore()
+ }
+
+ @After fun tearDown() {
+ backend.stop()
+ dir.deleteRecursively()
+ }
+
+ private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow()
+
+ override fun initialCredential(): ProviderCredential = ProviderCredential.ShonarLogin(
+ accountLabel = "test@example.com",
+ serverUrl = serverUrl(),
+ email = "test@example.com",
+ password = "correct-horse-battery",
+ )
+
+ override suspend fun makeProvider(): ShonarProvider = CustomShonarProvider(
+ auth = ShonarAuthStore(store),
+ sidecarRoot = File(dir, "sidecars"),
+ )
+
+ override suspend fun makeDraft(id: String): RecordingDraft {
+ val src = File(dir, "src-$id.m4a")
+ // mp4 magic (ftyp at [4:8]) like backend/tests/test_recordings.py mp4_bytes()
+ val rnd = java.util.Random(id.hashCode().toLong())
+ val payload = ByteArray(256 * 1024).also { rnd.nextBytes(it) }
+ src.writeBytes(byteArrayOf(0, 0, 0, 0x20.toByte()) + "ftypM4A ".toByteArray() + payload)
+ return RecordingDraft(
+ id = id,
+ title = "Contract recording",
+ createdAtEpochMs = 1_700_000_000_000,
+ durationMs = 12_345,
+ mime = "audio/mp4",
+ sourceFile = src,
+ sizeBytes = src.length(),
+ )
+ }
+
+ override fun credentialSecretStrings(cred: ProviderCredential): List =
+ super.credentialSecretStrings(cred)
+
+ @Test fun probe_identifiesShonarServer() = kotlinx.coroutines.runBlocking {
+ val p = makeProvider()
+ val probe = p.probe(serverUrl())
+ assertTrue(probe is ProbeResult.Compatible)
+ assertEquals(ProviderRegistry.CUSTOM_SHONAR_ID, (probe as ProbeResult.Compatible).descriptor.id)
+ }
+
+ @Test fun badPassword_loginRefusesWithoutLeaking() = kotlinx.coroutines.runBlocking {
+ val p = makeProvider()
+ val bad = ProviderCredential.ShonarLogin("t", serverUrl(), "test@example.com", "wrong-pw-123")
+ try {
+ p.connect(bad)
+ org.junit.Assert.fail("bad password must not connect")
+ } catch (e: ProviderError.Transient) {
+ assertFalse(e.message!!.contains("wrong-pw-123"))
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt b/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt
new file mode 100644
index 0000000..d58a4e0
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt
@@ -0,0 +1,104 @@
+package com.shonar.provider
+
+import java.io.File
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+
+/** Runs the shared provider contract suite against FolderSyncProvider. */
+class FolderSyncProviderContractTest : ProviderContractTest() {
+
+ private lateinit var root: File
+ private lateinit var store: com.shonar.settings.InMemorySettingsStore
+
+ @Before fun setUp() {
+ root = File(System.getProperty("java.io.tmpdir"), "foldersync-${System.nanoTime()}")
+ .apply { mkdirs() }
+ store = com.shonar.settings.InMemorySettingsStore()
+ }
+
+ @After fun tearDown() {
+ root.deleteRecursively()
+ }
+
+ override fun initialCredential(): ProviderCredential =
+ ProviderCredential.FolderPath(accountLabel = root.path, path = root.path)
+
+ override suspend fun makeProvider(): ShonarProvider = FolderSyncProvider(store)
+
+ override suspend fun makeDraft(id: String): RecordingDraft {
+ val src = File.createTempFile("draft-$id", ".bin")
+ val rnd = java.util.Random(7)
+ src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) })
+ src.deleteOnExit()
+ return RecordingDraft(
+ id = id,
+ title = "Contract recording",
+ createdAtEpochMs = 1_700_000_000_000,
+ durationMs = 12_345,
+ mime = "audio/mp4",
+ sourceFile = src,
+ sizeBytes = src.length(),
+ )
+ }
+
+ @Test fun refs_carrySyncFolderId() = kotlinx.coroutines.runBlocking {
+ val p = makeProvider()
+ p.connect(initialCredential())
+ val ref = p.upload(makeDraft("dddddddd-dddd-4ddd-8ddd-dddddddddddd")) { }
+ assertEquals(FolderSyncProvider.ID, ref.providerId)
+ assertEquals(1, p.list(null).items.count { it.ref.providerId == FolderSyncProvider.ID })
+ }
+
+ @Test fun missingFolder_refusedNotCreated() = kotlinx.coroutines.runBlocking {
+ val p = makeProvider()
+ val missing = File(root, "not-there")
+ try {
+ p.connect(ProviderCredential.FolderPath("x", missing.path))
+ fail("missing folder must be refused")
+ } catch (expected: ProviderError.InvalidUrl) {
+ }
+ assertFalse("a typo must never create directories", missing.exists())
+ }
+
+ @Test fun traversal_refused() = kotlinx.coroutines.runBlocking {
+ val p = makeProvider()
+ try {
+ p.connect(ProviderCredential.FolderPath("x", root.path + "/../evil"))
+ fail(".. must be refused")
+ } catch (expected: ProviderError.InvalidUrl) {
+ }
+ }
+
+ @Test fun deleteAccount_forgetsButKeepsFiles() = kotlinx.coroutines.runBlocking {
+ val p = makeProvider()
+ p.connect(initialCredential())
+ val ref = p.upload(makeDraft("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")) { }
+ val onDisk = File(root, ref.key)
+ assertTrue(onDisk.exists())
+ p.deleteAccountAndData()
+ // The files stay — they belong to the user and their sync tool.
+ assertTrue("user files must survive disconnect", onDisk.exists())
+ assertEquals(AuthState.DISCONNECTED, p.authState.value)
+ assertEquals(AuthState.DISCONNECTED, p.reconnect())
+ }
+
+ @Test fun reconnect_restoresPersistedFolder() = kotlinx.coroutines.runBlocking {
+ makeProvider().connect(initialCredential())
+ val p2 = makeProvider()
+ assertEquals(AuthState.CONNECTED, p2.reconnect())
+ }
+
+ @Test fun wrongCredentialType_rejected() = kotlinx.coroutines.runBlocking {
+ try {
+ makeProvider().connect(ProviderCredential.None)
+ fail("None must be rejected")
+ } catch (expected: ProviderError.InvalidUrl) {
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt b/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt
new file mode 100644
index 0000000..abe392b
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt
@@ -0,0 +1,211 @@
+package com.shonar.provider
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.RecordedRequest
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * P5 leak tests (docs/server-providers.md §4, §8): with debug logging ON,
+ * no credential, token, audio byte, or transcript may reach the log sink;
+ * with it OFF, nothing logs at all.
+ */
+class RedactingLoggerTest {
+
+ private lateinit var server: MockWebServer
+ private val logs = mutableListOf()
+ private var bodiesOn = false
+
+ @Before fun setUp() {
+ server = MockWebServer()
+ server.dispatcher = object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest): MockResponse =
+ MockResponse().setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody("""{"access_token":"resp-token-abc","ok":true}""")
+ }
+ server.start()
+ logs.clear()
+ bodiesOn = false
+ }
+
+ @After fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun client(): OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(RedactingLogger(bodiesEnabled = { bodiesOn }, sink = logs::add))
+ .build()
+
+ private fun joined(): String = logs.joinToString("\n")
+
+ @Test fun off_byDefault_logsNothing() {
+ client().newCall(Request.Builder().url(server.url("/x")).get().build())
+ .execute().close()
+ assertTrue("logging off must be silent", logs.isEmpty())
+ }
+
+ @Test fun on_logsSummaryButRedactsAuthHeader() {
+ bodiesOn = true
+ client().newCall(
+ Request.Builder().url(server.url("/api/v1/auth/me")).get()
+ .header("Authorization", "Bearer header-secret-123").build()
+ ).execute().close()
+ val out = joined()
+ assertTrue(out.contains("GET") && out.contains("200"))
+ assertTrue("header name stays, value goes", out.contains("Authorization: [redacted]"))
+ assertFalse(out.contains("header-secret-123"))
+ }
+
+ @Test fun on_redactsTokenJsonAndBasic() {
+ bodiesOn = true
+ val body = """{"email":"a@b.c","password":"pw-secret-1","refresh_token":"rt-secret-2"}"""
+ client().newCall(
+ Request.Builder().url(server.url("/api/v1/auth/login"))
+ .post(body.toRequestBody("application/json".toMediaType()))
+ .header("Authorization", "Basic dGVzdHVzZXI6YXBwLXBhc3M=").build()
+ ).execute().close()
+ val out = joined()
+ assertFalse(out.contains("pw-secret-1"))
+ assertFalse(out.contains("rt-secret-2"))
+ assertFalse("response token must be masked too", out.contains("resp-token-abc"))
+ assertFalse(out.contains("dGVzdHVzZXI6YXBwLXBhc3M="))
+ assertTrue(out.contains("***"))
+ }
+
+ @Test fun on_neverLogsAudioBytes() {
+ bodiesOn = true
+ val marker = "LEAKMARKER-AUDIO-0123456789".toByteArray()
+ val payload = marker + ByteArray(1024) { 0x7F }
+ client().newCall(
+ Request.Builder().url(server.url("/put"))
+ .put(payload.toRequestBody("audio/mp4".toMediaType())).build()
+ ).execute().close()
+ assertFalse("audio marker must not appear", joined().contains("LEAKMARKER-AUDIO"))
+ assertTrue(joined().contains("not logged"))
+ }
+
+ @Test fun on_neverLogsBigJson() {
+ bodiesOn = true
+ val big = """{"blob":"${"x".repeat(9000)}"}"""
+ client().newCall(
+ Request.Builder().url(server.url("/big"))
+ .post(big.toRequestBody("application/json".toMediaType())).build()
+ ).execute().close()
+ assertTrue(joined().contains("not logged"))
+ }
+
+ @Test fun sensitiveFlag_strippedAndSkipsBodies() {
+ bodiesOn = true
+ // JSON body that WOULD log (small, loggable type) is suppressed by
+ // the flag, and the flag itself never reaches the wire.
+ val transcript = """{"text":"LEAKMARKER-TRANSCRIPT-transcribed words here"}"""
+ client().newCall(
+ Request.Builder().url(server.url("/sidecar"))
+ .put(transcript.toRequestBody("application/json".toMediaType()))
+ .header(RedactingLogger.SENSITIVE_BODY, "1").build()
+ ).execute().close()
+ val out = joined()
+ assertFalse(out.contains("LEAKMARKER-TRANSCRIPT"))
+ // Response body (echo JSON with token) is suppressed too.
+ assertFalse(out.contains("resp-token-abc"))
+ assertTrue(out.contains("[sensitive, not logged]"))
+ val sent = server.takeRequest()
+ assertNull("flag must be stripped before sending", sent.getHeader(RedactingLogger.SENSITIVE_BODY))
+ }
+
+ @Test fun failure_logsClassOnly() {
+ bodiesOn = true
+ try {
+ // Nothing listens on this port: fast refusal, no secrets involved.
+ client().newCall(Request.Builder().url("http://127.0.0.1:1/unreachable").get().build())
+ .execute().close()
+ } catch (ignored: Exception) {
+ }
+ assertTrue(joined().contains("failed ("))
+ }
+}
+
+/**
+ * Provider-level leak test: a full Nextcloud upload + sidecar + download
+ * with debug logging ON must leave no credential, audio, or transcript
+ * material in the sink.
+ */
+class ProviderLeakTest {
+
+ private val backend = FakeNextcloud()
+ private lateinit var store: com.shonar.settings.InMemorySettingsStore
+ private val logs = mutableListOf()
+
+ private val audioMarker = "LEAKMARKER-AUDIO-555"
+ private val transcriptMarker = "LEAKMARKER-TRANSCRIPT-777"
+
+ @Before fun setUp() {
+ backend.start()
+ store = com.shonar.settings.InMemorySettingsStore()
+ logs.clear()
+ }
+
+ @After fun tearDown() {
+ backend.stop()
+ }
+
+ @Test fun nextcloudFlow_leaksNothing() = kotlinx.coroutines.runBlocking {
+ // Distinctive markers stand in for real secrets/audio/transcripts;
+ // the fake's fixed creds exercise the same code paths.
+ val tls = TlsPolicy(
+ tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())),
+ bodiesEnabled = { true },
+ sink = logs::add,
+ )
+ val p = NextcloudProvider(
+ auth = NextcloudAuthStore(store),
+ client = tls.nextcloudClient(),
+ loginFlow = NextcloudAuth(tls.nextcloudClient(), tls.tofu),
+ chunkSizeBytes = 64 * 1024,
+ )
+ p.connect(
+ ProviderCredential.AppPassword(
+ "t", backend.url, backend.userId, backend.appPassword
+ )
+ )
+ val dir = java.io.File(System.getProperty("java.io.tmpdir"), "leak-${System.nanoTime()}")
+ .apply { mkdirs() }
+ try {
+ val payload = (audioMarker + "|").toByteArray() + ByteArray(256 * 1024 - 32) { 0x3C }
+ val src = java.io.File(dir, "s.m4a").apply { writeBytes(payload) }
+ val draft = RecordingDraft(
+ "ffffffff-ffff-4fff-8fff-ffffffffffff", "t", 1_700_000_000_000,
+ 1000, "audio/mp4", src, src.length(),
+ )
+ val ref = p.upload(draft) { }
+ p.putSidecar(
+ ref, SidecarKind.TRANSCRIPT,
+ """{"text":"$transcriptMarker transcribed words"}""".toByteArray(),
+ )
+ val dest = java.io.File(dir, "out.m4a")
+ p.download(ref, dest) { }
+ val out = logs.joinToString("\n")
+ assertFalse("audio bytes in logs", out.contains(audioMarker))
+ assertFalse("transcript in logs", out.contains(transcriptMarker))
+ assertFalse("app password in logs", out.contains(backend.appPassword))
+ assertFalse("basic credentials in logs",
+ out.contains(java.util.Base64.getEncoder().encodeToString(
+ "${backend.userId}:${backend.appPassword}".toByteArray()
+ )))
+ assertTrue("something logged (logger was on)", out.isNotEmpty())
+ } finally {
+ dir.deleteRecursively()
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt b/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt
new file mode 100644
index 0000000..6e06fa7
--- /dev/null
+++ b/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt
@@ -0,0 +1,531 @@
+package com.shonar.provider
+
+import java.io.File
+import java.util.Base64
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.RecordedRequest
+import okio.Buffer
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * In-memory fake of a Nextcloud server: status.php, login flow v2, OCS
+ * user/quota, and the WebDAV file + chunking-v2 APIs. Shapes mirror the
+ * official developer manual (chunk names 1..10000 assembled in name order,
+ * `OC-Total-Length`, `MOVE …/.file` + `Destination`):
+ *
+ * - MKCOL {dav}uploads/{user}/{transfer}/ (405 when present)
+ * - PUT {dav}uploads/{user}/{transfer}/{00001..} (201)
+ * - MOVE {dav}uploads/{user}/{transfer}/.file + Destination (201)
+ * - PROPFIND Depth:1 multistatus, hrefs percent-encoded like the real thing
+ */
+internal class FakeNextcloud {
+
+ val server = MockWebServer()
+
+ var productName = "Nextcloud"
+ var versionString = "30.0.0"
+ val userId = "testuser"
+ val appPassword = "app-pass-123"
+ var quotaFree = 10L * 1024 * 1024 * 1024
+ var quotaTotal = 50L * 1024 * 1024 * 1024
+
+ /** Decoded DAV paths -> bytes. Dirs end with '/'. */
+ val files = mutableMapOf()
+ val dirs = mutableSetOf()
+
+ var pollApproved = false
+ val pollToken = "poll-token-1"
+
+ var chunkPuts = 0
+ val chunkNames = mutableListOf()
+ val chunkHeaders = mutableListOf