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