P3-M7: providers, background sync, AI pipeline

P3 CustomShonarProvider (auth, chunked uploads, secure token store).
P4 NextcloudProvider (login flow v2, DAV, chunking) + FolderSyncProvider
(Syncthing-style). P5 TOFU pinning + redacting logger + leak tests.
P6a generic hosted setup (Start9/Umbrel URL + auto-detect). P7 provider
switching (Room v2 slot, Storage screen, foreground migrator). M5
WorkManager drain (Room v3 backoff). M7 AI pipeline (4 adapters, arq
worker, transcript/summary/jobs endpoints). Docs updated throughout.
This commit is contained in:
avi 2026-09-12 11:29:55 -05:00
commit aad7cad78d
73 changed files with 9470 additions and 54 deletions

View file

@ -44,6 +44,4 @@ jobs:
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 } with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
# Skipped until the Android project lands (M3).
- run: ./gradlew test assembleDebug --no-daemon - run: ./gradlew test assembleDebug --no-daemon
continue-on-error: ${{ !hashFiles('android/**/build.gradle.kts') }}

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ data/
# --- Android / Gradle --- # --- Android / Gradle ---
android/.gradle/ android/.gradle/
android/.kotlin/
android/build/ android/build/
android/app/build/ android/app/build/
*.apk *.apk

View file

@ -18,10 +18,8 @@ Thanks for your interest in improving S.H.O.N.A.R.!
```bash ```bash
# Backend # Backend
./scripts/dev_bootstrap.sh
cd backend 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/uvicorn shonar.main:app --reload
.venv/bin/pytest && .venv/bin/ruff check . .venv/bin/pytest && .venv/bin/ruff check .

View file

@ -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 - Home Assistant is **not** part of the initial product. Prior work is
preserved but disabled under [`deferred/home-assistant/`](deferred/home-assistant/README.md) preserved but disabled under [`deferred/home-assistant/`](deferred/home-assistant/README.md)
and on branch `deferred/home-assistant`. 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 ## AI providers

View file

@ -1,6 +1,7 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
} }
@ -48,6 +49,9 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.4") 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.ui:ui")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
@ -62,12 +66,22 @@ dependencies {
// networking (server provider APIs over HTTPS) // networking (server provider APIs over HTTPS)
implementation("com.squareup.okhttp3:okhttp:4.12.0") 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-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// tests // tests
testImplementation("junit:junit:4.13.2") 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("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.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) androidTestImplementation(composeBom)
} }

View file

@ -4,8 +4,6 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Recording (M4). Declared now so the manifest is honest about the
full feature set; nothing records until M4 lands. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
@ -32,5 +30,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".recording.RecordingService"
android:exported="false"
android:foregroundServiceType="microphone" />
</application> </application>
</manifest> </manifest>

View file

@ -10,16 +10,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.shonar.ui.home.HomeScreen 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.settings.SettingsScreen
import com.shonar.ui.theme.ShonarTheme import com.shonar.ui.theme.ShonarTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val app = application as ShonarApplication
setContent { setContent {
ShonarTheme { ShonarTheme {
val nav = rememberNavController() val nav = rememberNavController()
@ -28,7 +28,24 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
NavHost(navController = nav, startDestination = "home") { 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() }) } composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
} }
} }
@ -36,4 +53,3 @@ class MainActivity : ComponentActivity() {
} }
} }
} }

View file

@ -1,16 +1,113 @@
package com.shonar package com.shonar
import android.app.Application 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.DataStoreSettingsStore
import com.shonar.settings.SecureSettingsStore import com.shonar.settings.SecureSettingsStore
import com.shonar.settings.SettingsManager 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() { 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 { val settingsManager: SettingsManager by lazy {
SettingsManager( SettingsManager(
store = DataStoreSettingsStore(this), 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<Boolean, Boolean>? = 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)
) )
} }
} }

View file

@ -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> = _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<RemoteRecording> = 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<RemoteRecording>()
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<String, Int> {
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<Int> {
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)
}
}
}
}

View file

@ -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> = _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<RemoteRecording> = 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"
}
}

View file

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

View file

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

View file

@ -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> = _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<RemoteRecording> = 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<Int> {
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<DavEntry> =
withContext(Dispatchers.IO) {
val body = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getetag/><d:resourcetype/><d:getcontenttype/><d:getlastmodified/><d:displayname/></d:prop></d:propfind>"""
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<DavEntry> {
if (xml.isBlank()) return emptyList()
val out = mutableListOf<DavEntry>()
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)
}
}
}
}

View file

@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.StateFlow
* user selected during setup; switching is just changing this id — the app * user selected during setup; switching is just changing this id — the app
* never branches on concrete provider classes. * never branches on concrete provider classes.
*/ */
class ProviderRegistry(private val factories: Map<String, () -> ShonarProvider>) { class ProviderRegistry(
private val factories: Map<String, () -> ShonarProvider>,
/** Builds every provider HTTP client (TOFU trust + redacting logger). */
val tls: TlsPolicy = defaultTls(),
) {
private val _activeId = MutableStateFlow(LocalOnlyProvider.ID) private val _activeId = MutableStateFlow(LocalOnlyProvider.ID)
val activeId: StateFlow<String> = _activeId val activeId: StateFlow<String> = _activeId
@ -37,14 +41,50 @@ class ProviderRegistry(private val factories: Map<String, () -> ShonarProvider>)
companion object { companion object {
const val NEXTCLOUD_ID = "nextcloud" const val NEXTCLOUD_ID = "nextcloud"
const val CUSTOM_SHONAR_ID = "custom-shonar" 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. */ /** Behaviour-identical default: system trust, logging off, no pins. */
fun withDefaults(appFilesDir: java.io.File): ProviderRegistry = ProviderRegistry( 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( mapOf(
LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) }, LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) },
// P3: CUSTOM_SHONAR_ID to { CustomShonarProvider(...) } CUSTOM_SHONAR_ID to { custom },
// P4: NEXTCLOUD_ID to { NextcloudProvider(...) } NEXTCLOUD_ID to { nextcloud },
SYNC_FOLDER_ID to { folder },
// P6: start9 / umbrel platform probes
), ),
tls = tlsPolicy,
) )
} }
} }
}

View file

@ -108,11 +108,38 @@ sealed class ProviderCredential {
override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])" 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). */ /** No credential needed (local-only provider). */
data object None : ProviderCredential() { data object None : ProviderCredential() {
override val accountLabel: String get() = "local" override val accountLabel: String get() = "local"
override fun toString(): String = "None" 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. */ /** Result of probing a base URL for a compatible service. */

View file

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

View file

@ -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<ProviderDescriptor.Capability>()
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()
}
}

View file

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

View file

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

View file

@ -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<String> {
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<String> {
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<X509Certificate>, 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<X509Certificate>, authType: String, socket: Socket,
) {
system.checkClientTrusted(chain, authType)
}
override fun checkClientTrusted(
chain: Array<X509Certificate>, authType: String, engine: SSLEngine,
) {
system.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
checkServerTrusted(chain, authType, host = null)
}
override fun checkServerTrusted(
chain: Array<X509Certificate>, 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<X509Certificate>, 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<X509Certificate>, 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<X509Certificate> = 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<String, Set<String>>()
private val failures = ConcurrentHashMap<String, TofuFailure>()
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<String, Set<String>>()
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<String> = 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<X509TrustManager>().first()
}
}
}

View file

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

View file

@ -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<List<RecordingEntity>>
@Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
suspend fun getAll(): List<RecordingEntity>
@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)
}

View file

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

View file

@ -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<List<RecordingEntity>> = 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)
}

View file

@ -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<RecordingSnapshot> = _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)
}
}
}
}

View file

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

View file

@ -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<String>, 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<String>()
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)

View file

@ -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<RecordingEntity> = 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<SyncState> = 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)
}

View file

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

View file

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

View file

@ -16,6 +16,10 @@ object BuiltInSettings {
// ids other code depends on (single source of truth) // ids other code depends on (single source of truth)
const val CONSENT = "consent_notice_seen" 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<SettingDefinition> = listOf( val all: List<SettingDefinition> = listOf(
// --- General ------------------------------------------------------- // --- General -------------------------------------------------------
@ -55,6 +59,24 @@ object BuiltInSettings {
type = SettingType.COLOR, type = SettingType.COLOR,
defaultJson = "\"#4FD1C5\"", 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 ------------------------------------------------------------- // --- Network -------------------------------------------------------------
SettingDefinition( SettingDefinition(
id = "wifi_only_upload", id = "wifi_only_upload",

View file

@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Mic
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -34,32 +36,74 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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.ShonarApplication
import com.shonar.settings.BuiltInSettings import com.shonar.settings.BuiltInSettings
import kotlinx.coroutines.launch 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 * a Settings entry, and the first-launch recording-consent notice which must
* be acknowledged before anything else. * be acknowledged before anything else.
*/ */
@Composable @Composable
fun HomeScreen(onOpenSettings: () -> Unit) { fun HomeScreen(
val app = LocalContext.current.applicationContext as ShonarApplication 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() val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf<Boolean?>(null) } var consentSeen by remember { mutableStateOf<Boolean?>(null) }
var storageHeadline by remember { mutableStateOf<String?>(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) { LaunchedEffect(Unit) {
app.settingsManager.ensureLoaded() app.settingsManager.ensureLoaded()
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) 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( Scaffold(
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton( 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) }, 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 -> ) { padding ->
@ -67,8 +111,9 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(24.dp), .padding(24.dp)
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text("SHONAR", style = MaterialTheme.typography.headlineMedium) Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
@ -77,17 +122,42 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
storageHeadline?.let { headline ->
StorageChip(headline = headline, onClick = onOpenStorage)
}
Spacer(Modifier.height(24.dp)) 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) Icons.Filled.Settings, onOpenSettings)
Spacer(Modifier.height(16.dp)) 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( Text(
"Recording is coming in the next milestone. Nothing records today.", "Your recordings will appear here and stay on this device until you choose a provider.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
}
if (consentSeen == false) { if (consentSeen == false) {
ConsentDialog(onAcknowledge = { ConsentDialog(onAcknowledge = {
@ -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 @Composable
private fun ConsentDialog(onAcknowledge: () -> Unit) { private fun ConsentDialog(onAcknowledge: () -> Unit) {
AlertDialog( AlertDialog(

View file

@ -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<List<RecordingEntity>> = app.recordingRepository.recordings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val recorderState = RecordingService.snapshot
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
val playingId: StateFlow<String?> = _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)
}
}
}
}

View file

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

View file

@ -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>(SelectionUiState.Idle)
val state: StateFlow<SelectionUiState> = _state.asStateFlow()
val options: List<ProviderOption> = 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
}

View file

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

View file

@ -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>(UiState.Loading)
val state: StateFlow<UiState> = _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)
}
}
}
}

View file

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

View file

@ -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<Int, ByteArray> = mutableMapOf(),
)
data class Recording(
val id: String,
val title: String,
val mime: String,
val bytes: ByteArray,
val durationSeconds: Double,
)
val sessions = mutableMapOf<String, Session>()
val recordings = mutableMapOf<String, Recording>() // id -> recording
val finalizedByClientId = mutableMapOf<String, String>() // client id -> recording id
val logouts = mutableListOf<String>()
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<String> =
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"))
}
}
}

View file

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

View file

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

View file

@ -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<String, ByteArray>()
val dirs = mutableSetOf<String>()
var pollApproved = false
val pollToken = "poll-token-1"
var chunkPuts = 0
val chunkNames = mutableListOf<String>()
val chunkHeaders = mutableListOf<Map<String, String?>>()
var lastMove: Map<String, String?> = emptyMap()
var revokeCalls = 0
var failNextPut507 = false
val url: String get() = server.url("/").toString().removeSuffix("/")
private val davRoot get() = "/remote.php/dav/files/$userId/"
private val uploadsRoot get() = "/remote.php/dav/uploads/$userId/"
fun start() {
dirs += davRoot
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
handle(request)
}
server.start()
}
fun stop() = server.shutdown()
private fun basicOk(request: RecordedRequest): Boolean {
val expect = "Basic " + Base64.getEncoder()
.encodeToString("$userId:$appPassword".toByteArray(Charsets.UTF_8))
return request.getHeader("Authorization") == expect
}
private fun dec(path: String): String = runCatching {
java.net.URLDecoder.decode(path, "UTF-8")
}.getOrDefault(path)
private fun encPath(path: String): String = path.split('/')
.joinToString("/") {
java.net.URLEncoder.encode(it, "UTF-8").replace("+", "%20")
}
private fun parentOf(dir: String): String {
val t = dir.trimEnd('/')
return t.substringBeforeLast('/', "") + "/"
}
private fun handle(request: RecordedRequest): MockResponse {
val rawPath = request.path?.substringBefore('?').orEmpty()
val path = dec(rawPath)
val method = request.method.orEmpty()
return when {
method == "GET" && path == "/status.php" -> json(200, JSONObject()
.put("productname", productName)
.put("versionstring", versionString))
method == "POST" && path == "/index.php/login/v2" -> json(200, JSONObject()
.put("poll", JSONObject()
.put("token", pollToken)
.put("endpoint", "$url/index.php/login/v2/poll"))
.put("login", "$url/login/v2/flow/abc123"))
method == "POST" && path == "/index.php/login/v2/poll" -> {
val token = runCatching { JSONObject(request.body.readUtf8()).optString("token") }
.getOrDefault("")
if (token == pollToken && pollApproved) {
json(200, JSONObject()
.put("server", url)
.put("loginName", userId)
.put("appPassword", appPassword))
} else {
json(404, JSONObject())
}
}
path == "/ocs/v2.php/cloud/user" -> {
if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized"))
json(200, JSONObject().put("ocs", JSONObject().put("data", JSONObject()
.put("id", userId)
.put("display-name", "Test User")
.put("quota", JSONObject()
.put("free", quotaFree).put("used", quotaTotal - quotaFree)
.put("total", quotaTotal).put("relative", 80.0)))))
}
method == "DELETE" && path == "/ocs/v2.php/core/apppassword" -> {
if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized"))
revokeCalls++
json(200, JSONObject())
}
// ---- chunked upload v2 -------------------------------------------------
method == "MKCOL" && path.startsWith(uploadsRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
val dir = path.trimEnd('/') + '/'
if (dir in dirs) return MockResponse().setResponseCode(405)
dirs += dir
MockResponse().setResponseCode(201)
}
method == "PUT" && path.startsWith(uploadsRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
if (failNextPut507) {
failNextPut507 = false
return MockResponse().setResponseCode(507)
}
val dir = parentOf(path)
if (dir !in dirs) return MockResponse().setResponseCode(404)
files[path] = request.body.readByteArray()
chunkPuts++
chunkNames += path.substringAfterLast('/')
chunkHeaders += mapOf(
"OC-Total-Length" to request.getHeader("OC-Total-Length"),
"Destination" to request.getHeader("Destination"),
)
MockResponse().setResponseCode(201)
}
method == "MOVE" && path.startsWith(uploadsRoot) && path.endsWith("/.file") -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
lastMove = mapOf(
"Destination" to request.getHeader("Destination"),
"Overwrite" to request.getHeader("Overwrite"),
"OC-Total-Length" to request.getHeader("OC-Total-Length"),
)
val destUrl = request.getHeader("Destination") ?: return MockResponse().setResponseCode(400)
val destPath = dec(runCatching { java.net.URI(destUrl).path }.getOrDefault(destUrl))
// Assemble transfer chunks in name order (server semantics).
val transferDir = path.removeSuffix(".file")
val chunks = files.keys.filter { it.startsWith(transferDir) && it != path }
.sortedBy { it.substringAfterLast('/') }
if (chunks.isEmpty()) return MockResponse().setResponseCode(404)
val assembled = chunks.fold(byteArrayOf()) { acc, k -> acc + files[k]!! }
files[destPath] = assembled
MockResponse().setResponseCode(201)
}
// ---- plain WebDAV ------------------------------------------------------
method == "MKCOL" && path.startsWith(davRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
val dir = path.trimEnd('/') + '/'
if (dir in dirs) return MockResponse().setResponseCode(405)
if (parentOf(dir) !in dirs) return MockResponse().setResponseCode(409)
dirs += dir
MockResponse().setResponseCode(201)
}
method == "PUT" && path.startsWith(davRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
if (parentOf(path) !in dirs && parentOf(path) != davRoot) {
return MockResponse().setResponseCode(409)
}
files[path] = request.body.readByteArray()
MockResponse().setResponseCode(201)
}
method == "GET" && path.startsWith(davRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
val bytes = files[path] ?: return MockResponse().setResponseCode(404)
MockResponse().setResponseCode(200)
.setHeader("Content-Type", "audio/mp4")
.setBody(Buffer().write(bytes))
}
method == "DELETE" && path.startsWith(davRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
// Collections may arrive with or without the trailing slash;
// real servers treat both the same.
val asDir = path.trimEnd('/') + '/'
val fileGone = files.remove(path) != null
val dirGone = if (asDir in dirs) {
dirs.removeIf { it == asDir || it.startsWith(asDir) }
files.keys.removeIf { it.startsWith(asDir) }
true
} else false
if (!fileGone && !dirGone) return MockResponse().setResponseCode(404)
MockResponse().setResponseCode(204)
}
method == "DELETE" && path.startsWith(uploadsRoot) -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
files.keys.removeIf { it.startsWith(path) }
dirs.removeIf { it == path || it.startsWith(path) }
MockResponse().setResponseCode(204)
}
method == "PROPFIND" -> {
if (!basicOk(request)) return MockResponse().setResponseCode(401)
val dir = if (path.endsWith('/')) path else "$path/"
val children = mutableListOf<String>()
// Self first, then direct children — like the real server.
if (dir in dirs || dir == davRoot) children += dir
val seen = mutableSetOf<String>()
for (d in dirs) {
if (d != dir && parentOf(d) == dir && seen.add(d)) children += d
}
for (f in files.keys) {
val d = parentOf(f)
if (d == dir && seen.add(f)) children += f
}
if (children.isEmpty()) return MockResponse().setResponseCode(404)
val items = children.joinToString("") { child ->
val isDir = child.endsWith('/')
val size = files[child]?.size ?: 0
"""<d:response><d:href>${encPath(child)}</d:href><d:propstat><d:prop>""" +
(if (!isDir) "<d:getcontentlength>$size</d:getcontentlength>" else "") +
(if (!isDir) """<d:getetag>"etag-${child.hashCode()}"</d:getetag>""" else "") +
(if (!isDir) """<d:getcontenttype>audio/mp4</d:getcontenttype>""" else "") +
"""<d:getlastmodified>Tue, 02 Jan 2026 03:04:05 GMT</d:getlastmodified>""" +
(if (isDir) "<d:resourcetype><d:collection/></d:resourcetype>" else "<d:resourcetype/>") +
"""</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"""
}
MockResponse().setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("""<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">$items</d:multistatus>""")
}
path.startsWith(uploadsRoot) -> MockResponse().setResponseCode(404)
else -> json(404, JSONObject().put("message", "unknown $method $rawPath"))
}
}
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 NextcloudProvider. */
class NextcloudProviderContractTest : ProviderContractTest() {
private val backend = FakeNextcloud()
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"), "nc-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.AppPassword(
accountLabel = "testuser",
loginUrl = backend.url,
user = backend.userId,
password = backend.appPassword,
)
override suspend fun makeProvider(): ShonarProvider = NextcloudProvider(
auth = NextcloudAuthStore(store),
chunkSizeBytes = 64 * 1024, // 4 chunks per 256 KiB draft
)
override suspend fun makeDraft(id: String): RecordingDraft {
val src = File(dir, "src-$id.m4a")
val rnd = java.util.Random(id.hashCode().toLong())
src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) })
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 probe_identifiesNextcloud() = kotlinx.coroutines.runBlocking {
val probe = makeProvider().probe(serverUrl())
assertTrue(probe is ProbeResult.Compatible)
probe as ProbeResult.Compatible
assertEquals(ProviderRegistry.NEXTCLOUD_ID, probe.descriptor.id)
assertTrue(probe.descriptor.isDefault)
assertTrue(
ProviderDescriptor.Capability.CHUNKED_UPLOAD in probe.descriptor.capabilities
)
}
@Test fun probe_rejectsNonNextcloud() = kotlinx.coroutines.runBlocking {
backend.productName = "ownCloud"
assertEquals(ProbeResult.Incompatible, makeProvider().probe(serverUrl()))
}
@Test fun wrongAppPassword_failsWithoutLeaking() = kotlinx.coroutines.runBlocking {
val p = makeProvider()
try {
p.connect(
ProviderCredential.AppPassword("t", backend.url, backend.userId, "wrong-pass-1")
)
fail("wrong app password must not connect")
} catch (e: ProviderError.Transient) {
assertFalse(e.message!!.contains("wrong-pass-1"))
}
}
@Test fun upload_usesNumberedChunksAndMoveAssembly() = kotlinx.coroutines.runBlocking {
val p = makeProvider()
p.connect(initialCredential())
val draft = makeDraft("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
p.upload(draft) { }
// 4 chunks named 00001..00004, each carrying quota + destination headers.
assertEquals(listOf("00001", "00002", "00003", "00004"), backend.chunkNames)
assertTrue(backend.chunkHeaders.all { it["OC-Total-Length"] == draft.sizeBytes.toString() })
assertTrue(backend.chunkHeaders.all { (it["Destination"] ?: "").endsWith(".m4a") })
// Assembly: MOVE …/.file with Destination + Overwrite + total length.
assertTrue(backend.lastMove["Destination"]!!.endsWith("SHONAR/audio/${draft.id}.m4a"))
assertEquals("T", backend.lastMove["Overwrite"])
assertEquals(draft.sizeBytes.toString(), backend.lastMove["OC-Total-Length"])
}
@Test fun reupload_skipsPresentChunks() = kotlinx.coroutines.runBlocking {
val p = makeProvider()
p.connect(initialCredential())
// Simulate an interrupted upload: transfer folder with 2 of 4 chunks
// already on the server (e.g. left by a killed process).
val draft = makeDraft("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
val transferDir = "/remote.php/dav/uploads/${backend.userId}/shonar-${draft.id}/"
backend.dirs += transferDir
val src = draft.sourceFile.readBytes()
val quarter = src.size / 4
backend.files[transferDir + "00001"] = src.copyOfRange(0, quarter)
backend.files[transferDir + "00002"] = src.copyOfRange(quarter, quarter * 2)
p.upload(draft) { }
assertEquals("only the 2 missing chunks are sent", 2, backend.chunkPuts)
assertEquals(listOf("00003", "00004"), backend.chunkNames)
// And the assembled file is complete.
val dest = java.io.File.createTempFile("resume-dl", ".bin")
try {
val ref = p.list(null).items.single().ref
p.download(ref, dest) { }
assertTrue(dest.readBytes().contentEquals(src))
} finally {
dest.delete()
}
}
@Test fun quotaExceeded_surfaces() = kotlinx.coroutines.runBlocking {
val p = makeProvider()
p.connect(initialCredential())
backend.failNextPut507 = true
try {
p.upload(makeDraft("cccccccc-cccc-4ccc-8ccc-cccccccccccc")) { }
fail("507 must surface as QuotaExceeded")
} catch (expected: ProviderError.QuotaExceeded) {
}
}
}
/** Auth lifecycle + store for the Nextcloud provider. */
class NextcloudProviderAuthTest {
private val backend = FakeNextcloud()
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"), "nc-auth-${System.nanoTime()}").apply { mkdirs() }
store = com.shonar.settings.InMemorySettingsStore()
}
@After fun tearDown() {
backend.stop()
dir.deleteRecursively()
}
private fun provider() = NextcloudProvider(auth = NextcloudAuthStore(store))
private fun cred() = ProviderCredential.AppPassword(
accountLabel = "testuser", loginUrl = backend.url,
user = backend.userId, password = backend.appPassword,
)
@Test fun loginFlow_endToEnd() = kotlinx.coroutines.runBlocking {
val flow = NextcloudAuth()
val url = ServerUrl.parse(backend.url)!!.getOrThrow()
assertTrue(flow.probe(url) is ProbeResult.Compatible)
val session = flow.startLogin(url)
assertTrue(session.loginUrl.isNotBlank())
assertEquals(com.shonar.provider.PollResult.Pending, flow.poll(session))
backend.pollApproved = true
val approved = flow.poll(session)
assertTrue(approved is com.shonar.provider.PollResult.Approved)
approved as com.shonar.provider.PollResult.Approved
assertEquals(backend.appPassword, approved.credential.password)
val p = provider()
p.connect(approved.credential)
assertEquals(AuthState.CONNECTED, p.authState.value)
val saved = NextcloudAuthStore(store).load()
assertNotNull(saved)
assertEquals(backend.url, saved!!.baseUrl)
assertEquals(backend.userId, saved.userId)
// The normal password was never involved; only the app password persists.
assertFalse(
store.keys().flatMap { listOf(store.getString(it).orEmpty()) }
.any { it.contains("normal-password") }
)
}
@Test fun login_persistsUserIdForDavPaths() = kotlinx.coroutines.runBlocking {
val p = provider()
p.connect(cred())
assertEquals(backend.userId, NextcloudAuthStore(store).load()!!.userId)
}
@Test fun reconnect_restoresSession() = kotlinx.coroutines.runBlocking {
provider().connect(cred())
val p2 = provider()
assertEquals(AuthState.CONNECTED, p2.reconnect())
}
@Test fun revokedPassword_reconnectReportsExpired() = kotlinx.coroutines.runBlocking {
val p = provider()
p.connect(cred())
// Server-side revocation: the stored app password stops working.
val saved = NextcloudAuthStore(store).load()!!
NextcloudAuthStore(store).save(saved.copy(appPassword = "revoked"))
assertEquals(AuthState.EXPIRED, p.reconnect())
}
@Test fun disconnect_revokesAppPassword() = kotlinx.coroutines.runBlocking {
val p = provider()
p.connect(cred())
p.disconnect(revokeOnServer = true)
assertEquals(1, backend.revokeCalls)
assertEquals(AuthState.DISCONNECTED, p.authState.value)
assertNull(NextcloudAuthStore(store).load())
}
@Test fun summary_carriesQuota() = kotlinx.coroutines.runBlocking {
val p = provider()
p.connect(cred())
val s = p.storageLocationSummary()
assertTrue(s.headline.startsWith("Nextcloud at"))
assertTrue("quota must be shown" , "free of" in s.detail)
}
}
class NextcloudAuthStoreTest {
private val store = com.shonar.settings.InMemorySettingsStore()
private val auth = NextcloudAuthStore(store)
@Test fun save_load_clear() = kotlinx.coroutines.runBlocking {
assertNull(auth.load())
auth.save(NcSession("https://nc.example.com", "u1", "User One", "pw"))
val loaded = auth.load()
assertEquals("https://nc.example.com", loaded!!.baseUrl)
assertEquals("u1", loaded.userId)
assertEquals("pw", loaded.appPassword)
auth.clear()
assertNull(auth.load())
}
}
class NextcloudProtocolTest {
@Test fun enc_spacesNotPlus() {
assertEquals("a%20b", NextcloudProvider.enc("a b"))
assertEquals("100%25", NextcloudProvider.enc("100%"))
}
@Test fun uuidForKey_stripsDirsAndSuffix() {
assertEquals("abc", NextcloudProvider.uuidForKey("SHONAR/audio/abc.m4a"))
}
@Test fun extFor_commonMimes() {
assertEquals(".m4a", NextcloudProvider.extFor("audio/mp4"))
assertEquals(".wav", NextcloudProvider.extFor("audio/x-wav"))
assertEquals(".ogg", NextcloudProvider.extFor("audio/opus"))
assertEquals(".mp3", NextcloudProvider.extFor("audio/mpeg"))
}
@Test fun formatBytes_units() {
assertEquals("512 B", NextcloudProvider.formatBytes(512))
assertEquals("10.0 GB", NextcloudProvider.formatBytes(10L * 1024 * 1024 * 1024))
}
}

View file

@ -174,6 +174,9 @@ abstract class ProviderContractTest {
is ProviderCredential.OAuthTokens -> is ProviderCredential.OAuthTokens ->
listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() } listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() }
is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() } is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() }
is ProviderCredential.ShonarLogin ->
listOf(cred.password).filter { it.isNotBlank() }
is ProviderCredential.FolderPath -> emptyList() // a path is not secret
ProviderCredential.None -> emptyList() ProviderCredential.None -> emptyList()
} }

View file

@ -81,6 +81,11 @@ class ServerUrlTest {
val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null) val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null)
assertFalse(o.toString().contains("ACCESS-XYZ")) assertFalse(o.toString().contains("ACCESS-XYZ"))
assertFalse(o.toString().contains("REFRESH-XYZ")) assertFalse(o.toString().contains("REFRESH-XYZ"))
val s = ProviderCredential.ShonarLogin(
"u@x.com", ServerUrl.parse("https://s.example.com")!!.getOrThrow(),
"u@x.com", "hunter2-hunter2",
)
assertFalse(s.toString().contains("hunter2-hunter2"))
} }
} }
@ -112,6 +117,16 @@ class ProviderRegistryTest {
@Test fun available_listsDescriptors() { @Test fun available_listsDescriptors() {
val ids = registry(createTempFileSafe()).available().map { it.id } val ids = registry(createTempFileSafe()).available().map { it.id }
assertTrue(ids.contains(LocalOnlyProvider.ID)) assertTrue(ids.contains(LocalOnlyProvider.ID))
assertTrue(ids.contains(ProviderRegistry.CUSTOM_SHONAR_ID))
assertTrue(ids.contains(ProviderRegistry.NEXTCLOUD_ID))
assertTrue(ids.contains(ProviderRegistry.SYNC_FOLDER_ID))
}
@Test fun default_isNextcloud() {
assertEquals(
ProviderRegistry.NEXTCLOUD_ID,
registry(createTempFileSafe()).defaultProviderId,
)
} }
private fun createTempFileSafe(): java.io.File = private fun createTempFileSafe(): java.io.File =

View file

@ -0,0 +1,241 @@
package com.shonar.provider
import java.security.MessageDigest
import okhttp3.Request
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okhttp3.tls.HandshakeCertificates
import okhttp3.tls.HeldCertificate
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
/**
* P5 cert-fixture tests. Certificates are generated at runtime with
* okhttp-tls HeldCertificate (no checked-in keys, nothing to expire):
* a private CA the system does NOT trust, so every handshake exercises
* the TOFU path.
*/
class TofuTrustTest {
private lateinit var server: MockWebServer
private lateinit var held: HeldCertificate
private lateinit var store: com.shonar.settings.InMemorySettingsStore
private lateinit var tofu: TofuManager
private lateinit var tls: TlsPolicy
private lateinit var base: String
private var port: Int = 0
@Before fun setUp() {
held = HeldCertificate.Builder()
.commonName("shonar-test")
.addSubjectAlternativeName("localhost")
.addSubjectAlternativeName("127.0.0.1")
.build()
val serverCerts = HandshakeCertificates.Builder()
.heldCertificate(held)
.build()
server = MockWebServer()
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"kind":"shonar","version":"t","capabilities":{}}""")
}
server.useHttps(serverCerts.sslSocketFactory(), false)
server.start()
port = server.port
base = "https://localhost:$port"
store = com.shonar.settings.InMemorySettingsStore()
tofu = TofuManager(TofuStore(store))
tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {})
}
@After fun tearDown() {
server.shutdown()
}
private fun expectedSpki(): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(held.certificate.publicKey.encoded)
return digest.joinToString(":") { "%02X".format(it) }
}
private fun get(url: String): Int {
val client = tls.newClient(10, 10, 10)
client.newCall(Request.Builder().url(url).get().build()).execute().use { resp ->
return resp.code
}
}
@Test fun untrusted_recordsFailureWithRealSpki() {
try {
get("$base/api/v1/provider-info")
fail("self-signed must not connect unapproved")
} catch (expected: javax.net.ssl.SSLHandshakeException) {
}
val failure = tofu.failureFor("localhost")
assertNotNull("failure must be recorded for approval UI", failure)
assertEquals(expectedSpki(), failure!!.spkiHex)
assertTrue("subject must name the test cert", failure.subject.contains("shonar-test"))
}
@Test fun approve_thenConnects() {
try {
get("$base/api/v1/provider-info")
} catch (ignored: Exception) {
}
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
assertEquals(200, get("$base/api/v1/provider-info"))
assertEquals(setOf("localhost"), tofu.pinnedHostsForTest())
}
@Test fun pin_isPerHost() {
try {
get("$base/api/v1/provider-info")
} catch (ignored: Exception) {
}
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
assertEquals(200, get("$base/api/v1/provider-info"))
// Same cert, different host: still untrusted (per-host binding).
try {
get("https://127.0.0.1:$port/api/v1/provider-info")
fail("pin for localhost must not cover 127.0.0.1")
} catch (expected: javax.net.ssl.SSLHandshakeException) {
}
assertNotNull(tofu.failureFor("127.0.0.1"))
}
@Test fun decline_clearsWithoutPinning() {
try {
get("$base/api/v1/provider-info")
} catch (ignored: Exception) {
}
assertNotNull(tofu.failureFor("localhost"))
kotlinx.coroutines.runBlocking { tofu.decline("localhost") }
assertNull(tofu.failureFor("localhost"))
}
@Test fun approve_withoutFailure_returnsFalse() {
assertFalse(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
}
@Test fun forget_revokesTrust() {
try {
get("$base/api/v1/provider-info")
} catch (ignored: Exception) {
}
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
assertEquals(200, get("$base/api/v1/provider-info"))
kotlinx.coroutines.runBlocking { tofu.forget("localhost") }
try {
get("$base/api/v1/provider-info")
fail("forgotten host must fail again")
} catch (expected: javax.net.ssl.SSLHandshakeException) {
}
}
@Test fun systemTrust_unaffectedForPlainHttp() {
// The TOFU trust manager must be invisible on non-TLS and on
// system-trusted paths: plain HTTP MockWebServer still works.
val plain = MockWebServer()
plain.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
MockResponse().setResponseCode(200).setBody("ok")
}
plain.start()
try {
assertEquals(200, get("http://localhost:${plain.port}/x"))
} finally {
plain.shutdown()
}
}
// test-only accessor without growing the public API
private fun TofuManager.pinnedHostsForTest(): Set<String> =
kotlinx.coroutines.runBlocking { pinnedHosts() }
}
class TofuProbeTest {
private lateinit var server: MockWebServer
private lateinit var tofu: TofuManager
private lateinit var tls: TlsPolicy
private var port: Int = 0
@Before fun setUp() {
val held = HeldCertificate.Builder()
.commonName("shonar-test")
.addSubjectAlternativeName("localhost")
.build()
val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build()
server = MockWebServer()
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val body = if (request.path == "/status.php") {
"""{"productname":"Nextcloud","versionstring":"30.0.0"}"""
} else {
"""{"kind":"shonar","version":"t","capabilities":{"chunked_upload":true}}"""
}
return MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json").setBody(body)
}
}
server.useHttps(serverCerts.sslSocketFactory(), false)
server.start()
port = server.port
tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore()))
tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {})
}
@After fun tearDown() {
server.shutdown()
}
@Test fun probe_reportsRealFingerprint_thenApproves() = kotlinx.coroutines.runBlocking {
val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow()
val handshake = ShonarHandshake(tls.probeClient(), tofu)
val first = handshake.probe(url)
assertTrue(first is ProbeResult.TlsFailure)
first as ProbeResult.TlsFailure
assertTrue("fingerprint must be real, not a placeholder",
first.fingerprintSha256 != "unknown" && ":" in first.fingerprintSha256)
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
val second = handshake.probe(url)
assertTrue("approved host probes cleanly: $second", second is ProbeResult.Compatible)
}
@Test fun nextcloudProbe_sameFlow() = kotlinx.coroutines.runBlocking {
val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow()
val auth = NextcloudAuth(tls.nextcloudClient(), tofu)
assertTrue(auth.probe(url) is ProbeResult.TlsFailure)
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
val second = auth.probe(url)
assertTrue("approved Nextcloud probes cleanly: $second", second is ProbeResult.Compatible)
}
}
class TofuStoreTest {
private val store = com.shonar.settings.InMemorySettingsStore()
private val tofu = TofuStore(store)
@Test fun roundtrip() = kotlinx.coroutines.runBlocking {
assertEquals(emptySet<String>(), tofu.hosts())
tofu.addPin("Nas.Local", "DER-1")
tofu.addPin("nas.local", "DER-2")
assertEquals(setOf("DER-1", "DER-2"), tofu.pins("NAS.LOCAL"))
assertEquals(setOf("nas.local"), tofu.hosts())
tofu.removeHost("nas.local")
assertEquals(emptySet<String>(), tofu.hosts())
assertEquals(emptySet<String>(), tofu.pins("nas.local"))
}
}

View file

@ -0,0 +1,87 @@
package com.shonar.recording
import com.shonar.provider.AuthState
import com.shonar.provider.ProviderCredential
import com.shonar.provider.ProviderDescriptor
import com.shonar.provider.ProviderRegistry
import com.shonar.provider.RecordingDraft
import com.shonar.provider.RemoteRef
import com.shonar.provider.Page
import com.shonar.provider.ProbeResult
import com.shonar.provider.ServerUrl
import com.shonar.provider.ShonarProvider
import com.shonar.provider.SidecarKind
import com.shonar.provider.StorageLocation
import com.shonar.provider.SyncState
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
/** In-memory RecordingDao: no Android needed. Shared by recording tests. */
internal class FakeDao(initial: List<RecordingEntity> = emptyList()) : RecordingDao {
private val rows = initial.associateBy { it.id }.toMutableMap()
override fun observeAll(): Flow<List<RecordingEntity>> = flowOf(rows.values.toList())
override suspend fun getAll(): List<RecordingEntity> = rows.values.sortedByDescending { it.createdAtEpochMs }
override suspend fun getById(id: String): RecordingEntity? = rows[id]
override suspend fun insert(recording: RecordingEntity) {
rows[recording.id] = recording
}
override suspend fun update(recording: RecordingEntity) {
check(rows.containsKey(recording.id)) { "no such row ${recording.id}" }
rows[recording.id] = recording
}
override suspend fun delete(recording: RecordingEntity) {
rows.remove(recording.id)
}
}
/** Scripted provider: per-draft behaviour for upload, inert elsewhere. */
internal class ScriptProvider(
val id: String = "nextcloud",
var failWith: (RecordingDraft) -> Exception? = { null },
) : ShonarProvider {
override val descriptor = ProviderDescriptor(id, id)
private val states = MutableStateFlow(AuthState.CONNECTED)
override val authState: kotlinx.coroutines.flow.StateFlow<AuthState> = states
val uploadedKeys = mutableListOf<String>()
override suspend fun probe(baseUrl: ServerUrl): ProbeResult = ProbeResult.Incompatible
override suspend fun connect(credential: ProviderCredential) {}
override suspend fun reconnect(): AuthState = AuthState.CONNECTED
override suspend fun disconnect(revokeOnServer: Boolean) {}
override suspend fun deleteAccountAndData() {}
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
failWith(draft)?.let { throw it }
onProgress(1f)
return RemoteRef(id, "SHONAR/audio/${draft.id}.m4a", etag = "e-${draft.id}", sizeBytes = draft.sizeBytes)
.also { uploadedKeys += draft.id }
}
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
throw UnsupportedOperationException()
override suspend fun delete(ref: RemoteRef) = throw UnsupportedOperationException()
override suspend fun list(cursor: String?): Page<com.shonar.provider.RemoteRecording> =
Page(emptyList(), null)
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
throw UnsupportedOperationException()
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = null
override suspend fun storageLocationSummary(): StorageLocation = StorageLocation(id, "", 0, 0, 0)
}
internal fun registryOf(vararg providers: ShonarProvider): ProviderRegistry =
ProviderRegistry(providers.associateBy({ it.descriptor.id }) { { it } })
internal fun entity(
id: String,
dir: File,
state: SyncState = SyncState.LOCAL_ONLY,
): RecordingEntity {
val f = File(dir, "$id.m4a").apply { writeBytes(ByteArray(128) { 0x1 }) }
return RecordingEntity(
id = id, title = "t-$id", createdAtEpochMs = 1_700_000_000_000L,
durationMs = 1000, filePath = f.absolutePath, mimeType = "audio/mp4",
sizeBytes = f.length(), syncState = state,
)
}
internal fun tmpDir(prefix: String): File =
File(System.getProperty("java.io.tmpdir"), "$prefix-${System.nanoTime()}").apply { mkdirs() }

View file

@ -0,0 +1,199 @@
package com.shonar.recording
import com.shonar.provider.LocalOnlyProvider
import com.shonar.provider.ProviderError
import com.shonar.provider.ProviderRegistry
import com.shonar.provider.SyncState
import java.io.File
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class MigrationRunnerTest {
@Test fun allSucceed_marksUploadedWithSlots() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("mig-ok")
try {
val dao = FakeDao(listOf(entity("a", dir), entity("b", dir)))
val script = ScriptProvider()
val res = MigrationRunner(SyncSlots(dao), registryOf(script))
.migrateAllTo("nextcloud")
assertEquals(2, res.uploaded)
assertTrue(res.failed.isEmpty())
assertEquals(
listOf("a", "b"),
dao.getAll().sortedBy { it.id }.map {
assertEquals(SyncState.UPLOADED, it.syncState)
assertEquals("nextcloud", it.originProviderId)
assertEquals("SHONAR/audio/${it.id}.m4a", it.remoteKey)
it.id
},
)
} finally {
dir.deleteRecursively()
}
}
@Test fun failure_recordsErrorAndContinues() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("mig-partial")
try {
val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir)))
val script = ScriptProvider().apply {
failWith = { if (it.id == "b") ProviderError.Transient("boom") else null }
}
val res = MigrationRunner(SyncSlots(dao), registryOf(script))
.migrateAllTo("nextcloud")
assertEquals(2, res.uploaded)
assertEquals(listOf("t-b"), res.failed)
val b = dao.getById("b")!!
assertEquals(SyncState.ERROR, b.syncState)
assertEquals("boom", b.syncReason)
assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState)
assertEquals(SyncState.UPLOADED, dao.getById("c")!!.syncState)
} finally {
dir.deleteRecursively()
}
}
@Test fun missingFile_recordsErrorWithoutUpload() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("mig-gone")
try {
val ghost = RecordingEntity(
id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1,
filePath = File(dir, "nope.m4a").absolutePath, mimeType = "audio/mp4", sizeBytes = 0,
)
val dao = FakeDao(listOf(ghost))
val script = ScriptProvider()
val res = MigrationRunner(SyncSlots(dao), registryOf(script)).migrateAllTo("nextcloud")
assertEquals(0, res.uploaded)
assertEquals(listOf("ghost"), res.failed)
assertTrue(script.uploadedKeys.isEmpty())
assertEquals(SyncState.ERROR, dao.getById("g")!!.syncState)
} finally {
dir.deleteRecursively()
}
}
@Test fun errorRow_rerunsThroughLegalTransitions() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("mig-retry")
try {
val dao = FakeDao(listOf(entity("a", dir, SyncState.ERROR)))
val res = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider()))
.migrateAllTo("nextcloud")
assertEquals(1, res.uploaded)
assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState)
} finally {
dir.deleteRecursively()
}
}
@Test fun cancel_leavesRestUntouched() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("mig-cancel")
try {
val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir)))
val runner = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider()))
var job: Job? = null
job = launch {
runner.migrateAllTo("nextcloud") {
// Cancel as soon as the first file reports progress.
if (it.done == 0) job?.cancel()
}
}
job.join()
// First file may or may not have finished (upload is quick); the
// point is later files were never started.
val states = dao.getAll().associate { it.id to it.syncState }
assertTrue(
"unstarted rows keep their state, got $states",
states["c"] == SyncState.LOCAL_ONLY,
)
} finally {
dir.deleteRecursively()
}
}
@Test fun localOnlyTarget_rejected() = kotlinx.coroutines.runBlocking {
val dao = FakeDao()
try {
MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("local-only")
fail("must reject local-only target")
} catch (expected: IllegalArgumentException) {
}
}
@Test fun unknownTarget_throws() = kotlinx.coroutines.runBlocking {
val dao = FakeDao()
try {
MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("dropbox")
fail("must reject unknown provider")
} catch (expected: ProviderError.InvalidUrl) {
}
}
@Test fun forget_clearsSlotsKeepsFiles() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("mig-forget")
try {
val synced = entity("a", dir, SyncState.UPLOADED).copy(
originProviderId = "nextcloud",
remoteKey = "SHONAR/audio/a.m4a",
remoteEtag = "e",
remoteSizeBytes = 128,
)
val dao = FakeDao(listOf(synced))
SyncSlots(dao).forgetAllRemotes("custom-shonar")
val row = dao.getById("a")!!
assertNull(row.remoteKey)
assertNull(row.remoteEtag)
assertNull(row.remoteSizeBytes)
assertEquals("custom-shonar", row.originProviderId)
assertEquals(SyncState.QUEUED, row.syncState)
assertTrue("files stay", File(row.filePath).exists())
} finally {
dir.deleteRecursively()
}
}
@Test fun localOnlyId_rejectedAsTargetConstant() {
assertEquals("local-only", LocalOnlyProvider.ID)
}
}
class SyncSlotsTest {
@Test fun draftFor_missingFileIsNull() = kotlinx.coroutines.runBlocking {
val ghost = RecordingEntity(
id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1,
filePath = "/nonexistent-dir-xyz/nope.m4a", mimeType = "audio/mp4", sizeBytes = 0,
)
assertNull(SyncSlots(FakeDao()).draftFor(ghost))
}
@Test fun entityDefaults_readAsNeverSyncedLocal() {
// A v1-shaped row (new columns absent) must behave as an unsynced local.
val row = RecordingEntity(
id = "v1", title = "old", createdAtEpochMs = 1L, durationMs = 1,
filePath = "/x.m4a", mimeType = "audio/mp4", sizeBytes = 10,
)
assertEquals("local-only", row.originProviderId)
assertNull(row.remoteKey)
assertEquals(SyncState.LOCAL_ONLY, row.syncState)
assertNull(row.syncReason)
}
@Test fun converter_roundtripsAndFallsBack() {
val c = SyncStateConverter()
assertEquals(SyncState.SYNCED, c.toState(c.toString(SyncState.SYNCED)))
assertEquals("UPLOADED", c.toString(SyncState.UPLOADED))
assertEquals(SyncState.LOCAL_ONLY, c.toState("FUTURE_STATE"))
assertEquals(SyncState.LOCAL_ONLY, c.toState(null))
}
@Test fun migration_movesV1ToV2() {
assertEquals(1, MIGRATION_1_2.startVersion)
assertEquals(2, MIGRATION_1_2.endVersion)
}
}

View file

@ -0,0 +1,204 @@
package com.shonar.recording
import androidx.work.NetworkType
import com.shonar.provider.ProviderError
import com.shonar.provider.SyncState
import com.shonar.provider.SyncStatus
import com.shonar.provider.canTransitionTo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
private val OPEN = SyncDrain.Gates(online = true, unmetered = true, charging = true)
private const val NOW = 1_000_000L
class SyncDrainTest {
@Test fun drainsQueuedAndDueErrors_skipsTheRest() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("drain-mix")
try {
val dueError = entity("b", dir, SyncState.ERROR)
.copy(syncRetryAtMs = NOW - 1, syncAttempts = 1)
val futureError = entity("c", dir, SyncState.ERROR)
.copy(syncRetryAtMs = NOW + 60_000, syncAttempts = 1)
val rows = listOf(
entity("a", dir, SyncState.QUEUED),
dueError,
futureError,
entity("d", dir, SyncState.SYNCED),
entity("e", dir, SyncState.UPLOADED),
entity("f", dir), // LOCAL_ONLY: the drain never volunteers rows
)
val dao = FakeDao(rows)
val res = SyncDrain(SyncSlots(dao)) { NOW }
.drainOnce(ScriptProvider(), wifiOnly = false, chargingOnly = false, gates = OPEN)
assertEquals(2, res.uploaded)
assertTrue(res.failed.isEmpty())
// P7 leftovers (ERROR, no retryAt) are due immediately too.
val states = dao.getAll().associate { it.id to it.syncState }
assertEquals(SyncState.UPLOADED, states["a"])
assertEquals(SyncState.UPLOADED, states["b"])
assertEquals(SyncState.ERROR, states["c"])
assertEquals(SyncState.SYNCED, states["d"])
assertEquals(SyncState.UPLOADED, states["e"])
assertEquals(SyncState.LOCAL_ONLY, states["f"])
} finally {
dir.deleteRecursively()
}
}
@Test fun legacyErrorWithoutRetryAt_isDue() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("drain-legacy")
try {
// Exactly how a P7-run failure reads after the v2->v3 upgrade.
val legacy = entity("a", dir, SyncState.ERROR)
.copy(syncReason = "boom", syncAttempts = 0, syncRetryAtMs = null)
val dao = FakeDao(listOf(legacy))
val res = SyncDrain(SyncSlots(dao)) { NOW }
.drainOnce(ScriptProvider(), false, false, OPEN)
assertEquals(1, res.uploaded)
} finally {
dir.deleteRecursively()
}
}
@Test fun localOnlyTarget_isNoop() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("drain-local")
try {
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
val script = ScriptProvider("local-only")
val res = SyncDrain(SyncSlots(dao)) { NOW }
.drainOnce(script, false, false, OPEN)
assertEquals(0, res.uploaded)
assertTrue(script.uploadedKeys.isEmpty())
assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState)
} finally {
dir.deleteRecursively()
}
}
@Test fun gatesBlocked_postponesWithoutTouching() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("drain-gates")
try {
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
val script = ScriptProvider()
val res = SyncDrain(SyncSlots(dao)) { NOW }.drainOnce(
script, wifiOnly = true, chargingOnly = false,
gates = SyncDrain.Gates(online = true, unmetered = false, charging = true),
)
assertTrue(res.postponed)
assertTrue(script.uploadedKeys.isEmpty())
assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState)
} finally {
dir.deleteRecursively()
}
}
@Test fun failure_recordsBackoffAttempts() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("drain-backoff")
try {
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
val script = ScriptProvider().apply {
failWith = { ProviderError.Transient("nope") }
}
val drain = SyncDrain(SyncSlots(dao)) { NOW }
val res = drain.drainOnce(script, false, false, OPEN)
assertEquals(0, res.uploaded)
assertEquals(listOf("t-a"), res.failed)
val first = dao.getById("a")!!
assertEquals(SyncState.ERROR, first.syncState)
assertEquals(1, first.syncAttempts)
assertEquals(NOW + 60_000L, first.syncRetryAtMs)
// Second failure doubles the delay.
val res2 = SyncDrain(SyncSlots(dao)) { NOW + 61_000L }
.drainOnce(script, false, false, OPEN)
assertEquals(0, res2.uploaded)
val second = dao.getById("a")!!
assertEquals(2, second.syncAttempts)
assertEquals(NOW + 61_000L + 120_000L, second.syncRetryAtMs)
} finally {
dir.deleteRecursively()
}
}
@Test fun interruption_revertsInflightToQueued() = kotlinx.coroutines.runBlocking {
val dir = tmpDir("drain-cancel")
try {
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
val script = ScriptProvider().apply {
failWith = { throw kotlinx.coroutines.CancellationException("stopped") }
}
try {
SyncDrain(SyncSlots(dao)) { NOW }.drainOnce(script, false, false, OPEN)
org.junit.Assert.fail("cancellation must propagate")
} catch (expected: kotlinx.coroutines.CancellationException) {
}
assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState)
} finally {
dir.deleteRecursively()
}
}
}
class SyncGatesTest {
@Test fun matrix() {
val onlineMetered = SyncDrain.Gates(online = true, unmetered = false, charging = false)
assertTrue(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = false))
assertFalse(onlineMetered.satisfiedBy(wifiOnly = true, chargingOnly = false))
assertFalse(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = true))
assertFalse(
SyncDrain.Gates(online = false, unmetered = false, charging = true)
.satisfiedBy(false, false)
)
assertTrue(OPEN.satisfiedBy(true, true))
}
}
class BackoffTest {
@Test fun exponentialCappedAtOneHour() {
assertEquals(60_000L, backoffDelayMs(1))
assertEquals(120_000L, backoffDelayMs(2))
assertEquals(240_000L, backoffDelayMs(3))
assertEquals(3_600_000L, backoffDelayMs(7))
assertEquals(3_600_000L, backoffDelayMs(100))
assertEquals(60_000L, backoffDelayMs(0))
}
@Test fun stepsToUploading_areLegalTransitions() {
for (from in SyncState.values()) {
val steps = stepsToUploading(from)
assertTrue("must end uploading, from=$from", steps.last() == SyncState.UPLOADING)
var cur = SyncStatus(from)
for (next in steps) {
assertTrue(
"$from -> $next must be legal",
cur.canTransitionTo(next),
)
cur = SyncStatus(next)
}
}
}
}
class SchedulerMappingTest {
@Test fun constraintsFollowSettings() {
val wifi = SyncScheduler.workConstraints(wifiOnly = true, chargingOnly = true)
assertEquals(NetworkType.UNMETERED, wifi.requiredNetworkType)
assertTrue(wifi.requiresCharging())
val any = SyncScheduler.workConstraints(wifiOnly = false, chargingOnly = false)
assertEquals(NetworkType.CONNECTED, any.requiredNetworkType)
assertFalse(any.requiresCharging())
}
}
class MigrationVersionsTest {
@Test fun v3() {
assertEquals(2, MIGRATION_2_3.startVersion)
assertEquals(3, MIGRATION_2_3.endVersion)
}
}

View file

@ -0,0 +1,79 @@
package com.shonar.ui.provider
import com.shonar.provider.ProbeResult
import com.shonar.provider.ProviderDescriptor
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* P6a hosted-routing matrix: a Start9/Umbrel URL (or anything reachable)
* routes to whichever protocol answered, TLS blocks everything, and
* anything else is an honest "nothing here".
*/
class HostedRouteTest {
private fun ncCompatible(): ProbeResult = ProbeResult.Compatible(
descriptor = ProviderDescriptor(id = "nextcloud", displayName = "Nextcloud"),
serverName = "Nextcloud",
version = "30",
)
private fun shonarCompatible(): ProbeResult = ProbeResult.Compatible(
descriptor = ProviderDescriptor(id = "custom-shonar", displayName = "Custom SHONAR server"),
serverName = "server",
version = "test",
)
@Test fun nextcloudWinsWhenBothAnswer() {
assertEquals(
HostedRoute.NEXT_CLOUD,
routeHosted(ncCompatible(), shonarCompatible()),
)
}
@Test fun shonarUsedWhenNextcloudDoesNotMatch() {
assertEquals(
HostedRoute.SHONAR,
routeHosted(ProbeResult.Incompatible, shonarCompatible()),
)
}
@Test fun tlsBlocksOnlyWhenNothingMatched() {
val tls = ProbeResult.TlsFailure("AB:CD")
assertEquals(HostedRoute.TLS, routeHosted(tls, ProbeResult.Incompatible))
assertEquals(
HostedRoute.TLS,
routeHosted(ProbeResult.Incompatible, tls),
)
assertEquals(
HostedRoute.TLS,
routeHosted(tls, ProbeResult.NetworkError("timeout")),
)
}
@Test fun compatiblesBeatTls() {
// A match is actionable; TLS only blocks when nothing matched.
assertEquals(
HostedRoute.NEXT_CLOUD,
routeHosted(ncCompatible(), ProbeResult.TlsFailure("AB:CD")),
)
assertEquals(
HostedRoute.SHONAR,
routeHosted(ProbeResult.TlsFailure("AB:CD"), shonarCompatible()),
)
}
@Test fun nothingMatchingIsNone() {
assertEquals(
HostedRoute.NONE,
routeHosted(ProbeResult.Incompatible, ProbeResult.Incompatible),
)
assertEquals(
HostedRoute.NONE,
routeHosted(
ProbeResult.NetworkError("timeout"),
ProbeResult.Incompatible,
),
)
}
}

View file

@ -1,6 +1,7 @@
plugins { plugins {
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.kapt") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
} }

View file

@ -76,3 +76,42 @@ class RecordingListOut(BaseModel):
total: int total: int
limit: int limit: int
offset: int offset: int
class SegmentOut(BaseModel):
start: float
end: float
text: str
speaker: str | None = None
class TranscriptOut(ORMModel):
version: int
language: str | None
provider: str
model: str | None
text: str
segments: list[SegmentOut]
edited_by_user: bool
created_at: datetime
updated_at: datetime
class SummaryOut(ORMModel):
version: int
provider: str
model: str | None
content: dict
edited_by_user: bool
created_at: datetime
updated_at: datetime
class ProcessingJobOut(ORMModel):
job_type: str
status: str
attempt: int
max_attempts: int
error: str | None
started_at: datetime | None
finished_at: datetime | None

View file

@ -2,13 +2,14 @@
from fastapi import APIRouter from fastapi import APIRouter
from shonar.api.v1 import auth, health, recordings, users from shonar.api.v1 import auth, health, provider_info, recordings, users
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router) api_router.include_router(health.router)
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(recordings.router) api_router.include_router(recordings.router)
api_router.include_router(provider_info.router)
# Included as later milestones land: # Included as later milestones land:
# - transcripts, summaries, tags, search, exports, jobs # - tags, search, exports (M9)

View file

@ -0,0 +1,38 @@
"""Public provider handshake.
`GET /api/v1/provider-info` lets SHONAR clients (and platform probes on
Start9/Umbrel) identify this server as SHONAR-compatible and learn its
capabilities BEFORE any credentials are involved. Returns static deployment
metadata only — never user data, never configuration secrets.
"""
from __future__ import annotations
from fastapi import APIRouter
from shonar import __version__
from shonar.core.config import get_settings
router = APIRouter(tags=["provider"])
@router.get("/provider-info")
async def provider_info() -> dict:
"""Static, unauthenticated deployment identity for client probing."""
settings = get_settings()
# Capability flags reflect what this deployment can actually do right now.
transcription = settings.transcription_provider != "none"
llm = settings.llm_provider != "none"
return {
"kind": "shonar",
"version": __version__,
"api_version": "v1",
"capabilities": {
"chunked_upload": True,
"server_transcription": transcription,
"server_summary": llm,
"account_deletion": True,
},
# Storage backend family (never a path): "local" | "s3"
"storage_backend": settings.storage_backend,
}

View file

@ -14,10 +14,13 @@ from sqlalchemy import func, select
from shonar.api.deps import CurrentUser, SessionDep from shonar.api.deps import CurrentUser, SessionDep
from shonar.api.schemas_recordings import ( from shonar.api.schemas_recordings import (
ProcessingJobOut,
RecordingFinalize, RecordingFinalize,
RecordingListOut, RecordingListOut,
RecordingOut, RecordingOut,
RecordingUpdate, RecordingUpdate,
SummaryOut,
TranscriptOut,
UploadSessionCreate, UploadSessionCreate,
UploadSessionOut, UploadSessionOut,
UploadStatusOut, UploadStatusOut,
@ -25,9 +28,12 @@ from shonar.api.schemas_recordings import (
from shonar.db.models import ( from shonar.db.models import (
Asset, Asset,
AssetKind, AssetKind,
ProcessingJob,
Recording, Recording,
RecordingTag, RecordingTag,
Summary,
Tag, Tag,
Transcript,
utcnow, utcnow,
) )
from shonar.services import uploads as up from shonar.services import uploads as up
@ -262,3 +268,72 @@ async def download_audio(recording_id: uuid.UUID, user: CurrentUser, session: Se
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
}, },
) )
# --- AI outputs (M7): latest transcript / summary / job states --------------
async def _owned_recording(
session: SessionDep, user: CurrentUser, recording_id: uuid.UUID
) -> Recording:
rec = await session.get(Recording, recording_id)
if rec is None or rec.user_id != user.id or rec.deleted_at is not None:
raise HTTPException(404, "Recording not found")
return rec
@router.get("/recordings/{recording_id}/transcript", response_model=TranscriptOut)
async def get_transcript(
recording_id: uuid.UUID, user: CurrentUser, session: SessionDep
):
rec = await _owned_recording(session, user, recording_id)
row = await session.scalar(
select(Transcript)
.where(
Transcript.recording_id == rec.id,
Transcript.superseded_at.is_(None),
)
.order_by(Transcript.version.desc())
)
if row is None:
raise HTTPException(404, "No transcript yet")
return TranscriptOut(
version=row.version,
language=row.language,
provider=row.provider,
model=row.model,
text=row.text,
segments=[
s for s in (row.segments or []) if isinstance(s, dict)
],
edited_by_user=row.edited_by_user,
created_at=row.created_at,
updated_at=row.updated_at,
)
@router.get("/recordings/{recording_id}/summary", response_model=SummaryOut)
async def get_summary(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
rec = await _owned_recording(session, user, recording_id)
row = await session.scalar(
select(Summary)
.where(
Summary.recording_id == rec.id,
Summary.superseded_at.is_(None),
)
.order_by(Summary.version.desc())
)
if row is None:
raise HTTPException(404, "No summary yet")
return row
@router.get("/recordings/{recording_id}/jobs", response_model=list[ProcessingJobOut])
async def list_jobs(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
rec = await _owned_recording(session, user, recording_id)
rows = await session.scalars(
select(ProcessingJob)
.where(ProcessingJob.recording_id == rec.id)
.order_by(ProcessingJob.id)
)
return list(rows)

View file

@ -46,3 +46,9 @@ async def get_session() -> AsyncIterator[AsyncSession]:
except Exception: except Exception:
await session.rollback() await session.rollback()
raise raise
def session_factory() -> async_sessionmaker[AsyncSession]:
"""Shareable session factory for the worker (outside requests)."""
assert _session_factory is not None, "engine not initialised"
return _session_factory

View file

@ -0,0 +1,159 @@
"""AI provider interfaces (M7).
Two independent axes, both optional and both configured only through
environment variables (never hard-coded keys):
- transcription: none | whisper_http | faster_whisper
- LLM (summary/action items): none | openai_compat | ollama
"none" is a first-class choice: recording, sync, playback, and manual
transcripts work with no AI configured at all. The pipeline treats a
missing provider as "skip this stage", never as an error.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from shonar.core.config import Settings
class AIError(Exception):
"""Base for AI failures. Messages must be user-safe: they surface in
``processing_error`` and therefore on screens."""
class ProviderConfigError(AIError):
"""Persistent misconfiguration (bad credentials, unknown model, missing
dependency). Fails the job immediately — retrying cannot help."""
class ProviderTransientError(AIError):
"""May succeed on retry (timeouts, 429/5xx). The worker requeues these
up to the job's max attempts."""
@dataclass(frozen=True)
class Segment:
start: float
end: float
text: str
speaker: str | None = None
@dataclass(frozen=True)
class TranscriptResult:
text: str
language: str | None
segments: list[Segment] = field(default_factory=list)
model: str = ""
class TranscriptionProvider(Protocol):
name: str
async def transcribe(
self,
audio: bytes,
mime: str,
*,
language_hint: str | None = None,
) -> TranscriptResult: ...
SUMMARY_KEYS = ("short", "detailed", "key_points", "decisions", "action_items", "questions")
@dataclass(frozen=True)
class SummaryResult:
short: str = ""
detailed: str = ""
key_points: tuple[str, ...] = ()
decisions: tuple[str, ...] = ()
action_items: tuple[str, ...] = ()
questions: tuple[str, ...] = ()
model: str = ""
def to_dict(self) -> dict:
return {
"short": self.short,
"detailed": self.detailed,
"key_points": list(self.key_points),
"decisions": list(self.decisions),
"action_items": list(self.action_items),
"questions": list(self.questions),
}
@classmethod
def from_dict(cls, raw: dict, model: str = "") -> SummaryResult:
def text(key: str) -> str:
v = raw.get(key)
return v if isinstance(v, str) else ""
def strs(key: str) -> tuple[str, ...]:
v = raw.get(key)
if not isinstance(v, list):
return ()
return tuple(s for s in v if isinstance(s, str) and s.strip())
return cls(
short=text("short"),
detailed=text("detailed"),
key_points=strs("key_points"),
decisions=strs("decisions"),
action_items=strs("action_items"),
questions=strs("questions"),
model=model,
)
class LlmProvider(Protocol):
name: str
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: ...
def get_transcription_provider(settings: Settings) -> TranscriptionProvider | None:
"""None means "transcription stage skipped", never an error."""
kind = settings.transcription_provider.strip().lower()
if kind in ("", "none"):
return None
if kind == "whisper_http":
from shonar.services.ai.whisper_http import WhisperHttpProvider
return WhisperHttpProvider(
base_url=settings.transcription_base_url,
model=settings.transcription_model,
api_key=settings.transcription_api_key,
)
if kind == "faster_whisper":
from shonar.services.ai.faster_whisper import FasterWhisperProvider
return FasterWhisperProvider(model=settings.transcription_model)
raise ProviderConfigError(
f"Unknown transcription provider: {settings.transcription_provider!r}"
)
def get_llm_provider(settings: Settings) -> LlmProvider | None:
"""None means "summary stage skipped", never an error."""
kind = settings.llm_provider.strip().lower()
if kind in ("", "none"):
return None
if kind == "openai_compat":
from shonar.services.ai.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
base_url=settings.llm_base_url,
model=settings.llm_model,
api_key=settings.llm_api_key,
)
if kind == "ollama":
from shonar.services.ai.ollama import OllamaProvider
return OllamaProvider(
base_url=settings.llm_base_url,
model=settings.llm_model,
)
raise ProviderConfigError(f"Unknown LLM provider: {settings.llm_provider!r}")

View file

@ -0,0 +1,35 @@
"""Shared summary contract: one system prompt, one JSON shape.
The model replies with JSON only; partial replies are accepted and missing
keys default to empty (a terse-but-valid summary beats a failed job).
Transcript input is truncated to bound context — a way in, not a report.
"""
from __future__ import annotations
from shonar.services.ai import SummaryResult
SYSTEM_PROMPT = (
"You summarize voice recordings for the speaker's own later reference. "
"Reply with JSON only, exactly these keys: "
'{"short": "1-2 sentences", "detailed": "a faithful paragraph", '
'"key_points": [], "decisions": [], "action_items": [], "questions": []}. '
"Empty arrays when absent. Never invent names, dates, or commitments "
"not stated in the transcript."
)
MAX_TRANSCRIPT_CHARS = 12_000
def build_user_message(transcript: str, title: str | None) -> str:
text = transcript[:MAX_TRANSCRIPT_CHARS]
if len(transcript) > MAX_TRANSCRIPT_CHARS:
text += f"\n\n[truncated from {len(transcript)} chars]"
head = f'Title: "{title}"\n\n' if title else ""
return head + "Transcript:\n" + text
def parse_summary(data: object, model: str) -> SummaryResult:
if not isinstance(data, dict):
return SummaryResult(model=model)
return SummaryResult.from_dict(data, model=model)

View file

@ -0,0 +1,66 @@
"""Local transcription via faster-whisper (optional dependency).
Runs fully on this machine: audio never leaves the server for this stage.
The import is lazy so the base install (and every test run) works without
the heavyweight dependency.
"""
from __future__ import annotations
import asyncio
import tempfile
from pathlib import Path
from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult
class FasterWhisperProvider:
name = "faster_whisper"
def __init__(self, model: str = "base") -> None:
try:
from faster_whisper import WhisperModel # noqa: F401
except ImportError as e:
raise ProviderConfigError(
"faster-whisper is not installed (pip install shonar-backend[faster-whisper])."
) from e
self.model = model or "base"
async def transcribe(
self,
audio: bytes,
mime: str,
*,
language_hint: str | None = None,
) -> TranscriptResult:
# faster-whisper is blocking CPU work: keep it off the event loop.
return await asyncio.to_thread(self._run, audio, language_hint)
def _run(self, audio: bytes, language_hint: str | None) -> TranscriptResult:
from faster_whisper import WhisperModel
path: Path | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f:
f.write(audio)
path = Path(f.name)
model = WhisperModel(self.model, device="auto")
segments_iter, info = model.transcribe(
str(path),
beam_size=5,
language=language_hint,
)
segments = [
Segment(start=s.start, end=s.end, text=s.text.strip())
for s in segments_iter
]
text = " ".join(s.text for s in segments).strip()
return TranscriptResult(
text=text,
language=getattr(info, "language", None),
segments=segments,
model=self.model,
)
finally:
if path is not None:
path.unlink(missing_ok=True)

View file

@ -0,0 +1,72 @@
"""Summaries via a local Ollama server (`/api/chat`, JSON mode)."""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult
from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary
class OllamaProvider:
name = "ollama"
def __init__(
self,
base_url: str,
model: str = "",
timeout_s: float = 300.0,
http_client: httpx.AsyncClient | None = None,
) -> None:
if not base_url.strip():
raise ProviderConfigError("ollama needs SHONAR_LLM_BASE_URL.")
if not model.strip():
raise ProviderConfigError("ollama needs SHONAR_LLM_MODEL.")
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout_s = timeout_s
self.http_client = http_client
@asynccontextmanager
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
if self.http_client is not None:
yield self.http_client
else:
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
yield client
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult:
payload = {
"model": self.model,
"stream": False,
"format": "json",
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_message(transcript, title)},
],
}
try:
async with self._client() as client:
resp = await client.post(f"{self.base_url}/api/chat", json=payload)
except (httpx.TimeoutException, httpx.TransportError) as e:
raise ProviderTransientError(f"Ollama unreachable: {type(e).__name__}") from e
if resp.status_code == 404:
# Missing model and missing route both 404 here; both are
# configuration, not weather.
raise ProviderConfigError("Ollama has no such model or route (HTTP 404).")
if resp.status_code != 200:
raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).")
try:
content = resp.json()["message"]["content"]
except (ValueError, KeyError, TypeError) as e:
raise ProviderTransientError("Ollama sent an unreadable reply.") from e
import json as _json
try:
data = _json.loads(content)
except ValueError as e:
raise ProviderTransientError("Ollama reply was not JSON.") from e
return parse_summary(data, self.model)

View file

@ -0,0 +1,82 @@
"""Summaries via any OpenAI-compatible chat endpoint (self-hosted
vLLM/llama.cpp server, commercial API, …) with JSON mode.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult
from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary
class OpenAICompatProvider:
name = "openai_compat"
def __init__(
self,
base_url: str,
model: str = "",
api_key: str = "",
timeout_s: float = 180.0,
http_client: httpx.AsyncClient | None = None,
) -> None:
if not base_url.strip():
raise ProviderConfigError("openai_compat needs SHONAR_LLM_BASE_URL.")
if not model.strip():
raise ProviderConfigError("openai_compat needs SHONAR_LLM_MODEL.")
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout_s = timeout_s
self.http_client = http_client
@asynccontextmanager
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
if self.http_client is not None:
yield self.http_client
else:
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
yield client
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult:
headers = (
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
)
payload = {
"model": self.model,
"temperature": 0.2,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_message(transcript, title)},
],
}
try:
async with self._client() as client:
resp = await client.post(
f"{self.base_url}/v1/chat/completions", headers=headers, json=payload
)
except (httpx.TimeoutException, httpx.TransportError) as e:
raise ProviderTransientError(f"LLM unreachable: {type(e).__name__}") from e
if resp.status_code in (401, 403, 404):
raise ProviderConfigError(f"LLM refused the request (HTTP {resp.status_code}).")
if resp.status_code == 429 or resp.status_code >= 500:
raise ProviderTransientError(f"LLM busy (HTTP {resp.status_code}).")
if resp.status_code != 200:
raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).")
try:
body = resp.json()
content = body["choices"][0]["message"]["content"]
except (ValueError, KeyError, IndexError, TypeError) as e:
raise ProviderTransientError("LLM sent an unreadable reply.") from e
import json as _json
try:
data = _json.loads(content)
except ValueError as e:
raise ProviderTransientError("LLM reply was not JSON.") from e
return parse_summary(data, self.model)

View file

@ -0,0 +1,127 @@
"""Transcription via any OpenAI-compatible `/v1/audio/transcriptions`
endpoint (self-hosted whisper.cpp server, commercial Whisper API, …).
Sends `verbose_json` so segment timings come back with the text.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
from shonar.services.ai import (
ProviderConfigError,
ProviderTransientError,
Segment,
TranscriptResult,
)
class WhisperHttpProvider:
name = "whisper_http"
def __init__(
self,
base_url: str,
model: str = "base",
api_key: str = "",
timeout_s: float = 300.0,
http_client: httpx.AsyncClient | None = None,
) -> None:
if not base_url.strip():
raise ProviderConfigError(
"whisper_http needs SHONAR_TRANSCRIPTION_BASE_URL."
)
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout_s = timeout_s
self.http_client = http_client
@asynccontextmanager
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
if self.http_client is not None:
yield self.http_client
else:
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
yield client
async def transcribe(
self,
audio: bytes,
mime: str,
*,
language_hint: str | None = None,
) -> TranscriptResult:
headers = (
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
)
data: dict[str, str] = {"model": self.model, "response_format": "verbose_json"}
if language_hint:
data["language"] = language_hint
files = {"file": (f"audio.{_ext(mime)}", audio, mime or "application/octet-stream")}
try:
async with self._client() as client:
resp = await client.post(
f"{self.base_url}/v1/audio/transcriptions",
headers=headers,
data=data,
files=files,
)
except (httpx.TimeoutException, httpx.TransportError) as e:
raise ProviderTransientError(
f"Transcription service unreachable: {type(e).__name__}"
) from e
if resp.status_code in (401, 403, 404):
raise ProviderConfigError(
f"Transcription service refused the request (HTTP {resp.status_code})."
)
if resp.status_code == 429 or resp.status_code >= 500:
raise ProviderTransientError(
f"Transcription service busy (HTTP {resp.status_code})."
)
if resp.status_code != 200:
raise ProviderTransientError(
f"Transcription failed (HTTP {resp.status_code})."
)
try:
body = resp.json()
except ValueError as e:
raise ProviderTransientError("Transcription service sent no JSON.") from e
segments = []
raw_segs = body.get("segments")
if isinstance(raw_segs, list):
for s in raw_segs:
if not isinstance(s, dict):
continue
try:
segments.append(
Segment(
start=float(s.get("start", 0.0)),
end=float(s.get("end", 0.0)),
text=str(s.get("text", "")),
)
)
except (TypeError, ValueError):
continue
text = body.get("text")
return TranscriptResult(
text=text if isinstance(text, str) else "",
language=body.get("language") if isinstance(body.get("language"), str) else None,
segments=segments,
model=self.model,
)
def _ext(mime: str) -> str:
return {
"audio/mp4": "m4a",
"audio/m4a": "m4a",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/ogg": "ogg",
"audio/opus": "ogg",
"audio/webm": "webm",
"audio/mpeg": "mp3",
}.get(mime.lower().split(";")[0].strip(), "bin")

View file

@ -0,0 +1,461 @@
"""AI processing pipeline (M7): uploaded -> transcribed -> summarized.
State lives in the database (ProcessingJob rows); arq/redis is transport
only. That ordering is deliberate: if redis is down, uploads still succeed
(the jobs sit queued) and the worker sweep picks them up. The only race is
a task running before the API transaction commits (recording invisible) —
missing rows are transient failures, so arq retry absorbs it.
Entry points:
- ``enqueue_for_recording``: called from upload finalize AND re-callable
later (manual transcripts in M8 re-enter here). Idempotent.
- ``run_transcribe`` / ``run_summarize``: arq task bodies. ``ctx`` is an
arq context in production and a plain dict in tests; only
``ctx.get("job_try", 1)`` is read.
"""
from __future__ import annotations
import logging
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from shonar.core.config import get_settings
from shonar.db.models import (
Asset,
AssetKind,
JobStatus,
JobType,
ProcessingJob,
ProcessingStatus,
Recording,
Summary,
Transcript,
utcnow,
)
from shonar.services.ai import (
AIError,
ProviderTransientError,
get_llm_provider,
get_transcription_provider,
)
from shonar.storage import get_storage
logger = logging.getLogger("shonar.processing")
MAX_TRIES = 3
# --- entry -----------------------------------------------------------------
async def enqueue_for_recording(session: AsyncSession, rec: Recording) -> list[JobType]:
"""Queue whatever AI stages apply. Safe to call repeatedly: completed
work is never redone, failed work is reset for another attempt."""
if rec.deleted_at is not None:
return []
settings = get_settings()
tprov = get_transcription_provider(settings)
lprov = get_llm_provider(settings)
queued: list[JobType] = []
if tprov is not None and await reset_or_create(session, rec, JobType.transcribe):
queued.append(JobType.transcribe)
if (
lprov is not None
and await latest_transcript_text(session, rec.id) is not None
and await reset_or_create(session, rec, JobType.summarize)
):
queued.append(JobType.summarize)
if tprov is None and lprov is None:
if rec.processing_status != ProcessingStatus.ai_disabled:
rec.processing_status = ProcessingStatus.ai_disabled
rec.processing_error = None
elif queued and rec.processing_status not in (
ProcessingStatus.processing,
ProcessingStatus.completed,
):
rec.processing_status = ProcessingStatus.queued
rec.processing_error = None
await session.flush()
for jt in queued:
await transport_enqueue(jt, rec.id)
return queued
async def reset_or_create(
session: AsyncSession, rec: Recording, job_type: JobType
) -> bool:
"""Ensure a queued job row. Returns True when (re)queued now: new rows,
plus failed/skipped rows (a re-upload or re-entry deserves another
attempt). Queued/running/succeeded rows are left alone."""
existing = await session.scalar(
select(ProcessingJob)
.where(
ProcessingJob.recording_id == rec.id,
ProcessingJob.job_type == job_type,
)
.order_by(ProcessingJob.id.desc())
)
if existing is None:
session.add(ProcessingJob(recording_id=rec.id, job_type=job_type))
return True
if existing.status in (JobStatus.queued, JobStatus.running, JobStatus.succeeded):
return False
existing.status = JobStatus.queued
existing.attempt = 0
existing.error = None
existing.started_at = None
existing.finished_at = None
return True
async def latest_transcript_text(
session: AsyncSession, recording_id: uuid.UUID
) -> str | None:
"""Newest non-superseded transcript; user-edited rows win over newer
machine rows (an edit is a verdict, not a draft)."""
rows = (
await session.scalars(
select(Transcript)
.where(
Transcript.recording_id == recording_id,
Transcript.superseded_at.is_(None),
)
.order_by(Transcript.version.desc())
)
).all()
if not rows:
return None
for r in rows:
if r.edited_by_user and r.text.strip():
return r.text
text = rows[0].text
return text if text.strip() else None
async def transport_enqueue(job_type: JobType, recording_id: uuid.UUID) -> None:
"""Best-effort arq trigger. Failure only logs: the DB rows are the real
queue and the worker sweep picks up anything the transport missed."""
from arq import create_pool
from arq.connections import RedisSettings
try:
pool = await create_pool(RedisSettings.from_dsn(get_settings().redis_url))
try:
await pool.enqueue_job("run_" + job_type.value, str(recording_id))
finally:
await pool.aclose()
except Exception as e: # noqa: BLE001 — transport must never break uploads
logger.warning("arq enqueue failed (%s); worker sweep will pick it up", e)
# --- tasks -----------------------------------------------------------------
def _job_try(ctx: dict) -> int:
try:
return int(ctx.get("job_try", 1))
except (TypeError, ValueError):
return 1
async def _load(session: AsyncSession, recording_id: str) -> Recording | None:
try:
rid = uuid.UUID(recording_id)
except ValueError:
return None
return await session.get(Recording, rid)
async def _job(
session: AsyncSession, rec: Recording, job_type: JobType
) -> ProcessingJob:
job = await session.scalar(
select(ProcessingJob)
.where(
ProcessingJob.recording_id == rec.id,
ProcessingJob.job_type == job_type,
)
.order_by(ProcessingJob.id.desc())
)
if job is None:
job = ProcessingJob(recording_id=rec.id, job_type=job_type)
session.add(job)
await session.flush()
return job
async def _fail(
session: AsyncSession,
rec: Recording,
job: ProcessingJob,
message: str,
ctx: dict,
exc: AIError | None = None,
) -> None:
"""Config errors fail now; transient errors fail only on the last try
(returning normally), otherwise they raise for arq retry."""
transient = exc is None or isinstance(exc, ProviderTransientError)
if transient and _job_try(ctx) < MAX_TRIES:
job.attempt = _job_try(ctx)
await session.flush()
raise ProviderTransientError(message)
job.status = JobStatus.failed
job.error = message
job.finished_at = utcnow()
rec.processing_status = ProcessingStatus.failed
rec.processing_error = message
await session.flush()
async def run_transcribe(ctx: dict, recording_id: str) -> None:
"""Transcribe the original audio; chain into summarization when an LLM
is configured."""
from shonar.db.session import session_factory
settings = get_settings()
async with session_factory()() as session:
rec = await _load(session, recording_id)
if rec is None or rec.deleted_at is not None:
# Finalize race: the API transaction may not have committed yet.
raise ProviderTransientError("Recording not ready; retrying.")
job = await _job(session, rec, JobType.transcribe)
provider = get_transcription_provider(settings)
if provider is None:
job.status = JobStatus.skipped
await session.flush()
await session.commit()
await _maybe_chain_summarize(session, rec)
return
original = await session.scalar(
select(Asset).where(
Asset.recording_id == rec.id, Asset.kind == AssetKind.original
)
)
if original is None:
raise ProviderTransientError("Audio not ready; retrying.")
job.status = JobStatus.running
job.attempt = _job_try(ctx)
job.started_at = utcnow()
rec.processing_status = ProcessingStatus.processing
rec.processing_error = None
await session.flush()
try:
audio = await get_storage().get(original.storage_key)
result = await provider.transcribe(audio, original.mime_type)
except AIError as e:
await _fail(session, rec, job, str(e), ctx, e)
await session.commit()
return
await store_transcript(
session, rec, result.text, result.segments, result.language,
provider.name, getattr(result, "model", ""),
)
job.status = JobStatus.succeeded
job.finished_at = utcnow()
await session.flush()
await _maybe_chain_summarize(session, rec)
await session.commit()
async def _maybe_chain_summarize(session: AsyncSession, rec: Recording) -> None:
"""After transcription (or a skip): summarize when possible, else finish."""
settings = get_settings()
if get_llm_provider(settings) is None:
if rec.processing_status != ProcessingStatus.completed:
rec.processing_status = ProcessingStatus.completed
rec.processing_error = None
await session.flush()
return
if await latest_transcript_text(session, rec.id) is None:
# LLM configured but nothing to summarize (e.g. empty transcript).
existing = await session.scalar(
select(ProcessingJob).where(
ProcessingJob.recording_id == rec.id,
ProcessingJob.job_type == JobType.summarize,
)
)
if existing is not None and existing.status == JobStatus.queued:
existing.status = JobStatus.skipped
if rec.processing_status != ProcessingStatus.completed:
rec.processing_status = ProcessingStatus.completed
await session.flush()
return
if await reset_or_create(session, rec, JobType.summarize):
await transport_enqueue(JobType.summarize, rec.id)
# Status stays `processing` until the summarize task lands.
async def run_summarize(ctx: dict, recording_id: str) -> None:
"""Summarize the latest transcript into the structured summary shape."""
from shonar.db.session import session_factory
settings = get_settings()
async with session_factory()() as session:
rec = await _load(session, recording_id)
if rec is None or rec.deleted_at is not None:
raise ProviderTransientError("Recording not ready; retrying.")
job = await _job(session, rec, JobType.summarize)
provider = get_llm_provider(settings)
if provider is None:
job.status = JobStatus.skipped
await session.flush()
await session.commit()
return
text = await latest_transcript_text(session, rec.id)
if text is None:
job.status = JobStatus.skipped
await session.flush()
if rec.processing_status != ProcessingStatus.completed:
rec.processing_status = ProcessingStatus.completed
await session.commit()
return
job.status = JobStatus.running
job.attempt = _job_try(ctx)
job.started_at = utcnow()
rec.processing_status = ProcessingStatus.processing
await session.flush()
try:
result = await provider.summarize(text, title=rec.title)
except AIError as e:
await _fail(session, rec, job, str(e), ctx, e)
await session.commit()
return
await store_summary(session, rec, result.to_dict(), provider.name, result.model)
job.status = JobStatus.succeeded
job.finished_at = utcnow()
rec.processing_status = ProcessingStatus.completed
rec.processing_error = None
await session.flush()
await session.commit()
async def store_transcript(
session: AsyncSession,
rec: Recording,
text: str,
segments: list,
language: str | None,
provider_name: str,
model: str,
) -> None:
"""Insert a new auto version; a newest user-edited row wins instead and
nothing is inserted (edits are verdicts)."""
existing = (
await session.scalars(
select(Transcript)
.where(
Transcript.recording_id == rec.id,
Transcript.superseded_at.is_(None),
)
.order_by(Transcript.version.desc())
)
).all()
if existing and existing[0].edited_by_user:
return
now = utcnow()
max_version = await session.scalar(
select(func.max(Transcript.version)).where(Transcript.recording_id == rec.id)
)
for row in existing:
row.superseded_at = now
session.add(
Transcript(
recording_id=rec.id,
version=(max_version or 0) + 1,
language=language,
provider=provider_name,
model=model,
text=text,
segments=[
{"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker}
for s in segments
],
edited_by_user=False,
)
)
await session.flush()
async def store_summary(
session: AsyncSession,
rec: Recording,
content: dict,
provider_name: str,
model: str,
) -> None:
existing = (
await session.scalars(
select(Summary)
.where(
Summary.recording_id == rec.id,
Summary.superseded_at.is_(None),
)
.order_by(Summary.version.desc())
)
).all()
if existing and existing[0].edited_by_user:
return
now = utcnow()
max_version = await session.scalar(
select(func.max(Summary.version)).where(Summary.recording_id == rec.id)
)
for row in existing:
row.superseded_at = now
session.add(
Summary(
recording_id=rec.id,
version=(max_version or 0) + 1,
provider=provider_name,
model=model,
content=content,
edited_by_user=False,
)
)
await session.flush()
async def sweep_stale(limit: int = 100) -> int:
"""Crash recovery + transport-loss backstop: requeue jobs stuck running
or sitting queued, newest catastrophe first. Returns jobs re-enqueued."""
from shonar.db.session import session_factory
count = 0
async with session_factory()() as session:
rows = (
await session.scalars(
select(ProcessingJob)
.where(ProcessingJob.status.in_([JobStatus.queued, JobStatus.running]))
.order_by(ProcessingJob.id.desc())
.limit(limit)
)
).all()
for job in rows:
rec = await session.get(Recording, job.recording_id)
if rec is None or rec.deleted_at is not None:
job.status = JobStatus.skipped
continue
if job.status == JobStatus.running and job.attempt >= MAX_TRIES:
job.status = JobStatus.failed
job.error = "Worker died too many times."
job.finished_at = utcnow()
continue
job.status = JobStatus.queued
job.error = None
count += 1
await session.commit()
# Transport outside the transaction: rows are the queue, this just pokes.
async with session_factory()() as session:
rows = (
await session.scalars(
select(ProcessingJob)
.where(ProcessingJob.status == JobStatus.queued)
.order_by(ProcessingJob.id.desc())
.limit(limit)
)
).all()
for job in rows:
await transport_enqueue(job.job_type, job.recording_id)
return count

View file

@ -231,6 +231,11 @@ async def finalize(
us.completed_asset_id = existing_original.id us.completed_asset_id = existing_original.id
recording.title = us.title or recording.title recording.title = us.title or recording.title
await session.flush() await session.flush()
from shonar.services import processing as _processing
# Same audio, maybe new metadata — and a failed pipeline deserves
# another attempt. Idempotent: completed work is never redone.
await _processing.enqueue_for_recording(session, recording)
return us, recording, existing_original return us, recording, existing_original
# Assemble into the final object (streamed per chunk to bound memory). # Assemble into the final object (streamed per chunk to bound memory).
@ -289,6 +294,11 @@ async def finalize(
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await storage.delete(c.storage_key) await storage.delete(c.storage_key)
await session.flush() await session.flush()
from shonar.services import processing as _processing
# New audio on disk: queue whatever AI stages apply (none configured =
# ai_disabled, never an error).
await _processing.enqueue_for_recording(session, recording)
return us, recording, asset return us, recording, asset

62
backend/shonar/worker.py Normal file
View file

@ -0,0 +1,62 @@
"""arq worker entrypoint (M7): runs the AI pipeline tasks.
Run: arq shonar.worker.WorkerSettings
"""
from __future__ import annotations
import logging
from arq import cron
from arq.connections import RedisSettings
from shonar.core.config import get_settings
from shonar.db.session import dispose_engine, get_engine
from shonar.services import processing
logger = logging.getLogger("shonar.worker")
async def run_transcribe(ctx: dict, recording_id: str) -> None:
await processing.run_transcribe(ctx, recording_id)
async def run_summarize(ctx: dict, recording_id: str) -> None:
await processing.run_summarize(ctx, recording_id)
async def sweep(ctx: dict) -> None: # noqa: ARG001 — arq cron signature
count = await processing.sweep_stale()
if count:
logger.info("sweep re-enqueued %d stale jobs", count)
async def startup(ctx: dict) -> None:
get_engine()
# Crash recovery before accepting new work: jobs stuck `running` and
# queued rows the transport missed go back through arq.
count = await processing.sweep_stale()
if count:
logger.info("startup sweep re-enqueued %d stale jobs", count)
async def shutdown(ctx: dict) -> None: # noqa: ARG001
await dispose_engine()
def _redis() -> RedisSettings:
return RedisSettings.from_dsn(get_settings().redis_url)
class WorkerSettings:
functions = [run_transcribe, run_summarize, sweep]
# Transport-loss backstop beyond the startup sweep: anything still
# queued (missed enqueue, dead worker between runs) goes back through
# arq every 5 minutes. Rows are the queue; this just pokes.
cron_jobs = [cron(sweep, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55})]
on_startup = startup
on_shutdown = shutdown
redis_settings = _redis()
# Retry budget for transient provider failures; the tasks themselves
# mark jobs failed on the last try (see processing.MAX_TRIES).
max_tries = 3

View file

@ -0,0 +1,184 @@
"""Adapter wire-protocol tests (M7): JSON shapes in/out, error mapping.
HTTP adapters take an injectable httpx client; faster-whisper is an
optional dependency and is only exercised when installed.
"""
from __future__ import annotations
import importlib.util
import httpx
import pytest
from shonar.core.config import Settings
from shonar.services import ai
from shonar.services.ai import ProviderConfigError, SummaryResult
from shonar.services.ai.ollama import OllamaProvider
from shonar.services.ai.openai_compat import OpenAICompatProvider
from shonar.services.ai.whisper_http import WhisperHttpProvider
def mock_client(handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
# --- factories -----------------------------------------------------------------
def test_factories_none_means_skip():
s = Settings(transcription_provider="none", llm_provider="none")
assert ai.get_transcription_provider(s) is None
assert ai.get_llm_provider(s) is None
def test_factories_unknown_names_raise_config_error():
s = Settings(transcription_provider="whisper-9k")
with pytest.raises(ProviderConfigError):
ai.get_transcription_provider(s)
s = Settings(llm_provider="clippy")
with pytest.raises(ProviderConfigError):
ai.get_llm_provider(s)
def test_factories_missing_urls_raise_config_error():
s = Settings(transcription_provider="whisper_http", transcription_base_url="")
with pytest.raises(ProviderConfigError):
ai.get_transcription_provider(s)
s = Settings(llm_provider="openai_compat", llm_base_url="http://x", llm_model="")
with pytest.raises(ProviderConfigError):
ai.get_llm_provider(s)
def test_faster_whisper_missing_dep_is_config_error():
if importlib.util.find_spec("faster_whisper") is not None:
pytest.skip("faster-whisper installed; missing-dep path N/A")
# Constructor validates eagerly so misconfiguration fails at startup,
# not on the first recording.
with pytest.raises(ProviderConfigError):
ai.get_transcription_provider(Settings(transcription_provider="faster_whisper"))
# --- whisper_http -----------------------------------------------------------------
def whisper_ok(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/audio/transcriptions"
assert request.method == "POST"
return httpx.Response(200, json={
"text": "hello world",
"language": "en",
"segments": [{"start": 0.0, "end": 1.2, "text": "hello world"}],
})
async def test_whisper_http_happy_path():
p = WhisperHttpProvider("http://stt:8000", model="small",
http_client=mock_client(whisper_ok))
res = await p.transcribe(b"\x00" * 16, "audio/wav")
assert res.text == "hello world"
assert res.language == "en"
assert [(s.start, s.end, s.text) for s in res.segments] == [(0.0, 1.2, "hello world")]
assert res.model == "small"
async def test_whisper_http_401_is_config_error():
async def denied(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "nope"})
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(denied))
with pytest.raises(ProviderConfigError):
await p.transcribe(b"\x00" * 16, "audio/wav")
async def test_whisper_http_503_is_transient():
async def busy(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="overloaded")
from shonar.services.ai import ProviderTransientError
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(busy))
with pytest.raises(ProviderTransientError):
await p.transcribe(b"\x00" * 16, "audio/wav")
# --- openai_compat ------------------------------------------------------------------
def chat_ok(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/chat/completions"
body = {
"short": "Standup.",
"detailed": "The team met.",
"key_points": ["a", "b"],
"decisions": ["ship"],
"action_items": [{"not": "a string"}, "call ana"],
"questions": [],
"extra_key": "ignored",
}
import json as _json
return httpx.Response(200, json={"choices": [{"message": {"content": _json.dumps(body)}}]})
async def test_openai_compat_parses_and_sanitizes():
p = OpenAICompatProvider("http://llm:8000", model="qwen",
http_client=mock_client(chat_ok))
res = await p.summarize("a very long meeting transcript", title="Standup")
assert isinstance(res, SummaryResult)
assert res.short == "Standup."
assert res.action_items == ("call ana",) # non-strings dropped
assert res.model == "qwen"
async def test_openai_compat_partial_json_gets_defaults():
import json as _json
async def partial(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"choices": [{"message": {"content": _json.dumps({"short": "Hi."})}}]
})
p = OpenAICompatProvider("http://llm:8000", model="qwen",
http_client=mock_client(partial))
res = await p.summarize("hi")
assert res.short == "Hi."
assert res.detailed == "" and res.key_points == ()
async def test_openai_compat_non_json_is_transient():
async def garbage(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"choices": [{"message": {"content": "Sure! Here it is..."}}]
})
from shonar.services.ai import ProviderTransientError
p = OpenAICompatProvider("http://llm:8000", model="qwen",
http_client=mock_client(garbage))
with pytest.raises(ProviderTransientError):
await p.summarize("hi")
# --- ollama ---------------------------------------------------------------------------
async def test_ollama_happy_path():
import json as _json
async def ok(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/chat"
payload = _json.loads(request.content.decode())
assert payload["format"] == "json" and payload["stream"] is False
return httpx.Response(200, json={
"message": {"content": _json.dumps({"short": "S.", "detailed": "D."})}
})
p = OllamaProvider("http://ollama:11434", model="llama3",
http_client=mock_client(ok))
res = await p.summarize("meeting notes")
assert res.short == "S." and res.model == "llama3"
async def test_ollama_404_is_config_error():
async def missing(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, text="model not found")
p = OllamaProvider("http://ollama:11434", model="nope",
http_client=mock_client(missing))
with pytest.raises(ProviderConfigError):
await p.summarize("hi")

View file

@ -0,0 +1,375 @@
"""AI pipeline tests (M7): status flow, versioning, failure modes, endpoints.
Provider fakes stand in for real STT/LLM services (no network, no model
downloads); adapter wire-protocol tests live in test_ai_adapters.py.
"""
from __future__ import annotations
import struct
import uuid
import pytest
from sqlalchemy import func, select
from shonar.db import session as db_session
from shonar.db.models import (
JobStatus,
JobType,
ProcessingJob,
Transcript,
)
from shonar.services import processing
from shonar.services.ai import (
ProviderConfigError,
ProviderTransientError,
Segment,
SummaryResult,
TranscriptResult,
)
def wav_bytes(payload_len: int = 64) -> bytes:
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
data = data[:payload_len]
header = (
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
+ b"data" + struct.pack("<I", len(data))
)
return header + data
AUTH = {"email": "m7@example.com", "password": "m7-test-passw0rd-123"}
async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]):
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password})
assert r.status_code == 201, r.text
return r.json()["access_token"]
async def upload_recording(client, token, client_id=None, title="M7 standup"):
h = {"Authorization": f"Bearer {token}"}
data = wav_bytes()
r = await client.post(
"/api/v1/uploads",
json={"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
"client_recording_id": client_id, "title": title},
headers=h,
)
assert r.status_code == 201, r.text
sid = r.json()["id"]
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
headers={**h, "content-type": "application/octet-stream"})
assert r.status_code == 201, r.text
r = await client.post(f"/api/v1/uploads/{sid}/finalize",
json={"duration_seconds": 5.0}, headers=h)
assert r.status_code == 201, r.text
return r.json()
class FakeTranscriber:
name = "fake-stt"
def __init__(self, text="hello world from the meeting", fail=None):
self.text = text
self.fail = fail
self.calls = 0
async def transcribe(self, audio, mime, *, language_hint=None):
self.calls += 1
assert len(audio) > 0 and mime == "audio/wav"
if self.fail is not None:
raise self.fail
return TranscriptResult(
text=self.text, language="en",
segments=[Segment(0.0, 1.0, self.text)], model="fake-stt-1",
)
class FakeLlm:
name = "fake-llm"
def __init__(self, fail=None):
self.fail = fail
self.seen = []
async def summarize(self, transcript, *, title=None):
self.seen.append(transcript)
if self.fail is not None:
raise self.fail
return SummaryResult(
short="Standup happened.", detailed="The team met and spoke.",
key_points=("a",), decisions=(), action_items=("ship it",),
questions=(), model="fake-llm-1",
)
_UNSET = object()
def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET):
tprov = FakeTranscriber() if stt is _UNSET else stt
lprov = FakeLlm() if llm is _UNSET else llm
monkeypatch.setattr(processing, "get_transcription_provider", lambda settings: tprov)
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: lprov)
async def jobs_for(recording_id):
async with db_session._session_factory() as s:
rows = (await s.scalars(
select(ProcessingJob).where(ProcessingJob.recording_id == uuid.UUID(recording_id))
)).all()
return {j.job_type: j for j in rows}
# --- no AI configured --------------------------------------------------------
async def test_none_configured_marks_ai_disabled(client):
token = await user_tokens(client)
rec = await upload_recording(client, token)
assert rec["processing_status"] == "ai_disabled"
assert await jobs_for(rec["id"]) == {}
# Status endpoints: nothing there yet, but the recording exists.
h = {"Authorization": f"Bearer {token}"}
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
assert r.status_code == 404
r = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
assert r.status_code == 404
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
assert r.status_code == 200 and r.json() == []
# --- happy path ----------------------------------------------------------------
async def test_full_pipeline_transcribe_then_summarize(client, monkeypatch):
stt, llm = FakeTranscriber(), FakeLlm()
use_fakes(monkeypatch, stt, llm)
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-full-1")
assert rec["processing_status"] == "queued"
jobs = await jobs_for(rec["id"])
assert set(jobs) == {JobType.transcribe}
assert jobs[JobType.transcribe].status == JobStatus.queued
await processing.run_transcribe({}, rec["id"])
jobs = await jobs_for(rec["id"])
assert jobs[JobType.transcribe].status == JobStatus.succeeded
assert set(jobs) == {JobType.transcribe, JobType.summarize}
h = {"Authorization": f"Bearer {token}"}
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
assert r.json()["processing_status"] == "processing"
t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
assert t.status_code == 200, t.text
body = t.json()
assert body["text"] == "hello world from the meeting"
assert body["version"] == 1 and body["provider"] == "fake-stt"
assert body["segments"][0]["text"].startswith("hello")
assert llm.seen == []
await processing.run_summarize({}, rec["id"])
jobs = await jobs_for(rec["id"])
assert jobs[JobType.summarize].status == JobStatus.succeeded
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
assert s.status_code == 200, s.text
content = s.json()["content"]
assert content["short"] == "Standup happened."
assert content["action_items"] == ["ship it"]
assert set(content) == {"short", "detailed", "key_points", "decisions",
"action_items", "questions"}
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
assert r.json()["processing_status"] == "completed"
assert llm.seen == ["hello world from the meeting"]
jobs_rows = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
assert jobs_rows.status_code == 200
assert {j["job_type"] for j in jobs_rows.json()} == {"transcribe", "summarize"}
async def test_enqueue_is_idempotent(client, monkeypatch):
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-idem-1")
await processing.run_transcribe({}, rec["id"])
await processing.run_summarize({}, rec["id"])
async with db_session._session_factory() as s:
from shonar.db.models import Recording
row = await s.get(Recording, uuid.UUID(rec["id"]))
queued = await processing.enqueue_for_recording(s, row)
assert queued == []
n = await s.scalar(
select(func.count(ProcessingJob.id)).where(
ProcessingJob.recording_id == uuid.UUID(rec["id"]))
)
assert n == 2
await s.commit()
# --- failure modes -------------------------------------------------------------
async def test_transient_failure_retries_then_fails(client, monkeypatch):
stt = FakeTranscriber(fail=ProviderTransientError("stt down"))
use_fakes(monkeypatch, stt, FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-fail-1")
# First tries raise for arq retry; the attempt rolls back with the
# transaction, so the row still reads queued (arq tracks the tries).
with pytest.raises(ProviderTransientError):
await processing.run_transcribe({"job_try": 1}, rec["id"])
jobs = await jobs_for(rec["id"])
assert jobs[JobType.transcribe].status == JobStatus.queued
# Last try marks the job (and recording) failed with a safe message.
await processing.run_transcribe({"job_try": 3}, rec["id"])
jobs = await jobs_for(rec["id"])
assert jobs[JobType.transcribe].status == JobStatus.failed
assert jobs[JobType.transcribe].error == "stt down"
h = {"Authorization": f"Bearer {token}"}
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
assert r.json()["processing_status"] == "failed"
assert r.json()["processing_error"] == "stt down"
async def test_config_error_fails_fast(client, monkeypatch):
stt = FakeTranscriber(fail=ProviderConfigError("bad credentials"))
use_fakes(monkeypatch, stt, FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-cfg-1")
await processing.run_transcribe({"job_try": 1}, rec["id"]) # no raise
jobs = await jobs_for(rec["id"])
assert jobs[JobType.transcribe].status == JobStatus.failed
assert stt.calls == 1
# --- versioning -----------------------------------------------------------------
async def test_rerun_supersedes_auto_but_not_user_edits(client, monkeypatch):
use_fakes(monkeypatch, FakeTranscriber("v1 text"), FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-ver-1")
await processing.run_transcribe({}, rec["id"])
rid = uuid.UUID(rec["id"])
async def versions():
async with db_session._session_factory() as s:
rows = (await s.scalars(
select(Transcript).where(Transcript.recording_id == rid)
.order_by(Transcript.version))).all()
return [(r.version, r.text, r.superseded_at is not None, r.edited_by_user)
for r in rows]
assert await versions() == [(1, "v1 text", False, False)]
# Queue another transcription run manually (re-entry path).
async with db_session._session_factory() as s:
from shonar.db.models import Recording
row = await s.get(Recording, rid)
await processing.enqueue_for_recording(s, row)
await s.commit()
use_fakes(monkeypatch, FakeTranscriber("v2 text"), FakeLlm())
await processing.run_transcribe({}, rec["id"])
assert await versions() == [(1, "v1 text", True, False), (2, "v2 text", False, False)]
# A user edit wins: the next auto run inserts nothing.
async with db_session._session_factory() as s:
v2 = await s.scalar(
select(Transcript).where(Transcript.recording_id == rid,
Transcript.version == 2))
v2.edited_by_user = True
v2.text = "user corrected text"
from shonar.db.models import Recording
row = await s.get(Recording, rid)
await processing.enqueue_for_recording(s, row)
await s.commit()
use_fakes(monkeypatch, FakeTranscriber("v3 text"), FakeLlm())
await processing.run_transcribe({}, rec["id"])
got = await versions()
assert len(got) == 2 and got[1][1] == "user corrected text"
# --- llm-only and skips ----------------------------------------------------------
async def test_llm_only_without_transcript_queues_nothing(client, monkeypatch):
use_fakes(monkeypatch, None, FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-llm-1")
assert rec["processing_status"] == "uploaded"
assert await jobs_for(rec["id"]) == {}
async def test_llm_only_with_manual_transcript_summarizes(client, monkeypatch):
use_fakes(monkeypatch, None, FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-llm-2")
rid = uuid.UUID(rec["id"])
async with db_session._session_factory() as s:
from shonar.db.models import Recording
row = await s.get(Recording, rid)
s.add(Transcript(recording_id=rid, version=1, provider="manual",
text="handwritten notes", edited_by_user=True))
await s.flush()
queued = await processing.enqueue_for_recording(s, row)
assert queued == [JobType.summarize]
await s.commit()
await processing.run_summarize({}, rec["id"])
h = {"Authorization": f"Bearer {token}"}
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
assert s.status_code == 200
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
assert r.json()["processing_status"] == "completed"
# --- sweep ------------------------------------------------------------------------
async def test_sweep_requeues_stale_jobs(client, monkeypatch):
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-sweep-1")
rid = uuid.UUID(rec["id"])
async with db_session._session_factory() as s:
from shonar.db.models import Recording
row = await s.get(Recording, rid)
# Simulate a crashed worker + a lost transport, respectively.
await processing.reset_or_create(s, row, JobType.transcribe)
jobs = await jobs_for(rec["id"])
jobs[JobType.transcribe].status = JobStatus.running
await s.commit()
# reset_or_create leaves running rows alone, so force the second shape:
async with db_session._session_factory() as s:
extra = ProcessingJob(recording_id=rid, job_type=JobType.summarize,
status=JobStatus.queued)
s.add(extra)
await s.commit()
count = await processing.sweep_stale()
assert count == 2
jobs = await jobs_for(rec["id"])
assert jobs[JobType.transcribe].status == JobStatus.queued
assert jobs[JobType.summarize].status == JobStatus.queued
# --- ownership ---------------------------------------------------------------------
async def test_ai_endpoints_enforce_ownership(client, monkeypatch):
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m7-own-1")
await processing.run_transcribe({}, rec["id"])
other = await user_tokens(client, email="m7-other@example.com",
password="m7-test-passw0rd-456")
h = {"Authorization": f"Bearer {other}"}
for path in ("transcript", "summary", "jobs"):
r = await client.get(f"/api/v1/recordings/{rec['id']}/{path}", headers=h)
assert r.status_code == 404, path

View file

@ -0,0 +1,35 @@
"""provider-info handshake tests (P2/P3 client probing depends on this)."""
async def test_provider_info_identifies_shonar(client):
r = await client.get("/api/v1/provider-info")
assert r.status_code == 200
body = r.json()
assert body["kind"] == "shonar"
assert body["api_version"] == "v1"
caps = body["capabilities"]
assert caps["chunked_upload"] is True
assert caps["account_deletion"] is True
# test env configures no AI providers -> flags must be false
assert caps["server_transcription"] is False
assert caps["server_summary"] is False
assert body["storage_backend"] in ("local", "s3")
async def test_provider_info_leaks_no_paths_or_secrets(client):
r = await client.get("/api/v1/provider-info")
body = r.json()
# every string value must be a bare identifier, never a filesystem path
def walk(v):
if isinstance(v, str):
assert not v.startswith("/"), f"path-like value in handshake: {v!r}"
low = v.lower()
for banned in ("secret", "token", "password", "key"):
assert banned not in low
elif isinstance(v, dict):
for x in v.values():
walk(x)
elif isinstance(v, list):
for x in v:
walk(x)
walk(body)

View file

@ -72,7 +72,8 @@ async def test_upload_happy_path_creates_recording(client):
rec = r.json() rec = r.json()
assert rec["title"] == "Standup" assert rec["title"] == "Standup"
assert rec["has_audio"] is True assert rec["has_audio"] is True
assert rec["processing_status"] == "uploaded" # M7: with no AI configured the pipeline marks audio-only explicitly.
assert rec["processing_status"] == "ai_disabled"
assert rec["duration_seconds"] == 12.5 assert rec["duration_seconds"] == 12.5
# No storage keys or internals leak. # No storage keys or internals leak.
assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "") assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "")

View file

@ -9,13 +9,13 @@ updated in the same commit as the work it describes.
| M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done | | M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done |
| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done | | M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done |
| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done | | M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done |
| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO | | M3 | Android: server URL config, login, token persistence + auto-refresh | implemented via P3 `CustomShonarProvider` (Keystore token store, transparent refresh, login UI in provider selection); on-device verification pending |
| S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done | | S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done |
| ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** | | ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** |
| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO | | M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | implemented; on-device verification pending |
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO | | M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | done — `SyncWorker` + `SyncScheduler` (15-min periodic, one-shot on startup/constraint change, backoff on retry), Context-free `SyncDrain` engine (drains QUEUED + due ERROR incl. P7 leftovers, exponential backoff 1m–1h, cancel reverts to QUEUED), pause as resting state; full suite 167 green. On-device behaviour pending |
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO | | M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | partial; local list, playback, and delete implemented |
| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | TODO | | M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | done — provider protocols + 4 adapters (faster-whisper lazy optional), versioned transcripts/summaries (user edits win), arq worker (`run_transcribe`/`run_summarize` + startup/5-min sweep, max 3 tries), transcript/summary/jobs endpoints, `none` means skipped; 50 backend tests green, ruff clean |
| M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO | | M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO |
| M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) | | M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) |
| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO | | M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO |

View file

@ -142,6 +142,11 @@ provider API first, local wipe second.
## 7. Start9 / Umbrel / custom adapters ## 7. Start9 / Umbrel / custom adapters
> Status: P6a shipped the generic path — Start9/Umbrel cards take a service
> URL and auto-detect Nextcloud vs SHONAR, handing off to those providers.
> The platform-RPC discovery below, multi-service picking, and Tor onion
> access (Orbot SOCKS) are deferred to P6b.
- **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is - **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is
"platform probe + service binding": a `PlatformProbe` (Start9: Server API "platform probe + service binding": a `PlatformProbe` (Start9: Server API
over its RPC; Umbrel: its app manifest endpoints, where available) over its RPC; Umbrel: its app manifest endpoints, where available)
@ -182,11 +187,12 @@ provider API first, local wipe second.
| P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green | | P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green |
| P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) | | P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) |
| P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device | | P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device |
| P3 | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | backend + app integration | | P3 (done) | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | contract suite (10 shared + 12 custom) + 9 auth tests vs MockWebServer fixtures mirroring `schemas_*.py`; backend pytest 28 green on live Postgres; `assembleDebug` clean |
| P4 | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking | contract suite + a real Nextcloud instance | | P4 (done) | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking — plus `FolderSyncProvider` (sync-folder for Syncthing etc., requested alongside) | contract suite (10 shared + 6 custom) + 6 auth + 4 protocol tests vs MockWebServer fake; DAV/chunking-v2 protocol verified live against Nextcloud 34 (MKCOL/PUT/MOVE-assemble/PROPFIND/OCS/login-v2 via curl); full suite 120 green, `assembleDebug` clean. On-device browser-approval tap-through still pending |
| P5 | TLS TOFU pinning + redaction/logging + leak tests | cert fixtures | | P5 (done) | TLS TOFU pinning + redaction/logging + leak tests | TOFU trust manager (system-first, per-host DER pins in secure store, explicit approval UI with fingerprint + issuer + validity) + redacting logger gated by `log_http_bodies` (transcript/sidecar bodies never log, in any mode) + HeldCertificate TLS fixtures; full suite 138 green, `assembleDebug` clean. Self-signed hosts now connect after one approval; hostname verification stays strict |
| P6 | Platform probes for Start9/Umbrel + service-binding UX | probe fakes | | P6a (done) | Generic hosted setup: Start9/Umbrel cards take a service URL, auto-detect Nextcloud vs SHONAR, hand off to the existing provider flows; the platform is an entry path, the persisted provider is the protocol | routing matrix unit tests (`routeHosted`); full suite + `assembleDebug` |
| P7 | Provider switching w/ migration prompts, account deletion, revocation | e2e | | P6b (deferred) | Platform RPC auto-discovery (Start9 Server API, Umbrel manifests), multi-service picker, Tor onion access via Orbot SOCKS | probe fakes + on-device, when needed |
| P7 (done) | Provider switching w/ migration prompts, account deletion, revocation | Room v2 provider slot per recording (origin + remote ref + sync state, honest migration) + "Where is my data?" screen (summary, reconnect, disconnect+revoke, delete account) + foreground migrate-now uploader (bounded: cancellable, one attempt/file, upload-only) + switch-time prompt (upload / keep / forget links); migration matrix + slots + converter tests, full suite 156 green, `assembleDebug` clean. Background/scheduled sync stays M5; pull-down and old-remote wipe are explicit non-goals (a future wipe is safe: everything lives under `SHONAR/` prefixes) |
Recording engine (M4) and playback (M6) proceed independently on top of the Recording engine (M4) and playback (M6) proceed independently on top of the
same Room model; provider work is orthogonal. same Room model; provider work is orthogonal.

View file

@ -8,8 +8,13 @@ docker compose -f deploy/docker-compose.dev.yml up -d
echo "==> backend deps" echo "==> backend deps"
cd backend cd backend
if command -v uv >/dev/null 2>&1; then
uv venv .venv 2>/dev/null || true uv venv .venv 2>/dev/null || true
uv pip install -e ".[dev]" uv pip install -e ".[dev]"
else
python3 -m venv .venv
.venv/bin/python -m pip install -e ".[dev]"
fi
echo "==> test database" echo "==> test database"
docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \ docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \

View file

@ -120,6 +120,72 @@
"title": "LogoutRequest", "title": "LogoutRequest",
"type": "object" "type": "object"
}, },
"ProcessingJobOut": {
"properties": {
"attempt": {
"title": "Attempt",
"type": "integer"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Error"
},
"finished_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Finished At"
},
"job_type": {
"title": "Job Type",
"type": "string"
},
"max_attempts": {
"title": "Max Attempts",
"type": "integer"
},
"started_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Started At"
},
"status": {
"title": "Status",
"type": "string"
}
},
"required": [
"job_type",
"status",
"attempt",
"max_attempts",
"error",
"started_at",
"finished_at"
],
"title": "ProcessingJobOut",
"type": "object"
},
"RecordingFinalize": { "RecordingFinalize": {
"properties": { "properties": {
"duration_seconds": { "duration_seconds": {
@ -475,6 +541,93 @@
"title": "RegisterRequest", "title": "RegisterRequest",
"type": "object" "type": "object"
}, },
"SegmentOut": {
"properties": {
"end": {
"title": "End",
"type": "number"
},
"speaker": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Speaker"
},
"start": {
"title": "Start",
"type": "number"
},
"text": {
"title": "Text",
"type": "string"
}
},
"required": [
"start",
"end",
"text"
],
"title": "SegmentOut",
"type": "object"
},
"SummaryOut": {
"properties": {
"content": {
"additionalProperties": true,
"title": "Content",
"type": "object"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"edited_by_user": {
"title": "Edited By User",
"type": "boolean"
},
"model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Model"
},
"provider": {
"title": "Provider",
"type": "string"
},
"updated_at": {
"format": "date-time",
"title": "Updated At",
"type": "string"
},
"version": {
"title": "Version",
"type": "integer"
}
},
"required": [
"version",
"provider",
"model",
"content",
"edited_by_user",
"created_at",
"updated_at"
],
"title": "SummaryOut",
"type": "object"
},
"TokenPair": { "TokenPair": {
"properties": { "properties": {
"access_token": { "access_token": {
@ -515,6 +668,78 @@
"title": "TokenPair", "title": "TokenPair",
"type": "object" "type": "object"
}, },
"TranscriptOut": {
"properties": {
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"edited_by_user": {
"title": "Edited By User",
"type": "boolean"
},
"language": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Language"
},
"model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Model"
},
"provider": {
"title": "Provider",
"type": "string"
},
"segments": {
"items": {
"$ref": "#/components/schemas/SegmentOut"
},
"title": "Segments",
"type": "array"
},
"text": {
"title": "Text",
"type": "string"
},
"updated_at": {
"format": "date-time",
"title": "Updated At",
"type": "string"
},
"version": {
"title": "Version",
"type": "integer"
}
},
"required": [
"version",
"language",
"provider",
"model",
"text",
"segments",
"edited_by_user",
"created_at",
"updated_at"
],
"title": "TranscriptOut",
"type": "object"
},
"UploadSessionCreate": { "UploadSessionCreate": {
"properties": { "properties": {
"client_recording_id": { "client_recording_id": {
@ -1093,6 +1318,30 @@
] ]
} }
}, },
"/api/v1/provider-info": {
"get": {
"description": "Static, unauthenticated deployment identity for client probing.",
"operationId": "provider_info_api_v1_provider_info_get",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"title": "Response Provider Info Api V1 Provider Info Get",
"type": "object"
}
}
},
"description": "Successful Response"
}
},
"summary": "Provider Info",
"tags": [
"provider"
]
}
},
"/api/v1/readyz": { "/api/v1/readyz": {
"get": { "get": {
"description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.", "description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.",
@ -1407,6 +1656,157 @@
] ]
} }
}, },
"/api/v1/recordings/{recording_id}/jobs": {
"get": {
"operationId": "list_jobs_api_v1_recordings__recording_id__jobs_get",
"parameters": [
{
"in": "path",
"name": "recording_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Recording Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/ProcessingJobOut"
},
"title": "Response List Jobs Api V1 Recordings Recording Id Jobs Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "List Jobs",
"tags": [
"uploads",
"recordings"
]
}
},
"/api/v1/recordings/{recording_id}/summary": {
"get": {
"operationId": "get_summary_api_v1_recordings__recording_id__summary_get",
"parameters": [
{
"in": "path",
"name": "recording_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Recording Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SummaryOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Get Summary",
"tags": [
"uploads",
"recordings"
]
}
},
"/api/v1/recordings/{recording_id}/transcript": {
"get": {
"operationId": "get_transcript_api_v1_recordings__recording_id__transcript_get",
"parameters": [
{
"in": "path",
"name": "recording_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Recording Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TranscriptOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Get Transcript",
"tags": [
"uploads",
"recordings"
]
}
},
"/api/v1/system/status": { "/api/v1/system/status": {
"get": { "get": {
"description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.", "description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.",

View file

@ -1,6 +1,22 @@
"""Worker entrypoint: runs the arq queue on the backend image. """Worker entrypoint: runs the arq queue on the backend image.
Pipeline tasks land with milestone M7; until then the worker image runs an Run (from `backend/`, venv active):
idle loop so `docker compose up` is complete and future-proof.
TODO(M7): implement sonar/processing/tasks.py and wire arq.WorkerSettings. arq shonar.worker.WorkerSettings
Needs Redis (`SHONAR_REDIS_URL`, wired automatically in compose) and the
same `SHONAR_*` env as the API — the worker reads AI provider config from
the same variables, so transcription/summary behavior matches what
`/api/v1/provider-info` advertises.
What it runs (M7):
- `run_transcribe(recording_id)` — original audio through the configured
transcription provider, stored versioned (user edits win, never touched).
- `run_summarize(recording_id)` — latest transcript through the LLM into
the structured summary shape.
- `sweep` on startup + every 5 min — crash recovery and transport-loss
backstop: stuck `running` and orphaned `queued` rows go back through arq.
With no AI configured the pipeline marks recordings `ai_disabled` and the
worker idles: recording, sync, and playback never need it.
""" """