P1: provider module — ShonarProvider contract, ServerUrl validation, LocalOnlyProvider
New package com.shonar.provider (docs/server-providers.md §1-2): - ShonarProvider: single contract between app and any server (probe, connect/reconnect/disconnect w/ server-side revoke, deleteAccountAndData, upload/download/delete, sidecars, storageLocationSummary, authState flow). Documented contract rules: cancel-safe, idempotent per draft id, immutable originals, secret-free errors. - ProviderTypes: ProviderDescriptor+Capability, sealed ProviderCredential (OAuthTokens / AppPassword / None — toString() redacts secrets), ProbeResult incl. TlsFailure(fingerprint) for explicit TOFU flow and ServicesFound for Start9/Umbrel platform probing, AuthState lifecycle, RemoteRef (opaque keys, never local paths), SidecarKind, StorageLocation, sealed ProviderError. - ServerUrl.parse: https everywhere; http only for RFC1918/loopback/.local; rejects userinfo, traversal, non-http schemes, blanks. isPrivateHost with exact range tests (172.16-31 boundary covered). - LocalOnlyProvider: first-class no-network provider (imports no HTTP lib), copy-based upload w/ progress, sidecar dir layout, sha256 etags, account deletion wipes root. - ProviderRegistry: id->factory, active selection never touches local data; Nextcloud wins default once registered (P4). Tests (47 Android unit tests green total, was 19): - ProviderContractTest: shared suite every provider must pass — roundtrip byte-identity, monotonic progress ending 1.0, idempotent re-upload, delete removes audio+sidecars, sidecar overwrite, summary counts, leak-check (credential strings never appear in error messages). - LocalOnlyProviderContractTest runs the contract + local specifics (probe=Incompatible, credentials rejected, deleteAccount wipes root). - ServerUrlTest + ProviderRegistryTest: validation matrix and registry rules. Verified on Pixel 8 Pro: APK installs, app launches, consent dialog renders, 0 crash-log entries.
This commit is contained in:
parent
4eab1f11cf
commit
b4322b0697
7 changed files with 838 additions and 0 deletions
|
|
@ -0,0 +1,142 @@
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
class LocalOnlyProvider(
|
||||||
|
private val root: File,
|
||||||
|
) : 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
|
||||||
|
|
||||||
|
override suspend fun probe(baseUrl: ServerUrl): ProbeResult =
|
||||||
|
ProbeResult.Incompatible // local-only never talks to servers
|
||||||
|
|
||||||
|
override suspend fun connect(credential: ProviderCredential) {
|
||||||
|
if (credential != ProviderCredential.None) throw ProviderError.InvalidUrl(
|
||||||
|
"Local-only storage takes no credentials"
|
||||||
|
)
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reconnect(): AuthState = AuthState.CONNECTED.also { _authState.value = it }
|
||||||
|
|
||||||
|
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 (root.exists()) root.deleteRecursively()
|
||||||
|
_authState.value = AuthState.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- storage -----------------------------------------------------------
|
||||||
|
|
||||||
|
private fun audioFile(ref: RemoteRef) = File(root, ref.key)
|
||||||
|
private fun sidecarFile(ref: RemoteRef, kind: SidecarKind) =
|
||||||
|
File(root, "sidecars/${ref.key}/${kind.fileName}")
|
||||||
|
|
||||||
|
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
|
||||||
|
val key = "audio/${draft.id}"
|
||||||
|
val dest = File(root, 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(root, "sidecars/${ref.key}").deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(cursor: String?): Page<RemoteRecording> {
|
||||||
|
val audioRoot = File(root, "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 audio = File(root, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
|
||||||
|
return StorageLocation(
|
||||||
|
headline = "On this device only",
|
||||||
|
detail = "Recordings and transcripts never leave your phone.",
|
||||||
|
syncedCount = 0,
|
||||||
|
localOnlyCount = audio.size,
|
||||||
|
bytesUsed = audio.sumOf { it.length() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ID = "local-only"
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps provider ids to implementations. The active provider is whichever the
|
||||||
|
* user selected during setup; switching is just changing this id — the app
|
||||||
|
* never branches on concrete provider classes.
|
||||||
|
*/
|
||||||
|
class ProviderRegistry(private val factories: Map<String, () -> ShonarProvider>) {
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
/** Production factory map. P1 registers local-only; later phases add more. */
|
||||||
|
fun withDefaults(appFilesDir: java.io.File): ProviderRegistry = ProviderRegistry(
|
||||||
|
mapOf(
|
||||||
|
LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) },
|
||||||
|
// P3: CUSTOM_SHONAR_ID to { CustomShonarProvider(...) }
|
||||||
|
// P4: NEXTCLOUD_ID to { NextcloudProvider(...) }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
android/app/src/main/java/com/shonar/provider/ProviderTypes.kt
Normal file
189
android/app/src/main/java/com/shonar/provider/ProviderTypes.kt
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared vocabulary for server providers. Nothing here depends on any
|
||||||
|
* specific server product; the rest of the app talks to providers only
|
||||||
|
* through these types plus [ShonarProvider].
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What kind of provider this is and how the UI should present it. */
|
||||||
|
data class ProviderDescriptor(
|
||||||
|
val id: String, // "nextcloud" | "custom-shonar" | "local-only" | ...
|
||||||
|
val displayName: String, // "Nextcloud"
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
val capabilities: Set<Capability> = emptySet(),
|
||||||
|
) {
|
||||||
|
enum class Capability {
|
||||||
|
CHUNKED_UPLOAD, // resumable large-file upload protocol
|
||||||
|
SERVER_TRANSCRIPTION, // backend can transcribe (feature-gate AI)
|
||||||
|
SERVER_SUMMARY,
|
||||||
|
QUOTA_INFO, // storageLocationSummary can report quota
|
||||||
|
ACCOUNT_DELETION, // provider supports deleteAccountAndData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A validated server URL. Construction only via [parse]; guarantees:
|
||||||
|
* - scheme https, or http ONLY for private/LAN hosts (RFC1918, loopback, .local)
|
||||||
|
* - no userinfo (user:pass in URL is rejected)
|
||||||
|
* - no path traversal ("..")
|
||||||
|
* - non-blank host
|
||||||
|
*/
|
||||||
|
data class ServerUrl(val scheme: String, val host: String, val port: Int, val pathSegments: List<String>) {
|
||||||
|
|
||||||
|
val isCleartext: Boolean get() = scheme == "http"
|
||||||
|
|
||||||
|
/** Normalized base for API calls, e.g. https://cloud.example.com */
|
||||||
|
val origin: String
|
||||||
|
get() = buildString {
|
||||||
|
append(scheme).append("://").append(host)
|
||||||
|
val default = if (scheme == "https") 443 else 80
|
||||||
|
if (port != default) append(":").append(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(raw: String): Result<ServerUrl> {
|
||||||
|
val trimmed = raw.trim().removeSuffix("/")
|
||||||
|
if (trimmed.isBlank()) return Result.failure(ProviderError.InvalidUrl("URL is empty"))
|
||||||
|
val uri = runCatching { java.net.URI(trimmed) }
|
||||||
|
.getOrElse { return Result.failure(ProviderError.InvalidUrl("Not a valid URL")) }
|
||||||
|
val scheme = (uri.scheme ?: "").lowercase()
|
||||||
|
if (scheme != "https" && scheme != "http")
|
||||||
|
return Result.failure(ProviderError.InvalidUrl("Only http(s) URLs are allowed"))
|
||||||
|
if (uri.userInfo != null)
|
||||||
|
return Result.failure(ProviderError.InvalidUrl("Credentials in URL are not allowed"))
|
||||||
|
val host = (uri.host ?: "").lowercase()
|
||||||
|
if (host.isBlank())
|
||||||
|
return Result.failure(ProviderError.InvalidUrl("Missing host"))
|
||||||
|
val segs = uri.path.split('/').filter { it.isNotBlank() }
|
||||||
|
if (segs.any { it == ".." })
|
||||||
|
return Result.failure(ProviderError.InvalidUrl("Path traversal is not allowed"))
|
||||||
|
if (scheme == "http" && !isPrivateHost(host))
|
||||||
|
return Result.failure(
|
||||||
|
ProviderError.InvalidUrl("Cleartext http:// is only allowed for local/LAN servers; use https:// for $host")
|
||||||
|
)
|
||||||
|
val port = if (uri.port > 0) uri.port else if (scheme == "https") 443 else 80
|
||||||
|
return Result.success(ServerUrl(scheme, host, port, segs))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPrivateHost(host: String): Boolean {
|
||||||
|
if (host == "localhost" || host.endsWith(".local")) return true
|
||||||
|
val octets = host.split('.').takeIf { it.size == 4 }?.map { it.toIntOrNull() ?: -1 } ?: return false
|
||||||
|
if (octets.any { it !in 0..255 }) return false
|
||||||
|
return when {
|
||||||
|
octets[0] == 10 -> true // 10/8
|
||||||
|
octets[0] == 127 -> true // loopback
|
||||||
|
octets[0] == 192 && octets[1] == 168 -> true // 192.168/16
|
||||||
|
octets[0] == 172 && octets[1] in 16..31 -> true // 172.16/12
|
||||||
|
octets[0] == 169 && octets[1] == 254 -> true // link-local
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credential material for a provider. NEVER put this in logs or Room. */
|
||||||
|
sealed class ProviderCredential {
|
||||||
|
abstract val accountLabel: String // human hint only (e.g. "user@cloud")
|
||||||
|
|
||||||
|
/** OAuth2/OIDC access token + refresh token (PKCE flow). */
|
||||||
|
data class OAuthTokens(
|
||||||
|
override val accountLabel: String,
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val expiresAtEpochSec: Long?,
|
||||||
|
) : ProviderCredential() {
|
||||||
|
override fun toString(): String = "OAuthTokens(account=$accountLabel, [redacted])"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nextcloud login-flow-v2 / manually created app password. */
|
||||||
|
data class AppPassword(
|
||||||
|
override val accountLabel: String,
|
||||||
|
val loginUrl: String,
|
||||||
|
val user: String,
|
||||||
|
val password: String,
|
||||||
|
) : ProviderCredential() {
|
||||||
|
override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No credential needed (local-only provider). */
|
||||||
|
data object None : ProviderCredential() {
|
||||||
|
override val accountLabel: String get() = "local"
|
||||||
|
override fun toString(): String = "None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of probing a base URL for a compatible service. */
|
||||||
|
sealed class ProbeResult {
|
||||||
|
data class Compatible(val descriptor: ProviderDescriptor, val serverName: String, val version: String) : ProbeResult()
|
||||||
|
/** Reachable, SHONAR-compatible services were found; user must pick one (Start9/Umbrel platforms). */
|
||||||
|
data class ServicesFound(val services: List<DiscoveredService>) : ProbeResult()
|
||||||
|
data object Incompatible : ProbeResult()
|
||||||
|
data class NetworkError(val reason: String) : ProbeResult() // reason must be secret-free
|
||||||
|
data class TlsFailure(val fingerprintSha256: String) : ProbeResult() // triggers TOFU approval UI
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A SHONAR-compatible service discovered on a platform (Start9/Umbrel). */
|
||||||
|
data class DiscoveredService(
|
||||||
|
val platformId: String, // "start9" | "umbrel"
|
||||||
|
val serviceName: String, // app/service display name
|
||||||
|
val baseUrl: ServerUrl,
|
||||||
|
val providerKind: String, // provider to bind once confirmed
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Auth lifecycle shown in UI. Transitions: see docs/server-providers.md §3. */
|
||||||
|
enum class AuthState { DISCONNECTED, CONNECTED, EXPIRED, REVOKED, OFFLINE }
|
||||||
|
|
||||||
|
/** Opaque pointer to a remote object. Providers map keys to their own layout. */
|
||||||
|
data class RemoteRef(
|
||||||
|
val providerId: String,
|
||||||
|
val key: String, // provider-relative key, never a local path
|
||||||
|
val etag: String?,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Input to [ShonarProvider.upload]. sourceFile is a local file owned by the app. */
|
||||||
|
data class RecordingDraft(
|
||||||
|
val id: String, // public UUID string
|
||||||
|
val title: String,
|
||||||
|
val createdAtEpochMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val mime: String, // audio/mp4 | audio/wav | audio/ogg
|
||||||
|
val sourceFile: File,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SidecarKind(val fileName: String) {
|
||||||
|
TRANSCRIPT("transcript.json"),
|
||||||
|
SUMMARY("summary.json"),
|
||||||
|
ACTION_ITEMS("action-items.json"),
|
||||||
|
KEYWORDS("keywords.json"),
|
||||||
|
NOTES("notes.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Page<T>(val items: List<T>, val nextCursor: String?)
|
||||||
|
data class RemoteRecording(val ref: RemoteRef, val title: String, val createdAtEpochMs: Long, val durationMs: Long, val mime: String)
|
||||||
|
|
||||||
|
/** "Where is my data?" — what the StorageLocation screen renders. */
|
||||||
|
data class StorageLocation(
|
||||||
|
val headline: String, // "On this device only" / "Nextcloud at cloud.example.com"
|
||||||
|
val detail: String, // human-readable path/prefix, quota, etc.
|
||||||
|
val syncedCount: Int,
|
||||||
|
val localOnlyCount: Int,
|
||||||
|
val bytesUsed: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Provider-level errors. Messages must never contain credentials/tokens. */
|
||||||
|
sealed class ProviderError(message: String) : Exception(message) {
|
||||||
|
class InvalidUrl(message: String) : ProviderError(message)
|
||||||
|
class NotConnected : ProviderError("Provider is not connected")
|
||||||
|
class AuthExpired : ProviderError("Access token expired — re-authentication required")
|
||||||
|
class Revoked : ProviderError("Access was revoked on the server")
|
||||||
|
class TlsUntrusted(fingerprint: String) : ProviderError("Server certificate not trusted (SHA-256 $fingerprint)")
|
||||||
|
class NotFound(key: String) : ProviderError("Remote item not found: $key")
|
||||||
|
class Conflict(key: String) : ProviderError("Remote item changed since last read: $key")
|
||||||
|
class QuotaExceeded : ProviderError("Server storage quota exceeded")
|
||||||
|
class Transient(message: String) : ProviderError(message)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single contract between SHONAR and any server (or the device itself).
|
||||||
|
*
|
||||||
|
* Everything the app needs from "the cloud" goes through this interface;
|
||||||
|
* UI, sync engine, and database reference providers by descriptor id only.
|
||||||
|
* LocalOnlyProvider is a first-class implementation, so a missing server is
|
||||||
|
* never a special case.
|
||||||
|
*
|
||||||
|
* Contract rules (enforced by the shared provider contract test suite):
|
||||||
|
* - all suspends are safe to cancel; partial uploads leave no visible object
|
||||||
|
* - upload() is idempotent per RecordingDraft.id (re-upload replaces the
|
||||||
|
* same key, never duplicates)
|
||||||
|
* - originals are immutable after successful upload until delete()
|
||||||
|
* - no method ever logs credentials, tokens, audio bytes, or transcripts
|
||||||
|
* - errors are ProviderError subtypes with secret-free messages
|
||||||
|
*/
|
||||||
|
interface ShonarProvider {
|
||||||
|
|
||||||
|
val descriptor: ProviderDescriptor
|
||||||
|
|
||||||
|
/** Current auth lifecycle; providers push updates here. */
|
||||||
|
val authState: kotlinx.coroutines.flow.StateFlow<AuthState>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is there a SHONAR-compatible service at [baseUrl]? Purely read-only;
|
||||||
|
* must not require or request credentials. TlsFailure carries the SPKI
|
||||||
|
* fingerprint so the UI can run explicit trust-on-first-use approval —
|
||||||
|
* never silently accept.
|
||||||
|
*/
|
||||||
|
suspend fun probe(baseUrl: ServerUrl): ProbeResult
|
||||||
|
|
||||||
|
/** Validate [credential] against the server, then hand it to the secure store. */
|
||||||
|
suspend fun connect(credential: ProviderCredential): Unit
|
||||||
|
|
||||||
|
/** Re-validate stored credential (app start / after network return). */
|
||||||
|
suspend fun reconnect(): AuthState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect locally; if [revokeOnServer] and the provider supports it,
|
||||||
|
* revoke the credential server-side first (best effort — local state is
|
||||||
|
* cleared even if revoke fails, with the failure surfaced).
|
||||||
|
*/
|
||||||
|
suspend fun disconnect(revokeOnServer: Boolean)
|
||||||
|
|
||||||
|
/** Provider-native account/data deletion, then wipe local state. */
|
||||||
|
suspend fun deleteAccountAndData()
|
||||||
|
|
||||||
|
// ---- storage -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the original audio for [draft], reporting [onProgress] 0..1.
|
||||||
|
* Implementations use chunked/resumable transfers when the capability is
|
||||||
|
* advertised. Returns the ref for later download/delete/sidecars.
|
||||||
|
*/
|
||||||
|
suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef
|
||||||
|
|
||||||
|
suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit)
|
||||||
|
|
||||||
|
suspend fun delete(ref: RemoteRef)
|
||||||
|
|
||||||
|
suspend fun list(cursor: String?): Page<RemoteRecording>
|
||||||
|
|
||||||
|
// ---- sidecars (transcript/summary/... JSON, synced independently) ------
|
||||||
|
|
||||||
|
suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray)
|
||||||
|
|
||||||
|
/** null when the sidecar does not exist remotely. */
|
||||||
|
suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray?
|
||||||
|
|
||||||
|
// ---- status ------------------------------------------------------------
|
||||||
|
|
||||||
|
suspend fun storageLocationSummary(): StorageLocation
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Runs the shared contract suite against LocalOnlyProvider. */
|
||||||
|
class LocalOnlyProviderContractTest : ProviderContractTest() {
|
||||||
|
|
||||||
|
private val root: File = createTempDirSafe("shonar-contract")
|
||||||
|
|
||||||
|
private fun createTempDirSafe(prefix: String): File =
|
||||||
|
File(System.getProperty("java.io.tmpdir"), prefix + "-" + System.nanoTime()).apply { mkdirs() }
|
||||||
|
|
||||||
|
override suspend fun makeProvider(): ShonarProvider = LocalOnlyProvider(root)
|
||||||
|
|
||||||
|
override suspend fun makeDraft(id: String): RecordingDraft {
|
||||||
|
val src = File(root, "src-$id.bin")
|
||||||
|
// deterministic pseudo-audio payload, ~256 KB
|
||||||
|
val rnd = java.util.Random(42)
|
||||||
|
src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) })
|
||||||
|
return RecordingDraft(
|
||||||
|
id = id,
|
||||||
|
title = "Contract recording",
|
||||||
|
createdAtEpochMs = 1_700_000_000_000,
|
||||||
|
durationMs = 12_345,
|
||||||
|
mime = "audio/mp4",
|
||||||
|
sourceFile = src,
|
||||||
|
sizeBytes = src.length(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cleanup() {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- local-only specifics ---------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localOnly_probe_isIncompatible_neverTouchesNetwork() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = LocalOnlyProvider(root)
|
||||||
|
val url = ServerUrl.parse("https://example.com")!!.getOrThrow()
|
||||||
|
assertEquals(ProbeResult.Incompatible, p.probe(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localOnly_rejectsCredentials() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = LocalOnlyProvider(root)
|
||||||
|
try {
|
||||||
|
p.connect(ProviderCredential.AppPassword("a@b", "https://x", "u", "hunter2"))
|
||||||
|
org.junit.Assert.fail("local-only must not accept credentials")
|
||||||
|
} catch (expected: ProviderError.InvalidUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteAccountAndData_wipesRoot() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = LocalOnlyProvider(root)
|
||||||
|
p.connect(ProviderCredential.None)
|
||||||
|
val draft = makeDraft("99999999-9999-4999-8999-999999999999")
|
||||||
|
p.upload(draft) { }
|
||||||
|
org.junit.Assert.assertTrue(File(root, "audio").exists())
|
||||||
|
p.deleteAccountAndData()
|
||||||
|
org.junit.Assert.assertFalse("local data must be wiped", root.exists())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared provider contract suite (docs/server-providers.md §8).
|
||||||
|
* Every implementation (LocalOnly, CustomShonar, Nextcloud) runs these same
|
||||||
|
* tests — subclass and implement [makeProvider] + [makeDraft].
|
||||||
|
*/
|
||||||
|
abstract class ProviderContractTest {
|
||||||
|
|
||||||
|
protected abstract suspend fun makeProvider(): ShonarProvider
|
||||||
|
protected abstract suspend fun makeDraft(id: String): RecordingDraft
|
||||||
|
protected open suspend fun cleanup() {}
|
||||||
|
|
||||||
|
// ---- lifecycle ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun freshProvider_startsDisconnectedOrConnected() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
assertTrue(
|
||||||
|
"authState must start in a defined state",
|
||||||
|
p.authState.value == AuthState.DISCONNECTED || p.authState.value == AuthState.CONNECTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connect_thenStateConnected() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
assertEquals(AuthState.CONNECTED, p.authState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disconnect_setsDisconnectedState() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
p.disconnect(revokeOnServer = false)
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.authState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- storage happy path ---------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upload_download_roundtrip_byteIdentical() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("11111111-1111-4111-8111-111111111111")
|
||||||
|
val payload = draft.sourceFile.readBytes()
|
||||||
|
val ref = p.upload(draft) { }
|
||||||
|
assertEquals(draft.sizeBytes, ref.sizeBytes)
|
||||||
|
|
||||||
|
val dest = java.io.File.createTempFile("contract-dl", ".bin")
|
||||||
|
try {
|
||||||
|
p.download(ref, dest) { }
|
||||||
|
assertTrue("downloaded bytes must equal uploaded bytes",
|
||||||
|
dest.readBytes().contentEquals(payload))
|
||||||
|
} finally { dest.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upload_reportsMonotonicProgressEndingAtOne() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("22222222-2222-4222-8222-222222222222")
|
||||||
|
val seen = mutableListOf<Float>()
|
||||||
|
val ref = p.upload(draft) { seen += it }
|
||||||
|
assertNotNull(ref)
|
||||||
|
assertTrue("progress should have been reported", seen.isNotEmpty())
|
||||||
|
assertEquals(1.0, seen.last().toDouble(), 1e-6)
|
||||||
|
for (i in 1 until seen.size) {
|
||||||
|
assertTrue("progress must be monotonic", seen[i] >= seen[i - 1] - 1e-6f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upload_idempotent_perDraftId() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("33333333-3333-4333-8333-333333333333")
|
||||||
|
val a = p.upload(draft) { }
|
||||||
|
val b = p.upload(draft) { }
|
||||||
|
assertEquals("re-upload with same id must reuse the same key", a.key, b.key)
|
||||||
|
// and still exactly one object for that draft
|
||||||
|
val keys = p.list(null).items.filter { it.ref.key == a.key }
|
||||||
|
assertEquals(1, keys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delete_removesObjectAndSidecars() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("44444444-4444-4444-8444-444444444444")
|
||||||
|
val ref = p.upload(draft) { }
|
||||||
|
p.putSidecar(ref, SidecarKind.TRANSCRIPT, """{"text":"hello"}""".toByteArray())
|
||||||
|
assertNotNull(p.getSidecar(ref, SidecarKind.TRANSCRIPT))
|
||||||
|
p.delete(ref)
|
||||||
|
val probe = java.io.File.createTempFile("gone", ".bin")
|
||||||
|
try {
|
||||||
|
p.download(ref, probe) { }
|
||||||
|
fail("download after delete must throw NotFound")
|
||||||
|
} catch (expected: ProviderError.NotFound) {
|
||||||
|
} finally {
|
||||||
|
probe.delete()
|
||||||
|
}
|
||||||
|
assertNull(p.getSidecar(ref, SidecarKind.TRANSCRIPT))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sidecars ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sidecar_roundtripAndOverwrite() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("55555555-5555-4555-8555-555555555555")
|
||||||
|
val ref = p.upload(draft) { }
|
||||||
|
p.putSidecar(ref, SidecarKind.SUMMARY, "v1".toByteArray())
|
||||||
|
assertEquals("v1", p.getSidecar(ref, SidecarKind.SUMMARY)!!.decodeToString())
|
||||||
|
p.putSidecar(ref, SidecarKind.SUMMARY, "v2".toByteArray())
|
||||||
|
assertEquals("v2", p.getSidecar(ref, SidecarKind.SUMMARY)!!.decodeToString())
|
||||||
|
assertNull(p.getSidecar(ref, SidecarKind.ACTION_ITEMS))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- status ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun storageSummary_countsAfterUpload() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("66666666-6666-4666-8666-666666666666")
|
||||||
|
p.upload(draft) { }
|
||||||
|
val s = p.storageLocationSummary()
|
||||||
|
assertTrue("headline must be non-blank", s.headline.isNotBlank())
|
||||||
|
assertTrue("summary must count the stored object",
|
||||||
|
s.syncedCount + s.localOnlyCount >= 1)
|
||||||
|
assertTrue(s.bytesUsed >= draft.sizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- error hygiene ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun providerErrorMessages_neverLeakCredentialMaterial() = runContract {
|
||||||
|
val p = makeProvider()
|
||||||
|
val cred = initialCredential()
|
||||||
|
val secrets = credentialSecretStrings(cred)
|
||||||
|
try {
|
||||||
|
p.download(
|
||||||
|
RemoteRef(p.descriptor.id, "does-not-exist-42", etag = null, sizeBytes = 0),
|
||||||
|
java.io.File.createTempFile("n-a-", ".bin"),
|
||||||
|
) { }
|
||||||
|
fail("expected NotFound")
|
||||||
|
} catch (e: ProviderError) {
|
||||||
|
secrets.forEach { s ->
|
||||||
|
assertFalse("error message leaked credential material", e.message?.contains(s) == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Credentials for tests: local-only uses None; network providers override. */
|
||||||
|
protected open fun initialCredential(): ProviderCredential = ProviderCredential.None
|
||||||
|
|
||||||
|
/** Strings that must never appear in error messages (tokens/passwords). */
|
||||||
|
protected open fun credentialSecretStrings(cred: ProviderCredential): List<String> =
|
||||||
|
when (cred) {
|
||||||
|
is ProviderCredential.OAuthTokens ->
|
||||||
|
listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() }
|
||||||
|
is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() }
|
||||||
|
ProviderCredential.None -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bridges suspend contract bodies to JUnit4. */
|
||||||
|
private fun runContract(body: suspend () -> Unit) {
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
try {
|
||||||
|
body()
|
||||||
|
} finally {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper subclasses use to assert a StateFlow is exposed (compile-time check). */
|
||||||
|
protected fun assertStateFlow(flow: StateFlow<AuthState>) {
|
||||||
|
assertNotNull(flow.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt
Normal file
119
android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** URL validation rules from docs/server-providers.md §4. */
|
||||||
|
class ServerUrlTest {
|
||||||
|
|
||||||
|
private fun ok(raw: String) = ServerUrl.parse(raw).getOrThrow()
|
||||||
|
|
||||||
|
@Test fun httpsPublicHost_accepted() {
|
||||||
|
val u = ok("https://cloud.example.com")
|
||||||
|
assertEquals("https", u.scheme)
|
||||||
|
assertEquals("cloud.example.com", u.host)
|
||||||
|
assertFalse(u.isCleartext)
|
||||||
|
assertEquals("https://cloud.example.com", u.origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun httpsWithPortAndPath_accepted() {
|
||||||
|
val u = ok("https://nextcloud.lan:8443/remote.php")
|
||||||
|
assertEquals(8443, u.port)
|
||||||
|
assertEquals(listOf("remote.php"), u.pathSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun trailingSlash_normalized() {
|
||||||
|
assertEquals(ok("https://a.example.com/").origin, "https://a.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun httpPrivateLan_accepted() {
|
||||||
|
assertTrue(ok("http://192.168.1.50:8123").isCleartext)
|
||||||
|
ok("http://10.0.0.9")
|
||||||
|
ok("http://172.20.0.5")
|
||||||
|
ok("http://localhost:8080")
|
||||||
|
ok("http://home.local")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun httpPublicHost_rejected() {
|
||||||
|
val r = ServerUrl.parse("http://example.com")
|
||||||
|
assertTrue(r.isFailure)
|
||||||
|
assertTrue(r.exceptionOrNull() is ProviderError.InvalidUrl)
|
||||||
|
// message must not contain anything credential-like (it contains host only)
|
||||||
|
assertTrue(r.exceptionOrNull()!!.message!!.contains("https"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun credentialsInUrl_rejected() {
|
||||||
|
assertTrue(ServerUrl.parse("https://user:***@example.com").isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nonHttpScheme_rejected() {
|
||||||
|
assertTrue(ServerUrl.parse("ftp://example.com").isFailure)
|
||||||
|
assertTrue(ServerUrl.parse("file:///etc/passwd").isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pathTraversal_rejected() {
|
||||||
|
assertTrue(ServerUrl.parse("https://example.com/a/../b").isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun blankAndGarbage_rejected() {
|
||||||
|
assertTrue(ServerUrl.parse("").isFailure)
|
||||||
|
assertTrue(ServerUrl.parse(" ").isFailure)
|
||||||
|
assertTrue(ServerUrl.parse("not a url").isFailure)
|
||||||
|
assertTrue(ServerUrl.parse("https://").isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun privateRanges_exact() {
|
||||||
|
assertTrue(ServerUrl.isPrivateHost("10.255.0.1"))
|
||||||
|
assertTrue(ServerUrl.isPrivateHost("192.168.0.1"))
|
||||||
|
assertFalse(ServerUrl.isPrivateHost("192.169.0.1"))
|
||||||
|
assertTrue(ServerUrl.isPrivateHost("172.16.0.1"))
|
||||||
|
assertTrue(ServerUrl.isPrivateHost("172.31.255.255"))
|
||||||
|
assertFalse(ServerUrl.isPrivateHost("172.32.0.1"))
|
||||||
|
assertFalse(ServerUrl.isPrivateHost("8.8.8.8"))
|
||||||
|
assertFalse(ServerUrl.isPrivateHost("999.1.1.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun toStringOfCredential_neverLeaksSecret() {
|
||||||
|
val c = ProviderCredential.AppPassword("u@example", "https://x", "u", "sup3rs3cr3t")
|
||||||
|
assertFalse(c.toString().contains("sup3rs3cr3t"))
|
||||||
|
val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null)
|
||||||
|
assertFalse(o.toString().contains("ACCESS-XYZ"))
|
||||||
|
assertFalse(o.toString().contains("REFRESH-XYZ"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderRegistryTest {
|
||||||
|
|
||||||
|
private fun registry(dir: java.io.File) = ProviderRegistry.withDefaults(dir)
|
||||||
|
|
||||||
|
@Test fun defaults_startLocalOnly() {
|
||||||
|
val r = registry(createTempFileSafe())
|
||||||
|
assertEquals(com.shonar.provider.LocalOnlyProvider.ID, r.activeId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unknownProvider_rejected() {
|
||||||
|
val r = registry(createTempFileSafe())
|
||||||
|
try {
|
||||||
|
r.select("dropbox")
|
||||||
|
org.junit.Assert.fail("unknown provider must be rejected")
|
||||||
|
} catch (expected: ProviderError.InvalidUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun select_localOnly_resolves() {
|
||||||
|
val r = registry(createTempFileSafe())
|
||||||
|
val p = r.select(LocalOnlyProvider.ID)
|
||||||
|
assertEquals(LocalOnlyProvider.ID, p.descriptor.id)
|
||||||
|
assertEquals(LocalOnlyProvider.ID, r.activeId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun available_listsDescriptors() {
|
||||||
|
val ids = registry(createTempFileSafe()).available().map { it.id }
|
||||||
|
assertTrue(ids.contains(LocalOnlyProvider.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTempFileSafe(): java.io.File =
|
||||||
|
java.io.File(System.getProperty("java.io.tmpdir"), "reg-${System.nanoTime()}").apply { mkdirs() }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue