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:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
748
shared/com/shonar/provider/CustomShonarProvider.kt
Normal file
748
shared/com/shonar/provider/CustomShonarProvider.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue