Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar

- shared/ = portable Android-origin sources vendored from deferred/desktop-server
  (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only)
- backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the
  old checkout, PYTHONPATH pins THIS backend's code over any editable install
- repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh
  watches shared/ + backend/
- Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010,
  self-migrates, /healthz ok
This commit is contained in:
avi 2026-09-14 17:14:54 -05:00
commit 76c867fca4
136 changed files with 21099 additions and 0 deletions

View file

@ -0,0 +1,748 @@
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: file cache under [sidecarRoot] keyed by remote recording id
* (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs)
* goes through the real M7 endpoints instead — see [fetchTranscript] and
* friends, consumed by the M8 details screen.
*
* 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(),
/** Identifies this client to the server (login device_name/platform). */
private val deviceName: String = "SHONAR Android",
private val platform: String = "android",
) : 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)
}
}
// ---- AI content (M7 endpoints, consumed by the M8 details screen) ----
//
// The recording id here is the server-side UUID — the local row's
// [com.shonar.recording.RecordingEntity.remoteKey] after upload.
// 404 surfaces as [ProviderError.NotFound] ("no transcript yet" is a
// normal state, not a failure). Raw JSON comes back so parsing stays in
// the testable `recording.AiContent` helpers, not in this IO class.
suspend fun fetchTranscript(recordingId: String): String = getAiJson(
"/api/v1/recordings/$recordingId/transcript", recordingId
)
suspend fun fetchSummary(recordingId: String): String = getAiJson(
"/api/v1/recordings/$recordingId/summary", recordingId
)
suspend fun fetchJobs(recordingId: String): String = getAiJson(
"/api/v1/recordings/$recordingId/jobs", recordingId
)
/** Force one pipeline stage to re-run on the server (no re-upload).
* [model] only applies to job="transcribe" (switches the saved
* per-recording override). 409 = stage already running or nothing to
* summarize; surfaced as ProviderError.Transient with the server's
* detail message. */
suspend fun reprocess(recordingId: String, job: String, model: String? = null): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
val q = if (model != null) "&model=$model" else ""
executeAuthed(base) { token ->
post(base, "/api/v1/recordings/$recordingId/reprocess?job=$job$q", token, "{}")
}.use { resp ->
when (resp.code) {
200, 201 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> {
val body = runCatching {
org.json.JSONObject(resp.body?.string().orEmpty())
.optString("detail")
}.getOrNull()
throw ProviderError.Transient(
body?.takeIf { it.isNotBlank() }
?: "Reprocess failed (HTTP ${resp.code})")
}
}
}
}
/** User edit → new server version with edited_by_user=true (pipeline won't clobber). */
suspend fun updateTranscript(
recordingId: String,
payloadJson: String,
): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/transcript", token, payloadJson) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Transcript update failed (HTTP ${resp.code})")
}
}
}
suspend fun updateSummary(recordingId: String, payloadJson: String): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/summary", token, payloadJson) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Summary update failed (HTTP ${resp.code})")
}
}
}
/** Title/notes rename from the details screen (server + Room updated by caller). */
suspend fun patchRecording(recordingId: String, payloadJson: String): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> patch(base, "/api/v1/recordings/$recordingId", token, payloadJson) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Recording update failed (HTTP ${resp.code})")
}
}
}
private suspend fun getAiJson(path: String, recordingId: String): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> get(base, path, token) }.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Request failed (HTTP ${resp.code})")
}
}
}
// ---- transcription models (Stage 1 endpoints, desktop Settings) --------
// Raw JSON throughout; parsing lives in the testable AiContent helpers.
suspend fun fetchModels(): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> get(base, "/api/v1/models", token) }.use { resp ->
if (resp.code != 200) throw ProviderError.Transient("Models request failed (HTTP ${resp.code})")
resp.body?.string().orEmpty()
}
}
suspend fun setDefaultModel(model: String): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
val payload = """{"model":${jsonStr(model)}}"""
executeAuthed(base) { token -> put(base, "/api/v1/models/default", token, payload) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
422 -> throw ProviderError.InvalidUrl("Unsupported model: $model")
else -> throw ProviderError.Transient("Default model update failed (HTTP ${resp.code})")
}
}
}
/** Fetch a model into the server cache (needs internet once; may take minutes). */
suspend fun downloadModel(model: String): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token ->
post(base, "/api/v1/models/$model/download", token, "{}")
}.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
422 -> throw ProviderError.InvalidUrl("Unsupported model: $model")
501 -> throw ProviderError.Transient("faster-whisper is not installed on the server")
else -> throw ProviderError.Transient("Model download failed (HTTP ${resp.code})")
}
}
}
// ---- 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":${jsonStr(deviceName)},"platform":${jsonStr(platform)}}"""
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 put(base: String, path: String, access: String, json: String): Request =
Request.Builder().url(base + path)
.put(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
.header("Authorization", "Bearer $access").build()
private fun patch(base: String, path: String, access: String, json: String): Request =
Request.Builder().url(base + path)
.patch(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 = buildString {
append("""{"declared_mime_type":${jsonStr(draft.mime)},""")
append(""""declared_size_bytes":${draft.sizeBytes},""")
append(""""title":${jsonStr(draft.title)},""")
append(""""client_recording_id":${jsonStr(draft.id)}""")
if (draft.transcriptionModel != null) {
append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""")
}
append("}")
}
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 = buildString {
append("""{"recorded_at":${jsonStr(recordedAt)},""")
append(""""duration_seconds":${draft.durationMs / 1000.0}""")
if (draft.transcriptionModel != null) {
append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""")
}
append("}")
}
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,194 @@
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
}
/**
* Create the folder, then connect. Safety rules (a typo must never
* spray directories across storage):
* - no `..` segments, never a blank path;
* - the direct parent must already exist, be a directory, and be
* writable — only the final segment is ever created;
* - when the folder already exists this is just [connect].
*/
suspend fun createRoot(rawPath: String) = withContext(Dispatchers.IO) {
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty")
val dir = File(trimmed)
if (".." in dir.path.split(File.separatorChar)) {
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
}
if (!dir.exists()) {
val parent = dir.absoluteFile.parentFile
?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed")
if (!parent.isDirectory) throw ProviderError.InvalidUrl(
"Parent is not a folder: ${parent.path}"
)
if (!parent.canWrite()) throw ProviderError.InvalidUrl(
"Parent folder is not writable: ${parent.path}"
)
if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl(
"Could not create folder: ${dir.path}"
)
}
val checked = checkDir(dir)
pathStore.putString(KEY_ROOT, checked.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 = "${audio.size} recordings — synced by your sync tool, not by this app.",
syncedCount = 0,
localOnlyCount = audio.size,
bytesUsed = audio.sumOf { it.length() },
path = r.path,
)
}
companion object {
const val ID = "sync-folder"
const val KEY_ROOT = "provider.sync-folder.root"
}
}

View file

@ -0,0 +1,249 @@
package com.shonar.provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
/**
* Local-only provider: everything stays in app-private storage. First-class
* so the app has no "no server" special case. No networking code paths at
* all — provable by construction (this file imports no HTTP library).
*
* The storage root is the app-private [defaultRoot], unless the user picked
* their own folder ([connect]/[createRoot] with a [ProviderCredential.FolderPath]):
* then files live there and [deleteAccountAndData] only forgets the path —
* a user folder is never deleted by this app.
*/
class LocalOnlyProvider(
private val defaultRoot: File,
private val pathStore: com.shonar.settings.SettingsStore =
com.shonar.settings.InMemorySettingsStore(),
private val rootKey: String = KEY_ROOT,
) : ShonarProvider {
override val descriptor = ProviderDescriptor(
id = ID,
displayName = "Local-only storage",
capabilities = setOf(), // no chunked protocol needed; no server AI
)
private val _authState = MutableStateFlow(AuthState.CONNECTED)
override val authState: StateFlow<AuthState> = _authState
private suspend fun storedRoot(): File? {
val raw = pathStore.getString(rootKey) ?: return null
return File(raw).takeIf { it.exists() }
}
/** App-private dir by default; the user's own folder once picked. */
private suspend fun effectiveRoot(): File = storedRoot() ?: defaultRoot
/** Where new recordings belong right now. Read by the recording repository. */
suspend fun currentRoot(): File = effectiveRoot()
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}."
)
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 // local-only never talks to servers
override suspend fun connect(credential: ProviderCredential) {
when (credential) {
ProviderCredential.None -> _authState.value = AuthState.CONNECTED
is ProviderCredential.FolderPath -> {
val dir = checkDir(File(credential.path))
pathStore.putString(rootKey, dir.canonicalPath)
_authState.value = AuthState.CONNECTED
}
else -> throw ProviderError.InvalidUrl(
"Local-only storage takes no credentials"
)
}
}
/**
* Use the app-private folder again (forget a previously picked folder;
* its files stay where they are).
*/
suspend fun useDefaultRoot() {
pathStore.remove(rootKey)
_authState.value = AuthState.CONNECTED
}
/**
* Create the folder, then use it. Same safety rules as the sync
* folder: only the final segment is ever created, the parent must
* already exist, no `..`. When it exists this is just [connect].
*/
suspend fun createRoot(rawPath: String) {
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty")
val dir = File(trimmed)
if (".." in dir.path.split(File.separatorChar)) {
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
}
if (!dir.exists()) {
val parent = dir.absoluteFile.parentFile
?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed")
if (!parent.isDirectory) throw ProviderError.InvalidUrl(
"Parent is not a folder: ${parent.path}"
)
if (!parent.canWrite()) throw ProviderError.InvalidUrl(
"Parent folder is not writable: ${parent.path}"
)
if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl(
"Could not create folder: ${dir.path}"
)
}
val checked = checkDir(dir)
pathStore.putString(rootKey, checked.canonicalPath)
_authState.value = AuthState.CONNECTED
}
override suspend fun reconnect(): AuthState {
// A picked folder may have been deleted out from under us; fall
// back to the app-private dir rather than stranding recordings.
_authState.value = AuthState.CONNECTED
return _authState.value
}
override suspend fun disconnect(revokeOnServer: Boolean) {
// Nothing to revoke; DISCONNECTED means "user left local mode" — the
// sync layer treats it as paused, files remain on disk.
_authState.value = AuthState.DISCONNECTED
}
override suspend fun deleteAccountAndData() {
if (storedRoot() != null) {
// A user-picked folder belongs to the user — forget it, never
// delete it.
pathStore.remove(rootKey)
} else if (defaultRoot.exists()) {
defaultRoot.deleteRecursively()
}
_authState.value = AuthState.DISCONNECTED
}
// ---- storage -----------------------------------------------------------
private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key)
private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) =
File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}")
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
val key = "audio/${draft.id}"
val dest = File(effectiveRoot(), key)
dest.parentFile?.mkdirs()
// "Upload" locally = copy; report progress in slices so UI behaves uniformly
draft.sourceFile.inputStream().use { input ->
dest.outputStream().use { output ->
val buf = ByteArray(64 * 1024)
val total = draft.sizeBytes.coerceAtLeast(1)
var written = 0L
while (true) {
val n = input.read(buf)
if (n < 0) break
output.write(buf, 0, n)
written += n
onProgress((written.toFloat() / total).coerceIn(0f, 1f))
}
}
}
return RemoteRef(ID, key, etag = "sha256:" + dest.sha256Hex(), sizeBytes = dest.length())
}
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) {
val src = audioFile(ref)
if (!src.exists()) throw ProviderError.NotFound(ref.key)
src.copyTo(dest, overwrite = true)
onProgress(1f)
}
override suspend fun delete(ref: RemoteRef) {
audioFile(ref).delete()
File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively()
}
override suspend fun list(cursor: String?): Page<RemoteRecording> {
val audioRoot = File(effectiveRoot(), "audio")
val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList()
// single page; no paging for local storage
val items = files.map { f ->
RemoteRecording(
ref = RemoteRef(ID, "audio/${f.name}", etag = "sha256:" + f.sha256Hex(), sizeBytes = f.length()),
title = f.nameWithoutExtension,
createdAtEpochMs = f.lastModified(),
durationMs = 0, // duration is tracked in Room, not on disk
mime = "application/octet-stream",
)
}
return Page(items, nextCursor = null)
}
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) {
val f = sidecarFile(ref, kind)
f.parentFile?.mkdirs()
f.writeBytes(bytes)
}
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? {
val f = sidecarFile(ref, kind)
return if (f.exists()) f.readBytes() else null
}
override suspend fun storageLocationSummary(): StorageLocation {
val raw = pathStore.getString(rootKey)
val custom = storedRoot()
if (raw != null && custom == null) {
return StorageLocation(
headline = "Folder unavailable",
detail = "Your picked folder is gone or unreadable. " +
"Reconnect storage or pick again — new recordings use the app folder meanwhile.",
syncedCount = 0,
localOnlyCount = 0,
bytesUsed = 0,
path = raw,
)
}
val r = custom ?: defaultRoot
val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
return StorageLocation(
headline = if (custom != null) "Your folder" else "On this device only",
detail = if (custom != null) "Recordings stay in your folder and never leave your phone."
else "Recordings and transcripts never leave your phone.",
syncedCount = 0,
localOnlyCount = audio.size,
bytesUsed = audio.sumOf { it.length() },
path = r.absolutePath,
)
}
companion object {
const val ID = "local-only"
const val KEY_ROOT = "provider.local-only.root"
private fun File.sha256Hex(): String {
val md = java.security.MessageDigest.getInstance("SHA-256")
inputStream().use { inn ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = inn.read(buf)
if (n < 0) break
md.update(buf, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it) }
}
}
}

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

@ -0,0 +1,96 @@
package com.shonar.provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Maps provider ids to implementations. The active provider is whichever the
* user selected during setup; switching is just changing this id — the app
* never branches on concrete provider classes.
*/
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)
val activeId: StateFlow<String> = _activeId
/** Nextcloud is the product default once its factory registers (P4). */
val defaultProviderId: String
get() = factories.keys.sorted().let { ids ->
ids.firstOrNull { it == NEXTCLOUD_ID } ?: ids.firstOrNull() ?: LocalOnlyProvider.ID
}
fun provider(id: String): ShonarProvider =
factories[id]?.invoke() ?: throw ProviderError.InvalidUrl("Unknown provider: $id")
val active: ShonarProvider get() = provider(_activeId.value)
/** Selecting a provider does not touch existing local data. */
fun select(id: String): ShonarProvider {
if (id !in factories) throw ProviderError.InvalidUrl("Unknown provider: $id")
_activeId.value = id
return provider(id)
}
fun available(): List<ProviderDescriptor> =
factories.keys.map { provider(it).descriptor }
companion object {
const val NEXTCLOUD_ID = "nextcloud"
const val CUSTOM_SHONAR_ID = "custom-shonar"
const val SYNC_FOLDER_ID = FolderSyncProvider.ID
/** Behaviour-identical default: system trust, logging off, no pins. */
fun defaultTls(sink: (String) -> Unit = {}): TlsPolicy = TlsPolicy(
tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())),
sink = sink,
)
/**
* Production factory map. P1 registered local-only, P3 the custom
* SHONAR server, P4 Nextcloud + the sync folder. Network providers
* are single shared instances (their sessions live in the secure
* store, not in the object) so connect/reconnect state survives
* `provider(id)` calls. P5: every HTTP client comes from [tlsPolicy].
*/
fun withDefaults(
appFilesDir: java.io.File,
secureStore: com.shonar.settings.SettingsStore =
com.shonar.settings.InMemorySettingsStore(),
plainStore: com.shonar.settings.SettingsStore =
com.shonar.settings.InMemorySettingsStore(),
tlsPolicy: TlsPolicy = defaultTls(),
): ProviderRegistry {
val custom = CustomShonarProvider(
auth = ShonarAuthStore(secureStore),
sidecarRoot = java.io.File(appFilesDir, "shonar-sidecars"),
client = tlsPolicy.apiClient(),
handshake = ShonarHandshake(tlsPolicy.probeClient(), tlsPolicy.tofu),
)
val nextcloud = NextcloudProvider(
auth = NextcloudAuthStore(secureStore),
client = tlsPolicy.nextcloudClient(),
loginFlow = NextcloudAuth(tlsPolicy.nextcloudClient(), tlsPolicy.tofu),
)
val folder = FolderSyncProvider(pathStore = plainStore)
return ProviderRegistry(
mapOf(
LocalOnlyProvider.ID to {
LocalOnlyProvider(
java.io.File(appFilesDir, "shonar-local"),
pathStore = plainStore,
)
},
CUSTOM_SHONAR_ID to { custom },
NEXTCLOUD_ID to { nextcloud },
SYNC_FOLDER_ID to { folder },
// P6: start9 / umbrel platform probes
),
tls = tlsPolicy,
)
}
}
}

View file

@ -0,0 +1,220 @@
package com.shonar.provider
import java.io.File
/**
* Shared vocabulary for server providers. Nothing here depends on any
* specific server product; the rest of the app talks to providers only
* through these types plus [ShonarProvider].
*/
/** What kind of provider this is and how the UI should present it. */
data class ProviderDescriptor(
val id: String, // "nextcloud" | "custom-shonar" | "local-only" | ...
val displayName: String, // "Nextcloud"
val isDefault: Boolean = false,
val capabilities: Set<Capability> = emptySet(),
) {
enum class Capability {
CHUNKED_UPLOAD, // resumable large-file upload protocol
SERVER_TRANSCRIPTION, // backend can transcribe (feature-gate AI)
SERVER_SUMMARY,
QUOTA_INFO, // storageLocationSummary can report quota
ACCOUNT_DELETION, // provider supports deleteAccountAndData
}
}
/**
* A validated server URL. Construction only via [parse]; guarantees:
* - scheme https, or http ONLY for private/LAN hosts (RFC1918, loopback, .local)
* - no userinfo (user:pass in URL is rejected)
* - no path traversal ("..")
* - non-blank host
*/
data class ServerUrl(val scheme: String, val host: String, val port: Int, val pathSegments: List<String>) {
val isCleartext: Boolean get() = scheme == "http"
/** Normalized base for API calls, e.g. https://cloud.example.com */
val origin: String
get() = buildString {
append(scheme).append("://").append(host)
val default = if (scheme == "https") 443 else 80
if (port != default) append(":").append(port)
}
companion object {
fun parse(raw: String): Result<ServerUrl> {
val trimmed = raw.trim().removeSuffix("/")
if (trimmed.isBlank()) return Result.failure(ProviderError.InvalidUrl("URL is empty"))
val uri = runCatching { java.net.URI(trimmed) }
.getOrElse { return Result.failure(ProviderError.InvalidUrl("Not a valid URL")) }
val scheme = (uri.scheme ?: "").lowercase()
if (scheme != "https" && scheme != "http")
return Result.failure(ProviderError.InvalidUrl("Only http(s) URLs are allowed"))
if (uri.userInfo != null)
return Result.failure(ProviderError.InvalidUrl("Credentials in URL are not allowed"))
val host = (uri.host ?: "").lowercase()
if (host.isBlank())
return Result.failure(ProviderError.InvalidUrl("Missing host"))
val segs = uri.path.split('/').filter { it.isNotBlank() }
if (segs.any { it == ".." })
return Result.failure(ProviderError.InvalidUrl("Path traversal is not allowed"))
if (scheme == "http" && !isPrivateHost(host))
return Result.failure(
ProviderError.InvalidUrl("Cleartext http:// is only allowed for local/LAN servers; use https:// for $host")
)
val port = if (uri.port > 0) uri.port else if (scheme == "https") 443 else 80
return Result.success(ServerUrl(scheme, host, port, segs))
}
fun isPrivateHost(host: String): Boolean {
if (host == "localhost" || host.endsWith(".local")) return true
val octets = host.split('.').takeIf { it.size == 4 }?.map { it.toIntOrNull() ?: -1 } ?: return false
if (octets.any { it !in 0..255 }) return false
return when {
octets[0] == 10 -> true // 10/8
octets[0] == 127 -> true // loopback
octets[0] == 192 && octets[1] == 168 -> true // 192.168/16
octets[0] == 172 && octets[1] in 16..31 -> true // 172.16/12
octets[0] == 169 && octets[1] == 254 -> true // link-local
else -> false
}
}
}
}
/** Credential material for a provider. NEVER put this in logs or Room. */
sealed class ProviderCredential {
abstract val accountLabel: String // human hint only (e.g. "user@cloud")
/** OAuth2/OIDC access token + refresh token (PKCE flow). */
data class OAuthTokens(
override val accountLabel: String,
val accessToken: String,
val refreshToken: String?,
val expiresAtEpochSec: Long?,
) : ProviderCredential() {
override fun toString(): String = "OAuthTokens(account=$accountLabel, [redacted])"
}
/** Nextcloud login-flow-v2 / manually created app password. */
data class AppPassword(
override val accountLabel: String,
val loginUrl: String,
val user: String,
val password: String,
) : ProviderCredential() {
override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])"
}
/**
* Custom SHONAR server login: email + password exchanged for a rotating
* token pair (backend M1). The password is held in memory only for the
* login call and never persisted — only the resulting tokens are stored
* (see ShonarAuthStore).
*/
data class ShonarLogin(
override val accountLabel: String,
val serverUrl: ServerUrl,
val email: String,
val password: String,
) : ProviderCredential() {
override fun toString(): String =
"ShonarLogin(account=$accountLabel, server=${serverUrl.origin}, [redacted])"
}
/** No credential needed (local-only provider). */
data object None : ProviderCredential() {
override val accountLabel: String get() = "local"
override fun toString(): String = "None"
}
/**
* Sync-folder provider: an absolute directory path owned by an external
* sync tool (Syncthing, the Nextcloud desktop client, rsync…). The path
* is not secret, but it is validated — connect refuses a missing or
* non-writable directory rather than creating whatever was typed.
*/
data class FolderPath(
override val accountLabel: String,
val path: String,
) : ProviderCredential()
}
/** Result of probing a base URL for a compatible service. */
sealed class ProbeResult {
data class Compatible(val descriptor: ProviderDescriptor, val serverName: String, val version: String) : ProbeResult()
/** Reachable, SHONAR-compatible services were found; user must pick one (Start9/Umbrel platforms). */
data class ServicesFound(val services: List<DiscoveredService>) : ProbeResult()
data object Incompatible : ProbeResult()
data class NetworkError(val reason: String) : ProbeResult() // reason must be secret-free
data class TlsFailure(val fingerprintSha256: String) : ProbeResult() // triggers TOFU approval UI
}
/** A SHONAR-compatible service discovered on a platform (Start9/Umbrel). */
data class DiscoveredService(
val platformId: String, // "start9" | "umbrel"
val serviceName: String, // app/service display name
val baseUrl: ServerUrl,
val providerKind: String, // provider to bind once confirmed
)
/** Auth lifecycle shown in UI. Transitions: see docs/server-providers.md §3. */
enum class AuthState { DISCONNECTED, CONNECTED, EXPIRED, REVOKED, OFFLINE }
/** Opaque pointer to a remote object. Providers map keys to their own layout. */
data class RemoteRef(
val providerId: String,
val key: String, // provider-relative key, never a local path
val etag: String?,
val sizeBytes: Long,
)
/** Input to [ShonarProvider.upload]. sourceFile is a local file owned by the app. */
data class RecordingDraft(
val id: String, // public UUID string
val title: String,
val createdAtEpochMs: Long,
val durationMs: Long,
val mime: String, // audio/mp4 | audio/wav | audio/ogg
val sourceFile: File,
val sizeBytes: Long,
/** Optional per-recording transcription model override (null = server default). */
val transcriptionModel: String? = null,
)
enum class SidecarKind(val fileName: String) {
TRANSCRIPT("transcript.json"),
SUMMARY("summary.json"),
ACTION_ITEMS("action-items.json"),
KEYWORDS("keywords.json"),
NOTES("notes.json"),
}
data class Page<T>(val items: List<T>, val nextCursor: String?)
data class RemoteRecording(val ref: RemoteRef, val title: String, val createdAtEpochMs: Long, val durationMs: Long, val mime: String)
/** "Where is my data?" — what the StorageLocation screen renders. */
data class StorageLocation(
val headline: String, // "On this device only" / "Nextcloud at cloud.example.com"
val detail: String, // human-readable path/prefix, quota, etc.
val syncedCount: Int,
val localOnlyCount: Int,
val bytesUsed: Long,
/** Concrete on-device path, when the files live in a real folder. */
val path: String? = null,
)
/** Provider-level errors. Messages must never contain credentials/tokens. */
sealed class ProviderError(message: String) : Exception(message) {
class InvalidUrl(message: String) : ProviderError(message)
class NotConnected : ProviderError("Provider is not connected")
class AuthExpired : ProviderError("Access token expired — re-authentication required")
class Revoked : ProviderError("Access was revoked on the server")
class TlsUntrusted(fingerprint: String) : ProviderError("Server certificate not trusted (SHA-256 $fingerprint)")
class NotFound(key: String) : ProviderError("Remote item not found: $key")
class Conflict(key: String) : ProviderError("Remote item changed since last read: $key")
class QuotaExceeded : ProviderError("Server storage quota exceeded")
class Transient(message: String) : ProviderError(message)
}

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,77 @@
package com.shonar.provider
import java.io.File
/**
* The single contract between SHONAR and any server (or the device itself).
*
* Everything the app needs from "the cloud" goes through this interface;
* UI, sync engine, and database reference providers by descriptor id only.
* LocalOnlyProvider is a first-class implementation, so a missing server is
* never a special case.
*
* Contract rules (enforced by the shared provider contract test suite):
* - all suspends are safe to cancel; partial uploads leave no visible object
* - upload() is idempotent per RecordingDraft.id (re-upload replaces the
* same key, never duplicates)
* - originals are immutable after successful upload until delete()
* - no method ever logs credentials, tokens, audio bytes, or transcripts
* - errors are ProviderError subtypes with secret-free messages
*/
interface ShonarProvider {
val descriptor: ProviderDescriptor
/** Current auth lifecycle; providers push updates here. */
val authState: kotlinx.coroutines.flow.StateFlow<AuthState>
/**
* Is there a SHONAR-compatible service at [baseUrl]? Purely read-only;
* must not require or request credentials. TlsFailure carries the SPKI
* fingerprint so the UI can run explicit trust-on-first-use approval —
* never silently accept.
*/
suspend fun probe(baseUrl: ServerUrl): ProbeResult
/** Validate [credential] against the server, then hand it to the secure store. */
suspend fun connect(credential: ProviderCredential): Unit
/** Re-validate stored credential (app start / after network return). */
suspend fun reconnect(): AuthState
/**
* Disconnect locally; if [revokeOnServer] and the provider supports it,
* revoke the credential server-side first (best effort — local state is
* cleared even if revoke fails, with the failure surfaced).
*/
suspend fun disconnect(revokeOnServer: Boolean)
/** Provider-native account/data deletion, then wipe local state. */
suspend fun deleteAccountAndData()
// ---- storage -----------------------------------------------------------
/**
* Upload the original audio for [draft], reporting [onProgress] 0..1.
* Implementations use chunked/resumable transfers when the capability is
* advertised. Returns the ref for later download/delete/sidecars.
*/
suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef
suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit)
suspend fun delete(ref: RemoteRef)
suspend fun list(cursor: String?): Page<RemoteRecording>
// ---- sidecars (transcript/summary/... JSON, synced independently) ------
suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray)
/** null when the sidecar does not exist remotely. */
suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray?
// ---- status ------------------------------------------------------------
suspend fun storageLocationSummary(): StorageLocation
}

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,190 @@
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.
*
* The log [sink] is an explicit constructor argument (no platform default)
* so this file compiles on Android and JVM desktop alike; each platform
* passes its own sink (Logcat vs stdout).
*/
class TlsPolicy(
val tofu: TofuManager,
private val bodiesEnabled: () -> Boolean = { false },
private val sink: (String) -> Unit,
) {
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()
}
}
}