Remove all Android/phone code: the app is standalone desktop
shared/ carried the vendored phone app's tree behind a 30-entry
exclude list. Deleted the 46 phone-only files (recording service,
widgets, Room DB, Nextcloud/local-only/folder-sync providers, TOFU
trust, Android settings stores, phone UI screens) and kept only the
seven the desktop compiles: CustomShonarProvider, ProviderTypes,
ShonarProvider, ShonarAuthStore, ShonarHandshake, ProviderRegistry,
AiContent. ProviderRegistry reduces to its id constant; handshake
drops the TOFU param; provider defaults self-identify as desktop.
Gradle exclude list gone — srcDir('../shared') is pure desktop now.
This commit is contained in:
parent
751428cf3e
commit
71b55715b4
52 changed files with 24 additions and 9049 deletions
|
|
@ -40,10 +40,10 @@ import okhttp3.Response
|
|||
* 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.
|
||||
* Sidecars: file cache under [sidecarRoot] keyed by remote recording id.
|
||||
* AI content (transcript/summary/jobs) goes through the real M7
|
||||
* endpoints instead — see [fetchTranscript] and friends, consumed by
|
||||
* the details screen.
|
||||
*
|
||||
* Cancellation safety: a cancelled upload leaves an open server session
|
||||
* with some chunks stored — invisible until finalize, resumable via the
|
||||
|
|
@ -56,8 +56,8 @@ class CustomShonarProvider(
|
|||
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",
|
||||
private val deviceName: String = "SHONAR Desktop",
|
||||
private val platform: String = "desktop",
|
||||
) : ShonarProvider {
|
||||
|
||||
override val descriptor = ProviderDescriptor(
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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])"
|
||||
}
|
||||
|
|
@ -1,644 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +1,11 @@
|
|||
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.
|
||||
* Provider ids. The desktop app talks to exactly one service — the local
|
||||
* SHONAR engine — so this is just the id constant its RemoteRefs carry.
|
||||
* (The phone's multi-provider registry — Nextcloud, local folder, sync
|
||||
* folder — was removed; the app is standalone desktop.)
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
object ProviderRegistry {
|
||||
const val CUSTOM_SHONAR_ID = "custom-shonar"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import javax.net.ssl.SSLHandshakeException
|
|||
*/
|
||||
class ShonarHandshake(
|
||||
private val client: OkHttpClient = defaultClient(),
|
||||
private val tofu: TofuManager? = null,
|
||||
) {
|
||||
|
||||
suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -74,12 +73,11 @@ class ShonarHandshake(
|
|||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* SPKI SHA-256 captured during the failed handshake. The desktop talks
|
||||
* to a local engine over plain HTTP, so this is a placeholder kept for
|
||||
* the ProbeResult contract.
|
||||
*/
|
||||
private fun fingerprintFor(host: String): String =
|
||||
tofu?.failureFor(host)?.spkiHex ?: "unknown"
|
||||
private fun fingerprintFor(host: String): String = "unknown"
|
||||
|
||||
companion object {
|
||||
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue