P3-M7: providers, background sync, AI pipeline
P3 CustomShonarProvider (auth, chunked uploads, secure token store). P4 NextcloudProvider (login flow v2, DAV, chunking) + FolderSyncProvider (Syncthing-style). P5 TOFU pinning + redacting logger + leak tests. P6a generic hosted setup (Start9/Umbrel URL + auto-detect). P7 provider switching (Room v2 slot, Storage screen, foreground migrator). M5 WorkManager drain (Room v3 backoff). M7 AI pipeline (4 adapters, arq worker, transcript/summary/jobs endpoints). Docs updated throughout.
This commit is contained in:
parent
b48f77d6e3
commit
aad7cad78d
73 changed files with 9470 additions and 54 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -44,6 +44,4 @@ jobs:
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with: { distribution: temurin, java-version: 17 }
|
with: { distribution: temurin, java-version: 17 }
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
# Skipped until the Android project lands (M3).
|
|
||||||
- run: ./gradlew test assembleDebug --no-daemon
|
- run: ./gradlew test assembleDebug --no-daemon
|
||||||
continue-on-error: ${{ !hashFiles('android/**/build.gradle.kts') }}
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,6 +18,7 @@ data/
|
||||||
|
|
||||||
# --- Android / Gradle ---
|
# --- Android / Gradle ---
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
|
android/.kotlin/
|
||||||
android/build/
|
android/build/
|
||||||
android/app/build/
|
android/app/build/
|
||||||
*.apk
|
*.apk
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,8 @@ Thanks for your interest in improving S.H.O.N.A.R.!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
|
./scripts/dev_bootstrap.sh
|
||||||
cd backend
|
cd backend
|
||||||
uv venv .venv && uv pip install -e ".[dev]"
|
|
||||||
docker compose -f ../deploy/docker-compose.dev.yml up -d
|
|
||||||
.venv/bin/alembic upgrade head
|
|
||||||
.venv/bin/uvicorn shonar.main:app --reload
|
.venv/bin/uvicorn shonar.main:app --reload
|
||||||
.venv/bin/pytest && .venv/bin/ruff check .
|
.venv/bin/pytest && .venv/bin/ruff check .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,11 @@ buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix.
|
||||||
- Home Assistant is **not** part of the initial product. Prior work is
|
- Home Assistant is **not** part of the initial product. Prior work is
|
||||||
preserved but disabled under [`deferred/home-assistant/`](deferred/home-assistant/README.md)
|
preserved but disabled under [`deferred/home-assistant/`](deferred/home-assistant/README.md)
|
||||||
and on branch `deferred/home-assistant`.
|
and on branch `deferred/home-assistant`.
|
||||||
- Recording engine, provider sync, playback, AI pipeline: TODO per roadmap.
|
- Provider sync and AI pipeline remain TODO; local recording, playback, and
|
||||||
|
library storage are implemented in the Android app. The custom SHONAR
|
||||||
|
server provider (login, token persistence + auto-refresh, chunked
|
||||||
|
upload/download) is implemented with provider-selection login UI;
|
||||||
|
on-device verification pending, WorkManager sync driver still TODO.
|
||||||
|
|
||||||
## AI providers
|
## AI providers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.kapt")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +49,9 @@ dependencies {
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.4")
|
implementation("androidx.navigation:navigation-compose:2.8.4")
|
||||||
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
kapt("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
|
|
@ -62,12 +66,22 @@ dependencies {
|
||||||
|
|
||||||
// networking (server provider APIs over HTTPS)
|
// networking (server provider APIs over HTTPS)
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
// background sync driver (M5)
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
||||||
// tests
|
// tests
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
// Real org.json for JVM unit tests: the android.jar stubs throw
|
||||||
|
// ("Method ... not mocked"), which would kill any MockWebServer
|
||||||
|
// dispatcher or provider JSON parsing under test.
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
// In-test certificate authority for the P5 TOFU tests (self-signed
|
||||||
|
// fixtures generated at runtime, no checked-in keys).
|
||||||
|
testImplementation("com.squareup.okhttp3:okhttp-tls:4.12.0")
|
||||||
|
testImplementation("androidx.room:room-testing:2.6.1")
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- Recording (M4). Declared now so the manifest is honest about the
|
|
||||||
full feature set; nothing records until M4 lands. -->
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
@ -32,5 +30,9 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".recording.RecordingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,16 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.shonar.ui.home.HomeScreen
|
import com.shonar.ui.home.HomeScreen
|
||||||
|
import com.shonar.ui.provider.ProviderSelectionScreen
|
||||||
|
import com.shonar.ui.provider.StorageScreen
|
||||||
import com.shonar.ui.settings.SettingsScreen
|
import com.shonar.ui.settings.SettingsScreen
|
||||||
import com.shonar.ui.theme.ShonarTheme
|
import com.shonar.ui.theme.ShonarTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val app = application as ShonarApplication
|
|
||||||
setContent {
|
setContent {
|
||||||
ShonarTheme {
|
ShonarTheme {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
|
|
@ -28,7 +28,24 @@ class MainActivity : ComponentActivity() {
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
NavHost(navController = nav, startDestination = "home") {
|
NavHost(navController = nav, startDestination = "home") {
|
||||||
composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) }
|
composable("home") {
|
||||||
|
HomeScreen(
|
||||||
|
onOpenSettings = { nav.navigate("settings") },
|
||||||
|
onOpenStorage = { nav.navigate("storage") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("storage") {
|
||||||
|
StorageScreen(
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onSwitchProvider = { nav.navigate("provider") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("provider") {
|
||||||
|
ProviderSelectionScreen(
|
||||||
|
onDone = { nav.popBackStack() },
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,4 +53,3 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,113 @@
|
||||||
package com.shonar
|
package com.shonar
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.shonar.recording.RecordingRepository
|
||||||
|
import com.shonar.recording.ShonarDatabase
|
||||||
import com.shonar.settings.DataStoreSettingsStore
|
import com.shonar.settings.DataStoreSettingsStore
|
||||||
import com.shonar.settings.SecureSettingsStore
|
import com.shonar.settings.SecureSettingsStore
|
||||||
import com.shonar.settings.SettingsManager
|
import com.shonar.settings.SettingsManager
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ShonarApplication : Application() {
|
class ShonarApplication : Application() {
|
||||||
|
|
||||||
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
val database: ShonarDatabase by lazy {
|
||||||
|
Room.databaseBuilder(this, ShonarDatabase::class.java, "shonar.db")
|
||||||
|
.addMigrations(
|
||||||
|
com.shonar.recording.MIGRATION_1_2,
|
||||||
|
com.shonar.recording.MIGRATION_2_3,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordingRepository: RecordingRepository by lazy {
|
||||||
|
RecordingRepository(this, database.recordingDao())
|
||||||
|
}
|
||||||
|
|
||||||
|
val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) }
|
||||||
|
|
||||||
val settingsManager: SettingsManager by lazy {
|
val settingsManager: SettingsManager by lazy {
|
||||||
SettingsManager(
|
SettingsManager(
|
||||||
store = DataStoreSettingsStore(this),
|
store = DataStoreSettingsStore(this),
|
||||||
secureStore = SecureSettingsStore(this),
|
secureStore = secureStore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P5: trust-on-first-use pins (secure store) + in-memory cache. */
|
||||||
|
val tofu: com.shonar.provider.TofuManager by lazy {
|
||||||
|
com.shonar.provider.TofuManager(
|
||||||
|
com.shonar.provider.TofuStore(secureStore)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP debug logging follows the `log_http_bodies` setting. Interceptors
|
||||||
|
* run on OkHttp threads that cannot suspend, so the flag is cached here
|
||||||
|
* and refreshed whenever settings change — toggling needs no restart.
|
||||||
|
*/
|
||||||
|
private val bodyLogging = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Server providers (P1 local-only, P3 custom SHONAR, P4 Nextcloud + sync folder). */
|
||||||
|
val providerRegistry: com.shonar.provider.ProviderRegistry by lazy {
|
||||||
|
com.shonar.provider.ProviderRegistry.withDefaults(
|
||||||
|
appFilesDir = filesDir,
|
||||||
|
secureStore = secureStore,
|
||||||
|
plainStore = DataStoreSettingsStore(this),
|
||||||
|
tlsPolicy = com.shonar.provider.TlsPolicy(
|
||||||
|
tofu = tofu,
|
||||||
|
bodiesEnabled = bodyLogging::get,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
appScope.launch {
|
||||||
|
settingsManager.ensureLoaded()
|
||||||
|
// TOFU pins must be in cache before any TLS handshake needs them.
|
||||||
|
tofu.refresh()
|
||||||
|
syncBodyLoggingFlag()
|
||||||
|
scheduleSync()
|
||||||
|
settingsManager.valuesChanged.collect {
|
||||||
|
syncBodyLoggingFlag()
|
||||||
|
scheduleSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M5: steady-state periodic drain plus an immediate drain at startup
|
||||||
|
* and whenever sync constraints change. Re-running on unrelated
|
||||||
|
* settings edits only refreshes the periodic schedule (cheap UPDATE),
|
||||||
|
* never a drain. */
|
||||||
|
private var lastSyncFlags: Pair<Boolean, Boolean>? = null
|
||||||
|
|
||||||
|
private suspend fun scheduleSync() {
|
||||||
|
val flags = wifiOnly() to chargingOnly()
|
||||||
|
com.shonar.recording.SyncScheduler.ensurePeriodic(this, flags.first, flags.second)
|
||||||
|
if (lastSyncFlags == null || lastSyncFlags != flags) {
|
||||||
|
com.shonar.recording.SyncScheduler.requestNow(this, flags.first, flags.second)
|
||||||
|
}
|
||||||
|
lastSyncFlags = flags
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun wifiOnly(): Boolean = runCatching {
|
||||||
|
settingsManager.bool(com.shonar.settings.BuiltInSettings.WIFI_ONLY_UPLOAD)
|
||||||
|
}.getOrDefault(true)
|
||||||
|
|
||||||
|
private suspend fun chargingOnly(): Boolean = runCatching {
|
||||||
|
settingsManager.bool(com.shonar.settings.BuiltInSettings.CHARGING_ONLY_UPLOAD)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private suspend fun syncBodyLoggingFlag() {
|
||||||
|
bodyLogging.set(
|
||||||
|
runCatching { settingsManager.bool("log_http_bodies") }.getOrDefault(false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,573 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P3: SHONAR-backend provider (this repo's FastAPI server, M1 auth + M2
|
||||||
|
* uploads). Speaks only documented endpoints:
|
||||||
|
*
|
||||||
|
* - GET /api/v1/provider-info (probe; no credentials)
|
||||||
|
* - POST /api/v1/auth/login {email, password, device_name, platform}
|
||||||
|
* - POST /api/v1/auth/refresh {refresh_token} (rotating; reuse-detected)
|
||||||
|
* - POST /api/v1/auth/logout {refresh_token}
|
||||||
|
* - GET /api/v1/auth/me (token validation)
|
||||||
|
* - POST /api/v1/auth/delete-account {password} (via [deleteAccount])
|
||||||
|
* - POST /api/v1/uploads (create session)
|
||||||
|
* - GET /api/v1/uploads/{id} (resume: received indexes)
|
||||||
|
* - PUT /api/v1/uploads/{id}/chunks/{n} (+ X-Chunk-Sha256)
|
||||||
|
* - POST /api/v1/uploads/{id}/finalize
|
||||||
|
* - GET /api/v1/recordings?limit&offset&sort&order
|
||||||
|
* - GET /api/v1/recordings/{id}/audio
|
||||||
|
* - DELETE /api/v1/recordings/{id}?purge=true
|
||||||
|
*
|
||||||
|
* Token discipline: the backend rotates refresh tokens with reuse
|
||||||
|
* detection, so the stored pair is overwritten on every login AND every
|
||||||
|
* refresh, and concurrent 401s serialize on [refreshMutex] — two parallel
|
||||||
|
* refreshes would look like token reuse and burn the whole family.
|
||||||
|
*
|
||||||
|
* Sidecars: the backend has no sidecar endpoints until M7 (transcripts),
|
||||||
|
* so transcript/summary JSON is cached in app-private storage keyed by the
|
||||||
|
* remote recording id. Same layout as LocalOnlyProvider, so the M7
|
||||||
|
* migration is a file walk, not a format change.
|
||||||
|
*
|
||||||
|
* Cancellation safety: a cancelled upload leaves an open server session
|
||||||
|
* with some chunks stored — invisible until finalize, resumable via the
|
||||||
|
* status endpoint, and idempotent per draft id through
|
||||||
|
* `client_recording_id`. Nothing half-visible ever appears in listings.
|
||||||
|
*/
|
||||||
|
class CustomShonarProvider(
|
||||||
|
private val auth: ShonarAuthStore,
|
||||||
|
private val sidecarRoot: File,
|
||||||
|
private val client: OkHttpClient = defaultClient(),
|
||||||
|
private val handshake: ShonarHandshake = ShonarHandshake(),
|
||||||
|
) : ShonarProvider {
|
||||||
|
|
||||||
|
override val descriptor = ProviderDescriptor(
|
||||||
|
id = ProviderRegistry.CUSTOM_SHONAR_ID,
|
||||||
|
displayName = "Custom SHONAR server",
|
||||||
|
capabilities = setOf(
|
||||||
|
ProviderDescriptor.Capability.CHUNKED_UPLOAD,
|
||||||
|
ProviderDescriptor.Capability.ACCOUNT_DELETION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
|
||||||
|
override val authState: StateFlow<AuthState> = _authState
|
||||||
|
|
||||||
|
/** Base origin, e.g. https://shonar.example.com. Set by connect/reconnect. */
|
||||||
|
private var origin: String? = null
|
||||||
|
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
|
|
||||||
|
// ---- lifecycle ---------------------------------------------------------
|
||||||
|
|
||||||
|
override suspend fun probe(baseUrl: ServerUrl): ProbeResult = handshake.probe(baseUrl)
|
||||||
|
|
||||||
|
override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
|
||||||
|
when (credential) {
|
||||||
|
is ProviderCredential.ShonarLogin -> login(
|
||||||
|
credential.serverUrl.origin, credential.email, credential.password
|
||||||
|
)
|
||||||
|
is ProviderCredential.OAuthTokens -> resumeWithTokens(credential)
|
||||||
|
else -> throw ProviderError.InvalidUrl(
|
||||||
|
"Custom SHONAR server needs an email + password login"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
|
||||||
|
val saved = auth.load()
|
||||||
|
if (saved == null || saved.refreshToken.isBlank()) {
|
||||||
|
_authState.value = AuthState.DISCONNECTED
|
||||||
|
return@withContext _authState.value
|
||||||
|
}
|
||||||
|
origin = saved.baseUrl
|
||||||
|
val base = saved.baseUrl
|
||||||
|
try {
|
||||||
|
executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp ->
|
||||||
|
if (resp.code != 200) throw ProviderError.AuthExpired()
|
||||||
|
}
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
} catch (e: ProviderError.AuthExpired) {
|
||||||
|
// Refresh already failed inside executeAuthed: tokens are dead.
|
||||||
|
auth.clearTokens()
|
||||||
|
_authState.value = AuthState.EXPIRED
|
||||||
|
} catch (e: ProviderError) {
|
||||||
|
_authState.value = AuthState.OFFLINE
|
||||||
|
}
|
||||||
|
_authState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) {
|
||||||
|
if (revokeOnServer) {
|
||||||
|
// Best effort: local state is cleared even if revoke fails.
|
||||||
|
runCatching {
|
||||||
|
val saved = auth.load()
|
||||||
|
if (saved != null && saved.refreshToken.isNotBlank()) {
|
||||||
|
postUnauthed(
|
||||||
|
saved.baseUrl, "/api/v1/auth/logout",
|
||||||
|
"""{"refresh_token":${jsonStr(saved.refreshToken)}}""",
|
||||||
|
).close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth.clearTokens()
|
||||||
|
_authState.value = AuthState.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAccountAndData() {
|
||||||
|
// No password is available here (never stored), so server-side
|
||||||
|
// account purge needs the explicit [deleteAccount] call below.
|
||||||
|
// This path revokes the session and wipes everything local.
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
disconnect(revokeOnServer = true)
|
||||||
|
sidecarRoot.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full server-side account purge (backend: 30-day grace, then hard
|
||||||
|
* delete). Needs the password because it is never stored — call this
|
||||||
|
* from a confirmation screen that asks for it once.
|
||||||
|
*/
|
||||||
|
suspend fun deleteAccount(password: String) = withContext(Dispatchers.IO) {
|
||||||
|
val base = origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected()
|
||||||
|
executeAuthed(base) { token ->
|
||||||
|
post(base, "/api/v1/auth/delete-account", token,
|
||||||
|
"""{"password":${jsonStr(password)}}""")
|
||||||
|
}.use { resp ->
|
||||||
|
if (resp.code != 202 && resp.code != 204) {
|
||||||
|
throw ProviderError.Transient("Account deletion refused (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth.clearAll()
|
||||||
|
origin = null
|
||||||
|
sidecarRoot.deleteRecursively()
|
||||||
|
_authState.value = AuthState.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- storage -----------------------------------------------------------
|
||||||
|
|
||||||
|
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ensureConnected()
|
||||||
|
val base = currentOrigin()
|
||||||
|
val total = draft.sizeBytes.coerceAtLeast(1)
|
||||||
|
|
||||||
|
val (sessionId, chunkSize) = createSession(base, draft)
|
||||||
|
val received = uploadStatus(base, sessionId).toMutableSet()
|
||||||
|
|
||||||
|
var sent = 0L
|
||||||
|
// Account progress for already-present chunks so resume continues
|
||||||
|
// the bar instead of restarting it.
|
||||||
|
var idx = 0
|
||||||
|
val chunkCount = ((draft.sizeBytes + chunkSize - 1) / chunkSize).toInt().coerceAtLeast(1)
|
||||||
|
var resumedBytes = 0L
|
||||||
|
// Estimate resumed bytes from the received set (last chunk may be short).
|
||||||
|
for (i in received) {
|
||||||
|
resumedBytes += if (i < chunkCount - 1) chunkSize.toLong()
|
||||||
|
else (draft.sizeBytes - chunkSize * (chunkCount - 1)).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
sent = resumedBytes
|
||||||
|
if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
|
||||||
|
|
||||||
|
while (idx < chunkCount) {
|
||||||
|
// Cooperative cancellation between chunks; a cancelled
|
||||||
|
// upload leaves a resumable server session, never a
|
||||||
|
// half-visible object.
|
||||||
|
ensureActive()
|
||||||
|
if (idx !in received) {
|
||||||
|
val slice = readSlice(draft.sourceFile, idx.toLong() * chunkSize, chunkSize)
|
||||||
|
putChunk(base, sessionId, idx, slice)
|
||||||
|
sent += slice.size
|
||||||
|
onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordingId = finalize(base, sessionId, draft)
|
||||||
|
onProgress(1f)
|
||||||
|
RemoteRef(ProviderRegistry.CUSTOM_SHONAR_ID, recordingId, etag = null, sizeBytes = draft.sizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ensureConnected()
|
||||||
|
val base = currentOrigin()
|
||||||
|
executeAuthed(base) { token -> get(base, "/api/v1/recordings/${ref.key}/audio", token) }.use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> {
|
||||||
|
val body = resp.body ?: throw ProviderError.Transient("Empty download response")
|
||||||
|
val total = body.contentLength().takeIf { it > 0 } ?: -1
|
||||||
|
dest.parentFile?.mkdirs()
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
dest.outputStream().use { output ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
var written = 0L
|
||||||
|
var lastReported = -1f
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
output.write(buf, 0, n)
|
||||||
|
written += n
|
||||||
|
if (total > 0) {
|
||||||
|
val p = (written.toFloat() / total).coerceIn(0f, 1f)
|
||||||
|
if (p > lastReported) {
|
||||||
|
onProgress(p)
|
||||||
|
lastReported = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress(1f)
|
||||||
|
}
|
||||||
|
404 -> throw ProviderError.NotFound(ref.key)
|
||||||
|
else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(ref: RemoteRef) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ensureConnected()
|
||||||
|
val base = currentOrigin()
|
||||||
|
executeAuthed(base) { token ->
|
||||||
|
Request.Builder().url("$base/api/v1/recordings/${ref.key}?purge=true")
|
||||||
|
.delete().header("Authorization", "Bearer $token").build()
|
||||||
|
}.use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
204, 200 -> {
|
||||||
|
sidecarDir(ref).deleteRecursively()
|
||||||
|
}
|
||||||
|
404 -> throw ProviderError.NotFound(ref.key)
|
||||||
|
else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(cursor: String?): Page<RemoteRecording> = withContext(Dispatchers.IO) {
|
||||||
|
ensureConnected()
|
||||||
|
val base = currentOrigin()
|
||||||
|
val offset = cursor?.toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||||
|
val limit = 200
|
||||||
|
executeAuthed(base) { token ->
|
||||||
|
get(base, "/api/v1/recordings?limit=$limit&offset=$offset&sort=recorded_at&order=desc", token)
|
||||||
|
}.use { resp ->
|
||||||
|
if (resp.code != 200) throw ProviderError.Transient("Listing failed (HTTP ${resp.code})")
|
||||||
|
val root = org.json.JSONObject(resp.body?.string().orEmpty())
|
||||||
|
val total = root.optInt("total", 0)
|
||||||
|
val items = root.optJSONArray("items") ?: org.json.JSONArray()
|
||||||
|
val out = mutableListOf<RemoteRecording>()
|
||||||
|
for (i in 0 until items.length()) {
|
||||||
|
parseRecording(items.getJSONObject(i))?.let { out += it }
|
||||||
|
}
|
||||||
|
val next = if (offset + limit < total) (offset + limit).toString() else null
|
||||||
|
Page(out, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sidecars (local cache until the backend gains endpoints in M7) ----
|
||||||
|
|
||||||
|
private fun sidecarDir(ref: RemoteRef) = File(sidecarRoot, ref.key)
|
||||||
|
|
||||||
|
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val f = File(sidecarDir(ref), kind.fileName)
|
||||||
|
f.parentFile?.mkdirs()
|
||||||
|
f.writeBytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val f = File(sidecarDir(ref), kind.fileName)
|
||||||
|
if (f.exists()) f.readBytes() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- status ------------------------------------------------------------
|
||||||
|
|
||||||
|
override suspend fun storageLocationSummary(): StorageLocation =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ensureConnected()
|
||||||
|
val base = currentOrigin()
|
||||||
|
val host = runCatching { java.net.URI(base).host }.getOrNull() ?: base
|
||||||
|
val email = auth.load()?.email.orEmpty()
|
||||||
|
var count = 0
|
||||||
|
var bytes = 0L
|
||||||
|
var cursor: String? = null
|
||||||
|
do {
|
||||||
|
val page = list(cursor)
|
||||||
|
count += page.items.size
|
||||||
|
bytes += page.items.sumOf { it.ref.sizeBytes }
|
||||||
|
cursor = page.nextCursor
|
||||||
|
} while (cursor != null)
|
||||||
|
StorageLocation(
|
||||||
|
headline = "Custom SHONAR server at $host",
|
||||||
|
detail = if (email.isBlank()) "$count recordings synced."
|
||||||
|
else "Signed in as $email · $count recordings synced.",
|
||||||
|
syncedCount = count,
|
||||||
|
localOnlyCount = 0,
|
||||||
|
bytesUsed = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ---------------------------------------------------------
|
||||||
|
|
||||||
|
private fun ensureConnected() {
|
||||||
|
if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun currentOrigin(): String =
|
||||||
|
origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected()
|
||||||
|
|
||||||
|
private suspend fun login(base: String, email: String, password: String) {
|
||||||
|
val body = """{"email":${jsonStr(email)},"password":${jsonStr(password)},""" +
|
||||||
|
""""device_name":"SHONAR Android","platform":"android"}"""
|
||||||
|
postUnauthed(base, "/api/v1/auth/login", body).use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> {
|
||||||
|
persistSession(base, email, org.json.JSONObject(resp.body?.string().orEmpty()))
|
||||||
|
origin = base
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
}
|
||||||
|
401 -> throw ProviderError.Transient("Invalid email or password")
|
||||||
|
else -> throw ProviderError.Transient("Login failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resumeWithTokens(credential: ProviderCredential.OAuthTokens) {
|
||||||
|
val base = origin ?: auth.load()?.baseUrl
|
||||||
|
?: throw ProviderError.InvalidUrl("No SHONAR server configured yet")
|
||||||
|
origin = base
|
||||||
|
auth.save(
|
||||||
|
ShonarSession(
|
||||||
|
baseUrl = base,
|
||||||
|
email = credential.accountLabel,
|
||||||
|
accessToken = credential.accessToken,
|
||||||
|
refreshToken = credential.refreshToken.orEmpty(),
|
||||||
|
expiresAtEpochSec = credential.expiresAtEpochSec,
|
||||||
|
deviceId = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp ->
|
||||||
|
if (resp.code != 200) throw ProviderError.AuthExpired()
|
||||||
|
}
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistSession(base: String, email: String, json: org.json.JSONObject) {
|
||||||
|
val nowSec = Instant.now().epochSecond
|
||||||
|
auth.save(
|
||||||
|
ShonarSession(
|
||||||
|
baseUrl = base,
|
||||||
|
email = email,
|
||||||
|
accessToken = json.getString("access_token"),
|
||||||
|
refreshToken = json.getString("refresh_token"),
|
||||||
|
expiresAtEpochSec = nowSec + json.optInt("expires_in", 900),
|
||||||
|
deviceId = json.optString("device_id", null).takeUnless { it.isNullOrBlank() },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-flight refresh; concurrent 401s must not double-refresh. */
|
||||||
|
private suspend fun refreshLocked(failedAccess: String): String = refreshMutex.withLock {
|
||||||
|
val current = auth.load() ?: throw ProviderError.NotConnected()
|
||||||
|
// A peer already refreshed while we queued — reuse its tokens.
|
||||||
|
if (current.accessToken != failedAccess && current.accessToken.isNotBlank()) {
|
||||||
|
return@withLock current.accessToken
|
||||||
|
}
|
||||||
|
if (current.refreshToken.isBlank()) {
|
||||||
|
_authState.value = AuthState.EXPIRED
|
||||||
|
throw ProviderError.AuthExpired()
|
||||||
|
}
|
||||||
|
val body = """{"refresh_token":${jsonStr(current.refreshToken)}}"""
|
||||||
|
try {
|
||||||
|
postUnauthed(current.baseUrl, "/api/v1/auth/refresh", body).use { resp ->
|
||||||
|
if (resp.code != 200) {
|
||||||
|
// Reuse detected or revoked family: stored tokens are dead.
|
||||||
|
auth.clearTokens()
|
||||||
|
_authState.value = AuthState.EXPIRED
|
||||||
|
throw ProviderError.AuthExpired()
|
||||||
|
}
|
||||||
|
persistSession(current.baseUrl, current.email, org.json.JSONObject(resp.body?.string().orEmpty()))
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
return@withLock auth.load()?.accessToken ?: throw ProviderError.AuthExpired()
|
||||||
|
}
|
||||||
|
} catch (e: ProviderError) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw ProviderError.Transient("Token refresh failed (${e.javaClass.simpleName})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute an authenticated request; one transparent refresh+retry on 401. */
|
||||||
|
private suspend fun executeAuthed(
|
||||||
|
base: String,
|
||||||
|
build: (access: String) -> Request,
|
||||||
|
): Response = withContext(Dispatchers.IO) {
|
||||||
|
val session = auth.load() ?: throw ProviderError.NotConnected()
|
||||||
|
val first = client.newCall(build(session.accessToken)).execute()
|
||||||
|
if (first.code != 401) return@withContext first
|
||||||
|
first.close()
|
||||||
|
val fresh = refreshLocked(session.accessToken)
|
||||||
|
val retry = client.newCall(build(fresh)).execute()
|
||||||
|
if (retry.code == 401) {
|
||||||
|
retry.close()
|
||||||
|
_authState.value = AuthState.EXPIRED
|
||||||
|
throw ProviderError.AuthExpired()
|
||||||
|
}
|
||||||
|
retry
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(base: String, path: String, access: String): Request =
|
||||||
|
Request.Builder().url(base + path).get()
|
||||||
|
.header("Authorization", "Bearer $access").build()
|
||||||
|
|
||||||
|
private fun post(base: String, path: String, access: String, json: String): Request =
|
||||||
|
Request.Builder().url(base + path)
|
||||||
|
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer $access").build()
|
||||||
|
|
||||||
|
private fun postUnauthed(base: String, path: String, json: String): Response {
|
||||||
|
val req = Request.Builder().url(base + path)
|
||||||
|
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build()
|
||||||
|
return client.newCall(req).execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload-session flow returns Triple(sessionId, chunkSize, declaredMime echo not needed).
|
||||||
|
private suspend fun createSession(base: String, draft: RecordingDraft): Pair<String, Int> {
|
||||||
|
val body = """{"declared_mime_type":${jsonStr(draft.mime)},""" +
|
||||||
|
""""declared_size_bytes":${draft.sizeBytes},""" +
|
||||||
|
""""title":${jsonStr(draft.title)},""" +
|
||||||
|
""""client_recording_id":${jsonStr(draft.id)}}"""
|
||||||
|
executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
201 -> {
|
||||||
|
val json = org.json.JSONObject(resp.body?.string().orEmpty())
|
||||||
|
return json.getString("id") to json.optInt("chunk_size_bytes", 16 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
413 -> throw ProviderError.Transient("Recording exceeds the server size limit")
|
||||||
|
415 -> throw ProviderError.Transient("Audio type not accepted by the server")
|
||||||
|
else -> throw ProviderError.Transient("Upload rejected (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun uploadStatus(base: String, sessionId: String): Set<Int> {
|
||||||
|
executeAuthed(base) { token -> get(base, "/api/v1/uploads/$sessionId", token) }.use { resp ->
|
||||||
|
if (resp.code != 200) throw ProviderError.Transient("Upload status failed (HTTP ${resp.code})")
|
||||||
|
val arr = org.json.JSONObject(resp.body?.string().orEmpty())
|
||||||
|
.optJSONArray("received_chunk_indexes") ?: return emptySet()
|
||||||
|
return (0 until arr.length()).map { arr.getInt(it) }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun putChunk(base: String, sessionId: String, index: Int, bytes: ByteArray) {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
|
||||||
|
val req = { token: String ->
|
||||||
|
Request.Builder().url("$base/api/v1/uploads/$sessionId/chunks/$index")
|
||||||
|
.put(bytes.toRequestBody("application/octet-stream".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.header("X-Chunk-Sha256", digest).build()
|
||||||
|
}
|
||||||
|
executeAuthed(base, req).use { resp ->
|
||||||
|
if (resp.code != 201) throw ProviderError.Transient("Chunk $index rejected (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String {
|
||||||
|
val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString()
|
||||||
|
val body = """{"recorded_at":${jsonStr(recordedAt)},""" +
|
||||||
|
""""duration_seconds":${draft.durationMs / 1000.0}}"""
|
||||||
|
executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) }
|
||||||
|
.use { resp ->
|
||||||
|
if (resp.code != 201) {
|
||||||
|
throw ProviderError.Transient("Upload finalize failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
return org.json.JSONObject(resp.body?.string().orEmpty()).getString("id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRecording(json: org.json.JSONObject): RemoteRecording? {
|
||||||
|
if (!json.optBoolean("has_audio", false)) return null
|
||||||
|
val id = json.optString("id", "")
|
||||||
|
if (id.isBlank()) return null
|
||||||
|
val createdAt = runCatching {
|
||||||
|
Instant.parse(json.getString("recorded_at")).toEpochMilli()
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
return RemoteRecording(
|
||||||
|
ref = RemoteRef(
|
||||||
|
providerId = ProviderRegistry.CUSTOM_SHONAR_ID,
|
||||||
|
key = id,
|
||||||
|
etag = null,
|
||||||
|
sizeBytes = json.optLong("size_bytes", 0),
|
||||||
|
),
|
||||||
|
title = json.optString("title", id),
|
||||||
|
createdAtEpochMs = createdAt,
|
||||||
|
durationMs = (json.optDouble("duration_seconds", 0.0) * 1000).toLong(),
|
||||||
|
mime = json.optString("mime_type", null) ?: "application/octet-stream",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
// No logging interceptor by policy (docs/server-providers.md §4):
|
||||||
|
// bodies would carry audio bytes and transcripts.
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** JSON string literal with escaping; never pass secrets to message strings. */
|
||||||
|
internal fun jsonStr(raw: String): String = buildString {
|
||||||
|
append('"')
|
||||||
|
for (c in raw) when (c) {
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> if (c < ' ') append("\\u%04x".format(c.code)) else append(c)
|
||||||
|
}
|
||||||
|
append('"')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun readSlice(file: File, offset: Long, max: Int): ByteArray {
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
var skipped = 0L
|
||||||
|
while (skipped < offset) {
|
||||||
|
val n = input.skip(offset - skipped)
|
||||||
|
if (n <= 0) break
|
||||||
|
skipped += n
|
||||||
|
}
|
||||||
|
val buf = ByteArray(max)
|
||||||
|
var read = 0
|
||||||
|
while (read < max) {
|
||||||
|
val n = input.read(buf, read, max - read)
|
||||||
|
if (n < 0) break
|
||||||
|
read += n
|
||||||
|
}
|
||||||
|
return if (read == max) buf else buf.copyOf(read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "${r.path} · ${audio.size} recordings — synced by your sync tool, not by this app.",
|
||||||
|
syncedCount = 0,
|
||||||
|
localOnlyCount = audio.size,
|
||||||
|
bytesUsed = audio.sumOf { it.length() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ID = "sync-folder"
|
||||||
|
const val KEY_ROOT = "provider.sync-folder.root"
|
||||||
|
}
|
||||||
|
}
|
||||||
157
android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt
Normal file
157
android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud server identification + login flow v2 (docs/server-providers.md
|
||||||
|
* §3, §6). No credentials are ever sent here: [startLogin] is anonymous,
|
||||||
|
* [poll] carries only the one-time poll token.
|
||||||
|
*
|
||||||
|
* Login flow v2 (official, supported):
|
||||||
|
* 1. POST {base}/index.php/login/v2 -> {poll:{token,endpoint}, login:url}
|
||||||
|
* 2. user approves {login} in a browser (server-owned consent screen)
|
||||||
|
* 3. POST {poll.endpoint} {"token":...} -> 404 while pending,
|
||||||
|
* 200 {server, loginName, appPassword} once approved
|
||||||
|
*/
|
||||||
|
class NextcloudAuth(
|
||||||
|
private val client: OkHttpClient = defaultClient(),
|
||||||
|
private val tofu: TofuManager? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Is there a Nextcloud at [url]? Read-only, no credentials. */
|
||||||
|
suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder().url(url.origin + "/status.php").get().build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (resp.code != 200) return@withContext ProbeResult.Incompatible
|
||||||
|
val body = JSONObject(resp.body?.string().orEmpty())
|
||||||
|
// status.php: {productname, versionstring, ...}. Some forks
|
||||||
|
// report "Nextcloud" with different casing — accept any.
|
||||||
|
val product = body.optString("productname", "")
|
||||||
|
if (!product.equals("nextcloud", ignoreCase = true)) {
|
||||||
|
return@withContext ProbeResult.Incompatible
|
||||||
|
}
|
||||||
|
ProbeResult.Compatible(
|
||||||
|
descriptor = ProviderDescriptor(
|
||||||
|
id = ProviderRegistry.NEXTCLOUD_ID,
|
||||||
|
displayName = "Nextcloud",
|
||||||
|
isDefault = true,
|
||||||
|
capabilities = setOf(
|
||||||
|
ProviderDescriptor.Capability.CHUNKED_UPLOAD,
|
||||||
|
ProviderDescriptor.Capability.QUOTA_INFO,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
serverName = product,
|
||||||
|
version = body.optString("versionstring", "unknown"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: javax.net.ssl.SSLHandshakeException) {
|
||||||
|
ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown")
|
||||||
|
} catch (e: javax.net.ssl.SSLPeerUnverifiedException) {
|
||||||
|
ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProbeResult.NetworkError(e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begins login flow v2. Returns the browser URL + poll handle. */
|
||||||
|
suspend fun startLogin(url: ServerUrl): LoginFlowSession = withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder().url(url.origin + "/index.php/login/v2")
|
||||||
|
.post(ByteArray(0).toRequestBody(null)).build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (resp.code != 200) {
|
||||||
|
throw ProviderError.Transient(
|
||||||
|
"Server refused the login request (HTTP ${resp.code}). " +
|
||||||
|
"Is this a Nextcloud?"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val body = JSONObject(resp.body?.string().orEmpty())
|
||||||
|
val poll = body.optJSONObject("poll")
|
||||||
|
val login = body.optString("login", "")
|
||||||
|
val token = poll?.optString("token", "").orEmpty()
|
||||||
|
val endpoint = poll?.optString("endpoint", "").orEmpty()
|
||||||
|
if (login.isBlank() || token.isBlank() || endpoint.isBlank()) {
|
||||||
|
throw ProviderError.Transient("Server gave an incomplete login response")
|
||||||
|
}
|
||||||
|
LoginFlowSession(
|
||||||
|
baseUrl = url.origin,
|
||||||
|
loginUrl = login,
|
||||||
|
pollToken = token,
|
||||||
|
pollEndpoint = endpoint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: ProviderError) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw ProviderError.Transient("Could not reach the server (${e.javaClass.simpleName})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One poll attempt. Call repeatedly until [PollResult.Approved]. */
|
||||||
|
suspend fun poll(flow: LoginFlowSession): PollResult = withContext(Dispatchers.IO) {
|
||||||
|
val json = """{"token":"${flow.pollToken}"}"""
|
||||||
|
val req = Request.Builder().url(flow.pollEndpoint)
|
||||||
|
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> {
|
||||||
|
val body = JSONObject(resp.body?.string().orEmpty())
|
||||||
|
val server = body.optString("server", flow.baseUrl)
|
||||||
|
val name = body.optString("loginName", "")
|
||||||
|
val pass = body.optString("appPassword", "")
|
||||||
|
if (name.isBlank() || pass.isBlank()) {
|
||||||
|
return@withContext PollResult.Failed("Server approved but sent no credentials")
|
||||||
|
}
|
||||||
|
PollResult.Approved(
|
||||||
|
ProviderCredential.AppPassword(
|
||||||
|
accountLabel = "$name@${baseHost(server)}",
|
||||||
|
loginUrl = server,
|
||||||
|
user = name,
|
||||||
|
password = pass,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
404 -> PollResult.Pending
|
||||||
|
else -> PollResult.Failed("Login poll failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PollResult.Failed("Login poll failed (${e.javaClass.simpleName})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun baseHost(server: String): String =
|
||||||
|
runCatching { java.net.URI(server).host }.getOrNull() ?: server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser URL + one-time poll handle from [NextcloudAuth.startLogin]. */
|
||||||
|
data class LoginFlowSession(
|
||||||
|
val baseUrl: String,
|
||||||
|
val loginUrl: String,
|
||||||
|
val pollToken: String,
|
||||||
|
val pollEndpoint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One poll attempt's outcome. The token itself never appears in messages. */
|
||||||
|
sealed class PollResult {
|
||||||
|
data object Pending : PollResult()
|
||||||
|
data class Approved(val credential: ProviderCredential.AppPassword) : PollResult()
|
||||||
|
data class Failed(val reason: String) : PollResult()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import com.shonar.settings.SettingsStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure persistence for the Nextcloud session: server origin, DAV user id,
|
||||||
|
* display name, and the app password from login flow v2. The user's *normal*
|
||||||
|
* account password is never requested, typed, or stored — only the
|
||||||
|
* server-issued app password lives here (docs/server-providers.md §3).
|
||||||
|
*/
|
||||||
|
class NextcloudAuthStore(private val secure: SettingsStore) {
|
||||||
|
|
||||||
|
suspend fun save(session: NcSession) {
|
||||||
|
secure.putString(KEY_BASE_URL, session.baseUrl)
|
||||||
|
secure.putString(KEY_USER_ID, session.userId)
|
||||||
|
secure.putString(KEY_USERNAME, session.username)
|
||||||
|
secure.putString(KEY_APP_PASSWORD, session.appPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun load(): NcSession? {
|
||||||
|
val base = secure.getString(KEY_BASE_URL) ?: return null
|
||||||
|
val pass = secure.getString(KEY_APP_PASSWORD) ?: return null
|
||||||
|
return NcSession(
|
||||||
|
baseUrl = base,
|
||||||
|
userId = secure.getString(KEY_USER_ID).orEmpty(),
|
||||||
|
username = secure.getString(KEY_USERNAME).orEmpty(),
|
||||||
|
appPassword = pass,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clear() {
|
||||||
|
secure.remove(KEY_BASE_URL)
|
||||||
|
secure.remove(KEY_USER_ID)
|
||||||
|
secure.remove(KEY_USERNAME)
|
||||||
|
secure.remove(KEY_APP_PASSWORD)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFIX = "provider.nextcloud."
|
||||||
|
const val KEY_BASE_URL = PREFIX + "base_url"
|
||||||
|
const val KEY_USER_ID = PREFIX + "user_id"
|
||||||
|
const val KEY_USERNAME = PREFIX + "username"
|
||||||
|
const val KEY_APP_PASSWORD = PREFIX + "app_password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated Nextcloud session. Never logged (see toString). */
|
||||||
|
data class NcSession(
|
||||||
|
val baseUrl: String,
|
||||||
|
val userId: String, // DAV path user (OCS `id`, not the display name)
|
||||||
|
val username: String, // human hint only
|
||||||
|
val appPassword: String,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "NcSession(server=$baseUrl, user=$username, [redacted])"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,644 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P4: Nextcloud provider — the product default. Talks only official,
|
||||||
|
* supported endpoints (docs/server-providers.md §6):
|
||||||
|
*
|
||||||
|
* - GET {base}/status.php (probe)
|
||||||
|
* - login flow v2 (see [NextcloudAuth])
|
||||||
|
* - GET {base}/ocs/v2.php/cloud/user (identity + quota)
|
||||||
|
* - DELETE {base}/ocs/v2.php/core/apppassword (revoke own app password)
|
||||||
|
* - WebDAV {base}/remote.php/dav/files/{user}/… (PROPFIND/GET/PUT/MKCOL/MOVE/DELETE)
|
||||||
|
* - Chunked upload v2 {base}/remote.php/dav/uploads/{user}/{transfer}/
|
||||||
|
* (MKCOL, PUT chunks 00001..N, MOVE {transfer}/.file -> destination)
|
||||||
|
*
|
||||||
|
* Layout: `SHONAR/audio/{uuid}.m4a`, `SHONAR/sidecars/{uuid}/{kind}.json`.
|
||||||
|
* Originals are never overwritten by processing artifacts — uploads with
|
||||||
|
* the same draft id MOVE onto the same key (idempotent replace).
|
||||||
|
*
|
||||||
|
* Chunk naming follows the developer manual: chunks are numbered 1..10000
|
||||||
|
* and assembled in name order, so names are zero-padded to 5 digits
|
||||||
|
* ("00001".."10000") to keep lexical order == numeric order. Chunk size
|
||||||
|
* defaults to 16 MiB — the server requires 5 MiB..5 GiB per chunk (last
|
||||||
|
* chunk exempt), so never lower the default for production use; the
|
||||||
|
* constructor parameter exists for tests only.
|
||||||
|
*
|
||||||
|
* Transfer ids are deterministic per recording (`shonar-{draft.id}`), so a
|
||||||
|
* killed upload resumes by PROPFIND-ing the transfer folder and skipping
|
||||||
|
* present chunks — including across process restarts.
|
||||||
|
*/
|
||||||
|
class NextcloudProvider(
|
||||||
|
private val auth: NextcloudAuthStore,
|
||||||
|
private val client: OkHttpClient = NextcloudAuth.defaultClient(),
|
||||||
|
private val loginFlow: NextcloudAuth = NextcloudAuth(client),
|
||||||
|
private val chunkSizeBytes: Long = 16 * 1024 * 1024,
|
||||||
|
) : ShonarProvider {
|
||||||
|
|
||||||
|
override val descriptor = ProviderDescriptor(
|
||||||
|
id = ProviderRegistry.NEXTCLOUD_ID,
|
||||||
|
displayName = "Nextcloud",
|
||||||
|
isDefault = true,
|
||||||
|
capabilities = setOf(
|
||||||
|
ProviderDescriptor.Capability.CHUNKED_UPLOAD,
|
||||||
|
ProviderDescriptor.Capability.QUOTA_INFO,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
|
||||||
|
override val authState: StateFlow<AuthState> = _authState
|
||||||
|
|
||||||
|
// ---- lifecycle ---------------------------------------------------------
|
||||||
|
|
||||||
|
override suspend fun probe(baseUrl: ServerUrl): ProbeResult = loginFlow.probe(baseUrl)
|
||||||
|
|
||||||
|
override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
|
||||||
|
val app = credential as? ProviderCredential.AppPassword
|
||||||
|
?: throw ProviderError.InvalidUrl(
|
||||||
|
"Nextcloud connects with an app password from the browser login flow"
|
||||||
|
)
|
||||||
|
val origin = ServerUrl.parse(app.loginUrl).getOrNull()?.origin
|
||||||
|
?: throw ProviderError.InvalidUrl("Not a valid server URL")
|
||||||
|
// Validate before persisting: a wrong password must not overwrite a
|
||||||
|
// working session.
|
||||||
|
val userId = try {
|
||||||
|
ocsUserId(origin, app.user, app.password)
|
||||||
|
} catch (e: ProviderError.AuthExpired) {
|
||||||
|
throw ProviderError.Transient(
|
||||||
|
"Nextcloud rejected the login — approve it in the browser again"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
auth.save(
|
||||||
|
NcSession(
|
||||||
|
baseUrl = origin,
|
||||||
|
userId = userId,
|
||||||
|
username = app.user,
|
||||||
|
appPassword = app.password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
|
||||||
|
val saved = auth.load()
|
||||||
|
if (saved == null || saved.appPassword.isBlank() || saved.userId.isBlank()) {
|
||||||
|
_authState.value = AuthState.DISCONNECTED
|
||||||
|
return@withContext _authState.value
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ocsUserId(saved.baseUrl, saved.userId, saved.appPassword)
|
||||||
|
_authState.value = AuthState.CONNECTED
|
||||||
|
} catch (e: ProviderError.AuthExpired) {
|
||||||
|
_authState.value = AuthState.EXPIRED
|
||||||
|
} catch (e: ProviderError) {
|
||||||
|
_authState.value = AuthState.OFFLINE
|
||||||
|
}
|
||||||
|
_authState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) {
|
||||||
|
if (revokeOnServer) {
|
||||||
|
// Best effort: local state is cleared even if revoke fails.
|
||||||
|
runCatching {
|
||||||
|
val saved = auth.load()
|
||||||
|
if (saved != null && saved.appPassword.isNotBlank()) {
|
||||||
|
dav(saved, "DELETE", ocsPath("/core/apppassword"), null).close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth.clear()
|
||||||
|
_authState.value = AuthState.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes the app password and forgets the session. There is no API for
|
||||||
|
* deleting the whole Nextcloud account — that stays a manual step on the
|
||||||
|
* server, and the message says so.
|
||||||
|
*/
|
||||||
|
override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) {
|
||||||
|
disconnect(revokeOnServer = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- storage -----------------------------------------------------------
|
||||||
|
|
||||||
|
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
val key = "SHONAR/audio/${draft.id}${extFor(draft.mime)}"
|
||||||
|
val total = draft.sizeBytes.coerceAtLeast(1)
|
||||||
|
ensureDir(session, "SHONAR")
|
||||||
|
ensureDir(session, "SHONAR/audio")
|
||||||
|
|
||||||
|
val transfer = "shonar-${draft.id}"
|
||||||
|
mkcol(session, transfer)
|
||||||
|
val received = transferChunks(session, transfer)
|
||||||
|
val chunkCount = ((draft.sizeBytes + chunkSizeBytes - 1) / chunkSizeBytes)
|
||||||
|
.toInt().coerceAtLeast(1)
|
||||||
|
var sent = 0L
|
||||||
|
for (i in received) {
|
||||||
|
sent += if (i < chunkCount) chunkSizeBytes else 0L
|
||||||
|
}
|
||||||
|
sent = sent.coerceAtMost(draft.sizeBytes)
|
||||||
|
if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
|
||||||
|
|
||||||
|
var idx = 1
|
||||||
|
while (idx <= chunkCount) {
|
||||||
|
ensureActive()
|
||||||
|
if (idx !in received) {
|
||||||
|
val slice = readSlice(draft.sourceFile, (idx - 1) * chunkSizeBytes, chunkSizeBytes)
|
||||||
|
putChunk(session, transfer, idx, slice, draft.sizeBytes, key)
|
||||||
|
sent += slice.size
|
||||||
|
onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
assemble(session, transfer, key, draft)
|
||||||
|
// Best-effort cleanup of the transfer folder; the server also
|
||||||
|
// expires stale upload dirs on its own.
|
||||||
|
runCatching {
|
||||||
|
dav(session, "DELETE", uploadsPath(session, transfer) + "/", null).close()
|
||||||
|
}
|
||||||
|
onProgress(1f)
|
||||||
|
RemoteRef(ProviderRegistry.NEXTCLOUD_ID, key, etag = null, sizeBytes = draft.sizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
dav(session, "GET", filesPath(session, ref.key), null).use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> {
|
||||||
|
val body = resp.body ?: throw ProviderError.Transient("Empty download response")
|
||||||
|
val total = body.contentLength().takeIf { it > 0 } ?: -1
|
||||||
|
dest.parentFile?.mkdirs()
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
dest.outputStream().use { output ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
var written = 0L
|
||||||
|
var last = -1f
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
output.write(buf, 0, n)
|
||||||
|
written += n
|
||||||
|
if (total > 0) {
|
||||||
|
val p = (written.toFloat() / total).coerceIn(0f, 1f)
|
||||||
|
if (p > last) {
|
||||||
|
onProgress(p)
|
||||||
|
last = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress(1f)
|
||||||
|
}
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
404 -> throw ProviderError.NotFound(ref.key)
|
||||||
|
else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(ref: RemoteRef) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
dav(session, "DELETE", filesPath(session, ref.key), null).use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200, 201, 204 -> Unit
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
404 -> throw ProviderError.NotFound(ref.key)
|
||||||
|
else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sidecars go with the recording; ignore failures (may not exist).
|
||||||
|
runCatching {
|
||||||
|
dav(session, "DELETE", filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/"), null)
|
||||||
|
.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(cursor: String?): Page<RemoteRecording> = withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
// Single page: a PROPFIND Depth:1 returns the whole folder. Paging
|
||||||
|
// stays null until a library outgrows one response.
|
||||||
|
if (cursor != null) return@withContext Page(emptyList(), null)
|
||||||
|
val items = propfind(session, filesPath(session, "SHONAR/audio/"), depth = "1")
|
||||||
|
.filter { it.isFile && it.relativePath != "SHONAR/audio/" && !it.relativePath.removePrefix("SHONAR/audio/").contains('/') }
|
||||||
|
.map { e ->
|
||||||
|
val name = e.relativePath.removePrefix("SHONAR/audio/")
|
||||||
|
RemoteRecording(
|
||||||
|
ref = RemoteRef(ProviderRegistry.NEXTCLOUD_ID, e.relativePath, e.etag, e.size),
|
||||||
|
title = name.substringBeforeLast('.'),
|
||||||
|
createdAtEpochMs = e.lastModified,
|
||||||
|
durationMs = 0, // duration is tracked locally, not over DAV
|
||||||
|
mime = e.contentType ?: "application/octet-stream",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Page(items, nextCursor = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sidecars: real remote files under SHONAR/sidecars/{uuid}/ --------
|
||||||
|
|
||||||
|
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
val dir = "SHONAR/sidecars/${uuidForKey(ref.key)}"
|
||||||
|
ensureDir(session, "SHONAR/sidecars")
|
||||||
|
ensureDir(session, dir)
|
||||||
|
dav(
|
||||||
|
session, "PUT", filesPath(session, "$dir/${kind.fileName}"),
|
||||||
|
bytes.toRequestBody("application/json; charset=utf-8".toMediaType()),
|
||||||
|
sensitiveBody = true, // transcripts are never logged, in any mode
|
||||||
|
).use { resp ->
|
||||||
|
if (resp.code !in 200..201 && resp.code != 204) {
|
||||||
|
throw mapError(resp.code, "Sidecar upload failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
dav(
|
||||||
|
session, "GET",
|
||||||
|
filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/${kind.fileName}"), null,
|
||||||
|
sensitiveBody = true, // transcripts are never logged, in any mode
|
||||||
|
).use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> resp.body?.bytes()
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
404 -> null
|
||||||
|
else -> throw mapError(resp.code, "Sidecar download failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- status ------------------------------------------------------------
|
||||||
|
|
||||||
|
override suspend fun storageLocationSummary(): StorageLocation =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val session = connectedSession()
|
||||||
|
val quota = ocsQuota(session)
|
||||||
|
var count = 0
|
||||||
|
var bytes = 0L
|
||||||
|
// list() is single-page for now; keep the loop for when it pages.
|
||||||
|
var cursor: String? = null
|
||||||
|
do {
|
||||||
|
val page = list(cursor)
|
||||||
|
count += page.items.size
|
||||||
|
bytes += page.items.sumOf { it.ref.sizeBytes }
|
||||||
|
cursor = page.nextCursor
|
||||||
|
} while (cursor != null)
|
||||||
|
val host = runCatching { java.net.URI(session.baseUrl).host }.getOrNull()
|
||||||
|
?: session.baseUrl
|
||||||
|
StorageLocation(
|
||||||
|
headline = "Nextcloud at $host",
|
||||||
|
detail = "${session.username} · $count recordings synced" +
|
||||||
|
(quota?.let { " · ${formatBytes(it.free)} free of ${formatBytes(it.total)}" } ?: ""),
|
||||||
|
syncedCount = count,
|
||||||
|
localOnlyCount = 0,
|
||||||
|
bytesUsed = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP + DAV plumbing -----------------------------------------------
|
||||||
|
|
||||||
|
private suspend fun connectedSession(): NcSession {
|
||||||
|
if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
|
||||||
|
return auth.load()?.takeIf { it.appPassword.isNotBlank() && it.userId.isNotBlank() }
|
||||||
|
?: throw ProviderError.NotConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basic(session: NcSession): String {
|
||||||
|
val raw = "${session.userId}:${session.appPassword}".toByteArray(Charsets.UTF_8)
|
||||||
|
return "Basic " + java.util.Base64.getEncoder().encodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw DAV/OCS call. Caller closes the response. [path] starts with '/'. */
|
||||||
|
private fun dav(
|
||||||
|
session: NcSession,
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
body: okhttp3.RequestBody?,
|
||||||
|
sensitiveBody: Boolean = false,
|
||||||
|
): Response {
|
||||||
|
val builder = Request.Builder().url(session.baseUrl + path)
|
||||||
|
.header("Authorization", basic(session))
|
||||||
|
if (sensitiveBody) builder.header(RedactingLogger.SENSITIVE_BODY, "1")
|
||||||
|
if (method == "GET") builder.get()
|
||||||
|
else builder.method(method, body)
|
||||||
|
if (path.startsWith("/ocs/")) {
|
||||||
|
builder.header("OCS-APIRequest", "true")
|
||||||
|
builder.header("Accept", "application/json")
|
||||||
|
}
|
||||||
|
return client.newCall(builder.build()).execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filesPath(session: NcSession, relative: String): String {
|
||||||
|
val segs = relative.split('/').filter { it.isNotEmpty() }.joinToString("/") { enc(it) }
|
||||||
|
return "/remote.php/dav/files/${enc(session.userId)}/$segs"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadsPath(session: NcSession, transfer: String): String =
|
||||||
|
"/remote.php/dav/uploads/${enc(session.userId)}/${enc(transfer)}"
|
||||||
|
|
||||||
|
private fun ocsPath(suffix: String): String = "/ocs/v2.php$suffix"
|
||||||
|
|
||||||
|
/** OCS identity check; returns the DAV user id or throws. */
|
||||||
|
private suspend fun ocsUserId(origin: String, user: String, appPassword: String): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val raw = "$user:$appPassword".toByteArray(Charsets.UTF_8)
|
||||||
|
val req = Request.Builder().url(origin + ocsPath("/cloud/user")).get()
|
||||||
|
.header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString(raw))
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.header("Accept", "application/json").build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200 -> {
|
||||||
|
val data = JSONObject(resp.body?.string().orEmpty())
|
||||||
|
.optJSONObject("ocs")?.optJSONObject("data")
|
||||||
|
val id = data?.optString("id", "").orEmpty()
|
||||||
|
if (id.isBlank()) throw ProviderError.Transient("Server identity reply was empty")
|
||||||
|
id
|
||||||
|
}
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
else -> throw ProviderError.Transient("Server identity check failed (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Quota(val free: Long, val total: Long)
|
||||||
|
|
||||||
|
private suspend fun ocsQuota(session: NcSession): Quota? = withContext(Dispatchers.IO) {
|
||||||
|
// Quota is informational; never fail the summary over it.
|
||||||
|
runCatching {
|
||||||
|
dav(session, "GET", ocsPath("/cloud/user"), null).use { resp ->
|
||||||
|
if (resp.code != 200) return@runCatching null
|
||||||
|
val q = JSONObject(resp.body?.string().orEmpty())
|
||||||
|
.optJSONObject("ocs")?.optJSONObject("data")?.optJSONObject("quota")
|
||||||
|
?: return@runCatching null
|
||||||
|
// Quota values may be numbers or numeric strings; total -3
|
||||||
|
// (or "unknown") means unlimited.
|
||||||
|
fun num(v: Any?): Long = when (v) {
|
||||||
|
is Number -> v.toLong()
|
||||||
|
is String -> v.toLongOrNull() ?: -3L
|
||||||
|
else -> -3L
|
||||||
|
}
|
||||||
|
val free = num(q.opt("free"))
|
||||||
|
val total = num(q.opt("total"))
|
||||||
|
if (total < 0) null else Quota(free.coerceAtLeast(0), total)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MKCOL tolerant of "already exists". */
|
||||||
|
private suspend fun ensureDir(session: NcSession, relative: String) {
|
||||||
|
// Create level by level so a missing parent reads as progress, not 409.
|
||||||
|
val parts = relative.split('/').filter { it.isNotEmpty() }
|
||||||
|
var prefix = ""
|
||||||
|
for (part in parts) {
|
||||||
|
prefix = if (prefix.isEmpty()) part else "$prefix/$part"
|
||||||
|
dav(session, "MKCOL", filesPath(session, prefix), null).use { resp ->
|
||||||
|
if (resp.code == 401) throw ProviderError.AuthExpired()
|
||||||
|
if (resp.code != 201 && resp.code != 405) {
|
||||||
|
throw mapError(resp.code, "Could not create folder $prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mkcol(session: NcSession, transfer: String) {
|
||||||
|
dav(session, "MKCOL", uploadsPath(session, transfer) + "/", null).use { resp ->
|
||||||
|
// 405: transfer folder from a previous attempt — resume into it.
|
||||||
|
if (resp.code == 401) throw ProviderError.AuthExpired()
|
||||||
|
if (resp.code != 201 && resp.code != 405) {
|
||||||
|
throw mapError(resp.code, "Could not start the upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunk names present in the transfer folder (resume). */
|
||||||
|
private suspend fun transferChunks(session: NcSession, transfer: String): Set<Int> {
|
||||||
|
val prefix = "/remote.php/dav/uploads/${session.userId}/"
|
||||||
|
val entries = propfind(session, uploadsPath(session, transfer) + "/", depth = "1", prefix = prefix)
|
||||||
|
return entries.mapNotNullTo(mutableSetOf()) { e ->
|
||||||
|
// Chunk names are "00001".. — compare by numeric value.
|
||||||
|
e.name.trimStart('0').ifEmpty { "0" }.toIntOrNull()
|
||||||
|
?.takeIf { it in 1..10000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun putChunk(
|
||||||
|
session: NcSession,
|
||||||
|
transfer: String,
|
||||||
|
index: Int, // 1-based
|
||||||
|
bytes: ByteArray,
|
||||||
|
totalBytes: Long,
|
||||||
|
destKey: String,
|
||||||
|
) {
|
||||||
|
val name = "%05d".format(index)
|
||||||
|
val dest = session.baseUrl + filesPath(session, destKey)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(session.baseUrl + uploadsPath(session, transfer) + "/" + name)
|
||||||
|
.put(bytes.toRequestBody("application/octet-stream".toMediaType()))
|
||||||
|
.header("Authorization", basic(session))
|
||||||
|
.header("OC-Total-Length", totalBytes.toString())
|
||||||
|
.header("Destination", dest).build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200, 201, 204 -> Unit
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
507 -> throw ProviderError.QuotaExceeded()
|
||||||
|
else -> throw mapError(resp.code, "Chunk $index rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun assemble(
|
||||||
|
session: NcSession,
|
||||||
|
transfer: String,
|
||||||
|
destKey: String,
|
||||||
|
draft: RecordingDraft,
|
||||||
|
) {
|
||||||
|
val dest = session.baseUrl + filesPath(session, destKey)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(session.baseUrl + uploadsPath(session, transfer) + "/.file")
|
||||||
|
.method("MOVE", null)
|
||||||
|
.header("Authorization", basic(session))
|
||||||
|
.header("Destination", dest)
|
||||||
|
.header("Overwrite", "T")
|
||||||
|
.header("OC-Total-Length", draft.sizeBytes.toString())
|
||||||
|
// Server mtime = recording time, so listings sort by when it
|
||||||
|
// was recorded, not when it finished uploading.
|
||||||
|
.header("X-OC-Mtime", (draft.createdAtEpochMs / 1000).toString()).build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200, 201, 204 -> Unit
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
404 -> throw ProviderError.Transient("Upload assembly failed — retry the upload")
|
||||||
|
507 -> throw ProviderError.QuotaExceeded()
|
||||||
|
else -> throw mapError(resp.code, "Upload assembly failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DavEntry(
|
||||||
|
val relativePath: String, // relative to files/{user}/, decoded
|
||||||
|
val name: String,
|
||||||
|
val isFile: Boolean,
|
||||||
|
val size: Long,
|
||||||
|
val etag: String?,
|
||||||
|
val contentType: String?,
|
||||||
|
val lastModified: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun propfind(
|
||||||
|
session: NcSession,
|
||||||
|
path: String,
|
||||||
|
depth: String,
|
||||||
|
prefix: String = "/remote.php/dav/files/${session.userId}/",
|
||||||
|
): List<DavEntry> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val body = """<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getetag/><d:resourcetype/><d:getcontenttype/><d:getlastmodified/><d:displayname/></d:prop></d:propfind>"""
|
||||||
|
val req = Request.Builder().url(session.baseUrl + path)
|
||||||
|
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||||
|
.header("Authorization", basic(session))
|
||||||
|
.header("Depth", depth).build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
when (resp.code) {
|
||||||
|
200, 207 -> parseMultistatus(resp.body?.string().orEmpty(), prefix = prefix)
|
||||||
|
401 -> throw ProviderError.AuthExpired()
|
||||||
|
404 -> emptyList()
|
||||||
|
else -> throw mapError(resp.code, "Listing failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapError(code: Int, fallback: String): ProviderError = when (code) {
|
||||||
|
401 -> ProviderError.AuthExpired()
|
||||||
|
507 -> ProviderError.QuotaExceeded()
|
||||||
|
else -> ProviderError.Transient("$fallback (HTTP $code)")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** One path segment, percent-encoded (spaces as %20, not '+'). */
|
||||||
|
internal fun enc(segment: String): String =
|
||||||
|
java.net.URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
|
||||||
|
|
||||||
|
internal fun uuidForKey(key: String): String =
|
||||||
|
key.substringAfterLast('/').substringBeforeLast('.')
|
||||||
|
|
||||||
|
internal fun extFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) {
|
||||||
|
"audio/mp4", "audio/m4a" -> ".m4a"
|
||||||
|
"audio/aac" -> ".aac"
|
||||||
|
"audio/wav", "audio/x-wav" -> ".wav"
|
||||||
|
"audio/ogg", "audio/opus" -> ".ogg"
|
||||||
|
"audio/webm" -> ".webm"
|
||||||
|
"audio/mpeg" -> ".mp3"
|
||||||
|
else -> ".m4a" // the app records m4a; unknown mimes keep a playable suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun formatBytes(n: Long): String {
|
||||||
|
if (n < 1024) return "$n B"
|
||||||
|
val units = arrayOf("KB", "MB", "GB", "TB")
|
||||||
|
var v = n.toDouble() / 1024
|
||||||
|
var u = 0
|
||||||
|
while (v >= 1024 && u < units.size - 1) {
|
||||||
|
v /= 1024
|
||||||
|
u++
|
||||||
|
}
|
||||||
|
return "%s %s".format(if (v >= 100) "%.0f" else "%.1f".format(v), units[u])
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseMultistatus(xml: String, prefix: String): List<DavEntry> {
|
||||||
|
if (xml.isBlank()) return emptyList()
|
||||||
|
val out = mutableListOf<DavEntry>()
|
||||||
|
try {
|
||||||
|
val factory = javax.xml.parsers.DocumentBuilderFactory.newInstance()
|
||||||
|
factory.isNamespaceAware = true
|
||||||
|
// Harden against XXE: multistatus docs never need doctypes.
|
||||||
|
runCatching {
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
|
||||||
|
}
|
||||||
|
val doc = factory.newDocumentBuilder()
|
||||||
|
.parse(java.io.ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8)))
|
||||||
|
val responses = doc.getElementsByTagNameNS("DAV:", "response")
|
||||||
|
for (i in 0 until responses.length) {
|
||||||
|
val el = responses.item(i) as? org.w3c.dom.Element ?: continue
|
||||||
|
fun text(tag: String): String? {
|
||||||
|
val nodes = el.getElementsByTagNameNS("DAV:", tag)
|
||||||
|
if (nodes.length == 0) return null
|
||||||
|
return nodes.item(0).textContent?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
val href = text("href") ?: continue
|
||||||
|
val decoded = runCatching {
|
||||||
|
java.net.URLDecoder.decode(href, "UTF-8")
|
||||||
|
}.getOrNull() ?: href
|
||||||
|
// Strip scheme+host when the server returns absolute hrefs.
|
||||||
|
val pathOnly = runCatching { java.net.URI(decoded).path }.getOrNull() ?: decoded
|
||||||
|
val relative = pathOnly.removePrefix(prefix).trim('/')
|
||||||
|
val isCollection = runCatching {
|
||||||
|
val rt = el.getElementsByTagNameNS("DAV:", "resourcetype")
|
||||||
|
rt.length > 0 && (rt.item(0) as org.w3c.dom.Element)
|
||||||
|
.getElementsByTagNameNS("DAV:", "collection").length > 0
|
||||||
|
}.getOrDefault(false)
|
||||||
|
val lastMod = text("getlastmodified")?.let {
|
||||||
|
runCatching {
|
||||||
|
java.time.ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME)
|
||||||
|
.toInstant().toEpochMilli()
|
||||||
|
}.getOrNull()
|
||||||
|
} ?: 0L
|
||||||
|
out += DavEntry(
|
||||||
|
relativePath = relative,
|
||||||
|
name = relative.substringAfterLast('/'),
|
||||||
|
isFile = !isCollection,
|
||||||
|
size = text("getcontentlength")?.toLongOrNull() ?: 0L,
|
||||||
|
etag = text("getetag")?.trim('"'),
|
||||||
|
contentType = text("getcontenttype"),
|
||||||
|
lastModified = lastMod,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw ProviderError.Transient("Could not read the server listing")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readSlice(file: File, offset: Long, max: Long): ByteArray {
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
var skipped = 0L
|
||||||
|
while (skipped < offset) {
|
||||||
|
val n = input.skip(offset - skipped)
|
||||||
|
if (n <= 0) break
|
||||||
|
skipped += n
|
||||||
|
}
|
||||||
|
val cap = max.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||||
|
val buf = ByteArray(cap)
|
||||||
|
var read = 0
|
||||||
|
while (read < cap) {
|
||||||
|
val n = input.read(buf, read, cap - read)
|
||||||
|
if (n < 0) break
|
||||||
|
read += n
|
||||||
|
}
|
||||||
|
return if (read == cap) buf else buf.copyOf(read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
* user selected during setup; switching is just changing this id — the app
|
* user selected during setup; switching is just changing this id — the app
|
||||||
* never branches on concrete provider classes.
|
* never branches on concrete provider classes.
|
||||||
*/
|
*/
|
||||||
class ProviderRegistry(private val factories: Map<String, () -> ShonarProvider>) {
|
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)
|
private val _activeId = MutableStateFlow(LocalOnlyProvider.ID)
|
||||||
val activeId: StateFlow<String> = _activeId
|
val activeId: StateFlow<String> = _activeId
|
||||||
|
|
@ -37,14 +41,50 @@ class ProviderRegistry(private val factories: Map<String, () -> ShonarProvider>)
|
||||||
companion object {
|
companion object {
|
||||||
const val NEXTCLOUD_ID = "nextcloud"
|
const val NEXTCLOUD_ID = "nextcloud"
|
||||||
const val CUSTOM_SHONAR_ID = "custom-shonar"
|
const val CUSTOM_SHONAR_ID = "custom-shonar"
|
||||||
|
const val SYNC_FOLDER_ID = FolderSyncProvider.ID
|
||||||
|
|
||||||
/** Production factory map. P1 registers local-only; later phases add more. */
|
/** Behaviour-identical default: system trust, logging off, no pins. */
|
||||||
fun withDefaults(appFilesDir: java.io.File): ProviderRegistry = ProviderRegistry(
|
fun defaultTls(): TlsPolicy = TlsPolicy(
|
||||||
|
tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
mapOf(
|
||||||
LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) },
|
LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) },
|
||||||
// P3: CUSTOM_SHONAR_ID to { CustomShonarProvider(...) }
|
CUSTOM_SHONAR_ID to { custom },
|
||||||
// P4: NEXTCLOUD_ID to { NextcloudProvider(...) }
|
NEXTCLOUD_ID to { nextcloud },
|
||||||
|
SYNC_FOLDER_ID to { folder },
|
||||||
|
// P6: start9 / umbrel platform probes
|
||||||
),
|
),
|
||||||
|
tls = tlsPolicy,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,11 +108,38 @@ sealed class ProviderCredential {
|
||||||
override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])"
|
override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom SHONAR server login: email + password exchanged for a rotating
|
||||||
|
* token pair (backend M1). The password is held in memory only for the
|
||||||
|
* login call and never persisted — only the resulting tokens are stored
|
||||||
|
* (see ShonarAuthStore).
|
||||||
|
*/
|
||||||
|
data class ShonarLogin(
|
||||||
|
override val accountLabel: String,
|
||||||
|
val serverUrl: ServerUrl,
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
) : ProviderCredential() {
|
||||||
|
override fun toString(): String =
|
||||||
|
"ShonarLogin(account=$accountLabel, server=${serverUrl.origin}, [redacted])"
|
||||||
|
}
|
||||||
|
|
||||||
/** No credential needed (local-only provider). */
|
/** No credential needed (local-only provider). */
|
||||||
data object None : ProviderCredential() {
|
data object None : ProviderCredential() {
|
||||||
override val accountLabel: String get() = "local"
|
override val accountLabel: String get() = "local"
|
||||||
override fun toString(): String = "None"
|
override fun toString(): String = "None"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync-folder provider: an absolute directory path owned by an external
|
||||||
|
* sync tool (Syncthing, the Nextcloud desktop client, rsync…). The path
|
||||||
|
* is not secret, but it is validated — connect refuses a missing or
|
||||||
|
* non-writable directory rather than creating whatever was typed.
|
||||||
|
*/
|
||||||
|
data class FolderPath(
|
||||||
|
override val accountLabel: String,
|
||||||
|
val path: String,
|
||||||
|
) : ProviderCredential()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of probing a base URL for a compatible service. */
|
/** Result of probing a base URL for a compatible service. */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import com.shonar.settings.SettingsStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure persistence for the custom SHONAR server session.
|
||||||
|
*
|
||||||
|
* Stored (all in the Keystore-backed secure store, never in Room or logs):
|
||||||
|
* - base URL origin (e.g. https://shonar.example.com)
|
||||||
|
* - account email (hint only)
|
||||||
|
* - access token + expiry
|
||||||
|
* - refresh token + device id
|
||||||
|
*
|
||||||
|
* The user's password is NEVER stored here: it lives in memory for exactly
|
||||||
|
* one login call (see [ProviderCredential.ShonarLogin]).
|
||||||
|
*
|
||||||
|
* Rotation discipline (backend M1 has refresh reuse detection): every
|
||||||
|
* successful refresh overwrites the stored pair immediately, so a stored
|
||||||
|
* refresh token is always the newest one the server has issued.
|
||||||
|
*/
|
||||||
|
class ShonarAuthStore(private val secure: SettingsStore) {
|
||||||
|
|
||||||
|
suspend fun save(session: ShonarSession) {
|
||||||
|
secure.putString(KEY_BASE_URL, session.baseUrl)
|
||||||
|
secure.putString(KEY_EMAIL, session.email)
|
||||||
|
secure.putString(KEY_ACCESS, session.accessToken)
|
||||||
|
secure.putString(KEY_REFRESH, session.refreshToken)
|
||||||
|
secure.putString(KEY_EXPIRES_AT, session.expiresAtEpochSec.toString())
|
||||||
|
if (session.deviceId != null) secure.putString(KEY_DEVICE_ID, session.deviceId)
|
||||||
|
else secure.remove(KEY_DEVICE_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun load(): ShonarSession? {
|
||||||
|
val baseUrl = secure.getString(KEY_BASE_URL) ?: return null
|
||||||
|
val access = secure.getString(KEY_ACCESS) ?: return null
|
||||||
|
val refresh = secure.getString(KEY_REFRESH) ?: return null
|
||||||
|
return ShonarSession(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
email = secure.getString(KEY_EMAIL).orEmpty(),
|
||||||
|
accessToken = access,
|
||||||
|
refreshToken = refresh,
|
||||||
|
expiresAtEpochSec = secure.getString(KEY_EXPIRES_AT)?.toLongOrNull(),
|
||||||
|
deviceId = secure.getString(KEY_DEVICE_ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop tokens but keep the URL + email so re-login is one step. */
|
||||||
|
suspend fun clearTokens() {
|
||||||
|
secure.remove(KEY_ACCESS)
|
||||||
|
secure.remove(KEY_REFRESH)
|
||||||
|
secure.remove(KEY_EXPIRES_AT)
|
||||||
|
secure.remove(KEY_DEVICE_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget everything, including which server was configured. */
|
||||||
|
suspend fun clearAll() {
|
||||||
|
secure.remove(KEY_BASE_URL)
|
||||||
|
secure.remove(KEY_EMAIL)
|
||||||
|
clearTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFIX = "provider.custom-shonar."
|
||||||
|
const val KEY_BASE_URL = PREFIX + "base_url"
|
||||||
|
const val KEY_EMAIL = PREFIX + "email"
|
||||||
|
const val KEY_ACCESS = PREFIX + "access_token"
|
||||||
|
const val KEY_REFRESH = PREFIX + "refresh_token"
|
||||||
|
const val KEY_EXPIRES_AT = PREFIX + "expires_at"
|
||||||
|
const val KEY_DEVICE_ID = PREFIX + "device_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory session handed between login/refresh calls and the store. */
|
||||||
|
data class ShonarSession(
|
||||||
|
val baseUrl: String,
|
||||||
|
val email: String,
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresAtEpochSec: Long?,
|
||||||
|
val deviceId: String?,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "ShonarSession(server=$baseUrl, account=$email, [redacted])"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticated handshake against the SHONAR backend's
|
||||||
|
* GET /api/v1/provider-info. Used by the provider-selection screen and by
|
||||||
|
* Start9/Umbrel platform probes to identify SHONAR-compatible services.
|
||||||
|
*
|
||||||
|
* TLS policy (docs/server-providers.md §4):
|
||||||
|
* - full system verification by default, no bypass, ever.
|
||||||
|
* - a handshake failure yields ProbeResult.TlsFailure with the peer cert's
|
||||||
|
* SPKI SHA-256 (recorded by the TOFU trust manager) so the UI can run
|
||||||
|
* explicit trust-on-first-use approval; this client NEVER retries with
|
||||||
|
* verification disabled.
|
||||||
|
*/
|
||||||
|
class ShonarHandshake(
|
||||||
|
private val client: OkHttpClient = defaultClient(),
|
||||||
|
private val tofu: TofuManager? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
|
||||||
|
val endpoint = url.origin + "/api/v1/provider-info"
|
||||||
|
val request = Request.Builder().url(endpoint).get().build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { resp ->
|
||||||
|
if (resp.code == 200) {
|
||||||
|
parseBody(resp.body?.string().orEmpty())
|
||||||
|
} else {
|
||||||
|
ProbeResult.Incompatible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SSLHandshakeException) {
|
||||||
|
ProbeResult.TlsFailure(fingerprintFor(url.host))
|
||||||
|
} catch (e: javax.net.ssl.SSLPeerUnverifiedException) {
|
||||||
|
// Hostname mismatch: strict verifier stays strict, but the UI
|
||||||
|
// should still say *why* instead of a generic network error.
|
||||||
|
ProbeResult.TlsFailure(fingerprintFor(url.host))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// message must stay generic: exception text can contain URLs but
|
||||||
|
// never credentials (this call sends no credentials at all)
|
||||||
|
ProbeResult.NetworkError(e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBody(json: String): ProbeResult = try {
|
||||||
|
val root = org.json.JSONObject(json)
|
||||||
|
if (root.optString("kind") != "shonar") {
|
||||||
|
ProbeResult.Incompatible
|
||||||
|
} else {
|
||||||
|
val caps = root.optJSONObject("capabilities") ?: org.json.JSONObject()
|
||||||
|
val set = mutableSetOf<ProviderDescriptor.Capability>()
|
||||||
|
if (caps.optBoolean("chunked_upload")) set += ProviderDescriptor.Capability.CHUNKED_UPLOAD
|
||||||
|
if (caps.optBoolean("server_transcription")) set += ProviderDescriptor.Capability.SERVER_TRANSCRIPTION
|
||||||
|
if (caps.optBoolean("server_summary")) set += ProviderDescriptor.Capability.SERVER_SUMMARY
|
||||||
|
if (caps.optBoolean("account_deletion")) set += ProviderDescriptor.Capability.ACCOUNT_DELETION
|
||||||
|
ProbeResult.Compatible(
|
||||||
|
descriptor = ProviderDescriptor(
|
||||||
|
id = ProviderRegistry.CUSTOM_SHONAR_ID,
|
||||||
|
displayName = "Custom SHONAR server",
|
||||||
|
capabilities = set,
|
||||||
|
),
|
||||||
|
serverName = root.optString("storage_backend", "server"),
|
||||||
|
version = root.optString("version", "unknown"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProbeResult.Incompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPKI SHA-256 recorded by the TOFU trust manager during the failed
|
||||||
|
* handshake, or "unknown" when nothing was captured (plain HTTP,
|
||||||
|
* pre-handshake failure, or no TOFU manager wired).
|
||||||
|
*/
|
||||||
|
private fun fingerprintFor(host: String): String =
|
||||||
|
tofu?.failureFor(host)?.spkiHex ?: "unknown"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(false) // do not silently follow redirects to other hosts
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
42
android/app/src/main/java/com/shonar/provider/SyncState.kt
Normal file
42
android/app/src/main/java/com/shonar/provider/SyncState.kt
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync lifecycle for one local recording against the active provider
|
||||||
|
* (docs/server-providers.md §2). P3 defines the vocabulary and the legal
|
||||||
|
* transitions; the WorkManager driver that moves recordings through it is
|
||||||
|
* M5 — until then uploads go through [ShonarProvider.upload] directly and
|
||||||
|
* land in UPLOADED.
|
||||||
|
*
|
||||||
|
* Pausing/canceling is a state, not a job kill: QUEUED and ERROR are stable
|
||||||
|
* resting states a later run resumes from.
|
||||||
|
*/
|
||||||
|
enum class SyncState {
|
||||||
|
LOCAL_ONLY, // provider is local-only, or user never enabled sync
|
||||||
|
QUEUED, // waiting for constraints (network, Wi-Fi-only, charging-only)
|
||||||
|
UPLOADING, // bytes in flight (resumable via the provider's session)
|
||||||
|
UPLOADED, // audio on the server; sidecars may still be pending
|
||||||
|
SYNCED, // audio + all sidecars confirmed server-side
|
||||||
|
ERROR, // failed; [SyncStatus.reasonCode] says why, [SyncStatus.retryAtEpochMs] when
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SyncStatus(
|
||||||
|
val state: SyncState,
|
||||||
|
val retryAtEpochMs: Long? = null,
|
||||||
|
val reasonCode: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal transitions. Anything not listed here is a programming error, not
|
||||||
|
* a state the UI should ever render.
|
||||||
|
*/
|
||||||
|
fun SyncStatus.canTransitionTo(next: SyncState): Boolean = when (state) {
|
||||||
|
SyncState.LOCAL_ONLY -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY
|
||||||
|
SyncState.QUEUED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY ||
|
||||||
|
next == SyncState.ERROR
|
||||||
|
SyncState.UPLOADING -> next == SyncState.UPLOADED || next == SyncState.QUEUED ||
|
||||||
|
next == SyncState.ERROR || next == SyncState.LOCAL_ONLY
|
||||||
|
SyncState.UPLOADED -> next == SyncState.SYNCED || next == SyncState.UPLOADING ||
|
||||||
|
next == SyncState.ERROR || next == SyncState.LOCAL_ONLY
|
||||||
|
SyncState.SYNCED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY
|
||||||
|
SyncState.ERROR -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY
|
||||||
|
}
|
||||||
186
android/app/src/main/java/com/shonar/provider/TlsPolicy.kt
Normal file
186
android/app/src/main/java/com/shonar/provider/TlsPolicy.kt
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
class TlsPolicy(
|
||||||
|
val tofu: TofuManager,
|
||||||
|
private val bodiesEnabled: () -> Boolean = { false },
|
||||||
|
private val sink: (String) -> Unit = { msg -> android.util.Log.d("ShonarNet", msg) },
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
287
android/app/src/main/java/com/shonar/provider/TofuTrust.kt
Normal file
287
android/app/src/main/java/com/shonar/provider/TofuTrust.kt
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import com.shonar.settings.SettingsStore
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLSession
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5: trust-on-first-use (TOFU) for self-signed LAN servers
|
||||||
|
* (docs/server-providers.md §4).
|
||||||
|
*
|
||||||
|
* Policy, enforced by construction:
|
||||||
|
* - system CAs are always tried first; TOFU pins are a fallback, never a
|
||||||
|
* replacement. A host that later gets a real certificate just works.
|
||||||
|
* - pins are per-host: an approved cert for `nas.local` is trusted ONLY
|
||||||
|
* when `nas.local` presents it. Byte-equality on the leaf DER, so a
|
||||||
|
* rotation needs a fresh approval (correct TOFU semantics).
|
||||||
|
* - nothing is ever trusted silently: the first failure only RECORDS the
|
||||||
|
* chain, and trust requires an explicit [TofuManager.approve] call from
|
||||||
|
* a UI that showed the fingerprint.
|
||||||
|
* - the normal TLS hostname verifier stays strict; TOFU covers unknown
|
||||||
|
* CAs, not name mismatches.
|
||||||
|
*/
|
||||||
|
class TofuStore(private val secure: SettingsStore) {
|
||||||
|
|
||||||
|
suspend fun addPin(host: String, derBase64: String) {
|
||||||
|
val pins = pins(host).toMutableSet()
|
||||||
|
pins += derBase64
|
||||||
|
secure.putString(pinKey(host), org.json.JSONArray(pins.toList()).toString())
|
||||||
|
val hosts = hosts().toMutableSet()
|
||||||
|
if (hosts.add(host.lowercase())) {
|
||||||
|
secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pins(host: String): Set<String> {
|
||||||
|
val raw = secure.getString(pinKey(host.lowercase())) ?: return emptySet()
|
||||||
|
return runCatching {
|
||||||
|
val arr = org.json.JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
||||||
|
}.getOrDefault(emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun hosts(): Set<String> {
|
||||||
|
val raw = secure.getString(KEY_HOSTS) ?: return emptySet()
|
||||||
|
return runCatching {
|
||||||
|
val arr = org.json.JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
||||||
|
}.getOrDefault(emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeHost(host: String) {
|
||||||
|
secure.remove(pinKey(host.lowercase()))
|
||||||
|
val hosts = hosts().toMutableSet()
|
||||||
|
if (hosts.remove(host.lowercase())) {
|
||||||
|
secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFIX = "tofu."
|
||||||
|
const val KEY_HOSTS = PREFIX + "hosts"
|
||||||
|
fun pinKey(host: String): String = PREFIX + "pins." + host.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recorded untrusted chain, shown to the user for approval. */
|
||||||
|
data class TofuFailure(
|
||||||
|
val host: String,
|
||||||
|
/** SPKI SHA-256, browser-style `AB:CD:…` uppercase hex. */
|
||||||
|
val spkiHex: String,
|
||||||
|
val subject: String,
|
||||||
|
val issuer: String,
|
||||||
|
val validFrom: String,
|
||||||
|
val validUntil: String,
|
||||||
|
val leafDer: ByteArray,
|
||||||
|
val recordedAtMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
override fun toString(): String =
|
||||||
|
"TofuFailure(host=$host, spki=$spkiHex, subject=$subject)"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is TofuFailure) return false
|
||||||
|
return host == other.host && spkiHex == other.spkiHex &&
|
||||||
|
leafDer.contentEquals(other.leafDer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = 31 * host.hashCode() + spkiHex.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown (as a [CertificateException], so it propagates through the TLS
|
||||||
|
* stack untouched) when a chain is neither system-trusted nor pinned.
|
||||||
|
* Carries no secrets — DNs and fingerprints are public cert contents.
|
||||||
|
*/
|
||||||
|
class TofuUntrustedException(
|
||||||
|
val failure: TofuFailure,
|
||||||
|
message: String = "Untrusted certificate for ${failure.host} (SPKI ${failure.spkiHex})",
|
||||||
|
) : CertificateException(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-first trust manager with per-host TOFU fallback. The pin cache is
|
||||||
|
* in-memory (handshakes run on TLS threads that cannot suspend); it is
|
||||||
|
* filled by [TofuManager.refresh] at startup and kept in sync by
|
||||||
|
* approve/forget.
|
||||||
|
*/
|
||||||
|
class TofuTrustManager(
|
||||||
|
private val system: X509TrustManager,
|
||||||
|
private val manager: TofuManager,
|
||||||
|
) : X509ExtendedTrustManager() {
|
||||||
|
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
system.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-auth paths: this app is always the TLS client, so these only
|
||||||
|
// need to exist (newer JDKs/Android declare them abstract). Delegate to
|
||||||
|
// the 2-arg system check.
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>, authType: String, socket: Socket,
|
||||||
|
) {
|
||||||
|
system.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>, authType: String, engine: SSLEngine,
|
||||||
|
) {
|
||||||
|
system.checkClientTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
checkServerTrusted(chain, authType, host = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>, authType: String, socket: Socket,
|
||||||
|
) {
|
||||||
|
val host = runCatching {
|
||||||
|
(socket as? javax.net.ssl.SSLSocket)?.handshakeSession?.peerHost
|
||||||
|
}.getOrNull()
|
||||||
|
checkServerTrusted(chain, authType, host = host)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>, authType: String, engine: SSLEngine,
|
||||||
|
) {
|
||||||
|
val host = runCatching {
|
||||||
|
(engine.session as? javax.net.ssl.ExtendedSSLSession)?.peerHost
|
||||||
|
}.getOrNull()
|
||||||
|
checkServerTrusted(chain, authType, host = host)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>, authType: String, host: String?,
|
||||||
|
) {
|
||||||
|
// pins are checked first so an approved self-signed cert keeps
|
||||||
|
// working even where a system path would also exist; system trust
|
||||||
|
// is the fallback that makes later real certs frictionless.
|
||||||
|
if (chain.isNotEmpty() && host != null && manager.isPinned(host, chain[0])) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
system.checkServerTrusted(chain, authType)
|
||||||
|
} catch (e: CertificateException) {
|
||||||
|
val failure = TofuFailure(
|
||||||
|
host = (host ?: "").lowercase(),
|
||||||
|
spkiHex = spkiHex(chain[0]),
|
||||||
|
subject = chain[0].subjectX500Principal.name,
|
||||||
|
issuer = chain[0].issuerX500Principal.name,
|
||||||
|
validFrom = chain[0].notBefore.toString(),
|
||||||
|
validUntil = chain[0].notAfter.toString(),
|
||||||
|
leafDer = chain[0].encoded,
|
||||||
|
)
|
||||||
|
manager.record(failure)
|
||||||
|
throw TofuUntrustedException(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = system.acceptedIssuers
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** SPKI SHA-256 as browser-style colon-separated uppercase hex. */
|
||||||
|
internal fun spkiHex(cert: X509Certificate): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(cert.publicKey.encoded)
|
||||||
|
return digest.joinToString(":") { "%02X".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the pin store, the in-memory cache the trust manager reads, and the
|
||||||
|
* recorded failures the approval UI consumes.
|
||||||
|
*/
|
||||||
|
class TofuManager(
|
||||||
|
private val store: TofuStore,
|
||||||
|
system: X509TrustManager = defaultSystemTrustManager(),
|
||||||
|
) {
|
||||||
|
private val cache = ConcurrentHashMap<String, Set<String>>()
|
||||||
|
private val failures = ConcurrentHashMap<String, TofuFailure>()
|
||||||
|
|
||||||
|
val trustManager: TofuTrustManager by lazy { TofuTrustManager(system, this) }
|
||||||
|
|
||||||
|
/** Fill the cache from the store. Call at startup (and only there). */
|
||||||
|
suspend fun refresh() {
|
||||||
|
val fresh = mutableMapOf<String, Set<String>>()
|
||||||
|
for (host in store.hosts()) {
|
||||||
|
fresh[host.lowercase()] = store.pins(host)
|
||||||
|
}
|
||||||
|
cache.clear()
|
||||||
|
cache.putAll(fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isPinned(host: String, leaf: X509Certificate): Boolean {
|
||||||
|
val pins = cache[host.lowercase()] ?: return false
|
||||||
|
val der = runCatching { leaf.encoded }.getOrNull() ?: return false
|
||||||
|
return pins.any { pin ->
|
||||||
|
runCatching {
|
||||||
|
MessageDigest.isEqual(
|
||||||
|
der,
|
||||||
|
java.util.Base64.getDecoder().decode(pin),
|
||||||
|
)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun record(failure: TofuFailure) {
|
||||||
|
failures[failure.host] = failure
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most recent failure for [host], else a fresh hostless one, else null. */
|
||||||
|
fun failureFor(host: String): TofuFailure? {
|
||||||
|
val key = host.lowercase()
|
||||||
|
failures[key]?.let { return it }
|
||||||
|
val fallback = failures[""] ?: return null
|
||||||
|
// A hostless record only belongs to this probe if it just happened
|
||||||
|
// (single onboarding flow, no concurrency to speak of).
|
||||||
|
if (System.currentTimeMillis() - fallback.recordedAtMs > FRESH_MS) return null
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust [host]'s recorded leaf from now on. Returns false when there is
|
||||||
|
* nothing recorded (never invent trust).
|
||||||
|
*/
|
||||||
|
suspend fun approve(host: String): Boolean {
|
||||||
|
val key = host.lowercase()
|
||||||
|
val failure = failureFor(key) ?: return false
|
||||||
|
val der = java.util.Base64.getEncoder().encodeToString(failure.leafDer)
|
||||||
|
store.addPin(key, der)
|
||||||
|
cache[key] = store.pins(key)
|
||||||
|
failures.remove(key)
|
||||||
|
failures.remove("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun decline(host: String) {
|
||||||
|
failures.remove(host.lowercase())
|
||||||
|
failures.remove("")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun forget(host: String) {
|
||||||
|
store.removeHost(host)
|
||||||
|
cache.remove(host.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pinnedHosts(): Set<String> = store.hosts()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FRESH_MS = 30_000L
|
||||||
|
|
||||||
|
fun defaultSystemTrustManager(): X509TrustManager {
|
||||||
|
val factory = javax.net.ssl.TrustManagerFactory.getInstance(
|
||||||
|
javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()
|
||||||
|
)
|
||||||
|
factory.init(null as java.security.KeyStore?)
|
||||||
|
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import com.shonar.provider.LocalOnlyProvider
|
||||||
|
import com.shonar.provider.ProviderRegistry
|
||||||
|
import com.shonar.provider.ShonarProvider
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P7 foreground "migrate now" uploader. Deliberately bounded — this is NOT
|
||||||
|
* the M5 background driver:
|
||||||
|
*
|
||||||
|
* - runs while the user watches (progress callback), cancellable; the
|
||||||
|
* in-flight file finishes, files never started stay exactly as they
|
||||||
|
* were, and the rest can run later (or under M5).
|
||||||
|
* - one attempt per file, no retry, no network/constraint checks.
|
||||||
|
* - upload-only: the local file is the source of truth and is never
|
||||||
|
* modified; success overwrites the row's remote slot with the new
|
||||||
|
* provider, failure records ERROR + the provider's message and moves on.
|
||||||
|
* - local-only is rejected as a target: there is nothing to upload to.
|
||||||
|
* ("Keep everything local" is a migration *choice*, handled by leaving
|
||||||
|
* rows untouched — not by this runner.)
|
||||||
|
*/
|
||||||
|
class MigrationRunner(
|
||||||
|
private val slots: SyncSlots,
|
||||||
|
private val registry: ProviderRegistry,
|
||||||
|
) {
|
||||||
|
data class Progress(val done: Int, val total: Int, val currentTitle: String)
|
||||||
|
data class Result(val uploaded: Int, val failed: List<String>, val cancelled: Boolean)
|
||||||
|
|
||||||
|
suspend fun migrateAllTo(
|
||||||
|
providerId: String,
|
||||||
|
onProgress: (Progress) -> Unit = {},
|
||||||
|
): Result {
|
||||||
|
require(providerId != LocalOnlyProvider.ID) { "cannot migrate to local-only" }
|
||||||
|
val target: ShonarProvider = registry.provider(providerId)
|
||||||
|
val rows = slots.rows()
|
||||||
|
var uploaded = 0
|
||||||
|
val failed = mutableListOf<String>()
|
||||||
|
var cancelled = false
|
||||||
|
rows.forEachIndexed { index, row ->
|
||||||
|
// Cancellation lands here between files: counted, honest, resumable.
|
||||||
|
try {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
cancelled = true
|
||||||
|
return Result(uploaded, failed, cancelled)
|
||||||
|
}
|
||||||
|
onProgress(Progress(index, rows.size, row.title))
|
||||||
|
val draft = slots.draftFor(row)
|
||||||
|
if (draft == null) {
|
||||||
|
slots.markFailed(row, "local file is gone")
|
||||||
|
failed += row.title
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
stepToUploading(row)
|
||||||
|
try {
|
||||||
|
val ref = target.upload(draft) {}
|
||||||
|
slots.markUploaded(slots.rows().firstOrNull { it.id == row.id } ?: row, providerId, ref)
|
||||||
|
uploaded++
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
// Interrupted mid-file: leave the row UPLOADING so a later
|
||||||
|
// run retries it explicitly rather than assuming either
|
||||||
|
// outcome.
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
|
||||||
|
// Backoff included: the M5 drain picks ERROR rows up when due.
|
||||||
|
slots.markFailed(fresh, e.message ?: e.javaClass.simpleName)
|
||||||
|
failed += row.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress(Progress(rows.size, rows.size, ""))
|
||||||
|
return Result(uploaded, failed, cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal first steps toward UPLOADING from any resting state, per the
|
||||||
|
* transition table (shared with the M5 drain).
|
||||||
|
*/
|
||||||
|
private suspend fun stepToUploading(row: RecordingEntity) {
|
||||||
|
var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
|
||||||
|
for (step in stepsToUploading(fresh.syncState)) {
|
||||||
|
slots.markState(fresh, step)
|
||||||
|
fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface RecordingDao {
|
||||||
|
@Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
|
||||||
|
fun observeAll(): Flow<List<RecordingEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
|
||||||
|
suspend fun getAll(): List<RecordingEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||||
|
suspend fun getById(id: String): RecordingEntity?
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(recording: RecordingEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(recording: RecordingEntity)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(recording: RecordingEntity)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for a completed local recording. Audio remains in app-private
|
||||||
|
* storage. P7: each row carries its provider slot —
|
||||||
|
*
|
||||||
|
* - [originProviderId] + [remoteKey]/[remoteEtag]/[remoteSizeBytes] name the
|
||||||
|
* server copy, if any. One slot is the whole history: switching providers
|
||||||
|
* never touches it until the user migrates (slot overwritten) or forgets
|
||||||
|
* (slot cleared). The local file is always the source of truth.
|
||||||
|
* - [syncState]/[syncReason] is the §2 lifecycle. Until the M5 background
|
||||||
|
* driver exists, the foreground migrator (P7) is the only writer besides
|
||||||
|
* these defaults.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "recordings")
|
||||||
|
data class RecordingEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val title: String,
|
||||||
|
val createdAtEpochMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val filePath: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val originProviderId: String = "local-only",
|
||||||
|
val remoteKey: String? = null,
|
||||||
|
val remoteEtag: String? = null,
|
||||||
|
val remoteSizeBytes: Long? = null,
|
||||||
|
val syncState: SyncState = SyncState.LOCAL_ONLY,
|
||||||
|
val syncReason: String? = null,
|
||||||
|
/** Consecutive failed attempts (M5 backoff). Reset on success/forget. */
|
||||||
|
val syncAttempts: Int = 0,
|
||||||
|
/** Next eligible retry, epoch ms. Null = due immediately. */
|
||||||
|
val syncRetryAtMs: Long? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.shonar.provider.RecordingDraft
|
||||||
|
import com.shonar.provider.RemoteRef
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.io.File
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class RecordingRepository(
|
||||||
|
private val context: Context,
|
||||||
|
private val dao: RecordingDao,
|
||||||
|
) {
|
||||||
|
private val slots = SyncSlots(dao)
|
||||||
|
private val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() }
|
||||||
|
private val tempDir = File(recordingsDir, ".in-progress").apply { mkdirs() }
|
||||||
|
|
||||||
|
val recordings: Flow<List<RecordingEntity>> = dao.observeAll()
|
||||||
|
|
||||||
|
fun newRecordingId(): String = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
fun tempFile(id: String): File = File(tempDir, "$id.m4a")
|
||||||
|
|
||||||
|
suspend fun finish(
|
||||||
|
id: String,
|
||||||
|
durationMs: Long,
|
||||||
|
tempFile: File,
|
||||||
|
createdAtEpochMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val finalFile = File(recordingsDir, "$id.m4a")
|
||||||
|
if (!tempFile.renameTo(finalFile)) {
|
||||||
|
tempFile.copyTo(finalFile, overwrite = true)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
dao.insert(
|
||||||
|
RecordingEntity(
|
||||||
|
id = id,
|
||||||
|
title = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
|
||||||
|
.format(Date(createdAtEpochMs)),
|
||||||
|
createdAtEpochMs = createdAtEpochMs,
|
||||||
|
durationMs = durationMs.coerceAtLeast(1L),
|
||||||
|
filePath = finalFile.absolutePath,
|
||||||
|
mimeType = "audio/mp4",
|
||||||
|
sizeBytes = finalFile.length(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(recording: RecordingEntity) {
|
||||||
|
val canonicalRoot = recordingsDir.canonicalFile
|
||||||
|
val canonicalFile = File(recording.filePath).canonicalFile
|
||||||
|
require(canonicalFile.toPath().startsWith(canonicalRoot.toPath())) { "Invalid recording path" }
|
||||||
|
canonicalFile.delete()
|
||||||
|
dao.delete(recording)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- P7 provider slot (delegates to SyncSlots; Context-free logic) -----
|
||||||
|
|
||||||
|
/** Draft for upload, or null when the local file is gone. */
|
||||||
|
fun toDraft(entity: RecordingEntity): RecordingDraft? = slots.draftFor(entity)
|
||||||
|
|
||||||
|
suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) =
|
||||||
|
slots.markState(entity, state, reason)
|
||||||
|
|
||||||
|
suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) =
|
||||||
|
slots.markUploaded(entity, providerId, ref)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget every remote link (P7 "start fresh" choice). Files stay;
|
||||||
|
* nothing remote is touched — forgetting is metadata-only.
|
||||||
|
*/
|
||||||
|
suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.shonar.MainActivity
|
||||||
|
import com.shonar.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class RecordingSnapshot(
|
||||||
|
val phase: Phase = Phase.IDLE,
|
||||||
|
val recordingId: String? = null,
|
||||||
|
val elapsedMs: Long = 0,
|
||||||
|
) {
|
||||||
|
enum class Phase { IDLE, RECORDING, PAUSED }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns microphone access and survives the activity leaving the foreground. */
|
||||||
|
class RecordingService : Service() {
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private var currentId: String? = null
|
||||||
|
private var currentTempFile: File? = null
|
||||||
|
private var accumulatedMs = 0L
|
||||||
|
private var resumedAtElapsed = 0L
|
||||||
|
private var tickerJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_START -> startRecording()
|
||||||
|
ACTION_PAUSE -> pauseRecording()
|
||||||
|
ACTION_RESUME -> resumeRecording()
|
||||||
|
ACTION_STOP -> stopRecording()
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRecording() {
|
||||||
|
if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return
|
||||||
|
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val app = application as com.shonar.ShonarApplication
|
||||||
|
val id = app.recordingRepository.newRecordingId()
|
||||||
|
val temp = app.recordingRepository.tempFile(id)
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification("Recording in progress"),
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification("Recording in progress"))
|
||||||
|
}
|
||||||
|
recorder = MediaRecorder().apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
setOutputFile(temp.absolutePath)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
currentId = id
|
||||||
|
currentTempFile = temp
|
||||||
|
resumedAtElapsed = SystemClock.elapsedRealtime()
|
||||||
|
accumulatedMs = 0L
|
||||||
|
updateSnapshot(RecordingSnapshot.Phase.RECORDING)
|
||||||
|
tickerJob = serviceScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(500)
|
||||||
|
_snapshot.value = snapshot.value.copy(elapsedMs = elapsedMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
recorder?.release()
|
||||||
|
recorder = null
|
||||||
|
temp.delete()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pauseRecording() {
|
||||||
|
if (snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
|
||||||
|
recorder?.pause()
|
||||||
|
accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed
|
||||||
|
updateSnapshot(RecordingSnapshot.Phase.PAUSED)
|
||||||
|
updateNotification("Recording paused")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resumeRecording() {
|
||||||
|
if (snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return
|
||||||
|
recorder?.resume()
|
||||||
|
resumedAtElapsed = SystemClock.elapsedRealtime()
|
||||||
|
updateSnapshot(RecordingSnapshot.Phase.RECORDING)
|
||||||
|
updateNotification("Recording in progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecording() {
|
||||||
|
if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val id = currentId
|
||||||
|
val temp = currentTempFile
|
||||||
|
val duration = elapsedMs()
|
||||||
|
try {
|
||||||
|
recorder?.stop()
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
temp?.delete()
|
||||||
|
}
|
||||||
|
recorder?.release()
|
||||||
|
recorder = null
|
||||||
|
currentId = null
|
||||||
|
currentTempFile = null
|
||||||
|
tickerJob?.cancel()
|
||||||
|
tickerJob = null
|
||||||
|
updateSnapshot(RecordingSnapshot.Phase.IDLE)
|
||||||
|
serviceScope.launch {
|
||||||
|
val app = application as com.shonar.ShonarApplication
|
||||||
|
if (id != null && temp != null && temp.exists() && temp.length() > 0) {
|
||||||
|
runCatching { app.recordingRepository.finish(id, duration, temp) }
|
||||||
|
.onFailure { temp.delete() }
|
||||||
|
}
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun elapsedMs(): Long = when (snapshot.value.phase) {
|
||||||
|
RecordingSnapshot.Phase.RECORDING ->
|
||||||
|
accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)
|
||||||
|
RecordingSnapshot.Phase.PAUSED -> accumulatedMs
|
||||||
|
RecordingSnapshot.Phase.IDLE -> 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSnapshot(phase: RecordingSnapshot.Phase) {
|
||||||
|
_snapshot.value = RecordingSnapshot(phase, currentId, elapsedMs())
|
||||||
|
updateNotification(if (phase == RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) return
|
||||||
|
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(text: String): Notification {
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this, 1, Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val stop = PendingIntent.getService(
|
||||||
|
this, 2, Intent(this, RecordingService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle("SHONAR")
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.addAction(0, "Stop", stop)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Recording", NotificationManager.IMPORTANCE_LOW),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
||||||
|
recorder?.reset()
|
||||||
|
recorder?.release()
|
||||||
|
currentTempFile?.delete()
|
||||||
|
recorder = null
|
||||||
|
tickerJob?.cancel()
|
||||||
|
tickerJob = null
|
||||||
|
_snapshot.value = RecordingSnapshot()
|
||||||
|
}
|
||||||
|
serviceScope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "recording"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
const val ACTION_START = "com.shonar.recording.START"
|
||||||
|
const val ACTION_PAUSE = "com.shonar.recording.PAUSE"
|
||||||
|
const val ACTION_RESUME = "com.shonar.recording.RESUME"
|
||||||
|
const val ACTION_STOP = "com.shonar.recording.STOP"
|
||||||
|
|
||||||
|
private val _snapshot = MutableStateFlow(RecordingSnapshot())
|
||||||
|
val snapshot: StateFlow<RecordingSnapshot> = _snapshot.asStateFlow()
|
||||||
|
|
||||||
|
fun command(context: Context, action: String) {
|
||||||
|
val intent = Intent(context, RecordingService::class.java).setAction(action)
|
||||||
|
if (action == ACTION_START) {
|
||||||
|
androidx.core.content.ContextCompat.startForegroundService(context, intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
@Database(entities = [RecordingEntity::class], version = 3, exportSchema = false)
|
||||||
|
@TypeConverters(SyncStateConverter::class)
|
||||||
|
abstract class ShonarDatabase : RoomDatabase() {
|
||||||
|
abstract fun recordingDao(): RecordingDao
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 -> v2 (P7): provider slot per recording. Existing rows predate sync
|
||||||
|
* tracking, so they read as never-synced locals — which is exactly what
|
||||||
|
* they are. NOT NULL columns carry defaults; no data moves.
|
||||||
|
*/
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN originProviderId TEXT NOT NULL DEFAULT 'local-only'")
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN remoteKey TEXT")
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN remoteEtag TEXT")
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN remoteSizeBytes INTEGER")
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN syncState TEXT NOT NULL DEFAULT 'LOCAL_ONLY'")
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN syncReason TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 -> v3 (M5): consecutive-failure backoff. attempts/retryAt default to
|
||||||
|
* "never failed, due immediately", which is exactly right for pre-existing
|
||||||
|
* ERROR rows left by P7 runs.
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN syncAttempts INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE recordings ADD COLUMN syncRetryAtMs INTEGER")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
android/app/src/main/java/com/shonar/recording/SyncDrain.kt
Normal file
94
android/app/src/main/java/com/shonar/recording/SyncDrain.kt
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import com.shonar.provider.LocalOnlyProvider
|
||||||
|
import com.shonar.provider.ShonarProvider
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5 background drain engine. Context-free on purpose: the [SyncWorker]
|
||||||
|
* (Context-bound) builds the inputs, unit tests drive this directly.
|
||||||
|
*
|
||||||
|
* One pass over due rows — QUEUED, plus ERROR whose backoff expired (null
|
||||||
|
* retryAt = due immediately, which is how P7 leftovers read). SYNCED,
|
||||||
|
* UPLOADED and LOCAL_ONLY rows are never touched. Local-only target is a
|
||||||
|
* no-op. Failures record backoff via [SyncSlots.markFailed]; cancellation
|
||||||
|
* between files stops honestly, and a row interrupted mid-upload reverts
|
||||||
|
* to QUEUED so a later pass retries it explicitly.
|
||||||
|
*/
|
||||||
|
class SyncDrain(
|
||||||
|
private val slots: SyncSlots,
|
||||||
|
private val clock: () -> Long = System::currentTimeMillis,
|
||||||
|
) {
|
||||||
|
/** Snapshot of device conditions, taken by the worker. Pure to evaluate. */
|
||||||
|
data class Gates(val online: Boolean, val unmetered: Boolean, val charging: Boolean)
|
||||||
|
|
||||||
|
data class Progress(val done: Int, val total: Int, val currentTitle: String)
|
||||||
|
data class Result(val uploaded: Int, val failed: List<String>, val postponed: Boolean)
|
||||||
|
|
||||||
|
suspend fun drainOnce(
|
||||||
|
provider: ShonarProvider,
|
||||||
|
wifiOnly: Boolean,
|
||||||
|
chargingOnly: Boolean,
|
||||||
|
gates: Gates,
|
||||||
|
onProgress: (Progress) -> Unit = {},
|
||||||
|
): Result {
|
||||||
|
if (provider.descriptor.id == LocalOnlyProvider.ID) {
|
||||||
|
return Result(0, emptyList(), postponed = false)
|
||||||
|
}
|
||||||
|
val now = clock()
|
||||||
|
val due = slots.rows().filter { row ->
|
||||||
|
row.syncState == SyncState.QUEUED ||
|
||||||
|
(row.syncState == SyncState.ERROR &&
|
||||||
|
(row.syncRetryAtMs == null || row.syncRetryAtMs <= now))
|
||||||
|
}
|
||||||
|
if (due.isEmpty()) return Result(0, emptyList(), postponed = false)
|
||||||
|
if (!gates.satisfiedBy(wifiOnly, chargingOnly)) {
|
||||||
|
return Result(0, emptyList(), postponed = true)
|
||||||
|
}
|
||||||
|
var uploaded = 0
|
||||||
|
val failed = mutableListOf<String>()
|
||||||
|
due.forEachIndexed { index, row ->
|
||||||
|
try {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
return Result(uploaded, failed, postponed = false)
|
||||||
|
}
|
||||||
|
onProgress(Progress(index, due.size, row.title))
|
||||||
|
val draft = slots.draftFor(row)
|
||||||
|
if (draft == null) {
|
||||||
|
slots.markFailed(row, "local file is gone", now)
|
||||||
|
failed += row.title
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
|
||||||
|
for (step in stepsToUploading(fresh.syncState)) {
|
||||||
|
slots.markState(fresh, step)
|
||||||
|
fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val ref = provider.upload(draft) {}
|
||||||
|
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
||||||
|
slots.markUploaded(done, provider.descriptor.id, ref)
|
||||||
|
uploaded++
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
// Interrupted mid-file: revert so a later pass retries
|
||||||
|
// explicitly instead of assuming either outcome.
|
||||||
|
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
||||||
|
slots.markState(done, SyncState.QUEUED)
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
||||||
|
slots.markFailed(done, e.message ?: e.javaClass.simpleName, now)
|
||||||
|
failed += row.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress(Progress(due.size, due.size, ""))
|
||||||
|
return Result(uploaded, failed, postponed = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure gate evaluation, unit-tested. */
|
||||||
|
internal fun SyncDrain.Gates.satisfiedBy(wifiOnly: Boolean, chargingOnly: Boolean): Boolean =
|
||||||
|
online && (!wifiOnly || unmetered) && (!chargingOnly || charging)
|
||||||
116
android/app/src/main/java/com/shonar/recording/SyncSlots.kt
Normal file
116
android/app/src/main/java/com/shonar/recording/SyncSlots.kt
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import com.shonar.provider.RecordingDraft
|
||||||
|
import com.shonar.provider.RemoteRef
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P7 provider-slot bookkeeping over [RecordingDao]. Context-free on purpose:
|
||||||
|
* the repository (Context-bound) delegates here, and the migration runner
|
||||||
|
* and unit tests drive it directly.
|
||||||
|
*
|
||||||
|
* One slot is the whole history: switching providers never touches it until
|
||||||
|
* the user migrates (slot overwritten) or forgets (slot cleared). The local
|
||||||
|
* file is always the source of truth.
|
||||||
|
*/
|
||||||
|
class SyncSlots(private val dao: RecordingDao) {
|
||||||
|
|
||||||
|
suspend fun rows(): List<RecordingEntity> = dao.getAll()
|
||||||
|
|
||||||
|
/** Draft for upload, or null when the local file is gone. */
|
||||||
|
fun draftFor(entity: RecordingEntity): RecordingDraft? {
|
||||||
|
val file = File(entity.filePath)
|
||||||
|
if (!file.isFile) return null
|
||||||
|
return RecordingDraft(
|
||||||
|
id = entity.id,
|
||||||
|
title = entity.title,
|
||||||
|
createdAtEpochMs = entity.createdAtEpochMs,
|
||||||
|
durationMs = entity.durationMs,
|
||||||
|
mime = entity.mimeType,
|
||||||
|
sourceFile = file,
|
||||||
|
sizeBytes = file.length(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) {
|
||||||
|
dao.update(entity.copy(syncState = state, syncReason = reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) {
|
||||||
|
dao.update(
|
||||||
|
entity.copy(
|
||||||
|
originProviderId = providerId,
|
||||||
|
remoteKey = ref.key,
|
||||||
|
remoteEtag = ref.etag,
|
||||||
|
remoteSizeBytes = ref.sizeBytes,
|
||||||
|
syncState = SyncState.UPLOADED,
|
||||||
|
syncReason = null,
|
||||||
|
syncAttempts = 0,
|
||||||
|
syncRetryAtMs = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failed attempt with exponential backoff. [nowMs] is injected
|
||||||
|
* (default wall-clock) so tests run on a fixed clock.
|
||||||
|
*/
|
||||||
|
suspend fun markFailed(
|
||||||
|
entity: RecordingEntity,
|
||||||
|
reason: String,
|
||||||
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val attempts = (entity.syncAttempts + 1).coerceAtLeast(1)
|
||||||
|
dao.update(
|
||||||
|
entity.copy(
|
||||||
|
syncState = SyncState.ERROR,
|
||||||
|
syncReason = reason,
|
||||||
|
syncAttempts = attempts,
|
||||||
|
syncRetryAtMs = nowMs + backoffDelayMs(attempts),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget every remote link ("start fresh"). Files stay; rows read as
|
||||||
|
* never-synced under [newOriginId], ready for a later upload run.
|
||||||
|
* Nothing remote is touched — forgetting is metadata-only.
|
||||||
|
*/
|
||||||
|
suspend fun forgetAllRemotes(newOriginId: String) { for (row in dao.getAll()) {
|
||||||
|
dao.update(
|
||||||
|
row.copy(
|
||||||
|
originProviderId = newOriginId,
|
||||||
|
remoteKey = null,
|
||||||
|
remoteEtag = null,
|
||||||
|
remoteSizeBytes = null,
|
||||||
|
syncState = SyncState.QUEUED,
|
||||||
|
syncReason = null,
|
||||||
|
syncAttempts = 0,
|
||||||
|
syncRetryAtMs = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal steps from any resting state toward UPLOADING, per the transition
|
||||||
|
* table. Shared by the P7 foreground migrator and the M5 background drain
|
||||||
|
* so both move rows through identical states.
|
||||||
|
*/
|
||||||
|
internal fun stepsToUploading(from: SyncState): List<SyncState> = when (from) {
|
||||||
|
SyncState.LOCAL_ONLY, SyncState.ERROR -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
|
||||||
|
// A leftover UPLOADING row (killed run) has no self-loop: detour via QUEUED.
|
||||||
|
SyncState.UPLOADING -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
|
||||||
|
else -> listOf(SyncState.UPLOADING)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive-failure backoff: 1m, 2m, 4m … capped at 1h. [attempt] is the
|
||||||
|
* 1-based count *including* the failure just recorded.
|
||||||
|
*/
|
||||||
|
internal fun backoffDelayMs(attempt: Int): Long {
|
||||||
|
val shift = (attempt.coerceAtLeast(1) - 1).coerceAtMost(6)
|
||||||
|
return (60_000L shl shift).coerceAtMost(3_600_000L)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
|
||||||
|
/** SyncState <-> String by enum name. Unknown names (future states from a
|
||||||
|
* newer app reading this DB are impossible — same app writes and reads) fall
|
||||||
|
* back to LOCAL_ONLY rather than crashing the library. */
|
||||||
|
class SyncStateConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun toString(state: SyncState): String = state.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toState(raw: String?): SyncState =
|
||||||
|
runCatching { SyncState.valueOf(raw!!) }.getOrDefault(SyncState.LOCAL_ONLY)
|
||||||
|
}
|
||||||
141
android/app/src/main/java/com/shonar/recording/SyncWorker.kt
Normal file
141
android/app/src/main/java/com/shonar/recording/SyncWorker.kt
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.shonar.ShonarApplication
|
||||||
|
import com.shonar.provider.AuthState
|
||||||
|
import com.shonar.provider.LocalOnlyProvider
|
||||||
|
import com.shonar.settings.BuiltInSettings
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5 background sync driver. Thin by design: it resolves settings, the
|
||||||
|
* active provider, and live device gates, then hands one pass to the
|
||||||
|
* Context-free [SyncDrain] (which owns all state transitions and is where
|
||||||
|
* the unit tests live).
|
||||||
|
*
|
||||||
|
* Result contract:
|
||||||
|
* - success: the pass completed (per-file failures are recorded in the DB
|
||||||
|
* with backoff — retrying the whole worker immediately would hammer).
|
||||||
|
* - retry: transient only — offline at start, postponed by gates. Auth
|
||||||
|
* states needing the user (disconnected/expired/revoked) are failure.
|
||||||
|
* - cancellation propagates: the drain reverts the in-flight row to QUEUED.
|
||||||
|
*/
|
||||||
|
class SyncWorker(appContext: Context, params: WorkerParameters) :
|
||||||
|
CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val app = applicationContext as ShonarApplication
|
||||||
|
app.settingsManager.ensureLoaded()
|
||||||
|
val settings = app.settingsManager
|
||||||
|
val providerId = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
|
||||||
|
if (providerId == LocalOnlyProvider.ID) return Result.success()
|
||||||
|
val wifiOnly = settings.bool(BuiltInSettings.WIFI_ONLY_UPLOAD)
|
||||||
|
val chargingOnly = settings.bool(BuiltInSettings.CHARGING_ONLY_UPLOAD)
|
||||||
|
|
||||||
|
val gates = readGates(applicationContext)
|
||||||
|
val provider = try {
|
||||||
|
app.providerRegistry.provider(providerId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
val auth = try {
|
||||||
|
provider.reconnect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
when (auth) {
|
||||||
|
AuthState.DISCONNECTED, AuthState.EXPIRED, AuthState.REVOKED ->
|
||||||
|
return Result.failure()
|
||||||
|
AuthState.OFFLINE -> return Result.retry()
|
||||||
|
AuthState.CONNECTED -> Unit
|
||||||
|
}
|
||||||
|
val drain = SyncDrain(SyncSlots(app.database.recordingDao()))
|
||||||
|
val res = drain.drainOnce(provider, wifiOnly, chargingOnly, gates)
|
||||||
|
return if (res.postponed) Result.retry() else Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Snapshot of device conditions for [SyncDrain.Gates]. */
|
||||||
|
internal fun readGates(context: Context): SyncDrain.Gates {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||||
|
as? android.net.ConnectivityManager
|
||||||
|
val caps = cm?.getNetworkCapabilities(cm.activeNetwork)
|
||||||
|
val online = caps?.hasCapability(
|
||||||
|
android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||||
|
) == true
|
||||||
|
val unmetered = caps?.hasCapability(
|
||||||
|
android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
|
||||||
|
) == true
|
||||||
|
val bm = context.getSystemService(Context.BATTERY_SERVICE)
|
||||||
|
as? android.os.BatteryManager
|
||||||
|
return SyncDrain.Gates(
|
||||||
|
online = online,
|
||||||
|
unmetered = unmetered,
|
||||||
|
charging = bm?.isCharging == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WorkManager wiring: one-shot drains on demand, a periodic 15-minute
|
||||||
|
* drain (the platform minimum) while constraints hold, and pause.
|
||||||
|
* Constraint mapping is a pure function ([workConstraints]) so the
|
||||||
|
* settings-to-WorkManager translation is unit-tested.
|
||||||
|
*/
|
||||||
|
object SyncScheduler {
|
||||||
|
const val ONCE_NAME = "shonar-sync-drain-once"
|
||||||
|
const val PERIODIC_NAME = "shonar-sync-drain-periodic"
|
||||||
|
|
||||||
|
/** Pure settings -> constraints mapping. */
|
||||||
|
internal fun workConstraints(wifiOnly: Boolean, chargingOnly: Boolean): Constraints =
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||||
|
.setRequiresCharging(chargingOnly)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Drain now (startup, after a switch, after settings change). */
|
||||||
|
fun requestNow(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
|
||||||
|
val req = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.setConstraints(workConstraints(wifiOnly, chargingOnly))
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||||
|
.addTag(ONCE_NAME)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(ONCE_NAME, ExistingWorkPolicy.REPLACE, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steady-state background drain. Idempotent; safe to call on every launch. */
|
||||||
|
fun ensurePeriodic(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
|
||||||
|
val req = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(workConstraints(wifiOnly, chargingOnly))
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||||
|
.addTag(PERIODIC_NAME)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
PERIODIC_NAME, ExistingPeriodicWorkPolicy.UPDATE, req
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause: cancel scheduled work and revert in-flight rows to QUEUED. */
|
||||||
|
suspend fun pauseAll(context: Context, dao: RecordingDao) {
|
||||||
|
val wm = WorkManager.getInstance(context)
|
||||||
|
wm.cancelUniqueWork(ONCE_NAME)
|
||||||
|
wm.cancelUniqueWork(PERIODIC_NAME)
|
||||||
|
for (row in dao.getAll()) {
|
||||||
|
if (row.syncState == com.shonar.provider.SyncState.UPLOADING) {
|
||||||
|
dao.update(row.copy(syncState = com.shonar.provider.SyncState.QUEUED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,10 @@ object BuiltInSettings {
|
||||||
|
|
||||||
// ids other code depends on (single source of truth)
|
// ids other code depends on (single source of truth)
|
||||||
const val CONSENT = "consent_notice_seen"
|
const val CONSENT = "consent_notice_seen"
|
||||||
|
const val PROVIDER_ID = "provider_id"
|
||||||
|
const val PROVIDER_URL = "provider_url"
|
||||||
|
const val WIFI_ONLY_UPLOAD = "wifi_only_upload"
|
||||||
|
const val CHARGING_ONLY_UPLOAD = "charging_only_upload"
|
||||||
|
|
||||||
val all: List<SettingDefinition> = listOf(
|
val all: List<SettingDefinition> = listOf(
|
||||||
// --- General -------------------------------------------------------
|
// --- General -------------------------------------------------------
|
||||||
|
|
@ -55,6 +59,24 @@ object BuiltInSettings {
|
||||||
type = SettingType.COLOR,
|
type = SettingType.COLOR,
|
||||||
defaultJson = "\"#4FD1C5\"",
|
defaultJson = "\"#4FD1C5\"",
|
||||||
),
|
),
|
||||||
|
// --- Storage provider (selection persisted here; see ProviderRegistry) ---
|
||||||
|
SettingDefinition(
|
||||||
|
id = PROVIDER_ID,
|
||||||
|
name = "Storage provider",
|
||||||
|
description = "Where your recordings live.",
|
||||||
|
category = CAT_GENERAL,
|
||||||
|
type = SettingType.SELECT,
|
||||||
|
choices = listOf("local-only", "nextcloud", "custom-shonar", "sync-folder", "start9", "umbrel"),
|
||||||
|
defaultJson = "\"local-only\"",
|
||||||
|
),
|
||||||
|
SettingDefinition(
|
||||||
|
id = PROVIDER_URL,
|
||||||
|
name = "Provider server URL",
|
||||||
|
description = "Server address for the selected provider (empty for local-only).",
|
||||||
|
category = CAT_GENERAL,
|
||||||
|
type = SettingType.URL,
|
||||||
|
defaultJson = "\"\"",
|
||||||
|
),
|
||||||
// --- Network -------------------------------------------------------------
|
// --- Network -------------------------------------------------------------
|
||||||
SettingDefinition(
|
SettingDefinition(
|
||||||
id = "wifi_only_upload",
|
id = "wifi_only_upload",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -34,32 +36,74 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import com.shonar.ShonarApplication
|
import com.shonar.ShonarApplication
|
||||||
import com.shonar.settings.BuiltInSettings
|
import com.shonar.settings.BuiltInSettings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: big record button (functional UI; recording engine lands in M4),
|
* Home: offline recording, local library, provider selection, and settings.
|
||||||
* a Settings entry, and the first-launch recording-consent notice which must
|
* a Settings entry, and the first-launch recording-consent notice which must
|
||||||
* be acknowledged before anything else.
|
* be acknowledged before anything else.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(onOpenSettings: () -> Unit) {
|
fun HomeScreen(
|
||||||
val app = LocalContext.current.applicationContext as ShonarApplication
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenStorage: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val app = context.applicationContext as ShonarApplication
|
||||||
|
val vm: RecordingViewModel = viewModel(factory = RecordingViewModel.Factory)
|
||||||
|
val recorderState by vm.recorderState.collectAsState()
|
||||||
|
val recordings by vm.recordings.collectAsState()
|
||||||
|
val playingId by vm.playingId.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
var storageHeadline by remember { mutableStateOf<String?>(null) }
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted -> if (granted) vm.start() }
|
||||||
|
|
||||||
|
fun startRecording() {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
vm.start()
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
app.settingsManager.ensureLoaded()
|
app.settingsManager.ensureLoaded()
|
||||||
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
|
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
|
||||||
|
val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID)
|
||||||
|
val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL)
|
||||||
|
storageHeadline = com.shonar.ui.provider.ProviderSelectionViewModel
|
||||||
|
.headlineFor(pid.ifBlank { "local-only" }, purl)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = { /* M4: start recording flow */ },
|
onClick = {
|
||||||
|
if (consentSeen == true) {
|
||||||
|
if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||||
|
startRecording()
|
||||||
|
} else {
|
||||||
|
vm.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
icon = { Icon(Icons.Filled.Mic, contentDescription = null) },
|
icon = { Icon(Icons.Filled.Mic, contentDescription = null) },
|
||||||
text = { Text("Record") },
|
text = {
|
||||||
|
Text(if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) "Record" else "Stop")
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
@ -67,8 +111,9 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp),
|
.padding(24.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
|
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
@ -77,17 +122,42 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
storageHeadline?.let { headline ->
|
||||||
|
StorageChip(headline = headline, onClick = onOpenStorage)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
QuickCard("Settings", "Server, sync, appearance, custom settings",
|
QuickCard("Choose storage", storageHeadline ?: "Where recordings live",
|
||||||
|
Icons.Filled.Cloud, onOpenStorage)
|
||||||
|
QuickCard("Settings", "Appearance, custom settings",
|
||||||
Icons.Filled.Settings, onOpenSettings)
|
Icons.Filled.Settings, onOpenSettings)
|
||||||
Spacer(Modifier.height(16.dp))
|
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||||
|
RecordingStatusCard(
|
||||||
|
state = recorderState,
|
||||||
|
onPause = vm::pause,
|
||||||
|
onResume = vm::resume,
|
||||||
|
onStop = vm::stop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (recordings.isNotEmpty()) {
|
||||||
|
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.align(Alignment.Start))
|
||||||
|
recordings.forEach { recording ->
|
||||||
|
RecordingRow(
|
||||||
|
recording = recording,
|
||||||
|
playing = playingId == recording.id,
|
||||||
|
onPlay = { vm.togglePlayback(recording) },
|
||||||
|
onDelete = { vm.delete(recording) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Recording is coming in the next milestone. Nothing records today.",
|
"Your recordings will appear here and stay on this device until you choose a provider.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (consentSeen == false) {
|
if (consentSeen == false) {
|
||||||
ConsentDialog(onAcknowledge = {
|
ConsentDialog(onAcknowledge = {
|
||||||
|
|
@ -99,6 +169,86 @@ fun HomeScreen(onOpenSettings: () -> Unit) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingStatusCard(
|
||||||
|
state: com.shonar.recording.RecordingSnapshot,
|
||||||
|
onPause: () -> Unit,
|
||||||
|
onResume: () -> Unit,
|
||||||
|
onStop: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(formatDuration(state.elapsedMs), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(onClick = if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) onResume else onPause) {
|
||||||
|
Text(if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Resume" else "Pause")
|
||||||
|
}
|
||||||
|
TextButton(onClick = onStop) { Text("Finish") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingRow(
|
||||||
|
recording: com.shonar.recording.RecordingEntity,
|
||||||
|
playing: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(recording.title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"${formatDuration(recording.durationMs)} • ${formatBytes(recording.sizeBytes)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") }
|
||||||
|
TextButton(onClick = onDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(durationMs: Long): String {
|
||||||
|
val totalSeconds = (durationMs / 1000).coerceAtLeast(0)
|
||||||
|
return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBytes(bytes: Long): String = when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0)
|
||||||
|
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistent "Stored: …" indicator — users always see where data goes. */
|
||||||
|
@Composable
|
||||||
|
private fun StorageChip(headline: String, onClick: () -> Unit) {
|
||||||
|
Card(onClick = onClick,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
|
||||||
|
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.Cloud, contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Stored: $headline",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConsentDialog(onAcknowledge: () -> Unit) {
|
private fun ConsentDialog(onAcknowledge: () -> Unit) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.shonar.ui.home
|
||||||
|
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.shonar.ShonarApplication
|
||||||
|
import com.shonar.recording.RecordingEntity
|
||||||
|
import com.shonar.recording.RecordingService
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
|
||||||
|
val recordings: StateFlow<List<RecordingEntity>> = app.recordingRepository.recordings
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
val recorderState = RecordingService.snapshot
|
||||||
|
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||||
|
val playingId: StateFlow<String?> = _playingId
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
|
||||||
|
fun start() = RecordingService.command(app, RecordingService.ACTION_START)
|
||||||
|
fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE)
|
||||||
|
fun resume() = RecordingService.command(app, RecordingService.ACTION_RESUME)
|
||||||
|
fun stop() = RecordingService.command(app, RecordingService.ACTION_STOP)
|
||||||
|
|
||||||
|
fun togglePlayback(recording: RecordingEntity) {
|
||||||
|
if (_playingId.value == recording.id) {
|
||||||
|
player?.let { if (it.isPlaying) it.pause() else it.start() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
player?.release()
|
||||||
|
player = runCatching {
|
||||||
|
MediaPlayer().apply {
|
||||||
|
setDataSource(recording.filePath)
|
||||||
|
setOnPreparedListener { it.start(); _playingId.value = recording.id }
|
||||||
|
setOnCompletionListener { _playingId.value = null; it.release(); player = null }
|
||||||
|
prepareAsync()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(recording: RecordingEntity) {
|
||||||
|
if (_playingId.value == recording.id) stopPlayback()
|
||||||
|
viewModelScope.launch { app.recordingRepository.delete(recording) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPlayback() {
|
||||||
|
player?.release()
|
||||||
|
player = null
|
||||||
|
_playingId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
stopPlayback()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
|
||||||
|
RecordingViewModel(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
package com.shonar.ui.provider
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Choose where your recordings live." One screen, every target.
|
||||||
|
* Unavailable providers are visible but disabled with an honest reason —
|
||||||
|
* never a fake-connected state.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProviderSelectionScreen(
|
||||||
|
onDone: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var selectedId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var urlInput by remember { mutableStateOf("") }
|
||||||
|
var pathInput by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Where your recordings live") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"You stay in control: recordings, transcripts, and summaries go where " +
|
||||||
|
"you choose. You can switch providers later without losing anything.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(vm.options) { opt ->
|
||||||
|
ProviderCard(
|
||||||
|
option = opt,
|
||||||
|
selected = selectedId == opt.id,
|
||||||
|
onSelect = {
|
||||||
|
selectedId = opt.id
|
||||||
|
if (!opt.available) {
|
||||||
|
// surface the honest reason immediately
|
||||||
|
vm.choose(opt, "")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val sel = vm.options.firstOrNull { it.id == selectedId }
|
||||||
|
if (sel != null && sel.available && sel.needsUrl) {
|
||||||
|
item {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = urlInput,
|
||||||
|
onValueChange = { urlInput = it },
|
||||||
|
label = { Text("Server URL (https://…)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = { vm.choose(sel, urlInput) },
|
||||||
|
enabled = state !is SelectionUiState.Probing && urlInput.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Check server") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sel != null && sel.available && sel.needsPath) {
|
||||||
|
item {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pathInput,
|
||||||
|
onValueChange = { pathInput = it },
|
||||||
|
label = { Text("Folder path (created by your sync tool)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = { vm.connectFolder(pathInput) },
|
||||||
|
enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Use this folder") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state is SelectionUiState.NeedsNcApproval) {
|
||||||
|
item {
|
||||||
|
val approval = state as SelectionUiState.NeedsNcApproval
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"Approve SHONAR in your browser, then come back here. " +
|
||||||
|
"Nextcloud issues an app password — your normal password " +
|
||||||
|
"never touches this app.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(approval.loginUrl))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Open Nextcloud login") }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { vm.cancelFlow() }) { Text("Cancel") }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
TextButton(onClick = { vm.pollNow() }) { Text("I've approved — check now") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state is SelectionUiState.NeedsCredentials) {
|
||||||
|
item {
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("Server email") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Server password") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
vm.login(
|
||||||
|
(state as SelectionUiState.NeedsCredentials).url,
|
||||||
|
email, password,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val s = state) {
|
||||||
|
is SelectionUiState.Probing -> item {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text("Checking ${s.url} …")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SelectionUiState.Error -> item {
|
||||||
|
Text(s.message, color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
is SelectionUiState.NeedsTlsApproval -> item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"The server at ${s.url} uses a certificate this app doesn't trust yet. " +
|
||||||
|
"Only approve if you run this server (or you verified the " +
|
||||||
|
"fingerprint with whoever does) — and only on a network you trust.",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
if (s.fingerprint != "unknown") {
|
||||||
|
Text(
|
||||||
|
"SHA-256: ${s.fingerprint}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.details.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
s.details,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { vm.declineCert() }) { Text("Cancel") }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = { vm.approvePendingCert() },
|
||||||
|
enabled = s.fingerprint != "unknown",
|
||||||
|
) { Text("Trust once") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SelectionUiState.Saved -> item {
|
||||||
|
Text(
|
||||||
|
"Connected: ${s.headline}",
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SelectionUiState.NeedsCredentials -> {}
|
||||||
|
is SelectionUiState.NeedsNcApproval -> {}
|
||||||
|
is SelectionUiState.NeedsMigration -> {}
|
||||||
|
is SelectionUiState.Migrating -> {}
|
||||||
|
is SelectionUiState.Idle -> {}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = onDone,
|
||||||
|
enabled = state !is SelectionUiState.Probing &&
|
||||||
|
state !is SelectionUiState.NeedsTlsApproval &&
|
||||||
|
state !is SelectionUiState.NeedsMigration &&
|
||||||
|
state !is SelectionUiState.Migrating,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(if (state is SelectionUiState.Saved) "Continue" else "Keep current setting") }
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val migration = state as? SelectionUiState.NeedsMigration
|
||||||
|
if (migration != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { vm.keepAsIs() },
|
||||||
|
title = { Text("Move your library?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"You have ${migration.count} recordings. Upload them to the new " +
|
||||||
|
"provider now, keep them as they are, or forget old server links " +
|
||||||
|
"and start fresh? Your local files never move either way."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { vm.migrateNow() },
|
||||||
|
enabled = migration.canUpload,
|
||||||
|
) { Text("Upload ${migration.count} now") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = { vm.forgetLinks() }) { Text("Forget links") }
|
||||||
|
TextButton(onClick = { vm.keepAsIs() }) { Text("Keep as-is") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val migrating = state as? SelectionUiState.Migrating
|
||||||
|
if (migrating != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { },
|
||||||
|
title = { Text("Uploading ${migrating.done} of ${migrating.total}") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (migrating.done.toFloat() / migrating.total.coerceAtLeast(1)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
migrating.current.ifBlank { "Finishing…" },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { vm.cancelMigration() }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProviderCard(option: ProviderOption, selected: Boolean, onSelect: () -> Unit) {
|
||||||
|
Card(onClick = onSelect) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = onSelect)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(option.name, style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
if (!option.available) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"coming soon",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(option.blurb, style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,564 @@
|
||||||
|
package com.shonar.ui.provider
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.shonar.ShonarApplication
|
||||||
|
import com.shonar.provider.ProbeResult
|
||||||
|
import com.shonar.provider.ProviderError
|
||||||
|
import com.shonar.provider.ProviderRegistry
|
||||||
|
import com.shonar.provider.ServerUrl
|
||||||
|
import com.shonar.provider.ShonarHandshake
|
||||||
|
import com.shonar.settings.BuiltInSettings
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** One card in the provider-selection list. */
|
||||||
|
data class ProviderOption(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val blurb: String,
|
||||||
|
val needsUrl: Boolean,
|
||||||
|
val available: Boolean, // false -> disabled with "coming soon"
|
||||||
|
val needsPath: Boolean = false, // sync-folder style: a local directory path
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SelectionUiState {
|
||||||
|
data object Idle : SelectionUiState
|
||||||
|
data class Probing(val url: String) : SelectionUiState
|
||||||
|
data class NeedsCredentials(val url: String) : SelectionUiState
|
||||||
|
data class NeedsNcApproval(
|
||||||
|
val url: String,
|
||||||
|
val loginUrl: String,
|
||||||
|
val flow: com.shonar.provider.LoginFlowSession,
|
||||||
|
) : SelectionUiState
|
||||||
|
data class NeedsTlsApproval(
|
||||||
|
val url: String,
|
||||||
|
val fingerprint: String,
|
||||||
|
val details: String = "",
|
||||||
|
) : SelectionUiState
|
||||||
|
data class Error(val message: String) : SelectionUiState
|
||||||
|
data class Saved(val providerId: String, val headline: String) : SelectionUiState
|
||||||
|
/**
|
||||||
|
* P7: switching providers with a non-empty library asks what happens to
|
||||||
|
* it before anything is moved or forgotten.
|
||||||
|
*/
|
||||||
|
data class NeedsMigration(
|
||||||
|
val providerId: String,
|
||||||
|
val count: Int,
|
||||||
|
val canUpload: Boolean,
|
||||||
|
) : SelectionUiState
|
||||||
|
data class Migrating(val done: Int, val total: Int, val current: String) : SelectionUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
||||||
|
|
||||||
|
private val settings = app.settingsManager
|
||||||
|
private val tofu = app.tofu
|
||||||
|
private val handshake = ShonarHandshake(app.providerRegistry.tls.probeClient(), app.tofu)
|
||||||
|
private val ncAuth = com.shonar.provider.NextcloudAuth(
|
||||||
|
app.providerRegistry.tls.nextcloudClient(), app.tofu
|
||||||
|
)
|
||||||
|
private val customProvider: com.shonar.provider.ShonarProvider by lazy {
|
||||||
|
app.providerRegistry.provider(ProviderRegistry.CUSTOM_SHONAR_ID)
|
||||||
|
}
|
||||||
|
private val nextcloudProvider: com.shonar.provider.ShonarProvider by lazy {
|
||||||
|
app.providerRegistry.provider(ProviderRegistry.NEXTCLOUD_ID)
|
||||||
|
}
|
||||||
|
private val folderProvider: com.shonar.provider.ShonarProvider by lazy {
|
||||||
|
app.providerRegistry.provider(ProviderRegistry.SYNC_FOLDER_ID)
|
||||||
|
}
|
||||||
|
private var pollJob: kotlinx.coroutines.Job? = null
|
||||||
|
private var migrationJob: kotlinx.coroutines.Job? = null
|
||||||
|
private var migratingTo: String? = null
|
||||||
|
private var pendingOption: ProviderOption? = null
|
||||||
|
private var pendingUrl: String = ""
|
||||||
|
private var pendingMigration: SelectionUiState.NeedsMigration? = null
|
||||||
|
|
||||||
|
private val registry = app.providerRegistry
|
||||||
|
private val repository = app.recordingRepository
|
||||||
|
private val dao = app.database.recordingDao()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<SelectionUiState>(SelectionUiState.Idle)
|
||||||
|
val state: StateFlow<SelectionUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
val options: List<ProviderOption> = listOf(
|
||||||
|
ProviderOption(
|
||||||
|
id = ProviderRegistry.NEXTCLOUD_ID,
|
||||||
|
name = "Nextcloud",
|
||||||
|
blurb = "Recommended. Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.",
|
||||||
|
needsUrl = true,
|
||||||
|
available = true, // P4
|
||||||
|
),
|
||||||
|
ProviderOption(
|
||||||
|
id = ProviderRegistry.CUSTOM_SHONAR_ID,
|
||||||
|
name = "Custom SHONAR server",
|
||||||
|
blurb = "A SHONAR backend you run yourself (this project's server). Sign in with your server account.",
|
||||||
|
needsUrl = true,
|
||||||
|
available = true, // P3
|
||||||
|
),
|
||||||
|
ProviderOption(
|
||||||
|
id = ProviderRegistry.SYNC_FOLDER_ID,
|
||||||
|
name = "Sync folder",
|
||||||
|
blurb = "A folder on this device kept in sync by Syncthing (or the Nextcloud " +
|
||||||
|
"desktop client, rsync…). The app writes plain files; your sync tool moves them.",
|
||||||
|
needsUrl = false,
|
||||||
|
available = true, // P4
|
||||||
|
needsPath = true,
|
||||||
|
),
|
||||||
|
ProviderOption(
|
||||||
|
id = "start9",
|
||||||
|
name = "Start9 Server",
|
||||||
|
blurb = "A service on your Start9 box — enter its URL and the app detects " +
|
||||||
|
"Nextcloud or SHONAR automatically.",
|
||||||
|
needsUrl = true,
|
||||||
|
available = true, // P6a: generic hosted setup (no platform RPC)
|
||||||
|
),
|
||||||
|
ProviderOption(
|
||||||
|
id = "umbrel",
|
||||||
|
name = "Umbrel",
|
||||||
|
blurb = "A service on your Umbrel — enter its URL and the app detects " +
|
||||||
|
"Nextcloud or SHONAR automatically.",
|
||||||
|
needsUrl = true,
|
||||||
|
available = true, // P6a: generic hosted setup (no platform RPC)
|
||||||
|
),
|
||||||
|
ProviderOption(
|
||||||
|
id = com.shonar.provider.LocalOnlyProvider.ID,
|
||||||
|
name = "Local only",
|
||||||
|
blurb = "Nothing leaves this phone. Recording, playback, and search work fully offline.",
|
||||||
|
needsUrl = false,
|
||||||
|
available = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun choose(option: ProviderOption, rawUrl: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
pollJob?.cancel()
|
||||||
|
migrationJob?.cancel()
|
||||||
|
if (!option.available) {
|
||||||
|
_state.value = SelectionUiState.Error("${option.name} support ships in an upcoming milestone.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (!option.needsUrl) {
|
||||||
|
// Local-only saves straight away; the sync folder has its
|
||||||
|
// own path step (connectFolder).
|
||||||
|
if (option.needsPath) return@launch
|
||||||
|
runCatching { save(option.id, "") }
|
||||||
|
.onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val parsed = ServerUrl.parse(rawUrl)
|
||||||
|
parsed.exceptionOrNull()?.let {
|
||||||
|
_state.value = SelectionUiState.Error(it.message ?: "Invalid server URL")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val url = parsed.getOrThrow()
|
||||||
|
pendingOption = option
|
||||||
|
pendingUrl = rawUrl
|
||||||
|
_state.value = SelectionUiState.Probing(rawUrl)
|
||||||
|
if (option.id == ProviderRegistry.NEXTCLOUD_ID) {
|
||||||
|
probeNextcloud(url, rawUrl)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (option.id == "start9" || option.id == "umbrel") {
|
||||||
|
probeHosted(url, rawUrl, option.name)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
when (val probe = handshake.probe(url)) {
|
||||||
|
is ProbeResult.Compatible ->
|
||||||
|
if (option.id == ProviderRegistry.CUSTOM_SHONAR_ID) {
|
||||||
|
// Probe proved the server; credentials come next (P3 login).
|
||||||
|
_state.value = SelectionUiState.NeedsCredentials(rawUrl)
|
||||||
|
} else {
|
||||||
|
save(option.id, rawUrl)
|
||||||
|
}
|
||||||
|
ProbeResult.Incompatible ->
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"No SHONAR-compatible service found at ${url.origin}. " +
|
||||||
|
"Check the URL, or choose Local only for now."
|
||||||
|
)
|
||||||
|
is ProbeResult.ServicesFound ->
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"Multiple services found — pick one: " + probe.services.joinToString { it.serviceName }
|
||||||
|
)
|
||||||
|
is ProbeResult.TlsFailure ->
|
||||||
|
_state.value = toTlsApproval(rawUrl, probe.fingerprintSha256)
|
||||||
|
is ProbeResult.NetworkError ->
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"Can't reach ${url.origin} (${probe.reason}). Check the address and your network."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5 approval: the user saw the fingerprint and trusts this server.
|
||||||
|
* Pins the leaf cert for that host only, then retries the probe.
|
||||||
|
*/
|
||||||
|
fun approvePendingCert() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val option = pendingOption ?: return@launch
|
||||||
|
val rawUrl = pendingUrl
|
||||||
|
if (rawUrl.isBlank()) return@launch
|
||||||
|
val host = ServerUrl.parse(rawUrl).getOrNull()?.host ?: return@launch
|
||||||
|
_state.value = SelectionUiState.Probing(rawUrl)
|
||||||
|
val ok = runCatching { tofu.approve(host) }.getOrDefault(false)
|
||||||
|
if (!ok) {
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"Nothing recorded for $host — try checking the server again."
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
choose(option, rawUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk away from the untrusted server; nothing is pinned. */
|
||||||
|
fun declineCert() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val host = ServerUrl.parse(pendingUrl).getOrNull()?.host ?: return@launch
|
||||||
|
tofu.decline(host)
|
||||||
|
}
|
||||||
|
_state.value = SelectionUiState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toTlsApproval(rawUrl: String, fingerprint: String): SelectionUiState {
|
||||||
|
val host = ServerUrl.parse(rawUrl).getOrNull()?.host.orEmpty()
|
||||||
|
val failure = tofu.failureFor(host)
|
||||||
|
val details = failure?.let {
|
||||||
|
"Issued to: ${it.subject}\nIssued by: ${it.issuer}\nValid: ${it.validFrom} → ${it.validUntil}"
|
||||||
|
}.orEmpty()
|
||||||
|
return SelectionUiState.NeedsTlsApproval(rawUrl, fingerprint, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P3 login: exchange email + password for the rotating token pair.
|
||||||
|
* The password lives in memory for this call only — only the tokens
|
||||||
|
* reach the secure store.
|
||||||
|
*/
|
||||||
|
fun login(rawUrl: String, email: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val parsed = ServerUrl.parse(rawUrl)
|
||||||
|
parsed.exceptionOrNull()?.let {
|
||||||
|
_state.value = SelectionUiState.Error(it.message ?: "Invalid server URL")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (email.isBlank() || password.isEmpty()) {
|
||||||
|
_state.value = SelectionUiState.Error("Enter your server email and password.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_state.value = SelectionUiState.Probing(rawUrl)
|
||||||
|
runCatching {
|
||||||
|
customProvider.connect(
|
||||||
|
com.shonar.provider.ProviderCredential.ShonarLogin(
|
||||||
|
accountLabel = email.trim(),
|
||||||
|
serverUrl = parsed.getOrThrow(),
|
||||||
|
email = email.trim(),
|
||||||
|
password = password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
it.message ?: "Could not sign in to ${parsed.getOrThrow().origin}"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
save(ProviderRegistry.CUSTOM_SHONAR_ID, rawUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P6a hosted setup: a Start9/Umbrel box (or anything else reachable) runs
|
||||||
|
* *some* service — probe for Nextcloud first, then SHONAR, and hand off
|
||||||
|
* to that provider's existing flow. The platform is an entry path, not a
|
||||||
|
* sync implementation: what gets persisted is the underlying protocol
|
||||||
|
* provider. No platform RPCs (deferred to P6b).
|
||||||
|
*/
|
||||||
|
private suspend fun probeHosted(url: ServerUrl, rawUrl: String, platform: String) {
|
||||||
|
val nc = ncAuth.probe(url)
|
||||||
|
// A compatible Nextcloud answers fast; only probe SHONAR when it
|
||||||
|
// didn't match — but a TLS failure on either blocks everything.
|
||||||
|
val shonar = if (nc is ProbeResult.Compatible) nc else handshake.probe(url)
|
||||||
|
when (routeHosted(nc, shonar)) {
|
||||||
|
HostedRoute.NEXT_CLOUD -> {
|
||||||
|
runCatching { ncAuth.startLogin(url) }
|
||||||
|
.onSuccess {
|
||||||
|
_state.value = SelectionUiState.NeedsNcApproval(rawUrl, it.loginUrl, it)
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
it.message ?: "Could not start the Nextcloud login"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostedRoute.SHONAR ->
|
||||||
|
_state.value = SelectionUiState.NeedsCredentials(rawUrl)
|
||||||
|
HostedRoute.TLS -> {
|
||||||
|
val fp = (nc as? ProbeResult.TlsFailure)?.fingerprintSha256
|
||||||
|
?: (shonar as? ProbeResult.TlsFailure)?.fingerprintSha256
|
||||||
|
?: "unknown"
|
||||||
|
_state.value = toTlsApproval(rawUrl, fp)
|
||||||
|
}
|
||||||
|
HostedRoute.NONE ->
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"No Nextcloud or SHONAR service found at ${url.origin} from $platform. " +
|
||||||
|
"Check the URL, or choose another option for now."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* P4 Nextcloud: probe for status.php, then start login flow v2. The
|
||||||
|
* browser approval + polling continue in [NeedsNcApproval].
|
||||||
|
*/
|
||||||
|
private suspend fun probeNextcloud(url: ServerUrl, rawUrl: String) {
|
||||||
|
when (val probe = ncAuth.probe(url)) {
|
||||||
|
is ProbeResult.Compatible -> {
|
||||||
|
runCatching { ncAuth.startLogin(url) }
|
||||||
|
.onSuccess {
|
||||||
|
_state.value = SelectionUiState.NeedsNcApproval(rawUrl, it.loginUrl, it)
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
it.message ?: "Could not start the Nextcloud login"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProbeResult.Incompatible ->
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"That doesn't look like a Nextcloud (no status.php). " +
|
||||||
|
"Check the URL, or choose another option for now."
|
||||||
|
)
|
||||||
|
is ProbeResult.ServicesFound ->
|
||||||
|
_state.value = SelectionUiState.Error("Unexpected probe result — try again.")
|
||||||
|
is ProbeResult.TlsFailure ->
|
||||||
|
_state.value = toTlsApproval(rawUrl, probe.fingerprintSha256)
|
||||||
|
is ProbeResult.NetworkError ->
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"Can't reach ${url.origin} (${probe.reason}). Check the address and your network."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
pollJob?.cancel()
|
||||||
|
pollJob = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(2500)
|
||||||
|
val cur = _state.value as? SelectionUiState.NeedsNcApproval ?: break
|
||||||
|
if (!pollOnce(cur)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One poll attempt. Returns false when polling should stop. */
|
||||||
|
private suspend fun pollOnce(cur: SelectionUiState.NeedsNcApproval): Boolean {
|
||||||
|
return when (val r = ncAuth.poll(cur.flow)) {
|
||||||
|
is com.shonar.provider.PollResult.Approved -> {
|
||||||
|
runCatching { nextcloudProvider.connect(r.credential) }
|
||||||
|
.onSuccess { save(ProviderRegistry.NEXTCLOUD_ID, cur.url) }
|
||||||
|
.onFailure {
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
it.message ?: "Could not connect to ${cur.url}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
is com.shonar.provider.PollResult.Failed -> {
|
||||||
|
_state.value = SelectionUiState.Error(r.reason)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
com.shonar.provider.PollResult.Pending -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "I've approved — check now" button. */
|
||||||
|
fun pollNow() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = _state.value as? SelectionUiState.NeedsNcApproval ?: return@launch
|
||||||
|
pollOnce(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave the approval flow without disconnecting anything. */
|
||||||
|
fun cancelFlow() {
|
||||||
|
pollJob?.cancel()
|
||||||
|
_state.value = SelectionUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P4 sync folder: validate the directory and remember it. The folder
|
||||||
|
* must already exist (created by you or by Syncthing) — a typo is an
|
||||||
|
* error, never a silently created directory.
|
||||||
|
*/
|
||||||
|
fun connectFolder(rawPath: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val path = rawPath.trim()
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
_state.value = SelectionUiState.Error(
|
||||||
|
"Enter the folder path — e.g. the Syncthing folder for SHONAR."
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_state.value = SelectionUiState.Probing(path)
|
||||||
|
runCatching {
|
||||||
|
folderProvider.connect(
|
||||||
|
com.shonar.provider.ProviderCredential.FolderPath(
|
||||||
|
accountLabel = path,
|
||||||
|
path = path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
_state.value = SelectionUiState.Error(it.message ?: "Could not use $path")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
save(ProviderRegistry.SYNC_FOLDER_ID, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun save(providerId: String, url: String) {
|
||||||
|
settings.ensureLoaded()
|
||||||
|
val previous = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
|
||||||
|
settings.setValue(BuiltInSettings.PROVIDER_ID, "\"" + providerId + "\"")
|
||||||
|
if (url.isBlank()) {
|
||||||
|
// An empty URL is represented by the setting's default, not by an
|
||||||
|
// invalid URL value. This also clears a previously configured
|
||||||
|
// remote provider when switching back to local-only storage.
|
||||||
|
settings.reset(BuiltInSettings.PROVIDER_URL)
|
||||||
|
} else {
|
||||||
|
settings.setValue(
|
||||||
|
BuiltInSettings.PROVIDER_URL,
|
||||||
|
"\"" + url.replace("\\", "\\\\").replace("\"", "\\\"") + "\"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// P7: switching providers with a non-empty library asks what happens
|
||||||
|
// to it — nothing moves or is forgotten silently.
|
||||||
|
val count = runCatching { dao.getAll().size }.getOrDefault(0)
|
||||||
|
if (previous != providerId && count > 0) {
|
||||||
|
val ask = SelectionUiState.NeedsMigration(
|
||||||
|
providerId = providerId,
|
||||||
|
count = count,
|
||||||
|
canUpload = providerId != com.shonar.provider.LocalOnlyProvider.ID,
|
||||||
|
)
|
||||||
|
pendingMigration = ask
|
||||||
|
_state.value = ask
|
||||||
|
} else {
|
||||||
|
_state.value = SelectionUiState.Saved(providerId, headlineFor(providerId, url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P7 "keep everything as it is": history stays, states stay. */
|
||||||
|
fun keepAsIs() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ask = pendingMigration ?: return@launch
|
||||||
|
pendingMigration = null
|
||||||
|
_state.value = SelectionUiState.Saved(
|
||||||
|
ask.providerId, headlineFor(ask.providerId, pendingUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P7 "start fresh": clear every remote link. Files stay; nothing remote
|
||||||
|
* is touched — forgetting is metadata-only. */
|
||||||
|
fun forgetLinks() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ask = pendingMigration ?: return@launch
|
||||||
|
pendingMigration = null
|
||||||
|
runCatching { repository.forgetAllRemotes(ask.providerId) }
|
||||||
|
_state.value = SelectionUiState.Saved(
|
||||||
|
ask.providerId, headlineFor(ask.providerId, pendingUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P7 "upload now": foreground migration to the new provider. Cancellable;
|
||||||
|
* already-finished files stay uploaded, the rest stay QUEUED for a later
|
||||||
|
* run. Failures are recorded per recording and reported, never retried
|
||||||
|
* here — retry policy is M5's job.
|
||||||
|
*/
|
||||||
|
fun migrateNow() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ask = pendingMigration ?: return@launch
|
||||||
|
pendingMigration = null
|
||||||
|
migrationJob?.cancel()
|
||||||
|
val runner = com.shonar.recording.MigrationRunner(
|
||||||
|
com.shonar.recording.SyncSlots(dao), registry
|
||||||
|
)
|
||||||
|
migratingTo = ask.providerId
|
||||||
|
migrationJob = viewModelScope.launch {
|
||||||
|
val res = runner.migrateAllTo(ask.providerId) { p ->
|
||||||
|
_state.value = SelectionUiState.Migrating(p.done, p.total, p.currentTitle)
|
||||||
|
}
|
||||||
|
migratingTo = null
|
||||||
|
_state.value = if (res.failed.isEmpty()) {
|
||||||
|
SelectionUiState.Saved(
|
||||||
|
ask.providerId, headlineFor(ask.providerId, pendingUrl)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SelectionUiState.Error(
|
||||||
|
"Uploaded ${res.uploaded} of ${res.uploaded + res.failed.size}. " +
|
||||||
|
"Failed (kept locally, retried by a future sync): " +
|
||||||
|
res.failed.take(3).joinToString(", ") +
|
||||||
|
if (res.failed.size > 3) ", …" else ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a running migration. Finished files stay uploaded. */
|
||||||
|
fun cancelMigration() {
|
||||||
|
migrationJob?.cancel()
|
||||||
|
viewModelScope.launch {
|
||||||
|
// The runner reports cancellation itself by leaving Migrating;
|
||||||
|
// this is only the fallback if it somehow didn't.
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
val target = migratingTo
|
||||||
|
if (_state.value is SelectionUiState.Migrating && target != null) {
|
||||||
|
migratingTo = null
|
||||||
|
_state.value = SelectionUiState.Saved(target, headlineFor(target, pendingUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
|
||||||
|
ProviderSelectionViewModel(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun headlineFor(providerId: String, url: String): String = when (providerId) {
|
||||||
|
com.shonar.provider.LocalOnlyProvider.ID -> "On this device only"
|
||||||
|
ProviderRegistry.SYNC_FOLDER_ID -> "Sync folder"
|
||||||
|
else -> {
|
||||||
|
val host = ServerUrl.parse(url).getOrNull()?.host
|
||||||
|
if (host.isNullOrBlank()) providerId else "$providerId at $host"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a hosted-service URL routes after probing. Pure; unit-tested. */
|
||||||
|
enum class HostedRoute { NEXT_CLOUD, SHONAR, TLS, NONE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P6a routing: a compatible match wins on either probe; otherwise a TLS
|
||||||
|
* failure on either blocks everything (approval retries the whole flow);
|
||||||
|
* anything else means "nothing we speak lives here".
|
||||||
|
*/
|
||||||
|
fun routeHosted(nc: ProbeResult, shonar: ProbeResult): HostedRoute = when {
|
||||||
|
nc is ProbeResult.Compatible -> HostedRoute.NEXT_CLOUD
|
||||||
|
shonar is ProbeResult.Compatible -> HostedRoute.SHONAR
|
||||||
|
nc is ProbeResult.TlsFailure || shonar is ProbeResult.TlsFailure -> HostedRoute.TLS
|
||||||
|
else -> HostedRoute.NONE
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
package com.shonar.ui.provider
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.shonar.provider.AuthState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P7 "Where is my data?": live provider summary plus the account
|
||||||
|
* lifecycle. Switching providers lives in the selection screen (the
|
||||||
|
* migration question is asked at the moment of switching).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun StorageScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSwitchProvider: () -> Unit,
|
||||||
|
vm: StorageViewModel = viewModel(factory = StorageViewModel.Factory),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { vm.refresh() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Where your recordings live") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
when (val s = state) {
|
||||||
|
is StorageViewModel.UiState.Loading -> {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text("Loading storage info …")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StorageViewModel.UiState.Error -> {
|
||||||
|
Text(s.message, color = MaterialTheme.colorScheme.error)
|
||||||
|
Button(onClick = onSwitchProvider, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Choose a provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StorageViewModel.UiState.ReadyState -> ReadyBody(
|
||||||
|
ready = s.ready,
|
||||||
|
onSwitchProvider = onSwitchProvider,
|
||||||
|
onReconnect = { vm.reconnect() },
|
||||||
|
onDisconnect = { vm.disconnect(it) },
|
||||||
|
onPauseSync = { vm.pauseSync() },
|
||||||
|
onAskDelete = { vm.askDelete() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ready = (state as? StorageViewModel.UiState.ReadyState)?.ready
|
||||||
|
if (ready?.confirmDelete == true) {
|
||||||
|
DeleteDialog(
|
||||||
|
ready = ready,
|
||||||
|
onCancel = { vm.cancelDelete() },
|
||||||
|
onConfirm = { vm.confirmDelete(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReadyBody(
|
||||||
|
ready: StorageViewModel.Ready,
|
||||||
|
onSwitchProvider: () -> Unit,
|
||||||
|
onReconnect: () -> Unit,
|
||||||
|
onDisconnect: (Boolean) -> Unit,
|
||||||
|
onPauseSync: () -> Unit,
|
||||||
|
onAskDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
var revoke by remember(ready.providerId) { mutableStateOf(true) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
ready.displayName,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Status: ${authLabel(ready.auth)}",
|
||||||
|
color = if (ready.auth == AuthState.CONNECTED) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ready.summary?.let {
|
||||||
|
Text(it.headline, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
it.detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Synced ${it.syncedCount} · local-only ${it.localOnlyCount}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ready.summaryError?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
ready.busy?.let {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val enabled = ready.busy == null
|
||||||
|
if (ready.auth != AuthState.CONNECTED) {
|
||||||
|
Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Switch provider")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onPauseSync,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Pause sync") }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled)
|
||||||
|
Text("Also revoke on the server")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onDisconnect(revoke) },
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Disconnect") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onAskDelete,
|
||||||
|
enabled = enabled,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Delete account & data") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authLabel(auth: AuthState): String = when (auth) {
|
||||||
|
AuthState.CONNECTED -> "connected"
|
||||||
|
AuthState.DISCONNECTED -> "disconnected"
|
||||||
|
AuthState.EXPIRED -> "expired — reconnect"
|
||||||
|
AuthState.REVOKED -> "revoked — connect again"
|
||||||
|
AuthState.OFFLINE -> "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteDialog(
|
||||||
|
ready: StorageViewModel.Ready,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Delete account & data?") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
if (ready.needPassword) {
|
||||||
|
"This asks the server to purge the account (30-day grace), " +
|
||||||
|
"revokes this device, and forgets every remote link. " +
|
||||||
|
"Your local library files stay on this phone."
|
||||||
|
} else {
|
||||||
|
"This revokes access and forgets every remote link on " +
|
||||||
|
"${ready.displayName}. Your local library files stay " +
|
||||||
|
"on this phone; server copies are left alone."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (ready.needPassword) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Server password") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ready.deleteError?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(password) },
|
||||||
|
enabled = !ready.needPassword || password.isNotEmpty(),
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onCancel) { Text("Keep") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
package com.shonar.ui.provider
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.shonar.ShonarApplication
|
||||||
|
import com.shonar.provider.AuthState
|
||||||
|
import com.shonar.provider.StorageLocation
|
||||||
|
import com.shonar.settings.BuiltInSettings
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P7 "Where is my data?" screen: the active provider's live summary plus
|
||||||
|
* the account lifecycle — reconnect, disconnect (optionally revoking
|
||||||
|
* server-side), and delete-account-and-data. Switching providers happens
|
||||||
|
* in the selection screen; the migration question is asked there, at the
|
||||||
|
* moment of switching.
|
||||||
|
*/
|
||||||
|
class StorageViewModel(private val app: ShonarApplication) : ViewModel() {
|
||||||
|
|
||||||
|
data class Ready(
|
||||||
|
val providerId: String,
|
||||||
|
val displayName: String,
|
||||||
|
val auth: AuthState,
|
||||||
|
val summary: StorageLocation?,
|
||||||
|
val summaryError: String?,
|
||||||
|
val busy: String? = null,
|
||||||
|
val confirmDelete: Boolean = false,
|
||||||
|
val needPassword: Boolean = false,
|
||||||
|
val deleteError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface UiState {
|
||||||
|
data object Loading : UiState
|
||||||
|
data class ReadyState(val ready: Ready) : UiState
|
||||||
|
data class Error(val message: String) : UiState
|
||||||
|
}
|
||||||
|
|
||||||
|
private val settings = app.settingsManager
|
||||||
|
private val registry = app.providerRegistry
|
||||||
|
private val repository = app.recordingRepository
|
||||||
|
private val dao = app.database.recordingDao()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
_state.value = load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load(): UiState {
|
||||||
|
settings.ensureLoaded()
|
||||||
|
val id = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
|
||||||
|
val provider = try {
|
||||||
|
registry.provider(id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return UiState.Error("Unknown provider \"$id\" — pick one again.")
|
||||||
|
}
|
||||||
|
val auth = provider.authState.value
|
||||||
|
var summary: StorageLocation? = null
|
||||||
|
var summaryError: String? = null
|
||||||
|
try {
|
||||||
|
summary = provider.storageLocationSummary()
|
||||||
|
} catch (e: com.shonar.provider.ProviderError.NotConnected) {
|
||||||
|
summaryError = "Not connected — reconnect to see live numbers."
|
||||||
|
} catch (e: Exception) {
|
||||||
|
summaryError = e.message ?: "Could not load storage info."
|
||||||
|
}
|
||||||
|
return UiState.ReadyState(
|
||||||
|
Ready(
|
||||||
|
providerId = id,
|
||||||
|
displayName = provider.descriptor.displayName,
|
||||||
|
auth = auth,
|
||||||
|
summary = summary,
|
||||||
|
summaryError = summaryError,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = currentReady() ?: return@launch
|
||||||
|
setBusy(cur, "Reconnecting…")
|
||||||
|
val provider = registry.provider(cur.providerId)
|
||||||
|
runCatching { provider.reconnect() }
|
||||||
|
_state.value = loadWith(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect(revokeOnServer: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = currentReady() ?: return@launch
|
||||||
|
setBusy(cur, "Disconnecting…")
|
||||||
|
runCatching { registry.provider(cur.providerId).disconnect(revokeOnServer) }
|
||||||
|
_state.value = loadWith(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5 pause: cancel scheduled sync work and revert in-flight rows to
|
||||||
|
* QUEUED. Pause is a resting state, not a job kill — the next drain
|
||||||
|
* (or a settings change) resumes cleanly.
|
||||||
|
*/
|
||||||
|
fun pauseSync() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = currentReady() ?: return@launch
|
||||||
|
setBusy(cur, "Pausing…")
|
||||||
|
runCatching {
|
||||||
|
com.shonar.recording.SyncScheduler.pauseAll(app.applicationContext, dao)
|
||||||
|
}
|
||||||
|
_state.value = loadWith(cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun askDelete() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = currentReady() ?: return@launch
|
||||||
|
val needsPassword = registry.provider(cur.providerId) is
|
||||||
|
com.shonar.provider.CustomShonarProvider
|
||||||
|
_state.value = UiState.ReadyState(
|
||||||
|
cur.copy(confirmDelete = true, needPassword = needsPassword, deleteError = null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDelete() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = currentReady() ?: return@launch
|
||||||
|
_state.value = UiState.ReadyState(cur.copy(confirmDelete = false, deleteError = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete account & data on the active provider, then fall back to
|
||||||
|
* local-only with cleared remote links. Provider semantics differ and
|
||||||
|
* the confirm dialog says which apply; the local library files always
|
||||||
|
* stay — only the remote links are forgotten.
|
||||||
|
*/
|
||||||
|
fun confirmDelete(password: String = "") {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cur = currentReady() ?: return@launch
|
||||||
|
setBusy(cur, "Deleting…")
|
||||||
|
val provider = registry.provider(cur.providerId)
|
||||||
|
val failure = runCatching {
|
||||||
|
val custom = provider as? com.shonar.provider.CustomShonarProvider
|
||||||
|
if (custom != null) {
|
||||||
|
if (password.isEmpty()) throw IllegalArgumentException("Password required")
|
||||||
|
custom.deleteAccount(password)
|
||||||
|
} else {
|
||||||
|
provider.deleteAccountAndData()
|
||||||
|
}
|
||||||
|
}.exceptionOrNull()
|
||||||
|
if (failure != null) {
|
||||||
|
_state.value = UiState.ReadyState(
|
||||||
|
cur.copy(
|
||||||
|
busy = null,
|
||||||
|
deleteError = failure.message ?: "Delete failed.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
settings.ensureLoaded()
|
||||||
|
settings.setValue(BuiltInSettings.PROVIDER_ID, "\"local-only\"")
|
||||||
|
settings.reset(BuiltInSettings.PROVIDER_URL)
|
||||||
|
repository.forgetAllRemotes("local-only")
|
||||||
|
_state.value = load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun currentReady(): Ready? =
|
||||||
|
(_state.value as? UiState.ReadyState)?.ready
|
||||||
|
|
||||||
|
private suspend fun setBusy(cur: Ready, label: String) {
|
||||||
|
_state.value = UiState.ReadyState(cur.copy(busy = label))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadWith(cur: Ready): UiState {
|
||||||
|
val fresh = load()
|
||||||
|
// Preserve an open confirm dialog across refreshes.
|
||||||
|
if (fresh is UiState.ReadyState && cur.confirmDelete) {
|
||||||
|
return UiState.ReadyState(fresh.ready.copy(confirmDelete = true))
|
||||||
|
}
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
|
||||||
|
StorageViewModel(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
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.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth lifecycle of CustomShonarProvider against the fake backend:
|
||||||
|
* persistence, transparent refresh, reuse-detection burn, revocation,
|
||||||
|
* restore, and purge. The storage happy path is covered by
|
||||||
|
* [CustomShonarProviderContractTest].
|
||||||
|
*/
|
||||||
|
class CustomShonarProviderAuthTest {
|
||||||
|
|
||||||
|
private val backend = FakeShonarBackend()
|
||||||
|
private lateinit var dir: File
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
backend.start()
|
||||||
|
dir = File(System.getProperty("java.io.tmpdir"), "custom-auth-${System.nanoTime()}").apply { mkdirs() }
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
backend.stop()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow()
|
||||||
|
|
||||||
|
private fun loginCred() = ProviderCredential.ShonarLogin(
|
||||||
|
accountLabel = "test@example.com",
|
||||||
|
serverUrl = serverUrl(),
|
||||||
|
email = "test@example.com",
|
||||||
|
password = "correct-horse-battery",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun connectedProvider(): CustomShonarProvider = runBlocking {
|
||||||
|
val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
|
||||||
|
p.connect(loginCred())
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun login_persistsSessionSecurely() = runBlocking {
|
||||||
|
connectedProvider()
|
||||||
|
val saved = ShonarAuthStore(store).load()
|
||||||
|
assertNotNull(saved)
|
||||||
|
assertEquals(backend.url, saved!!.baseUrl)
|
||||||
|
assertEquals("test@example.com", saved.email)
|
||||||
|
assertEquals(backend.currentAccess, saved.accessToken)
|
||||||
|
assertEquals(backend.currentRefresh, saved.refreshToken)
|
||||||
|
// password must never be persisted
|
||||||
|
assertFalse(store.keys().flatMap { listOf(store.getString(it).orEmpty()) }
|
||||||
|
.any { it.contains("correct-horse-battery") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun staleAccessToken_triggersSingleRefreshAndRetry() = runBlocking {
|
||||||
|
val p = connectedProvider()
|
||||||
|
// Simulate an expired access token without touching the refresh token.
|
||||||
|
val saved = ShonarAuthStore(store).load()!!
|
||||||
|
ShonarAuthStore(store).save(saved.copy(accessToken = "access-stale"))
|
||||||
|
val before = backend.accessSeq
|
||||||
|
|
||||||
|
val page = p.list(null) // 401 -> refresh -> retry, transparently
|
||||||
|
assertTrue(page.items.isEmpty())
|
||||||
|
assertEquals(before + 1, backend.accessSeq)
|
||||||
|
assertEquals(backend.currentAccess, ShonarAuthStore(store).load()!!.accessToken)
|
||||||
|
assertEquals(AuthState.CONNECTED, p.authState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun deadRefreshToken_burnsToExpired() = runBlocking {
|
||||||
|
val p = connectedProvider()
|
||||||
|
val saved = ShonarAuthStore(store).load()!!
|
||||||
|
// Both tokens unknown to the server (reuse-detection burn / revocation).
|
||||||
|
ShonarAuthStore(store).save(saved.copy(accessToken = "dead", refreshToken = "dead"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
p.list(null)
|
||||||
|
fail("dead tokens must raise AuthExpired")
|
||||||
|
} catch (expected: ProviderError.AuthExpired) {
|
||||||
|
}
|
||||||
|
assertEquals(AuthState.EXPIRED, p.authState.value)
|
||||||
|
// Dead tokens are dropped; URL + email stay for one-step re-login.
|
||||||
|
val after = ShonarAuthStore(store).load()
|
||||||
|
assertNull(after?.accessToken?.takeIf { it.isNotBlank() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun disconnect_revokesServerSideAndClearsTokens() = runBlocking {
|
||||||
|
val p = connectedProvider()
|
||||||
|
val refresh = ShonarAuthStore(store).load()!!.refreshToken
|
||||||
|
p.disconnect(revokeOnServer = true)
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.authState.value)
|
||||||
|
assertTrue("logout must carry the refresh token", backend.logouts.contains(refresh))
|
||||||
|
assertNull(ShonarAuthStore(store).load()?.refreshToken?.takeIf { it.isNotBlank() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun disconnectWithoutRevoke_keepsServerSession() = runBlocking {
|
||||||
|
val p = connectedProvider()
|
||||||
|
p.disconnect(revokeOnServer = false)
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.authState.value)
|
||||||
|
assertTrue(backend.logouts.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reconnect_restoresPersistedSession() = runBlocking {
|
||||||
|
connectedProvider()
|
||||||
|
// Fresh provider object, same secure store — like an app restart.
|
||||||
|
val p2 = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
|
||||||
|
assertEquals(AuthState.CONNECTED, p2.reconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reconnect_withNothingStored_staysDisconnected() = runBlocking {
|
||||||
|
val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.reconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun deleteAccount_purgesServerSideAndWipesLocal() = runBlocking {
|
||||||
|
val p = connectedProvider()
|
||||||
|
p.deleteAccount("correct-horse-battery")
|
||||||
|
assertEquals(1, backend.deleteAccountCalls)
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.authState.value)
|
||||||
|
assertNull(ShonarAuthStore(store).load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun operationsWhileDisconnected_throwNotConnected() = runBlocking {
|
||||||
|
val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars"))
|
||||||
|
try {
|
||||||
|
p.list(null)
|
||||||
|
fail("must throw NotConnected")
|
||||||
|
} catch (expected: ProviderError.NotConnected) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShonarAuthStoreTest {
|
||||||
|
|
||||||
|
private val store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
private val auth = ShonarAuthStore(store)
|
||||||
|
|
||||||
|
@Test fun save_load_roundtrip() = runBlocking {
|
||||||
|
auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 123L, "d1"))
|
||||||
|
val loaded = auth.load()
|
||||||
|
assertEquals("https://s.example.com", loaded!!.baseUrl)
|
||||||
|
assertEquals("a1", loaded.accessToken)
|
||||||
|
assertEquals("r1", loaded.refreshToken)
|
||||||
|
assertEquals("d1", loaded.deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun load_empty_isNull() = runBlocking {
|
||||||
|
assertNull(auth.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun clearTokens_keepsUrlAndEmail() = runBlocking {
|
||||||
|
auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null))
|
||||||
|
auth.clearTokens()
|
||||||
|
assertNull(auth.load()) // tokens gone -> no usable session
|
||||||
|
assertEquals("https://s.example.com", store.getString(ShonarAuthStore.KEY_BASE_URL))
|
||||||
|
assertEquals("u@x.com", store.getString(ShonarAuthStore.KEY_EMAIL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun clearAll_forgetsEverything() = runBlocking {
|
||||||
|
auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null))
|
||||||
|
auth.clearAll()
|
||||||
|
assertNull(auth.load())
|
||||||
|
assertNull(store.getString(ShonarAuthStore.KEY_BASE_URL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncStateTest {
|
||||||
|
|
||||||
|
@Test fun queued_canStartUploading() {
|
||||||
|
assertTrue(SyncStatus(SyncState.QUEUED).canTransitionTo(SyncState.UPLOADING))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun uploading_canFinishOrFailButNotSkipToSynced() {
|
||||||
|
assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.UPLOADED))
|
||||||
|
assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.ERROR))
|
||||||
|
assertFalse(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.SYNCED))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun error_onlyRetriesOrGoesLocal() {
|
||||||
|
assertTrue(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.QUEUED))
|
||||||
|
assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.UPLOADING))
|
||||||
|
assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.SYNCED))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun synced_canReuploadAfterLocalEdit() {
|
||||||
|
assertTrue(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.UPLOADING))
|
||||||
|
assertFalse(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.QUEUED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.After
|
||||||
|
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.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory fake of the SHONAR backend's M1 (auth) + M2 (uploads/recordings)
|
||||||
|
* endpoints. Response shapes mirror the real schemas
|
||||||
|
* (`backend/shonar/api/schemas_*.py`) so the contract suite below exercises
|
||||||
|
* the same JSON the app will meet in production:
|
||||||
|
*
|
||||||
|
* - rotating refresh tokens with reuse detection (old refresh dies on use)
|
||||||
|
* - chunk sessions with resume status + idempotent finalize per
|
||||||
|
* client_recording_id
|
||||||
|
*/
|
||||||
|
internal class FakeShonarBackend {
|
||||||
|
|
||||||
|
val server = MockWebServer()
|
||||||
|
|
||||||
|
var accessSeq = 0
|
||||||
|
var currentAccess = "access-0"
|
||||||
|
var currentRefresh = "refresh-0"
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val id: String,
|
||||||
|
val declaredSize: Long,
|
||||||
|
val mime: String,
|
||||||
|
val title: String?,
|
||||||
|
val clientRecordingId: String?,
|
||||||
|
val chunks: MutableMap<Int, ByteArray> = mutableMapOf(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Recording(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val mime: String,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val durationSeconds: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
val sessions = mutableMapOf<String, Session>()
|
||||||
|
val recordings = mutableMapOf<String, Recording>() // id -> recording
|
||||||
|
val finalizedByClientId = mutableMapOf<String, String>() // client id -> recording id
|
||||||
|
val logouts = mutableListOf<String>()
|
||||||
|
var deleteAccountCalls = 0
|
||||||
|
|
||||||
|
val url: String get() = server.url("/").toString().removeSuffix("/")
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
server.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
handle(request)
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
private fun authed(request: RecordedRequest): Boolean =
|
||||||
|
request.getHeader("Authorization") == "Bearer $currentAccess"
|
||||||
|
|
||||||
|
private fun handle(request: RecordedRequest): MockResponse {
|
||||||
|
val path = request.path?.substringBefore('?').orEmpty()
|
||||||
|
val method = request.method.orEmpty()
|
||||||
|
return when {
|
||||||
|
method == "GET" && path == "/api/v1/provider-info" -> json(200, JSONObject()
|
||||||
|
.put("kind", "shonar").put("version", "test")
|
||||||
|
.put("api_version", "v1")
|
||||||
|
.put("capabilities", JSONObject()
|
||||||
|
.put("chunked_upload", true).put("server_transcription", false)
|
||||||
|
.put("server_summary", false).put("account_deletion", true))
|
||||||
|
.put("storage_backend", "local"))
|
||||||
|
|
||||||
|
method == "POST" && path == "/api/v1/auth/login" -> {
|
||||||
|
val body = JSONObject(request.body.readUtf8())
|
||||||
|
if (body.optString("email") == "test@example.com" &&
|
||||||
|
body.optString("password") == "correct-horse-battery"
|
||||||
|
) {
|
||||||
|
accessSeq++
|
||||||
|
currentAccess = "access-$accessSeq"
|
||||||
|
currentRefresh = "refresh-$accessSeq"
|
||||||
|
json(200, tokenPair())
|
||||||
|
} else {
|
||||||
|
json(401, JSONObject().put("detail", "Invalid credentials"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "POST" && path == "/api/v1/auth/refresh" -> {
|
||||||
|
val body = JSONObject(request.body.readUtf8())
|
||||||
|
if (body.optString("refresh_token") == currentRefresh) {
|
||||||
|
accessSeq++
|
||||||
|
currentAccess = "access-$accessSeq"
|
||||||
|
currentRefresh = "refresh-$accessSeq"
|
||||||
|
json(200, tokenPair())
|
||||||
|
} else {
|
||||||
|
// reuse detection: unknown/rotated token burns the family
|
||||||
|
json(401, JSONObject().put("detail", "Invalid refresh token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "POST" && path == "/api/v1/auth/logout" -> {
|
||||||
|
logouts += JSONObject(request.body.readUtf8()).optString("refresh_token")
|
||||||
|
MockResponse().setResponseCode(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "POST" && path == "/api/v1/auth/delete-account" -> {
|
||||||
|
if (!authed(request)) return json(401, JSONObject().put("detail", "Nope"))
|
||||||
|
deleteAccountCalls++
|
||||||
|
json(202, JSONObject().put("detail", "scheduled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "GET" && path == "/api/v1/auth/me" ->
|
||||||
|
if (authed(request)) json(200, JSONObject()
|
||||||
|
.put("id", UUID.randomUUID().toString()).put("email", "test@example.com"))
|
||||||
|
else json(401, JSONObject().put("detail", "Invalid or expired token"))
|
||||||
|
|
||||||
|
method == "POST" && path == "/api/v1/uploads" -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val body = JSONObject(request.body.readUtf8())
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
sessions[id] = Session(
|
||||||
|
id = id,
|
||||||
|
declaredSize = body.getLong("declared_size_bytes"),
|
||||||
|
mime = body.getString("declared_mime_type"),
|
||||||
|
title = body.optString("title", null),
|
||||||
|
clientRecordingId = body.optString("client_recording_id", null)
|
||||||
|
.takeUnless { it.isNullOrBlank() },
|
||||||
|
)
|
||||||
|
json(201, JSONObject().put("id", id).put("status", "open")
|
||||||
|
.put("chunk_size_bytes", 64 * 1024)
|
||||||
|
.put("declared_mime_type", body.getString("declared_mime_type"))
|
||||||
|
.put("declared_size_bytes", body.getLong("declared_size_bytes")))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "GET" && path.startsWith("/api/v1/uploads/") -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val id = path.removePrefix("/api/v1/uploads/")
|
||||||
|
val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone"))
|
||||||
|
json(200, JSONObject().put("id", id).put("status", "open")
|
||||||
|
.put("received_chunk_indexes", JSONArray(s.chunks.keys.sorted())))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "PUT" && "/chunks/" in path -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val rest = path.removePrefix("/api/v1/uploads/") // {id}/chunks/{n}
|
||||||
|
val id = rest.substringBefore("/chunks/")
|
||||||
|
val idx = rest.substringAfter("/chunks/").toInt()
|
||||||
|
val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone"))
|
||||||
|
s.chunks[idx] = request.body.readByteArray()
|
||||||
|
json(201, JSONObject().put("chunk_index", idx)
|
||||||
|
.put("size_bytes", s.chunks[idx]!!.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "POST" && path.endsWith("/finalize") -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val id = path.removePrefix("/api/v1/uploads/").removeSuffix("/finalize")
|
||||||
|
val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone"))
|
||||||
|
val total = s.chunks.toSortedMap().values.sumOf { it.size.toLong() }
|
||||||
|
if (total != s.declaredSize) {
|
||||||
|
return json(422, JSONObject().put("detail", "Size mismatch"))
|
||||||
|
}
|
||||||
|
val existing = s.clientRecordingId?.let { finalizedByClientId[it] }
|
||||||
|
if (existing != null) return json(201, recordingJson(recordings[existing]!!))
|
||||||
|
val assembled = s.chunks.toSortedMap().values
|
||||||
|
.fold(byteArrayOf()) { acc, b -> acc + b }
|
||||||
|
val body = JSONObject(request.body.readUtf8())
|
||||||
|
val rec = Recording(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
title = s.title ?: "Untitled recording",
|
||||||
|
mime = s.mime,
|
||||||
|
bytes = assembled,
|
||||||
|
durationSeconds = body.optDouble("duration_seconds", 0.0),
|
||||||
|
)
|
||||||
|
recordings[rec.id] = rec
|
||||||
|
s.clientRecordingId?.let { finalizedByClientId[it] = rec.id }
|
||||||
|
json(201, recordingJson(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "GET" && path == "/api/v1/recordings" -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val q = request.path?.substringAfter('?', "").orEmpty()
|
||||||
|
.split('&').associate {
|
||||||
|
val (k, v) = it.split('=', limit = 2) + ""
|
||||||
|
k to v
|
||||||
|
}
|
||||||
|
val limit = q["limit"]?.toIntOrNull() ?: 50
|
||||||
|
val offset = q["offset"]?.toIntOrNull() ?: 0
|
||||||
|
val all = recordings.values.sortedByDescending { it.id }
|
||||||
|
val page = all.drop(offset).take(limit)
|
||||||
|
json(200, JSONObject()
|
||||||
|
.put("items", JSONArray(page.map { recordingJson(it) }))
|
||||||
|
.put("total", all.size).put("limit", limit).put("offset", offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "GET" && path.endsWith("/audio") -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val id = path.removePrefix("/api/v1/recordings/").removeSuffix("/audio")
|
||||||
|
val rec = recordings[id] ?: return json(404, JSONObject().put("detail", "gone"))
|
||||||
|
MockResponse().setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", rec.mime)
|
||||||
|
.setBody(okio.Buffer().write(rec.bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "DELETE" && path.startsWith("/api/v1/recordings/") -> {
|
||||||
|
if (!authed(request)) return unauthorized()
|
||||||
|
val id = path.removePrefix("/api/v1/recordings/")
|
||||||
|
if (recordings.remove(id) == null) return json(404, JSONObject().put("detail", "gone"))
|
||||||
|
finalizedByClientId.entries.removeIf { it.value == id }
|
||||||
|
MockResponse().setResponseCode(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> json(404, JSONObject().put("detail", "unknown $method $path"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unauthorized() = json(401, JSONObject().put("detail", "Invalid or expired token"))
|
||||||
|
|
||||||
|
private fun tokenPair() = JSONObject()
|
||||||
|
.put("access_token", currentAccess).put("token_type", "bearer")
|
||||||
|
.put("expires_in", 900).put("refresh_token", currentRefresh)
|
||||||
|
|
||||||
|
private fun recordingJson(r: Recording) = JSONObject()
|
||||||
|
.put("id", r.id).put("title", r.title)
|
||||||
|
.put("recorded_at", "2026-01-02T03:04:05Z")
|
||||||
|
.put("duration_seconds", r.durationSeconds)
|
||||||
|
.put("has_audio", true).put("mime_type", r.mime).put("size_bytes", r.bytes.size)
|
||||||
|
|
||||||
|
private fun json(code: Int, obj: JSONObject) = MockResponse()
|
||||||
|
.setResponseCode(code).setHeader("Content-Type", "application/json")
|
||||||
|
.setBody(obj.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the shared provider contract suite against CustomShonarProvider. */
|
||||||
|
class CustomShonarProviderContractTest : ProviderContractTest() {
|
||||||
|
|
||||||
|
private val backend = FakeShonarBackend()
|
||||||
|
private lateinit var dir: File
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
|
||||||
|
@org.junit.Before fun setUp() {
|
||||||
|
backend.start()
|
||||||
|
dir = File(System.getProperty("java.io.tmpdir"), "custom-contract-${System.nanoTime()}").apply { mkdirs() }
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
backend.stop()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow()
|
||||||
|
|
||||||
|
override fun initialCredential(): ProviderCredential = ProviderCredential.ShonarLogin(
|
||||||
|
accountLabel = "test@example.com",
|
||||||
|
serverUrl = serverUrl(),
|
||||||
|
email = "test@example.com",
|
||||||
|
password = "correct-horse-battery",
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun makeProvider(): ShonarProvider = CustomShonarProvider(
|
||||||
|
auth = ShonarAuthStore(store),
|
||||||
|
sidecarRoot = File(dir, "sidecars"),
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun makeDraft(id: String): RecordingDraft {
|
||||||
|
val src = File(dir, "src-$id.m4a")
|
||||||
|
// mp4 magic (ftyp at [4:8]) like backend/tests/test_recordings.py mp4_bytes()
|
||||||
|
val rnd = java.util.Random(id.hashCode().toLong())
|
||||||
|
val payload = ByteArray(256 * 1024).also { rnd.nextBytes(it) }
|
||||||
|
src.writeBytes(byteArrayOf(0, 0, 0, 0x20.toByte()) + "ftypM4A ".toByteArray() + payload)
|
||||||
|
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 fun credentialSecretStrings(cred: ProviderCredential): List<String> =
|
||||||
|
super.credentialSecretStrings(cred)
|
||||||
|
|
||||||
|
@Test fun probe_identifiesShonarServer() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
val probe = p.probe(serverUrl())
|
||||||
|
assertTrue(probe is ProbeResult.Compatible)
|
||||||
|
assertEquals(ProviderRegistry.CUSTOM_SHONAR_ID, (probe as ProbeResult.Compatible).descriptor.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun badPassword_loginRefusesWithoutLeaking() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
val bad = ProviderCredential.ShonarLogin("t", serverUrl(), "test@example.com", "wrong-pw-123")
|
||||||
|
try {
|
||||||
|
p.connect(bad)
|
||||||
|
org.junit.Assert.fail("bad password must not connect")
|
||||||
|
} catch (e: ProviderError.Transient) {
|
||||||
|
assertFalse(e.message!!.contains("wrong-pw-123"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Runs the shared provider contract suite against FolderSyncProvider. */
|
||||||
|
class FolderSyncProviderContractTest : ProviderContractTest() {
|
||||||
|
|
||||||
|
private lateinit var root: File
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
root = File(System.getProperty("java.io.tmpdir"), "foldersync-${System.nanoTime()}")
|
||||||
|
.apply { mkdirs() }
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialCredential(): ProviderCredential =
|
||||||
|
ProviderCredential.FolderPath(accountLabel = root.path, path = root.path)
|
||||||
|
|
||||||
|
override suspend fun makeProvider(): ShonarProvider = FolderSyncProvider(store)
|
||||||
|
|
||||||
|
override suspend fun makeDraft(id: String): RecordingDraft {
|
||||||
|
val src = File.createTempFile("draft-$id", ".bin")
|
||||||
|
val rnd = java.util.Random(7)
|
||||||
|
src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) })
|
||||||
|
src.deleteOnExit()
|
||||||
|
return RecordingDraft(
|
||||||
|
id = id,
|
||||||
|
title = "Contract recording",
|
||||||
|
createdAtEpochMs = 1_700_000_000_000,
|
||||||
|
durationMs = 12_345,
|
||||||
|
mime = "audio/mp4",
|
||||||
|
sourceFile = src,
|
||||||
|
sizeBytes = src.length(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refs_carrySyncFolderId() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val ref = p.upload(makeDraft("dddddddd-dddd-4ddd-8ddd-dddddddddddd")) { }
|
||||||
|
assertEquals(FolderSyncProvider.ID, ref.providerId)
|
||||||
|
assertEquals(1, p.list(null).items.count { it.ref.providerId == FolderSyncProvider.ID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun missingFolder_refusedNotCreated() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
val missing = File(root, "not-there")
|
||||||
|
try {
|
||||||
|
p.connect(ProviderCredential.FolderPath("x", missing.path))
|
||||||
|
fail("missing folder must be refused")
|
||||||
|
} catch (expected: ProviderError.InvalidUrl) {
|
||||||
|
}
|
||||||
|
assertFalse("a typo must never create directories", missing.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun traversal_refused() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
try {
|
||||||
|
p.connect(ProviderCredential.FolderPath("x", root.path + "/../evil"))
|
||||||
|
fail(".. must be refused")
|
||||||
|
} catch (expected: ProviderError.InvalidUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun deleteAccount_forgetsButKeepsFiles() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val ref = p.upload(makeDraft("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")) { }
|
||||||
|
val onDisk = File(root, ref.key)
|
||||||
|
assertTrue(onDisk.exists())
|
||||||
|
p.deleteAccountAndData()
|
||||||
|
// The files stay — they belong to the user and their sync tool.
|
||||||
|
assertTrue("user files must survive disconnect", onDisk.exists())
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.authState.value)
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.reconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reconnect_restoresPersistedFolder() = kotlinx.coroutines.runBlocking {
|
||||||
|
makeProvider().connect(initialCredential())
|
||||||
|
val p2 = makeProvider()
|
||||||
|
assertEquals(AuthState.CONNECTED, p2.reconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun wrongCredentialType_rejected() = kotlinx.coroutines.runBlocking {
|
||||||
|
try {
|
||||||
|
makeProvider().connect(ProviderCredential.None)
|
||||||
|
fail("None must be rejected")
|
||||||
|
} catch (expected: ProviderError.InvalidUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt
Normal file
211
android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5 leak tests (docs/server-providers.md §4, §8): with debug logging ON,
|
||||||
|
* no credential, token, audio byte, or transcript may reach the log sink;
|
||||||
|
* with it OFF, nothing logs at all.
|
||||||
|
*/
|
||||||
|
class RedactingLoggerTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private val logs = mutableListOf<String>()
|
||||||
|
private var bodiesOn = false
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
server = MockWebServer()
|
||||||
|
server.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
MockResponse().setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setBody("""{"access_token":"resp-token-abc","ok":true}""")
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
logs.clear()
|
||||||
|
bodiesOn = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(RedactingLogger(bodiesEnabled = { bodiesOn }, sink = logs::add))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun joined(): String = logs.joinToString("\n")
|
||||||
|
|
||||||
|
@Test fun off_byDefault_logsNothing() {
|
||||||
|
client().newCall(Request.Builder().url(server.url("/x")).get().build())
|
||||||
|
.execute().close()
|
||||||
|
assertTrue("logging off must be silent", logs.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun on_logsSummaryButRedactsAuthHeader() {
|
||||||
|
bodiesOn = true
|
||||||
|
client().newCall(
|
||||||
|
Request.Builder().url(server.url("/api/v1/auth/me")).get()
|
||||||
|
.header("Authorization", "Bearer header-secret-123").build()
|
||||||
|
).execute().close()
|
||||||
|
val out = joined()
|
||||||
|
assertTrue(out.contains("GET") && out.contains("200"))
|
||||||
|
assertTrue("header name stays, value goes", out.contains("Authorization: [redacted]"))
|
||||||
|
assertFalse(out.contains("header-secret-123"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun on_redactsTokenJsonAndBasic() {
|
||||||
|
bodiesOn = true
|
||||||
|
val body = """{"email":"a@b.c","password":"pw-secret-1","refresh_token":"rt-secret-2"}"""
|
||||||
|
client().newCall(
|
||||||
|
Request.Builder().url(server.url("/api/v1/auth/login"))
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Basic dGVzdHVzZXI6YXBwLXBhc3M=").build()
|
||||||
|
).execute().close()
|
||||||
|
val out = joined()
|
||||||
|
assertFalse(out.contains("pw-secret-1"))
|
||||||
|
assertFalse(out.contains("rt-secret-2"))
|
||||||
|
assertFalse("response token must be masked too", out.contains("resp-token-abc"))
|
||||||
|
assertFalse(out.contains("dGVzdHVzZXI6YXBwLXBhc3M="))
|
||||||
|
assertTrue(out.contains("***"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun on_neverLogsAudioBytes() {
|
||||||
|
bodiesOn = true
|
||||||
|
val marker = "LEAKMARKER-AUDIO-0123456789".toByteArray()
|
||||||
|
val payload = marker + ByteArray(1024) { 0x7F }
|
||||||
|
client().newCall(
|
||||||
|
Request.Builder().url(server.url("/put"))
|
||||||
|
.put(payload.toRequestBody("audio/mp4".toMediaType())).build()
|
||||||
|
).execute().close()
|
||||||
|
assertFalse("audio marker must not appear", joined().contains("LEAKMARKER-AUDIO"))
|
||||||
|
assertTrue(joined().contains("not logged"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun on_neverLogsBigJson() {
|
||||||
|
bodiesOn = true
|
||||||
|
val big = """{"blob":"${"x".repeat(9000)}"}"""
|
||||||
|
client().newCall(
|
||||||
|
Request.Builder().url(server.url("/big"))
|
||||||
|
.post(big.toRequestBody("application/json".toMediaType())).build()
|
||||||
|
).execute().close()
|
||||||
|
assertTrue(joined().contains("not logged"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun sensitiveFlag_strippedAndSkipsBodies() {
|
||||||
|
bodiesOn = true
|
||||||
|
// JSON body that WOULD log (small, loggable type) is suppressed by
|
||||||
|
// the flag, and the flag itself never reaches the wire.
|
||||||
|
val transcript = """{"text":"LEAKMARKER-TRANSCRIPT-transcribed words here"}"""
|
||||||
|
client().newCall(
|
||||||
|
Request.Builder().url(server.url("/sidecar"))
|
||||||
|
.put(transcript.toRequestBody("application/json".toMediaType()))
|
||||||
|
.header(RedactingLogger.SENSITIVE_BODY, "1").build()
|
||||||
|
).execute().close()
|
||||||
|
val out = joined()
|
||||||
|
assertFalse(out.contains("LEAKMARKER-TRANSCRIPT"))
|
||||||
|
// Response body (echo JSON with token) is suppressed too.
|
||||||
|
assertFalse(out.contains("resp-token-abc"))
|
||||||
|
assertTrue(out.contains("[sensitive, not logged]"))
|
||||||
|
val sent = server.takeRequest()
|
||||||
|
assertNull("flag must be stripped before sending", sent.getHeader(RedactingLogger.SENSITIVE_BODY))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failure_logsClassOnly() {
|
||||||
|
bodiesOn = true
|
||||||
|
try {
|
||||||
|
// Nothing listens on this port: fast refusal, no secrets involved.
|
||||||
|
client().newCall(Request.Builder().url("http://127.0.0.1:1/unreachable").get().build())
|
||||||
|
.execute().close()
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
}
|
||||||
|
assertTrue(joined().contains("failed ("))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-level leak test: a full Nextcloud upload + sidecar + download
|
||||||
|
* with debug logging ON must leave no credential, audio, or transcript
|
||||||
|
* material in the sink.
|
||||||
|
*/
|
||||||
|
class ProviderLeakTest {
|
||||||
|
|
||||||
|
private val backend = FakeNextcloud()
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
private val logs = mutableListOf<String>()
|
||||||
|
|
||||||
|
private val audioMarker = "LEAKMARKER-AUDIO-555"
|
||||||
|
private val transcriptMarker = "LEAKMARKER-TRANSCRIPT-777"
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
backend.start()
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
logs.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
backend.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nextcloudFlow_leaksNothing() = kotlinx.coroutines.runBlocking {
|
||||||
|
// Distinctive markers stand in for real secrets/audio/transcripts;
|
||||||
|
// the fake's fixed creds exercise the same code paths.
|
||||||
|
val tls = TlsPolicy(
|
||||||
|
tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())),
|
||||||
|
bodiesEnabled = { true },
|
||||||
|
sink = logs::add,
|
||||||
|
)
|
||||||
|
val p = NextcloudProvider(
|
||||||
|
auth = NextcloudAuthStore(store),
|
||||||
|
client = tls.nextcloudClient(),
|
||||||
|
loginFlow = NextcloudAuth(tls.nextcloudClient(), tls.tofu),
|
||||||
|
chunkSizeBytes = 64 * 1024,
|
||||||
|
)
|
||||||
|
p.connect(
|
||||||
|
ProviderCredential.AppPassword(
|
||||||
|
"t", backend.url, backend.userId, backend.appPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val dir = java.io.File(System.getProperty("java.io.tmpdir"), "leak-${System.nanoTime()}")
|
||||||
|
.apply { mkdirs() }
|
||||||
|
try {
|
||||||
|
val payload = (audioMarker + "|").toByteArray() + ByteArray(256 * 1024 - 32) { 0x3C }
|
||||||
|
val src = java.io.File(dir, "s.m4a").apply { writeBytes(payload) }
|
||||||
|
val draft = RecordingDraft(
|
||||||
|
"ffffffff-ffff-4fff-8fff-ffffffffffff", "t", 1_700_000_000_000,
|
||||||
|
1000, "audio/mp4", src, src.length(),
|
||||||
|
)
|
||||||
|
val ref = p.upload(draft) { }
|
||||||
|
p.putSidecar(
|
||||||
|
ref, SidecarKind.TRANSCRIPT,
|
||||||
|
"""{"text":"$transcriptMarker transcribed words"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val dest = java.io.File(dir, "out.m4a")
|
||||||
|
p.download(ref, dest) { }
|
||||||
|
val out = logs.joinToString("\n")
|
||||||
|
assertFalse("audio bytes in logs", out.contains(audioMarker))
|
||||||
|
assertFalse("transcript in logs", out.contains(transcriptMarker))
|
||||||
|
assertFalse("app password in logs", out.contains(backend.appPassword))
|
||||||
|
assertFalse("basic credentials in logs",
|
||||||
|
out.contains(java.util.Base64.getEncoder().encodeToString(
|
||||||
|
"${backend.userId}:${backend.appPassword}".toByteArray()
|
||||||
|
)))
|
||||||
|
assertTrue("something logged (logger was on)", out.isNotEmpty())
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,531 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Base64
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import okio.Buffer
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.After
|
||||||
|
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.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory fake of a Nextcloud server: status.php, login flow v2, OCS
|
||||||
|
* user/quota, and the WebDAV file + chunking-v2 APIs. Shapes mirror the
|
||||||
|
* official developer manual (chunk names 1..10000 assembled in name order,
|
||||||
|
* `OC-Total-Length`, `MOVE …/.file` + `Destination`):
|
||||||
|
*
|
||||||
|
* - MKCOL {dav}uploads/{user}/{transfer}/ (405 when present)
|
||||||
|
* - PUT {dav}uploads/{user}/{transfer}/{00001..} (201)
|
||||||
|
* - MOVE {dav}uploads/{user}/{transfer}/.file + Destination (201)
|
||||||
|
* - PROPFIND Depth:1 multistatus, hrefs percent-encoded like the real thing
|
||||||
|
*/
|
||||||
|
internal class FakeNextcloud {
|
||||||
|
|
||||||
|
val server = MockWebServer()
|
||||||
|
|
||||||
|
var productName = "Nextcloud"
|
||||||
|
var versionString = "30.0.0"
|
||||||
|
val userId = "testuser"
|
||||||
|
val appPassword = "app-pass-123"
|
||||||
|
var quotaFree = 10L * 1024 * 1024 * 1024
|
||||||
|
var quotaTotal = 50L * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
/** Decoded DAV paths -> bytes. Dirs end with '/'. */
|
||||||
|
val files = mutableMapOf<String, ByteArray>()
|
||||||
|
val dirs = mutableSetOf<String>()
|
||||||
|
|
||||||
|
var pollApproved = false
|
||||||
|
val pollToken = "poll-token-1"
|
||||||
|
|
||||||
|
var chunkPuts = 0
|
||||||
|
val chunkNames = mutableListOf<String>()
|
||||||
|
val chunkHeaders = mutableListOf<Map<String, String?>>()
|
||||||
|
var lastMove: Map<String, String?> = emptyMap()
|
||||||
|
var revokeCalls = 0
|
||||||
|
var failNextPut507 = false
|
||||||
|
|
||||||
|
val url: String get() = server.url("/").toString().removeSuffix("/")
|
||||||
|
private val davRoot get() = "/remote.php/dav/files/$userId/"
|
||||||
|
private val uploadsRoot get() = "/remote.php/dav/uploads/$userId/"
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
dirs += davRoot
|
||||||
|
server.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
handle(request)
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
private fun basicOk(request: RecordedRequest): Boolean {
|
||||||
|
val expect = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("$userId:$appPassword".toByteArray(Charsets.UTF_8))
|
||||||
|
return request.getHeader("Authorization") == expect
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dec(path: String): String = runCatching {
|
||||||
|
java.net.URLDecoder.decode(path, "UTF-8")
|
||||||
|
}.getOrDefault(path)
|
||||||
|
|
||||||
|
private fun encPath(path: String): String = path.split('/')
|
||||||
|
.joinToString("/") {
|
||||||
|
java.net.URLEncoder.encode(it, "UTF-8").replace("+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parentOf(dir: String): String {
|
||||||
|
val t = dir.trimEnd('/')
|
||||||
|
return t.substringBeforeLast('/', "") + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handle(request: RecordedRequest): MockResponse {
|
||||||
|
val rawPath = request.path?.substringBefore('?').orEmpty()
|
||||||
|
val path = dec(rawPath)
|
||||||
|
val method = request.method.orEmpty()
|
||||||
|
return when {
|
||||||
|
method == "GET" && path == "/status.php" -> json(200, JSONObject()
|
||||||
|
.put("productname", productName)
|
||||||
|
.put("versionstring", versionString))
|
||||||
|
|
||||||
|
method == "POST" && path == "/index.php/login/v2" -> json(200, JSONObject()
|
||||||
|
.put("poll", JSONObject()
|
||||||
|
.put("token", pollToken)
|
||||||
|
.put("endpoint", "$url/index.php/login/v2/poll"))
|
||||||
|
.put("login", "$url/login/v2/flow/abc123"))
|
||||||
|
|
||||||
|
method == "POST" && path == "/index.php/login/v2/poll" -> {
|
||||||
|
val token = runCatching { JSONObject(request.body.readUtf8()).optString("token") }
|
||||||
|
.getOrDefault("")
|
||||||
|
if (token == pollToken && pollApproved) {
|
||||||
|
json(200, JSONObject()
|
||||||
|
.put("server", url)
|
||||||
|
.put("loginName", userId)
|
||||||
|
.put("appPassword", appPassword))
|
||||||
|
} else {
|
||||||
|
json(404, JSONObject())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path == "/ocs/v2.php/cloud/user" -> {
|
||||||
|
if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized"))
|
||||||
|
json(200, JSONObject().put("ocs", JSONObject().put("data", JSONObject()
|
||||||
|
.put("id", userId)
|
||||||
|
.put("display-name", "Test User")
|
||||||
|
.put("quota", JSONObject()
|
||||||
|
.put("free", quotaFree).put("used", quotaTotal - quotaFree)
|
||||||
|
.put("total", quotaTotal).put("relative", 80.0)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "DELETE" && path == "/ocs/v2.php/core/apppassword" -> {
|
||||||
|
if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized"))
|
||||||
|
revokeCalls++
|
||||||
|
json(200, JSONObject())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chunked upload v2 -------------------------------------------------
|
||||||
|
method == "MKCOL" && path.startsWith(uploadsRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
val dir = path.trimEnd('/') + '/'
|
||||||
|
if (dir in dirs) return MockResponse().setResponseCode(405)
|
||||||
|
dirs += dir
|
||||||
|
MockResponse().setResponseCode(201)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "PUT" && path.startsWith(uploadsRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
if (failNextPut507) {
|
||||||
|
failNextPut507 = false
|
||||||
|
return MockResponse().setResponseCode(507)
|
||||||
|
}
|
||||||
|
val dir = parentOf(path)
|
||||||
|
if (dir !in dirs) return MockResponse().setResponseCode(404)
|
||||||
|
files[path] = request.body.readByteArray()
|
||||||
|
chunkPuts++
|
||||||
|
chunkNames += path.substringAfterLast('/')
|
||||||
|
chunkHeaders += mapOf(
|
||||||
|
"OC-Total-Length" to request.getHeader("OC-Total-Length"),
|
||||||
|
"Destination" to request.getHeader("Destination"),
|
||||||
|
)
|
||||||
|
MockResponse().setResponseCode(201)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "MOVE" && path.startsWith(uploadsRoot) && path.endsWith("/.file") -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
lastMove = mapOf(
|
||||||
|
"Destination" to request.getHeader("Destination"),
|
||||||
|
"Overwrite" to request.getHeader("Overwrite"),
|
||||||
|
"OC-Total-Length" to request.getHeader("OC-Total-Length"),
|
||||||
|
)
|
||||||
|
val destUrl = request.getHeader("Destination") ?: return MockResponse().setResponseCode(400)
|
||||||
|
val destPath = dec(runCatching { java.net.URI(destUrl).path }.getOrDefault(destUrl))
|
||||||
|
// Assemble transfer chunks in name order (server semantics).
|
||||||
|
val transferDir = path.removeSuffix(".file")
|
||||||
|
val chunks = files.keys.filter { it.startsWith(transferDir) && it != path }
|
||||||
|
.sortedBy { it.substringAfterLast('/') }
|
||||||
|
if (chunks.isEmpty()) return MockResponse().setResponseCode(404)
|
||||||
|
val assembled = chunks.fold(byteArrayOf()) { acc, k -> acc + files[k]!! }
|
||||||
|
files[destPath] = assembled
|
||||||
|
MockResponse().setResponseCode(201)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- plain WebDAV ------------------------------------------------------
|
||||||
|
method == "MKCOL" && path.startsWith(davRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
val dir = path.trimEnd('/') + '/'
|
||||||
|
if (dir in dirs) return MockResponse().setResponseCode(405)
|
||||||
|
if (parentOf(dir) !in dirs) return MockResponse().setResponseCode(409)
|
||||||
|
dirs += dir
|
||||||
|
MockResponse().setResponseCode(201)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "PUT" && path.startsWith(davRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
if (parentOf(path) !in dirs && parentOf(path) != davRoot) {
|
||||||
|
return MockResponse().setResponseCode(409)
|
||||||
|
}
|
||||||
|
files[path] = request.body.readByteArray()
|
||||||
|
MockResponse().setResponseCode(201)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "GET" && path.startsWith(davRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
val bytes = files[path] ?: return MockResponse().setResponseCode(404)
|
||||||
|
MockResponse().setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "audio/mp4")
|
||||||
|
.setBody(Buffer().write(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "DELETE" && path.startsWith(davRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
// Collections may arrive with or without the trailing slash;
|
||||||
|
// real servers treat both the same.
|
||||||
|
val asDir = path.trimEnd('/') + '/'
|
||||||
|
val fileGone = files.remove(path) != null
|
||||||
|
val dirGone = if (asDir in dirs) {
|
||||||
|
dirs.removeIf { it == asDir || it.startsWith(asDir) }
|
||||||
|
files.keys.removeIf { it.startsWith(asDir) }
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
if (!fileGone && !dirGone) return MockResponse().setResponseCode(404)
|
||||||
|
MockResponse().setResponseCode(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "DELETE" && path.startsWith(uploadsRoot) -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
files.keys.removeIf { it.startsWith(path) }
|
||||||
|
dirs.removeIf { it == path || it.startsWith(path) }
|
||||||
|
MockResponse().setResponseCode(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
method == "PROPFIND" -> {
|
||||||
|
if (!basicOk(request)) return MockResponse().setResponseCode(401)
|
||||||
|
val dir = if (path.endsWith('/')) path else "$path/"
|
||||||
|
val children = mutableListOf<String>()
|
||||||
|
// Self first, then direct children — like the real server.
|
||||||
|
if (dir in dirs || dir == davRoot) children += dir
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
for (d in dirs) {
|
||||||
|
if (d != dir && parentOf(d) == dir && seen.add(d)) children += d
|
||||||
|
}
|
||||||
|
for (f in files.keys) {
|
||||||
|
val d = parentOf(f)
|
||||||
|
if (d == dir && seen.add(f)) children += f
|
||||||
|
}
|
||||||
|
if (children.isEmpty()) return MockResponse().setResponseCode(404)
|
||||||
|
val items = children.joinToString("") { child ->
|
||||||
|
val isDir = child.endsWith('/')
|
||||||
|
val size = files[child]?.size ?: 0
|
||||||
|
"""<d:response><d:href>${encPath(child)}</d:href><d:propstat><d:prop>""" +
|
||||||
|
(if (!isDir) "<d:getcontentlength>$size</d:getcontentlength>" else "") +
|
||||||
|
(if (!isDir) """<d:getetag>"etag-${child.hashCode()}"</d:getetag>""" else "") +
|
||||||
|
(if (!isDir) """<d:getcontenttype>audio/mp4</d:getcontenttype>""" else "") +
|
||||||
|
"""<d:getlastmodified>Tue, 02 Jan 2026 03:04:05 GMT</d:getlastmodified>""" +
|
||||||
|
(if (isDir) "<d:resourcetype><d:collection/></d:resourcetype>" else "<d:resourcetype/>") +
|
||||||
|
"""</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"""
|
||||||
|
}
|
||||||
|
MockResponse().setResponseCode(207)
|
||||||
|
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.setBody("""<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">$items</d:multistatus>""")
|
||||||
|
}
|
||||||
|
|
||||||
|
path.startsWith(uploadsRoot) -> MockResponse().setResponseCode(404)
|
||||||
|
|
||||||
|
else -> json(404, JSONObject().put("message", "unknown $method $rawPath"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun json(code: Int, obj: JSONObject) = MockResponse()
|
||||||
|
.setResponseCode(code).setHeader("Content-Type", "application/json")
|
||||||
|
.setBody(obj.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the shared provider contract suite against NextcloudProvider. */
|
||||||
|
class NextcloudProviderContractTest : ProviderContractTest() {
|
||||||
|
|
||||||
|
private val backend = FakeNextcloud()
|
||||||
|
private lateinit var dir: File
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
backend.start()
|
||||||
|
dir = File(System.getProperty("java.io.tmpdir"), "nc-contract-${System.nanoTime()}").apply { mkdirs() }
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
backend.stop()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow()
|
||||||
|
|
||||||
|
override fun initialCredential(): ProviderCredential = ProviderCredential.AppPassword(
|
||||||
|
accountLabel = "testuser",
|
||||||
|
loginUrl = backend.url,
|
||||||
|
user = backend.userId,
|
||||||
|
password = backend.appPassword,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun makeProvider(): ShonarProvider = NextcloudProvider(
|
||||||
|
auth = NextcloudAuthStore(store),
|
||||||
|
chunkSizeBytes = 64 * 1024, // 4 chunks per 256 KiB draft
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun makeDraft(id: String): RecordingDraft {
|
||||||
|
val src = File(dir, "src-$id.m4a")
|
||||||
|
val rnd = java.util.Random(id.hashCode().toLong())
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun probe_identifiesNextcloud() = kotlinx.coroutines.runBlocking {
|
||||||
|
val probe = makeProvider().probe(serverUrl())
|
||||||
|
assertTrue(probe is ProbeResult.Compatible)
|
||||||
|
probe as ProbeResult.Compatible
|
||||||
|
assertEquals(ProviderRegistry.NEXTCLOUD_ID, probe.descriptor.id)
|
||||||
|
assertTrue(probe.descriptor.isDefault)
|
||||||
|
assertTrue(
|
||||||
|
ProviderDescriptor.Capability.CHUNKED_UPLOAD in probe.descriptor.capabilities
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun probe_rejectsNonNextcloud() = kotlinx.coroutines.runBlocking {
|
||||||
|
backend.productName = "ownCloud"
|
||||||
|
assertEquals(ProbeResult.Incompatible, makeProvider().probe(serverUrl()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun wrongAppPassword_failsWithoutLeaking() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
try {
|
||||||
|
p.connect(
|
||||||
|
ProviderCredential.AppPassword("t", backend.url, backend.userId, "wrong-pass-1")
|
||||||
|
)
|
||||||
|
fail("wrong app password must not connect")
|
||||||
|
} catch (e: ProviderError.Transient) {
|
||||||
|
assertFalse(e.message!!.contains("wrong-pass-1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun upload_usesNumberedChunksAndMoveAssembly() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
val draft = makeDraft("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||||
|
p.upload(draft) { }
|
||||||
|
// 4 chunks named 00001..00004, each carrying quota + destination headers.
|
||||||
|
assertEquals(listOf("00001", "00002", "00003", "00004"), backend.chunkNames)
|
||||||
|
assertTrue(backend.chunkHeaders.all { it["OC-Total-Length"] == draft.sizeBytes.toString() })
|
||||||
|
assertTrue(backend.chunkHeaders.all { (it["Destination"] ?: "").endsWith(".m4a") })
|
||||||
|
// Assembly: MOVE …/.file with Destination + Overwrite + total length.
|
||||||
|
assertTrue(backend.lastMove["Destination"]!!.endsWith("SHONAR/audio/${draft.id}.m4a"))
|
||||||
|
assertEquals("T", backend.lastMove["Overwrite"])
|
||||||
|
assertEquals(draft.sizeBytes.toString(), backend.lastMove["OC-Total-Length"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reupload_skipsPresentChunks() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
// Simulate an interrupted upload: transfer folder with 2 of 4 chunks
|
||||||
|
// already on the server (e.g. left by a killed process).
|
||||||
|
val draft = makeDraft("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||||
|
val transferDir = "/remote.php/dav/uploads/${backend.userId}/shonar-${draft.id}/"
|
||||||
|
backend.dirs += transferDir
|
||||||
|
val src = draft.sourceFile.readBytes()
|
||||||
|
val quarter = src.size / 4
|
||||||
|
backend.files[transferDir + "00001"] = src.copyOfRange(0, quarter)
|
||||||
|
backend.files[transferDir + "00002"] = src.copyOfRange(quarter, quarter * 2)
|
||||||
|
p.upload(draft) { }
|
||||||
|
assertEquals("only the 2 missing chunks are sent", 2, backend.chunkPuts)
|
||||||
|
assertEquals(listOf("00003", "00004"), backend.chunkNames)
|
||||||
|
// And the assembled file is complete.
|
||||||
|
val dest = java.io.File.createTempFile("resume-dl", ".bin")
|
||||||
|
try {
|
||||||
|
val ref = p.list(null).items.single().ref
|
||||||
|
p.download(ref, dest) { }
|
||||||
|
assertTrue(dest.readBytes().contentEquals(src))
|
||||||
|
} finally {
|
||||||
|
dest.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun quotaExceeded_surfaces() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = makeProvider()
|
||||||
|
p.connect(initialCredential())
|
||||||
|
backend.failNextPut507 = true
|
||||||
|
try {
|
||||||
|
p.upload(makeDraft("cccccccc-cccc-4ccc-8ccc-cccccccccccc")) { }
|
||||||
|
fail("507 must surface as QuotaExceeded")
|
||||||
|
} catch (expected: ProviderError.QuotaExceeded) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth lifecycle + store for the Nextcloud provider. */
|
||||||
|
class NextcloudProviderAuthTest {
|
||||||
|
|
||||||
|
private val backend = FakeNextcloud()
|
||||||
|
private lateinit var dir: File
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
backend.start()
|
||||||
|
dir = File(System.getProperty("java.io.tmpdir"), "nc-auth-${System.nanoTime()}").apply { mkdirs() }
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
backend.stop()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provider() = NextcloudProvider(auth = NextcloudAuthStore(store))
|
||||||
|
|
||||||
|
private fun cred() = ProviderCredential.AppPassword(
|
||||||
|
accountLabel = "testuser", loginUrl = backend.url,
|
||||||
|
user = backend.userId, password = backend.appPassword,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun loginFlow_endToEnd() = kotlinx.coroutines.runBlocking {
|
||||||
|
val flow = NextcloudAuth()
|
||||||
|
val url = ServerUrl.parse(backend.url)!!.getOrThrow()
|
||||||
|
assertTrue(flow.probe(url) is ProbeResult.Compatible)
|
||||||
|
val session = flow.startLogin(url)
|
||||||
|
assertTrue(session.loginUrl.isNotBlank())
|
||||||
|
assertEquals(com.shonar.provider.PollResult.Pending, flow.poll(session))
|
||||||
|
backend.pollApproved = true
|
||||||
|
val approved = flow.poll(session)
|
||||||
|
assertTrue(approved is com.shonar.provider.PollResult.Approved)
|
||||||
|
approved as com.shonar.provider.PollResult.Approved
|
||||||
|
assertEquals(backend.appPassword, approved.credential.password)
|
||||||
|
|
||||||
|
val p = provider()
|
||||||
|
p.connect(approved.credential)
|
||||||
|
assertEquals(AuthState.CONNECTED, p.authState.value)
|
||||||
|
val saved = NextcloudAuthStore(store).load()
|
||||||
|
assertNotNull(saved)
|
||||||
|
assertEquals(backend.url, saved!!.baseUrl)
|
||||||
|
assertEquals(backend.userId, saved.userId)
|
||||||
|
// The normal password was never involved; only the app password persists.
|
||||||
|
assertFalse(
|
||||||
|
store.keys().flatMap { listOf(store.getString(it).orEmpty()) }
|
||||||
|
.any { it.contains("normal-password") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun login_persistsUserIdForDavPaths() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = provider()
|
||||||
|
p.connect(cred())
|
||||||
|
assertEquals(backend.userId, NextcloudAuthStore(store).load()!!.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reconnect_restoresSession() = kotlinx.coroutines.runBlocking {
|
||||||
|
provider().connect(cred())
|
||||||
|
val p2 = provider()
|
||||||
|
assertEquals(AuthState.CONNECTED, p2.reconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun revokedPassword_reconnectReportsExpired() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = provider()
|
||||||
|
p.connect(cred())
|
||||||
|
// Server-side revocation: the stored app password stops working.
|
||||||
|
val saved = NextcloudAuthStore(store).load()!!
|
||||||
|
NextcloudAuthStore(store).save(saved.copy(appPassword = "revoked"))
|
||||||
|
assertEquals(AuthState.EXPIRED, p.reconnect())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun disconnect_revokesAppPassword() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = provider()
|
||||||
|
p.connect(cred())
|
||||||
|
p.disconnect(revokeOnServer = true)
|
||||||
|
assertEquals(1, backend.revokeCalls)
|
||||||
|
assertEquals(AuthState.DISCONNECTED, p.authState.value)
|
||||||
|
assertNull(NextcloudAuthStore(store).load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun summary_carriesQuota() = kotlinx.coroutines.runBlocking {
|
||||||
|
val p = provider()
|
||||||
|
p.connect(cred())
|
||||||
|
val s = p.storageLocationSummary()
|
||||||
|
assertTrue(s.headline.startsWith("Nextcloud at"))
|
||||||
|
assertTrue("quota must be shown" , "free of" in s.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NextcloudAuthStoreTest {
|
||||||
|
|
||||||
|
private val store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
private val auth = NextcloudAuthStore(store)
|
||||||
|
|
||||||
|
@Test fun save_load_clear() = kotlinx.coroutines.runBlocking {
|
||||||
|
assertNull(auth.load())
|
||||||
|
auth.save(NcSession("https://nc.example.com", "u1", "User One", "pw"))
|
||||||
|
val loaded = auth.load()
|
||||||
|
assertEquals("https://nc.example.com", loaded!!.baseUrl)
|
||||||
|
assertEquals("u1", loaded.userId)
|
||||||
|
assertEquals("pw", loaded.appPassword)
|
||||||
|
auth.clear()
|
||||||
|
assertNull(auth.load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NextcloudProtocolTest {
|
||||||
|
|
||||||
|
@Test fun enc_spacesNotPlus() {
|
||||||
|
assertEquals("a%20b", NextcloudProvider.enc("a b"))
|
||||||
|
assertEquals("100%25", NextcloudProvider.enc("100%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun uuidForKey_stripsDirsAndSuffix() {
|
||||||
|
assertEquals("abc", NextcloudProvider.uuidForKey("SHONAR/audio/abc.m4a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun extFor_commonMimes() {
|
||||||
|
assertEquals(".m4a", NextcloudProvider.extFor("audio/mp4"))
|
||||||
|
assertEquals(".wav", NextcloudProvider.extFor("audio/x-wav"))
|
||||||
|
assertEquals(".ogg", NextcloudProvider.extFor("audio/opus"))
|
||||||
|
assertEquals(".mp3", NextcloudProvider.extFor("audio/mpeg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun formatBytes_units() {
|
||||||
|
assertEquals("512 B", NextcloudProvider.formatBytes(512))
|
||||||
|
assertEquals("10.0 GB", NextcloudProvider.formatBytes(10L * 1024 * 1024 * 1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -174,6 +174,9 @@ abstract class ProviderContractTest {
|
||||||
is ProviderCredential.OAuthTokens ->
|
is ProviderCredential.OAuthTokens ->
|
||||||
listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() }
|
listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() }
|
||||||
is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() }
|
is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() }
|
||||||
|
is ProviderCredential.ShonarLogin ->
|
||||||
|
listOf(cred.password).filter { it.isNotBlank() }
|
||||||
|
is ProviderCredential.FolderPath -> emptyList() // a path is not secret
|
||||||
ProviderCredential.None -> emptyList()
|
ProviderCredential.None -> emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ class ServerUrlTest {
|
||||||
val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null)
|
val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null)
|
||||||
assertFalse(o.toString().contains("ACCESS-XYZ"))
|
assertFalse(o.toString().contains("ACCESS-XYZ"))
|
||||||
assertFalse(o.toString().contains("REFRESH-XYZ"))
|
assertFalse(o.toString().contains("REFRESH-XYZ"))
|
||||||
|
val s = ProviderCredential.ShonarLogin(
|
||||||
|
"u@x.com", ServerUrl.parse("https://s.example.com")!!.getOrThrow(),
|
||||||
|
"u@x.com", "hunter2-hunter2",
|
||||||
|
)
|
||||||
|
assertFalse(s.toString().contains("hunter2-hunter2"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +117,16 @@ class ProviderRegistryTest {
|
||||||
@Test fun available_listsDescriptors() {
|
@Test fun available_listsDescriptors() {
|
||||||
val ids = registry(createTempFileSafe()).available().map { it.id }
|
val ids = registry(createTempFileSafe()).available().map { it.id }
|
||||||
assertTrue(ids.contains(LocalOnlyProvider.ID))
|
assertTrue(ids.contains(LocalOnlyProvider.ID))
|
||||||
|
assertTrue(ids.contains(ProviderRegistry.CUSTOM_SHONAR_ID))
|
||||||
|
assertTrue(ids.contains(ProviderRegistry.NEXTCLOUD_ID))
|
||||||
|
assertTrue(ids.contains(ProviderRegistry.SYNC_FOLDER_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun default_isNextcloud() {
|
||||||
|
assertEquals(
|
||||||
|
ProviderRegistry.NEXTCLOUD_ID,
|
||||||
|
registry(createTempFileSafe()).defaultProviderId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTempFileSafe(): java.io.File =
|
private fun createTempFileSafe(): java.io.File =
|
||||||
|
|
|
||||||
241
android/app/src/test/java/com/shonar/provider/TofuTest.kt
Normal file
241
android/app/src/test/java/com/shonar/provider/TofuTest.kt
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
package com.shonar.provider
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import okhttp3.tls.HandshakeCertificates
|
||||||
|
import okhttp3.tls.HeldCertificate
|
||||||
|
import org.junit.After
|
||||||
|
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.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5 cert-fixture tests. Certificates are generated at runtime with
|
||||||
|
* okhttp-tls HeldCertificate (no checked-in keys, nothing to expire):
|
||||||
|
* a private CA the system does NOT trust, so every handshake exercises
|
||||||
|
* the TOFU path.
|
||||||
|
*/
|
||||||
|
class TofuTrustTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var held: HeldCertificate
|
||||||
|
private lateinit var store: com.shonar.settings.InMemorySettingsStore
|
||||||
|
private lateinit var tofu: TofuManager
|
||||||
|
private lateinit var tls: TlsPolicy
|
||||||
|
private lateinit var base: String
|
||||||
|
private var port: Int = 0
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
held = HeldCertificate.Builder()
|
||||||
|
.commonName("shonar-test")
|
||||||
|
.addSubjectAlternativeName("localhost")
|
||||||
|
.addSubjectAlternativeName("127.0.0.1")
|
||||||
|
.build()
|
||||||
|
val serverCerts = HandshakeCertificates.Builder()
|
||||||
|
.heldCertificate(held)
|
||||||
|
.build()
|
||||||
|
server = MockWebServer()
|
||||||
|
server.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
MockResponse().setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setBody("""{"kind":"shonar","version":"t","capabilities":{}}""")
|
||||||
|
}
|
||||||
|
server.useHttps(serverCerts.sslSocketFactory(), false)
|
||||||
|
server.start()
|
||||||
|
port = server.port
|
||||||
|
base = "https://localhost:$port"
|
||||||
|
store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
tofu = TofuManager(TofuStore(store))
|
||||||
|
tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectedSpki(): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(held.certificate.publicKey.encoded)
|
||||||
|
return digest.joinToString(":") { "%02X".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(url: String): Int {
|
||||||
|
val client = tls.newClient(10, 10, 10)
|
||||||
|
client.newCall(Request.Builder().url(url).get().build()).execute().use { resp ->
|
||||||
|
return resp.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun untrusted_recordsFailureWithRealSpki() {
|
||||||
|
try {
|
||||||
|
get("$base/api/v1/provider-info")
|
||||||
|
fail("self-signed must not connect unapproved")
|
||||||
|
} catch (expected: javax.net.ssl.SSLHandshakeException) {
|
||||||
|
}
|
||||||
|
val failure = tofu.failureFor("localhost")
|
||||||
|
assertNotNull("failure must be recorded for approval UI", failure)
|
||||||
|
assertEquals(expectedSpki(), failure!!.spkiHex)
|
||||||
|
assertTrue("subject must name the test cert", failure.subject.contains("shonar-test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun approve_thenConnects() {
|
||||||
|
try {
|
||||||
|
get("$base/api/v1/provider-info")
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
}
|
||||||
|
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
|
||||||
|
assertEquals(200, get("$base/api/v1/provider-info"))
|
||||||
|
assertEquals(setOf("localhost"), tofu.pinnedHostsForTest())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pin_isPerHost() {
|
||||||
|
try {
|
||||||
|
get("$base/api/v1/provider-info")
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
}
|
||||||
|
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
|
||||||
|
assertEquals(200, get("$base/api/v1/provider-info"))
|
||||||
|
// Same cert, different host: still untrusted (per-host binding).
|
||||||
|
try {
|
||||||
|
get("https://127.0.0.1:$port/api/v1/provider-info")
|
||||||
|
fail("pin for localhost must not cover 127.0.0.1")
|
||||||
|
} catch (expected: javax.net.ssl.SSLHandshakeException) {
|
||||||
|
}
|
||||||
|
assertNotNull(tofu.failureFor("127.0.0.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun decline_clearsWithoutPinning() {
|
||||||
|
try {
|
||||||
|
get("$base/api/v1/provider-info")
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
}
|
||||||
|
assertNotNull(tofu.failureFor("localhost"))
|
||||||
|
kotlinx.coroutines.runBlocking { tofu.decline("localhost") }
|
||||||
|
assertNull(tofu.failureFor("localhost"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun approve_withoutFailure_returnsFalse() {
|
||||||
|
assertFalse(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun forget_revokesTrust() {
|
||||||
|
try {
|
||||||
|
get("$base/api/v1/provider-info")
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
}
|
||||||
|
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
|
||||||
|
assertEquals(200, get("$base/api/v1/provider-info"))
|
||||||
|
kotlinx.coroutines.runBlocking { tofu.forget("localhost") }
|
||||||
|
try {
|
||||||
|
get("$base/api/v1/provider-info")
|
||||||
|
fail("forgotten host must fail again")
|
||||||
|
} catch (expected: javax.net.ssl.SSLHandshakeException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun systemTrust_unaffectedForPlainHttp() {
|
||||||
|
// The TOFU trust manager must be invisible on non-TLS and on
|
||||||
|
// system-trusted paths: plain HTTP MockWebServer still works.
|
||||||
|
val plain = MockWebServer()
|
||||||
|
plain.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
MockResponse().setResponseCode(200).setBody("ok")
|
||||||
|
}
|
||||||
|
plain.start()
|
||||||
|
try {
|
||||||
|
assertEquals(200, get("http://localhost:${plain.port}/x"))
|
||||||
|
} finally {
|
||||||
|
plain.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// test-only accessor without growing the public API
|
||||||
|
private fun TofuManager.pinnedHostsForTest(): Set<String> =
|
||||||
|
kotlinx.coroutines.runBlocking { pinnedHosts() }
|
||||||
|
}
|
||||||
|
|
||||||
|
class TofuProbeTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var tofu: TofuManager
|
||||||
|
private lateinit var tls: TlsPolicy
|
||||||
|
private var port: Int = 0
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
val held = HeldCertificate.Builder()
|
||||||
|
.commonName("shonar-test")
|
||||||
|
.addSubjectAlternativeName("localhost")
|
||||||
|
.build()
|
||||||
|
val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build()
|
||||||
|
server = MockWebServer()
|
||||||
|
server.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val body = if (request.path == "/status.php") {
|
||||||
|
"""{"productname":"Nextcloud","versionstring":"30.0.0"}"""
|
||||||
|
} else {
|
||||||
|
"""{"kind":"shonar","version":"t","capabilities":{"chunked_upload":true}}"""
|
||||||
|
}
|
||||||
|
return MockResponse().setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json").setBody(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.useHttps(serverCerts.sslSocketFactory(), false)
|
||||||
|
server.start()
|
||||||
|
port = server.port
|
||||||
|
tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore()))
|
||||||
|
tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun probe_reportsRealFingerprint_thenApproves() = kotlinx.coroutines.runBlocking {
|
||||||
|
val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow()
|
||||||
|
val handshake = ShonarHandshake(tls.probeClient(), tofu)
|
||||||
|
val first = handshake.probe(url)
|
||||||
|
assertTrue(first is ProbeResult.TlsFailure)
|
||||||
|
first as ProbeResult.TlsFailure
|
||||||
|
assertTrue("fingerprint must be real, not a placeholder",
|
||||||
|
first.fingerprintSha256 != "unknown" && ":" in first.fingerprintSha256)
|
||||||
|
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
|
||||||
|
val second = handshake.probe(url)
|
||||||
|
assertTrue("approved host probes cleanly: $second", second is ProbeResult.Compatible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nextcloudProbe_sameFlow() = kotlinx.coroutines.runBlocking {
|
||||||
|
val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow()
|
||||||
|
val auth = NextcloudAuth(tls.nextcloudClient(), tofu)
|
||||||
|
assertTrue(auth.probe(url) is ProbeResult.TlsFailure)
|
||||||
|
assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") })
|
||||||
|
val second = auth.probe(url)
|
||||||
|
assertTrue("approved Nextcloud probes cleanly: $second", second is ProbeResult.Compatible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TofuStoreTest {
|
||||||
|
|
||||||
|
private val store = com.shonar.settings.InMemorySettingsStore()
|
||||||
|
private val tofu = TofuStore(store)
|
||||||
|
|
||||||
|
@Test fun roundtrip() = kotlinx.coroutines.runBlocking {
|
||||||
|
assertEquals(emptySet<String>(), tofu.hosts())
|
||||||
|
tofu.addPin("Nas.Local", "DER-1")
|
||||||
|
tofu.addPin("nas.local", "DER-2")
|
||||||
|
assertEquals(setOf("DER-1", "DER-2"), tofu.pins("NAS.LOCAL"))
|
||||||
|
assertEquals(setOf("nas.local"), tofu.hosts())
|
||||||
|
tofu.removeHost("nas.local")
|
||||||
|
assertEquals(emptySet<String>(), tofu.hosts())
|
||||||
|
assertEquals(emptySet<String>(), tofu.pins("nas.local"))
|
||||||
|
}
|
||||||
|
}
|
||||||
87
android/app/src/test/java/com/shonar/recording/Fixtures.kt
Normal file
87
android/app/src/test/java/com/shonar/recording/Fixtures.kt
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import com.shonar.provider.AuthState
|
||||||
|
import com.shonar.provider.ProviderCredential
|
||||||
|
import com.shonar.provider.ProviderDescriptor
|
||||||
|
import com.shonar.provider.ProviderRegistry
|
||||||
|
import com.shonar.provider.RecordingDraft
|
||||||
|
import com.shonar.provider.RemoteRef
|
||||||
|
import com.shonar.provider.Page
|
||||||
|
import com.shonar.provider.ProbeResult
|
||||||
|
import com.shonar.provider.ServerUrl
|
||||||
|
import com.shonar.provider.ShonarProvider
|
||||||
|
import com.shonar.provider.SidecarKind
|
||||||
|
import com.shonar.provider.StorageLocation
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
/** In-memory RecordingDao: no Android needed. Shared by recording tests. */
|
||||||
|
internal class FakeDao(initial: List<RecordingEntity> = emptyList()) : RecordingDao {
|
||||||
|
private val rows = initial.associateBy { it.id }.toMutableMap()
|
||||||
|
override fun observeAll(): Flow<List<RecordingEntity>> = flowOf(rows.values.toList())
|
||||||
|
override suspend fun getAll(): List<RecordingEntity> = rows.values.sortedByDescending { it.createdAtEpochMs }
|
||||||
|
override suspend fun getById(id: String): RecordingEntity? = rows[id]
|
||||||
|
override suspend fun insert(recording: RecordingEntity) {
|
||||||
|
rows[recording.id] = recording
|
||||||
|
}
|
||||||
|
override suspend fun update(recording: RecordingEntity) {
|
||||||
|
check(rows.containsKey(recording.id)) { "no such row ${recording.id}" }
|
||||||
|
rows[recording.id] = recording
|
||||||
|
}
|
||||||
|
override suspend fun delete(recording: RecordingEntity) {
|
||||||
|
rows.remove(recording.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scripted provider: per-draft behaviour for upload, inert elsewhere. */
|
||||||
|
internal class ScriptProvider(
|
||||||
|
val id: String = "nextcloud",
|
||||||
|
var failWith: (RecordingDraft) -> Exception? = { null },
|
||||||
|
) : ShonarProvider {
|
||||||
|
override val descriptor = ProviderDescriptor(id, id)
|
||||||
|
private val states = MutableStateFlow(AuthState.CONNECTED)
|
||||||
|
override val authState: kotlinx.coroutines.flow.StateFlow<AuthState> = states
|
||||||
|
val uploadedKeys = mutableListOf<String>()
|
||||||
|
override suspend fun probe(baseUrl: ServerUrl): ProbeResult = ProbeResult.Incompatible
|
||||||
|
override suspend fun connect(credential: ProviderCredential) {}
|
||||||
|
override suspend fun reconnect(): AuthState = AuthState.CONNECTED
|
||||||
|
override suspend fun disconnect(revokeOnServer: Boolean) {}
|
||||||
|
override suspend fun deleteAccountAndData() {}
|
||||||
|
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
|
||||||
|
failWith(draft)?.let { throw it }
|
||||||
|
onProgress(1f)
|
||||||
|
return RemoteRef(id, "SHONAR/audio/${draft.id}.m4a", etag = "e-${draft.id}", sizeBytes = draft.sizeBytes)
|
||||||
|
.also { uploadedKeys += draft.id }
|
||||||
|
}
|
||||||
|
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
override suspend fun delete(ref: RemoteRef) = throw UnsupportedOperationException()
|
||||||
|
override suspend fun list(cursor: String?): Page<com.shonar.provider.RemoteRecording> =
|
||||||
|
Page(emptyList(), null)
|
||||||
|
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = null
|
||||||
|
override suspend fun storageLocationSummary(): StorageLocation = StorageLocation(id, "", 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun registryOf(vararg providers: ShonarProvider): ProviderRegistry =
|
||||||
|
ProviderRegistry(providers.associateBy({ it.descriptor.id }) { { it } })
|
||||||
|
|
||||||
|
internal fun entity(
|
||||||
|
id: String,
|
||||||
|
dir: File,
|
||||||
|
state: SyncState = SyncState.LOCAL_ONLY,
|
||||||
|
): RecordingEntity {
|
||||||
|
val f = File(dir, "$id.m4a").apply { writeBytes(ByteArray(128) { 0x1 }) }
|
||||||
|
return RecordingEntity(
|
||||||
|
id = id, title = "t-$id", createdAtEpochMs = 1_700_000_000_000L,
|
||||||
|
durationMs = 1000, filePath = f.absolutePath, mimeType = "audio/mp4",
|
||||||
|
sizeBytes = f.length(), syncState = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun tmpDir(prefix: String): File =
|
||||||
|
File(System.getProperty("java.io.tmpdir"), "$prefix-${System.nanoTime()}").apply { mkdirs() }
|
||||||
199
android/app/src/test/java/com/shonar/recording/MigrationTest.kt
Normal file
199
android/app/src/test/java/com/shonar/recording/MigrationTest.kt
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import com.shonar.provider.LocalOnlyProvider
|
||||||
|
import com.shonar.provider.ProviderError
|
||||||
|
import com.shonar.provider.ProviderRegistry
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MigrationRunnerTest {
|
||||||
|
|
||||||
|
@Test fun allSucceed_marksUploadedWithSlots() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("mig-ok")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir), entity("b", dir)))
|
||||||
|
val script = ScriptProvider()
|
||||||
|
val res = MigrationRunner(SyncSlots(dao), registryOf(script))
|
||||||
|
.migrateAllTo("nextcloud")
|
||||||
|
assertEquals(2, res.uploaded)
|
||||||
|
assertTrue(res.failed.isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf("a", "b"),
|
||||||
|
dao.getAll().sortedBy { it.id }.map {
|
||||||
|
assertEquals(SyncState.UPLOADED, it.syncState)
|
||||||
|
assertEquals("nextcloud", it.originProviderId)
|
||||||
|
assertEquals("SHONAR/audio/${it.id}.m4a", it.remoteKey)
|
||||||
|
it.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failure_recordsErrorAndContinues() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("mig-partial")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir)))
|
||||||
|
val script = ScriptProvider().apply {
|
||||||
|
failWith = { if (it.id == "b") ProviderError.Transient("boom") else null }
|
||||||
|
}
|
||||||
|
val res = MigrationRunner(SyncSlots(dao), registryOf(script))
|
||||||
|
.migrateAllTo("nextcloud")
|
||||||
|
assertEquals(2, res.uploaded)
|
||||||
|
assertEquals(listOf("t-b"), res.failed)
|
||||||
|
val b = dao.getById("b")!!
|
||||||
|
assertEquals(SyncState.ERROR, b.syncState)
|
||||||
|
assertEquals("boom", b.syncReason)
|
||||||
|
assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState)
|
||||||
|
assertEquals(SyncState.UPLOADED, dao.getById("c")!!.syncState)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun missingFile_recordsErrorWithoutUpload() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("mig-gone")
|
||||||
|
try {
|
||||||
|
val ghost = RecordingEntity(
|
||||||
|
id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1,
|
||||||
|
filePath = File(dir, "nope.m4a").absolutePath, mimeType = "audio/mp4", sizeBytes = 0,
|
||||||
|
)
|
||||||
|
val dao = FakeDao(listOf(ghost))
|
||||||
|
val script = ScriptProvider()
|
||||||
|
val res = MigrationRunner(SyncSlots(dao), registryOf(script)).migrateAllTo("nextcloud")
|
||||||
|
assertEquals(0, res.uploaded)
|
||||||
|
assertEquals(listOf("ghost"), res.failed)
|
||||||
|
assertTrue(script.uploadedKeys.isEmpty())
|
||||||
|
assertEquals(SyncState.ERROR, dao.getById("g")!!.syncState)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun errorRow_rerunsThroughLegalTransitions() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("mig-retry")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir, SyncState.ERROR)))
|
||||||
|
val res = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider()))
|
||||||
|
.migrateAllTo("nextcloud")
|
||||||
|
assertEquals(1, res.uploaded)
|
||||||
|
assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancel_leavesRestUntouched() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("mig-cancel")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir)))
|
||||||
|
val runner = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider()))
|
||||||
|
var job: Job? = null
|
||||||
|
job = launch {
|
||||||
|
runner.migrateAllTo("nextcloud") {
|
||||||
|
// Cancel as soon as the first file reports progress.
|
||||||
|
if (it.done == 0) job?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job.join()
|
||||||
|
// First file may or may not have finished (upload is quick); the
|
||||||
|
// point is later files were never started.
|
||||||
|
val states = dao.getAll().associate { it.id to it.syncState }
|
||||||
|
assertTrue(
|
||||||
|
"unstarted rows keep their state, got $states",
|
||||||
|
states["c"] == SyncState.LOCAL_ONLY,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun localOnlyTarget_rejected() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dao = FakeDao()
|
||||||
|
try {
|
||||||
|
MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("local-only")
|
||||||
|
fail("must reject local-only target")
|
||||||
|
} catch (expected: IllegalArgumentException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unknownTarget_throws() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dao = FakeDao()
|
||||||
|
try {
|
||||||
|
MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("dropbox")
|
||||||
|
fail("must reject unknown provider")
|
||||||
|
} catch (expected: ProviderError.InvalidUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun forget_clearsSlotsKeepsFiles() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("mig-forget")
|
||||||
|
try {
|
||||||
|
val synced = entity("a", dir, SyncState.UPLOADED).copy(
|
||||||
|
originProviderId = "nextcloud",
|
||||||
|
remoteKey = "SHONAR/audio/a.m4a",
|
||||||
|
remoteEtag = "e",
|
||||||
|
remoteSizeBytes = 128,
|
||||||
|
)
|
||||||
|
val dao = FakeDao(listOf(synced))
|
||||||
|
SyncSlots(dao).forgetAllRemotes("custom-shonar")
|
||||||
|
val row = dao.getById("a")!!
|
||||||
|
assertNull(row.remoteKey)
|
||||||
|
assertNull(row.remoteEtag)
|
||||||
|
assertNull(row.remoteSizeBytes)
|
||||||
|
assertEquals("custom-shonar", row.originProviderId)
|
||||||
|
assertEquals(SyncState.QUEUED, row.syncState)
|
||||||
|
assertTrue("files stay", File(row.filePath).exists())
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun localOnlyId_rejectedAsTargetConstant() {
|
||||||
|
assertEquals("local-only", LocalOnlyProvider.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncSlotsTest {
|
||||||
|
|
||||||
|
@Test fun draftFor_missingFileIsNull() = kotlinx.coroutines.runBlocking {
|
||||||
|
val ghost = RecordingEntity(
|
||||||
|
id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1,
|
||||||
|
filePath = "/nonexistent-dir-xyz/nope.m4a", mimeType = "audio/mp4", sizeBytes = 0,
|
||||||
|
)
|
||||||
|
assertNull(SyncSlots(FakeDao()).draftFor(ghost))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun entityDefaults_readAsNeverSyncedLocal() {
|
||||||
|
// A v1-shaped row (new columns absent) must behave as an unsynced local.
|
||||||
|
val row = RecordingEntity(
|
||||||
|
id = "v1", title = "old", createdAtEpochMs = 1L, durationMs = 1,
|
||||||
|
filePath = "/x.m4a", mimeType = "audio/mp4", sizeBytes = 10,
|
||||||
|
)
|
||||||
|
assertEquals("local-only", row.originProviderId)
|
||||||
|
assertNull(row.remoteKey)
|
||||||
|
assertEquals(SyncState.LOCAL_ONLY, row.syncState)
|
||||||
|
assertNull(row.syncReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun converter_roundtripsAndFallsBack() {
|
||||||
|
val c = SyncStateConverter()
|
||||||
|
assertEquals(SyncState.SYNCED, c.toState(c.toString(SyncState.SYNCED)))
|
||||||
|
assertEquals("UPLOADED", c.toString(SyncState.UPLOADED))
|
||||||
|
assertEquals(SyncState.LOCAL_ONLY, c.toState("FUTURE_STATE"))
|
||||||
|
assertEquals(SyncState.LOCAL_ONLY, c.toState(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun migration_movesV1ToV2() {
|
||||||
|
assertEquals(1, MIGRATION_1_2.startVersion)
|
||||||
|
assertEquals(2, MIGRATION_1_2.endVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
204
android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt
Normal file
204
android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package com.shonar.recording
|
||||||
|
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import com.shonar.provider.ProviderError
|
||||||
|
import com.shonar.provider.SyncState
|
||||||
|
import com.shonar.provider.SyncStatus
|
||||||
|
import com.shonar.provider.canTransitionTo
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
private val OPEN = SyncDrain.Gates(online = true, unmetered = true, charging = true)
|
||||||
|
private const val NOW = 1_000_000L
|
||||||
|
|
||||||
|
class SyncDrainTest {
|
||||||
|
|
||||||
|
@Test fun drainsQueuedAndDueErrors_skipsTheRest() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("drain-mix")
|
||||||
|
try {
|
||||||
|
val dueError = entity("b", dir, SyncState.ERROR)
|
||||||
|
.copy(syncRetryAtMs = NOW - 1, syncAttempts = 1)
|
||||||
|
val futureError = entity("c", dir, SyncState.ERROR)
|
||||||
|
.copy(syncRetryAtMs = NOW + 60_000, syncAttempts = 1)
|
||||||
|
val rows = listOf(
|
||||||
|
entity("a", dir, SyncState.QUEUED),
|
||||||
|
dueError,
|
||||||
|
futureError,
|
||||||
|
entity("d", dir, SyncState.SYNCED),
|
||||||
|
entity("e", dir, SyncState.UPLOADED),
|
||||||
|
entity("f", dir), // LOCAL_ONLY: the drain never volunteers rows
|
||||||
|
)
|
||||||
|
val dao = FakeDao(rows)
|
||||||
|
val res = SyncDrain(SyncSlots(dao)) { NOW }
|
||||||
|
.drainOnce(ScriptProvider(), wifiOnly = false, chargingOnly = false, gates = OPEN)
|
||||||
|
assertEquals(2, res.uploaded)
|
||||||
|
assertTrue(res.failed.isEmpty())
|
||||||
|
// P7 leftovers (ERROR, no retryAt) are due immediately too.
|
||||||
|
val states = dao.getAll().associate { it.id to it.syncState }
|
||||||
|
assertEquals(SyncState.UPLOADED, states["a"])
|
||||||
|
assertEquals(SyncState.UPLOADED, states["b"])
|
||||||
|
assertEquals(SyncState.ERROR, states["c"])
|
||||||
|
assertEquals(SyncState.SYNCED, states["d"])
|
||||||
|
assertEquals(SyncState.UPLOADED, states["e"])
|
||||||
|
assertEquals(SyncState.LOCAL_ONLY, states["f"])
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun legacyErrorWithoutRetryAt_isDue() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("drain-legacy")
|
||||||
|
try {
|
||||||
|
// Exactly how a P7-run failure reads after the v2->v3 upgrade.
|
||||||
|
val legacy = entity("a", dir, SyncState.ERROR)
|
||||||
|
.copy(syncReason = "boom", syncAttempts = 0, syncRetryAtMs = null)
|
||||||
|
val dao = FakeDao(listOf(legacy))
|
||||||
|
val res = SyncDrain(SyncSlots(dao)) { NOW }
|
||||||
|
.drainOnce(ScriptProvider(), false, false, OPEN)
|
||||||
|
assertEquals(1, res.uploaded)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun localOnlyTarget_isNoop() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("drain-local")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
|
||||||
|
val script = ScriptProvider("local-only")
|
||||||
|
val res = SyncDrain(SyncSlots(dao)) { NOW }
|
||||||
|
.drainOnce(script, false, false, OPEN)
|
||||||
|
assertEquals(0, res.uploaded)
|
||||||
|
assertTrue(script.uploadedKeys.isEmpty())
|
||||||
|
assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun gatesBlocked_postponesWithoutTouching() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("drain-gates")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
|
||||||
|
val script = ScriptProvider()
|
||||||
|
val res = SyncDrain(SyncSlots(dao)) { NOW }.drainOnce(
|
||||||
|
script, wifiOnly = true, chargingOnly = false,
|
||||||
|
gates = SyncDrain.Gates(online = true, unmetered = false, charging = true),
|
||||||
|
)
|
||||||
|
assertTrue(res.postponed)
|
||||||
|
assertTrue(script.uploadedKeys.isEmpty())
|
||||||
|
assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failure_recordsBackoffAttempts() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("drain-backoff")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
|
||||||
|
val script = ScriptProvider().apply {
|
||||||
|
failWith = { ProviderError.Transient("nope") }
|
||||||
|
}
|
||||||
|
val drain = SyncDrain(SyncSlots(dao)) { NOW }
|
||||||
|
val res = drain.drainOnce(script, false, false, OPEN)
|
||||||
|
assertEquals(0, res.uploaded)
|
||||||
|
assertEquals(listOf("t-a"), res.failed)
|
||||||
|
val first = dao.getById("a")!!
|
||||||
|
assertEquals(SyncState.ERROR, first.syncState)
|
||||||
|
assertEquals(1, first.syncAttempts)
|
||||||
|
assertEquals(NOW + 60_000L, first.syncRetryAtMs)
|
||||||
|
// Second failure doubles the delay.
|
||||||
|
val res2 = SyncDrain(SyncSlots(dao)) { NOW + 61_000L }
|
||||||
|
.drainOnce(script, false, false, OPEN)
|
||||||
|
assertEquals(0, res2.uploaded)
|
||||||
|
val second = dao.getById("a")!!
|
||||||
|
assertEquals(2, second.syncAttempts)
|
||||||
|
assertEquals(NOW + 61_000L + 120_000L, second.syncRetryAtMs)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun interruption_revertsInflightToQueued() = kotlinx.coroutines.runBlocking {
|
||||||
|
val dir = tmpDir("drain-cancel")
|
||||||
|
try {
|
||||||
|
val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED)))
|
||||||
|
val script = ScriptProvider().apply {
|
||||||
|
failWith = { throw kotlinx.coroutines.CancellationException("stopped") }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
SyncDrain(SyncSlots(dao)) { NOW }.drainOnce(script, false, false, OPEN)
|
||||||
|
org.junit.Assert.fail("cancellation must propagate")
|
||||||
|
} catch (expected: kotlinx.coroutines.CancellationException) {
|
||||||
|
}
|
||||||
|
assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState)
|
||||||
|
} finally {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncGatesTest {
|
||||||
|
|
||||||
|
@Test fun matrix() {
|
||||||
|
val onlineMetered = SyncDrain.Gates(online = true, unmetered = false, charging = false)
|
||||||
|
assertTrue(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = false))
|
||||||
|
assertFalse(onlineMetered.satisfiedBy(wifiOnly = true, chargingOnly = false))
|
||||||
|
assertFalse(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = true))
|
||||||
|
assertFalse(
|
||||||
|
SyncDrain.Gates(online = false, unmetered = false, charging = true)
|
||||||
|
.satisfiedBy(false, false)
|
||||||
|
)
|
||||||
|
assertTrue(OPEN.satisfiedBy(true, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackoffTest {
|
||||||
|
|
||||||
|
@Test fun exponentialCappedAtOneHour() {
|
||||||
|
assertEquals(60_000L, backoffDelayMs(1))
|
||||||
|
assertEquals(120_000L, backoffDelayMs(2))
|
||||||
|
assertEquals(240_000L, backoffDelayMs(3))
|
||||||
|
assertEquals(3_600_000L, backoffDelayMs(7))
|
||||||
|
assertEquals(3_600_000L, backoffDelayMs(100))
|
||||||
|
assertEquals(60_000L, backoffDelayMs(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun stepsToUploading_areLegalTransitions() {
|
||||||
|
for (from in SyncState.values()) {
|
||||||
|
val steps = stepsToUploading(from)
|
||||||
|
assertTrue("must end uploading, from=$from", steps.last() == SyncState.UPLOADING)
|
||||||
|
var cur = SyncStatus(from)
|
||||||
|
for (next in steps) {
|
||||||
|
assertTrue(
|
||||||
|
"$from -> $next must be legal",
|
||||||
|
cur.canTransitionTo(next),
|
||||||
|
)
|
||||||
|
cur = SyncStatus(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SchedulerMappingTest {
|
||||||
|
|
||||||
|
@Test fun constraintsFollowSettings() {
|
||||||
|
val wifi = SyncScheduler.workConstraints(wifiOnly = true, chargingOnly = true)
|
||||||
|
assertEquals(NetworkType.UNMETERED, wifi.requiredNetworkType)
|
||||||
|
assertTrue(wifi.requiresCharging())
|
||||||
|
val any = SyncScheduler.workConstraints(wifiOnly = false, chargingOnly = false)
|
||||||
|
assertEquals(NetworkType.CONNECTED, any.requiredNetworkType)
|
||||||
|
assertFalse(any.requiresCharging())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MigrationVersionsTest {
|
||||||
|
|
||||||
|
@Test fun v3() {
|
||||||
|
assertEquals(2, MIGRATION_2_3.startVersion)
|
||||||
|
assertEquals(3, MIGRATION_2_3.endVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.shonar.ui.provider
|
||||||
|
|
||||||
|
import com.shonar.provider.ProbeResult
|
||||||
|
import com.shonar.provider.ProviderDescriptor
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P6a hosted-routing matrix: a Start9/Umbrel URL (or anything reachable)
|
||||||
|
* routes to whichever protocol answered, TLS blocks everything, and
|
||||||
|
* anything else is an honest "nothing here".
|
||||||
|
*/
|
||||||
|
class HostedRouteTest {
|
||||||
|
|
||||||
|
private fun ncCompatible(): ProbeResult = ProbeResult.Compatible(
|
||||||
|
descriptor = ProviderDescriptor(id = "nextcloud", displayName = "Nextcloud"),
|
||||||
|
serverName = "Nextcloud",
|
||||||
|
version = "30",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun shonarCompatible(): ProbeResult = ProbeResult.Compatible(
|
||||||
|
descriptor = ProviderDescriptor(id = "custom-shonar", displayName = "Custom SHONAR server"),
|
||||||
|
serverName = "server",
|
||||||
|
version = "test",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun nextcloudWinsWhenBothAnswer() {
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.NEXT_CLOUD,
|
||||||
|
routeHosted(ncCompatible(), shonarCompatible()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun shonarUsedWhenNextcloudDoesNotMatch() {
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.SHONAR,
|
||||||
|
routeHosted(ProbeResult.Incompatible, shonarCompatible()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun tlsBlocksOnlyWhenNothingMatched() {
|
||||||
|
val tls = ProbeResult.TlsFailure("AB:CD")
|
||||||
|
assertEquals(HostedRoute.TLS, routeHosted(tls, ProbeResult.Incompatible))
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.TLS,
|
||||||
|
routeHosted(ProbeResult.Incompatible, tls),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.TLS,
|
||||||
|
routeHosted(tls, ProbeResult.NetworkError("timeout")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun compatiblesBeatTls() {
|
||||||
|
// A match is actionable; TLS only blocks when nothing matched.
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.NEXT_CLOUD,
|
||||||
|
routeHosted(ncCompatible(), ProbeResult.TlsFailure("AB:CD")),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.SHONAR,
|
||||||
|
routeHosted(ProbeResult.TlsFailure("AB:CD"), shonarCompatible()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nothingMatchingIsNone() {
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.NONE,
|
||||||
|
routeHosted(ProbeResult.Incompatible, ProbeResult.Incompatible),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
HostedRoute.NONE,
|
||||||
|
routeHosted(
|
||||||
|
ProbeResult.NetworkError("timeout"),
|
||||||
|
ProbeResult.Incompatible,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.7.3" apply false
|
id("com.android.application") version "8.7.3" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.kapt") version "2.0.21" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,3 +76,42 @@ class RecordingListOut(BaseModel):
|
||||||
total: int
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class SegmentOut(BaseModel):
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
text: str
|
||||||
|
speaker: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptOut(ORMModel):
|
||||||
|
version: int
|
||||||
|
language: str | None
|
||||||
|
provider: str
|
||||||
|
model: str | None
|
||||||
|
text: str
|
||||||
|
segments: list[SegmentOut]
|
||||||
|
edited_by_user: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryOut(ORMModel):
|
||||||
|
version: int
|
||||||
|
provider: str
|
||||||
|
model: str | None
|
||||||
|
content: dict
|
||||||
|
edited_by_user: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingJobOut(ORMModel):
|
||||||
|
job_type: str
|
||||||
|
status: str
|
||||||
|
attempt: int
|
||||||
|
max_attempts: int
|
||||||
|
error: str | None
|
||||||
|
started_at: datetime | None
|
||||||
|
finished_at: datetime | None
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from shonar.api.v1 import auth, health, recordings, users
|
from shonar.api.v1 import auth, health, provider_info, recordings, users
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(recordings.router)
|
api_router.include_router(recordings.router)
|
||||||
|
api_router.include_router(provider_info.router)
|
||||||
|
|
||||||
# Included as later milestones land:
|
# Included as later milestones land:
|
||||||
# - transcripts, summaries, tags, search, exports, jobs
|
# - tags, search, exports (M9)
|
||||||
|
|
|
||||||
38
backend/shonar/api/v1/provider_info.py
Normal file
38
backend/shonar/api/v1/provider_info.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Public provider handshake.
|
||||||
|
|
||||||
|
`GET /api/v1/provider-info` lets SHONAR clients (and platform probes on
|
||||||
|
Start9/Umbrel) identify this server as SHONAR-compatible and learn its
|
||||||
|
capabilities BEFORE any credentials are involved. Returns static deployment
|
||||||
|
metadata only — never user data, never configuration secrets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from shonar import __version__
|
||||||
|
from shonar.core.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["provider"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/provider-info")
|
||||||
|
async def provider_info() -> dict:
|
||||||
|
"""Static, unauthenticated deployment identity for client probing."""
|
||||||
|
settings = get_settings()
|
||||||
|
# Capability flags reflect what this deployment can actually do right now.
|
||||||
|
transcription = settings.transcription_provider != "none"
|
||||||
|
llm = settings.llm_provider != "none"
|
||||||
|
return {
|
||||||
|
"kind": "shonar",
|
||||||
|
"version": __version__,
|
||||||
|
"api_version": "v1",
|
||||||
|
"capabilities": {
|
||||||
|
"chunked_upload": True,
|
||||||
|
"server_transcription": transcription,
|
||||||
|
"server_summary": llm,
|
||||||
|
"account_deletion": True,
|
||||||
|
},
|
||||||
|
# Storage backend family (never a path): "local" | "s3"
|
||||||
|
"storage_backend": settings.storage_backend,
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,13 @@ from sqlalchemy import func, select
|
||||||
|
|
||||||
from shonar.api.deps import CurrentUser, SessionDep
|
from shonar.api.deps import CurrentUser, SessionDep
|
||||||
from shonar.api.schemas_recordings import (
|
from shonar.api.schemas_recordings import (
|
||||||
|
ProcessingJobOut,
|
||||||
RecordingFinalize,
|
RecordingFinalize,
|
||||||
RecordingListOut,
|
RecordingListOut,
|
||||||
RecordingOut,
|
RecordingOut,
|
||||||
RecordingUpdate,
|
RecordingUpdate,
|
||||||
|
SummaryOut,
|
||||||
|
TranscriptOut,
|
||||||
UploadSessionCreate,
|
UploadSessionCreate,
|
||||||
UploadSessionOut,
|
UploadSessionOut,
|
||||||
UploadStatusOut,
|
UploadStatusOut,
|
||||||
|
|
@ -25,9 +28,12 @@ from shonar.api.schemas_recordings import (
|
||||||
from shonar.db.models import (
|
from shonar.db.models import (
|
||||||
Asset,
|
Asset,
|
||||||
AssetKind,
|
AssetKind,
|
||||||
|
ProcessingJob,
|
||||||
Recording,
|
Recording,
|
||||||
RecordingTag,
|
RecordingTag,
|
||||||
|
Summary,
|
||||||
Tag,
|
Tag,
|
||||||
|
Transcript,
|
||||||
utcnow,
|
utcnow,
|
||||||
)
|
)
|
||||||
from shonar.services import uploads as up
|
from shonar.services import uploads as up
|
||||||
|
|
@ -262,3 +268,72 @@ async def download_audio(recording_id: uuid.UUID, user: CurrentUser, session: Se
|
||||||
"Cache-Control": "private, no-store",
|
"Cache-Control": "private, no-store",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- AI outputs (M7): latest transcript / summary / job states --------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned_recording(
|
||||||
|
session: SessionDep, user: CurrentUser, recording_id: uuid.UUID
|
||||||
|
) -> Recording:
|
||||||
|
rec = await session.get(Recording, recording_id)
|
||||||
|
if rec is None or rec.user_id != user.id or rec.deleted_at is not None:
|
||||||
|
raise HTTPException(404, "Recording not found")
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recordings/{recording_id}/transcript", response_model=TranscriptOut)
|
||||||
|
async def get_transcript(
|
||||||
|
recording_id: uuid.UUID, user: CurrentUser, session: SessionDep
|
||||||
|
):
|
||||||
|
rec = await _owned_recording(session, user, recording_id)
|
||||||
|
row = await session.scalar(
|
||||||
|
select(Transcript)
|
||||||
|
.where(
|
||||||
|
Transcript.recording_id == rec.id,
|
||||||
|
Transcript.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Transcript.version.desc())
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "No transcript yet")
|
||||||
|
return TranscriptOut(
|
||||||
|
version=row.version,
|
||||||
|
language=row.language,
|
||||||
|
provider=row.provider,
|
||||||
|
model=row.model,
|
||||||
|
text=row.text,
|
||||||
|
segments=[
|
||||||
|
s for s in (row.segments or []) if isinstance(s, dict)
|
||||||
|
],
|
||||||
|
edited_by_user=row.edited_by_user,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recordings/{recording_id}/summary", response_model=SummaryOut)
|
||||||
|
async def get_summary(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||||
|
rec = await _owned_recording(session, user, recording_id)
|
||||||
|
row = await session.scalar(
|
||||||
|
select(Summary)
|
||||||
|
.where(
|
||||||
|
Summary.recording_id == rec.id,
|
||||||
|
Summary.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Summary.version.desc())
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "No summary yet")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recordings/{recording_id}/jobs", response_model=list[ProcessingJobOut])
|
||||||
|
async def list_jobs(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||||
|
rec = await _owned_recording(session, user, recording_id)
|
||||||
|
rows = await session.scalars(
|
||||||
|
select(ProcessingJob)
|
||||||
|
.where(ProcessingJob.recording_id == rec.id)
|
||||||
|
.order_by(ProcessingJob.id)
|
||||||
|
)
|
||||||
|
return list(rows)
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,9 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Shareable session factory for the worker (outside requests)."""
|
||||||
|
assert _session_factory is not None, "engine not initialised"
|
||||||
|
return _session_factory
|
||||||
|
|
|
||||||
159
backend/shonar/services/ai/__init__.py
Normal file
159
backend/shonar/services/ai/__init__.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""AI provider interfaces (M7).
|
||||||
|
|
||||||
|
Two independent axes, both optional and both configured only through
|
||||||
|
environment variables (never hard-coded keys):
|
||||||
|
|
||||||
|
- transcription: none | whisper_http | faster_whisper
|
||||||
|
- LLM (summary/action items): none | openai_compat | ollama
|
||||||
|
|
||||||
|
"none" is a first-class choice: recording, sync, playback, and manual
|
||||||
|
transcripts work with no AI configured at all. The pipeline treats a
|
||||||
|
missing provider as "skip this stage", never as an error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from shonar.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class AIError(Exception):
|
||||||
|
"""Base for AI failures. Messages must be user-safe: they surface in
|
||||||
|
``processing_error`` and therefore on screens."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderConfigError(AIError):
|
||||||
|
"""Persistent misconfiguration (bad credentials, unknown model, missing
|
||||||
|
dependency). Fails the job immediately — retrying cannot help."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderTransientError(AIError):
|
||||||
|
"""May succeed on retry (timeouts, 429/5xx). The worker requeues these
|
||||||
|
up to the job's max attempts."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Segment:
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
text: str
|
||||||
|
speaker: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TranscriptResult:
|
||||||
|
text: str
|
||||||
|
language: str | None
|
||||||
|
segments: list[Segment] = field(default_factory=list)
|
||||||
|
model: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionProvider(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio: bytes,
|
||||||
|
mime: str,
|
||||||
|
*,
|
||||||
|
language_hint: str | None = None,
|
||||||
|
) -> TranscriptResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
SUMMARY_KEYS = ("short", "detailed", "key_points", "decisions", "action_items", "questions")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummaryResult:
|
||||||
|
short: str = ""
|
||||||
|
detailed: str = ""
|
||||||
|
key_points: tuple[str, ...] = ()
|
||||||
|
decisions: tuple[str, ...] = ()
|
||||||
|
action_items: tuple[str, ...] = ()
|
||||||
|
questions: tuple[str, ...] = ()
|
||||||
|
model: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"short": self.short,
|
||||||
|
"detailed": self.detailed,
|
||||||
|
"key_points": list(self.key_points),
|
||||||
|
"decisions": list(self.decisions),
|
||||||
|
"action_items": list(self.action_items),
|
||||||
|
"questions": list(self.questions),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, raw: dict, model: str = "") -> SummaryResult:
|
||||||
|
def text(key: str) -> str:
|
||||||
|
v = raw.get(key)
|
||||||
|
return v if isinstance(v, str) else ""
|
||||||
|
|
||||||
|
def strs(key: str) -> tuple[str, ...]:
|
||||||
|
v = raw.get(key)
|
||||||
|
if not isinstance(v, list):
|
||||||
|
return ()
|
||||||
|
return tuple(s for s in v if isinstance(s, str) and s.strip())
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
short=text("short"),
|
||||||
|
detailed=text("detailed"),
|
||||||
|
key_points=strs("key_points"),
|
||||||
|
decisions=strs("decisions"),
|
||||||
|
action_items=strs("action_items"),
|
||||||
|
questions=strs("questions"),
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LlmProvider(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
def get_transcription_provider(settings: Settings) -> TranscriptionProvider | None:
|
||||||
|
"""None means "transcription stage skipped", never an error."""
|
||||||
|
kind = settings.transcription_provider.strip().lower()
|
||||||
|
if kind in ("", "none"):
|
||||||
|
return None
|
||||||
|
if kind == "whisper_http":
|
||||||
|
from shonar.services.ai.whisper_http import WhisperHttpProvider
|
||||||
|
|
||||||
|
return WhisperHttpProvider(
|
||||||
|
base_url=settings.transcription_base_url,
|
||||||
|
model=settings.transcription_model,
|
||||||
|
api_key=settings.transcription_api_key,
|
||||||
|
)
|
||||||
|
if kind == "faster_whisper":
|
||||||
|
from shonar.services.ai.faster_whisper import FasterWhisperProvider
|
||||||
|
|
||||||
|
return FasterWhisperProvider(model=settings.transcription_model)
|
||||||
|
raise ProviderConfigError(
|
||||||
|
f"Unknown transcription provider: {settings.transcription_provider!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_provider(settings: Settings) -> LlmProvider | None:
|
||||||
|
"""None means "summary stage skipped", never an error."""
|
||||||
|
kind = settings.llm_provider.strip().lower()
|
||||||
|
if kind in ("", "none"):
|
||||||
|
return None
|
||||||
|
if kind == "openai_compat":
|
||||||
|
from shonar.services.ai.openai_compat import OpenAICompatProvider
|
||||||
|
|
||||||
|
return OpenAICompatProvider(
|
||||||
|
base_url=settings.llm_base_url,
|
||||||
|
model=settings.llm_model,
|
||||||
|
api_key=settings.llm_api_key,
|
||||||
|
)
|
||||||
|
if kind == "ollama":
|
||||||
|
from shonar.services.ai.ollama import OllamaProvider
|
||||||
|
|
||||||
|
return OllamaProvider(
|
||||||
|
base_url=settings.llm_base_url,
|
||||||
|
model=settings.llm_model,
|
||||||
|
)
|
||||||
|
raise ProviderConfigError(f"Unknown LLM provider: {settings.llm_provider!r}")
|
||||||
35
backend/shonar/services/ai/_llm.py
Normal file
35
backend/shonar/services/ai/_llm.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Shared summary contract: one system prompt, one JSON shape.
|
||||||
|
|
||||||
|
The model replies with JSON only; partial replies are accepted and missing
|
||||||
|
keys default to empty (a terse-but-valid summary beats a failed job).
|
||||||
|
Transcript input is truncated to bound context — a way in, not a report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from shonar.services.ai import SummaryResult
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"You summarize voice recordings for the speaker's own later reference. "
|
||||||
|
"Reply with JSON only, exactly these keys: "
|
||||||
|
'{"short": "1-2 sentences", "detailed": "a faithful paragraph", '
|
||||||
|
'"key_points": [], "decisions": [], "action_items": [], "questions": []}. '
|
||||||
|
"Empty arrays when absent. Never invent names, dates, or commitments "
|
||||||
|
"not stated in the transcript."
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_TRANSCRIPT_CHARS = 12_000
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(transcript: str, title: str | None) -> str:
|
||||||
|
text = transcript[:MAX_TRANSCRIPT_CHARS]
|
||||||
|
if len(transcript) > MAX_TRANSCRIPT_CHARS:
|
||||||
|
text += f"\n\n[truncated from {len(transcript)} chars]"
|
||||||
|
head = f'Title: "{title}"\n\n' if title else ""
|
||||||
|
return head + "Transcript:\n" + text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_summary(data: object, model: str) -> SummaryResult:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return SummaryResult(model=model)
|
||||||
|
return SummaryResult.from_dict(data, model=model)
|
||||||
66
backend/shonar/services/ai/faster_whisper.py
Normal file
66
backend/shonar/services/ai/faster_whisper.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Local transcription via faster-whisper (optional dependency).
|
||||||
|
|
||||||
|
Runs fully on this machine: audio never leaves the server for this stage.
|
||||||
|
The import is lazy so the base install (and every test run) works without
|
||||||
|
the heavyweight dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult
|
||||||
|
|
||||||
|
|
||||||
|
class FasterWhisperProvider:
|
||||||
|
name = "faster_whisper"
|
||||||
|
|
||||||
|
def __init__(self, model: str = "base") -> None:
|
||||||
|
try:
|
||||||
|
from faster_whisper import WhisperModel # noqa: F401
|
||||||
|
except ImportError as e:
|
||||||
|
raise ProviderConfigError(
|
||||||
|
"faster-whisper is not installed (pip install shonar-backend[faster-whisper])."
|
||||||
|
) from e
|
||||||
|
self.model = model or "base"
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio: bytes,
|
||||||
|
mime: str,
|
||||||
|
*,
|
||||||
|
language_hint: str | None = None,
|
||||||
|
) -> TranscriptResult:
|
||||||
|
# faster-whisper is blocking CPU work: keep it off the event loop.
|
||||||
|
return await asyncio.to_thread(self._run, audio, language_hint)
|
||||||
|
|
||||||
|
def _run(self, audio: bytes, language_hint: str | None) -> TranscriptResult:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
path: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f:
|
||||||
|
f.write(audio)
|
||||||
|
path = Path(f.name)
|
||||||
|
model = WhisperModel(self.model, device="auto")
|
||||||
|
segments_iter, info = model.transcribe(
|
||||||
|
str(path),
|
||||||
|
beam_size=5,
|
||||||
|
language=language_hint,
|
||||||
|
)
|
||||||
|
segments = [
|
||||||
|
Segment(start=s.start, end=s.end, text=s.text.strip())
|
||||||
|
for s in segments_iter
|
||||||
|
]
|
||||||
|
text = " ".join(s.text for s in segments).strip()
|
||||||
|
return TranscriptResult(
|
||||||
|
text=text,
|
||||||
|
language=getattr(info, "language", None),
|
||||||
|
segments=segments,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if path is not None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
72
backend/shonar/services/ai/ollama.py
Normal file
72
backend/shonar/services/ai/ollama.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Summaries via a local Ollama server (`/api/chat`, JSON mode)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult
|
||||||
|
from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaProvider:
|
||||||
|
name = "ollama"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
model: str = "",
|
||||||
|
timeout_s: float = 300.0,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not base_url.strip():
|
||||||
|
raise ProviderConfigError("ollama needs SHONAR_LLM_BASE_URL.")
|
||||||
|
if not model.strip():
|
||||||
|
raise ProviderConfigError("ollama needs SHONAR_LLM_MODEL.")
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
self.http_client = http_client
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||||
|
if self.http_client is not None:
|
||||||
|
yield self.http_client
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult:
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": build_user_message(transcript, title)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.post(f"{self.base_url}/api/chat", json=payload)
|
||||||
|
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||||
|
raise ProviderTransientError(f"Ollama unreachable: {type(e).__name__}") from e
|
||||||
|
if resp.status_code == 404:
|
||||||
|
# Missing model and missing route both 404 here; both are
|
||||||
|
# configuration, not weather.
|
||||||
|
raise ProviderConfigError("Ollama has no such model or route (HTTP 404).")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).")
|
||||||
|
try:
|
||||||
|
content = resp.json()["message"]["content"]
|
||||||
|
except (ValueError, KeyError, TypeError) as e:
|
||||||
|
raise ProviderTransientError("Ollama sent an unreadable reply.") from e
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _json.loads(content)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ProviderTransientError("Ollama reply was not JSON.") from e
|
||||||
|
return parse_summary(data, self.model)
|
||||||
82
backend/shonar/services/ai/openai_compat.py
Normal file
82
backend/shonar/services/ai/openai_compat.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Summaries via any OpenAI-compatible chat endpoint (self-hosted
|
||||||
|
vLLM/llama.cpp server, commercial API, …) with JSON mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult
|
||||||
|
from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatProvider:
|
||||||
|
name = "openai_compat"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
model: str = "",
|
||||||
|
api_key: str = "",
|
||||||
|
timeout_s: float = 180.0,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not base_url.strip():
|
||||||
|
raise ProviderConfigError("openai_compat needs SHONAR_LLM_BASE_URL.")
|
||||||
|
if not model.strip():
|
||||||
|
raise ProviderConfigError("openai_compat needs SHONAR_LLM_MODEL.")
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
self.http_client = http_client
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||||
|
if self.http_client is not None:
|
||||||
|
yield self.http_client
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult:
|
||||||
|
headers = (
|
||||||
|
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": build_user_message(transcript, title)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.base_url}/v1/chat/completions", headers=headers, json=payload
|
||||||
|
)
|
||||||
|
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||||
|
raise ProviderTransientError(f"LLM unreachable: {type(e).__name__}") from e
|
||||||
|
if resp.status_code in (401, 403, 404):
|
||||||
|
raise ProviderConfigError(f"LLM refused the request (HTTP {resp.status_code}).")
|
||||||
|
if resp.status_code == 429 or resp.status_code >= 500:
|
||||||
|
raise ProviderTransientError(f"LLM busy (HTTP {resp.status_code}).")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).")
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
content = body["choices"][0]["message"]["content"]
|
||||||
|
except (ValueError, KeyError, IndexError, TypeError) as e:
|
||||||
|
raise ProviderTransientError("LLM sent an unreadable reply.") from e
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _json.loads(content)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ProviderTransientError("LLM reply was not JSON.") from e
|
||||||
|
return parse_summary(data, self.model)
|
||||||
127
backend/shonar/services/ai/whisper_http.py
Normal file
127
backend/shonar/services/ai/whisper_http.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""Transcription via any OpenAI-compatible `/v1/audio/transcriptions`
|
||||||
|
endpoint (self-hosted whisper.cpp server, commercial Whisper API, …).
|
||||||
|
Sends `verbose_json` so segment timings come back with the text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from shonar.services.ai import (
|
||||||
|
ProviderConfigError,
|
||||||
|
ProviderTransientError,
|
||||||
|
Segment,
|
||||||
|
TranscriptResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WhisperHttpProvider:
|
||||||
|
name = "whisper_http"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
model: str = "base",
|
||||||
|
api_key: str = "",
|
||||||
|
timeout_s: float = 300.0,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not base_url.strip():
|
||||||
|
raise ProviderConfigError(
|
||||||
|
"whisper_http needs SHONAR_TRANSCRIPTION_BASE_URL."
|
||||||
|
)
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
self.http_client = http_client
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||||
|
if self.http_client is not None:
|
||||||
|
yield self.http_client
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio: bytes,
|
||||||
|
mime: str,
|
||||||
|
*,
|
||||||
|
language_hint: str | None = None,
|
||||||
|
) -> TranscriptResult:
|
||||||
|
headers = (
|
||||||
|
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||||
|
)
|
||||||
|
data: dict[str, str] = {"model": self.model, "response_format": "verbose_json"}
|
||||||
|
if language_hint:
|
||||||
|
data["language"] = language_hint
|
||||||
|
files = {"file": (f"audio.{_ext(mime)}", audio, mime or "application/octet-stream")}
|
||||||
|
try:
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.base_url}/v1/audio/transcriptions",
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||||
|
raise ProviderTransientError(
|
||||||
|
f"Transcription service unreachable: {type(e).__name__}"
|
||||||
|
) from e
|
||||||
|
if resp.status_code in (401, 403, 404):
|
||||||
|
raise ProviderConfigError(
|
||||||
|
f"Transcription service refused the request (HTTP {resp.status_code})."
|
||||||
|
)
|
||||||
|
if resp.status_code == 429 or resp.status_code >= 500:
|
||||||
|
raise ProviderTransientError(
|
||||||
|
f"Transcription service busy (HTTP {resp.status_code})."
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ProviderTransientError(
|
||||||
|
f"Transcription failed (HTTP {resp.status_code})."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError as e:
|
||||||
|
raise ProviderTransientError("Transcription service sent no JSON.") from e
|
||||||
|
segments = []
|
||||||
|
raw_segs = body.get("segments")
|
||||||
|
if isinstance(raw_segs, list):
|
||||||
|
for s in raw_segs:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
segments.append(
|
||||||
|
Segment(
|
||||||
|
start=float(s.get("start", 0.0)),
|
||||||
|
end=float(s.get("end", 0.0)),
|
||||||
|
text=str(s.get("text", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
text = body.get("text")
|
||||||
|
return TranscriptResult(
|
||||||
|
text=text if isinstance(text, str) else "",
|
||||||
|
language=body.get("language") if isinstance(body.get("language"), str) else None,
|
||||||
|
segments=segments,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ext(mime: str) -> str:
|
||||||
|
return {
|
||||||
|
"audio/mp4": "m4a",
|
||||||
|
"audio/m4a": "m4a",
|
||||||
|
"audio/wav": "wav",
|
||||||
|
"audio/x-wav": "wav",
|
||||||
|
"audio/ogg": "ogg",
|
||||||
|
"audio/opus": "ogg",
|
||||||
|
"audio/webm": "webm",
|
||||||
|
"audio/mpeg": "mp3",
|
||||||
|
}.get(mime.lower().split(";")[0].strip(), "bin")
|
||||||
461
backend/shonar/services/processing.py
Normal file
461
backend/shonar/services/processing.py
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
"""AI processing pipeline (M7): uploaded -> transcribed -> summarized.
|
||||||
|
|
||||||
|
State lives in the database (ProcessingJob rows); arq/redis is transport
|
||||||
|
only. That ordering is deliberate: if redis is down, uploads still succeed
|
||||||
|
(the jobs sit queued) and the worker sweep picks them up. The only race is
|
||||||
|
a task running before the API transaction commits (recording invisible) —
|
||||||
|
missing rows are transient failures, so arq retry absorbs it.
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
- ``enqueue_for_recording``: called from upload finalize AND re-callable
|
||||||
|
later (manual transcripts in M8 re-enter here). Idempotent.
|
||||||
|
- ``run_transcribe`` / ``run_summarize``: arq task bodies. ``ctx`` is an
|
||||||
|
arq context in production and a plain dict in tests; only
|
||||||
|
``ctx.get("job_try", 1)`` is read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shonar.core.config import get_settings
|
||||||
|
from shonar.db.models import (
|
||||||
|
Asset,
|
||||||
|
AssetKind,
|
||||||
|
JobStatus,
|
||||||
|
JobType,
|
||||||
|
ProcessingJob,
|
||||||
|
ProcessingStatus,
|
||||||
|
Recording,
|
||||||
|
Summary,
|
||||||
|
Transcript,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
from shonar.services.ai import (
|
||||||
|
AIError,
|
||||||
|
ProviderTransientError,
|
||||||
|
get_llm_provider,
|
||||||
|
get_transcription_provider,
|
||||||
|
)
|
||||||
|
from shonar.storage import get_storage
|
||||||
|
|
||||||
|
logger = logging.getLogger("shonar.processing")
|
||||||
|
|
||||||
|
MAX_TRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- entry -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_for_recording(session: AsyncSession, rec: Recording) -> list[JobType]:
|
||||||
|
"""Queue whatever AI stages apply. Safe to call repeatedly: completed
|
||||||
|
work is never redone, failed work is reset for another attempt."""
|
||||||
|
if rec.deleted_at is not None:
|
||||||
|
return []
|
||||||
|
settings = get_settings()
|
||||||
|
tprov = get_transcription_provider(settings)
|
||||||
|
lprov = get_llm_provider(settings)
|
||||||
|
queued: list[JobType] = []
|
||||||
|
if tprov is not None and await reset_or_create(session, rec, JobType.transcribe):
|
||||||
|
queued.append(JobType.transcribe)
|
||||||
|
if (
|
||||||
|
lprov is not None
|
||||||
|
and await latest_transcript_text(session, rec.id) is not None
|
||||||
|
and await reset_or_create(session, rec, JobType.summarize)
|
||||||
|
):
|
||||||
|
queued.append(JobType.summarize)
|
||||||
|
if tprov is None and lprov is None:
|
||||||
|
if rec.processing_status != ProcessingStatus.ai_disabled:
|
||||||
|
rec.processing_status = ProcessingStatus.ai_disabled
|
||||||
|
rec.processing_error = None
|
||||||
|
elif queued and rec.processing_status not in (
|
||||||
|
ProcessingStatus.processing,
|
||||||
|
ProcessingStatus.completed,
|
||||||
|
):
|
||||||
|
rec.processing_status = ProcessingStatus.queued
|
||||||
|
rec.processing_error = None
|
||||||
|
await session.flush()
|
||||||
|
for jt in queued:
|
||||||
|
await transport_enqueue(jt, rec.id)
|
||||||
|
return queued
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_or_create(
|
||||||
|
session: AsyncSession, rec: Recording, job_type: JobType
|
||||||
|
) -> bool:
|
||||||
|
"""Ensure a queued job row. Returns True when (re)queued now: new rows,
|
||||||
|
plus failed/skipped rows (a re-upload or re-entry deserves another
|
||||||
|
attempt). Queued/running/succeeded rows are left alone."""
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ProcessingJob)
|
||||||
|
.where(
|
||||||
|
ProcessingJob.recording_id == rec.id,
|
||||||
|
ProcessingJob.job_type == job_type,
|
||||||
|
)
|
||||||
|
.order_by(ProcessingJob.id.desc())
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
session.add(ProcessingJob(recording_id=rec.id, job_type=job_type))
|
||||||
|
return True
|
||||||
|
if existing.status in (JobStatus.queued, JobStatus.running, JobStatus.succeeded):
|
||||||
|
return False
|
||||||
|
existing.status = JobStatus.queued
|
||||||
|
existing.attempt = 0
|
||||||
|
existing.error = None
|
||||||
|
existing.started_at = None
|
||||||
|
existing.finished_at = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def latest_transcript_text(
|
||||||
|
session: AsyncSession, recording_id: uuid.UUID
|
||||||
|
) -> str | None:
|
||||||
|
"""Newest non-superseded transcript; user-edited rows win over newer
|
||||||
|
machine rows (an edit is a verdict, not a draft)."""
|
||||||
|
rows = (
|
||||||
|
await session.scalars(
|
||||||
|
select(Transcript)
|
||||||
|
.where(
|
||||||
|
Transcript.recording_id == recording_id,
|
||||||
|
Transcript.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Transcript.version.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
for r in rows:
|
||||||
|
if r.edited_by_user and r.text.strip():
|
||||||
|
return r.text
|
||||||
|
text = rows[0].text
|
||||||
|
return text if text.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
async def transport_enqueue(job_type: JobType, recording_id: uuid.UUID) -> None:
|
||||||
|
"""Best-effort arq trigger. Failure only logs: the DB rows are the real
|
||||||
|
queue and the worker sweep picks up anything the transport missed."""
|
||||||
|
from arq import create_pool
|
||||||
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
|
try:
|
||||||
|
pool = await create_pool(RedisSettings.from_dsn(get_settings().redis_url))
|
||||||
|
try:
|
||||||
|
await pool.enqueue_job("run_" + job_type.value, str(recording_id))
|
||||||
|
finally:
|
||||||
|
await pool.aclose()
|
||||||
|
except Exception as e: # noqa: BLE001 — transport must never break uploads
|
||||||
|
logger.warning("arq enqueue failed (%s); worker sweep will pick it up", e)
|
||||||
|
|
||||||
|
|
||||||
|
# --- tasks -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _job_try(ctx: dict) -> int:
|
||||||
|
try:
|
||||||
|
return int(ctx.get("job_try", 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _load(session: AsyncSession, recording_id: str) -> Recording | None:
|
||||||
|
try:
|
||||||
|
rid = uuid.UUID(recording_id)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return await session.get(Recording, rid)
|
||||||
|
|
||||||
|
|
||||||
|
async def _job(
|
||||||
|
session: AsyncSession, rec: Recording, job_type: JobType
|
||||||
|
) -> ProcessingJob:
|
||||||
|
job = await session.scalar(
|
||||||
|
select(ProcessingJob)
|
||||||
|
.where(
|
||||||
|
ProcessingJob.recording_id == rec.id,
|
||||||
|
ProcessingJob.job_type == job_type,
|
||||||
|
)
|
||||||
|
.order_by(ProcessingJob.id.desc())
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
job = ProcessingJob(recording_id=rec.id, job_type=job_type)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
async def _fail(
|
||||||
|
session: AsyncSession,
|
||||||
|
rec: Recording,
|
||||||
|
job: ProcessingJob,
|
||||||
|
message: str,
|
||||||
|
ctx: dict,
|
||||||
|
exc: AIError | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Config errors fail now; transient errors fail only on the last try
|
||||||
|
(returning normally), otherwise they raise for arq retry."""
|
||||||
|
transient = exc is None or isinstance(exc, ProviderTransientError)
|
||||||
|
if transient and _job_try(ctx) < MAX_TRIES:
|
||||||
|
job.attempt = _job_try(ctx)
|
||||||
|
await session.flush()
|
||||||
|
raise ProviderTransientError(message)
|
||||||
|
job.status = JobStatus.failed
|
||||||
|
job.error = message
|
||||||
|
job.finished_at = utcnow()
|
||||||
|
rec.processing_status = ProcessingStatus.failed
|
||||||
|
rec.processing_error = message
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_transcribe(ctx: dict, recording_id: str) -> None:
|
||||||
|
"""Transcribe the original audio; chain into summarization when an LLM
|
||||||
|
is configured."""
|
||||||
|
from shonar.db.session import session_factory
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
async with session_factory()() as session:
|
||||||
|
rec = await _load(session, recording_id)
|
||||||
|
if rec is None or rec.deleted_at is not None:
|
||||||
|
# Finalize race: the API transaction may not have committed yet.
|
||||||
|
raise ProviderTransientError("Recording not ready; retrying.")
|
||||||
|
job = await _job(session, rec, JobType.transcribe)
|
||||||
|
provider = get_transcription_provider(settings)
|
||||||
|
if provider is None:
|
||||||
|
job.status = JobStatus.skipped
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
await _maybe_chain_summarize(session, rec)
|
||||||
|
return
|
||||||
|
original = await session.scalar(
|
||||||
|
select(Asset).where(
|
||||||
|
Asset.recording_id == rec.id, Asset.kind == AssetKind.original
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if original is None:
|
||||||
|
raise ProviderTransientError("Audio not ready; retrying.")
|
||||||
|
job.status = JobStatus.running
|
||||||
|
job.attempt = _job_try(ctx)
|
||||||
|
job.started_at = utcnow()
|
||||||
|
rec.processing_status = ProcessingStatus.processing
|
||||||
|
rec.processing_error = None
|
||||||
|
await session.flush()
|
||||||
|
try:
|
||||||
|
audio = await get_storage().get(original.storage_key)
|
||||||
|
result = await provider.transcribe(audio, original.mime_type)
|
||||||
|
except AIError as e:
|
||||||
|
await _fail(session, rec, job, str(e), ctx, e)
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
await store_transcript(
|
||||||
|
session, rec, result.text, result.segments, result.language,
|
||||||
|
provider.name, getattr(result, "model", ""),
|
||||||
|
)
|
||||||
|
job.status = JobStatus.succeeded
|
||||||
|
job.finished_at = utcnow()
|
||||||
|
await session.flush()
|
||||||
|
await _maybe_chain_summarize(session, rec)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_chain_summarize(session: AsyncSession, rec: Recording) -> None:
|
||||||
|
"""After transcription (or a skip): summarize when possible, else finish."""
|
||||||
|
settings = get_settings()
|
||||||
|
if get_llm_provider(settings) is None:
|
||||||
|
if rec.processing_status != ProcessingStatus.completed:
|
||||||
|
rec.processing_status = ProcessingStatus.completed
|
||||||
|
rec.processing_error = None
|
||||||
|
await session.flush()
|
||||||
|
return
|
||||||
|
if await latest_transcript_text(session, rec.id) is None:
|
||||||
|
# LLM configured but nothing to summarize (e.g. empty transcript).
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ProcessingJob).where(
|
||||||
|
ProcessingJob.recording_id == rec.id,
|
||||||
|
ProcessingJob.job_type == JobType.summarize,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None and existing.status == JobStatus.queued:
|
||||||
|
existing.status = JobStatus.skipped
|
||||||
|
if rec.processing_status != ProcessingStatus.completed:
|
||||||
|
rec.processing_status = ProcessingStatus.completed
|
||||||
|
await session.flush()
|
||||||
|
return
|
||||||
|
if await reset_or_create(session, rec, JobType.summarize):
|
||||||
|
await transport_enqueue(JobType.summarize, rec.id)
|
||||||
|
# Status stays `processing` until the summarize task lands.
|
||||||
|
|
||||||
|
|
||||||
|
async def run_summarize(ctx: dict, recording_id: str) -> None:
|
||||||
|
"""Summarize the latest transcript into the structured summary shape."""
|
||||||
|
from shonar.db.session import session_factory
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
async with session_factory()() as session:
|
||||||
|
rec = await _load(session, recording_id)
|
||||||
|
if rec is None or rec.deleted_at is not None:
|
||||||
|
raise ProviderTransientError("Recording not ready; retrying.")
|
||||||
|
job = await _job(session, rec, JobType.summarize)
|
||||||
|
provider = get_llm_provider(settings)
|
||||||
|
if provider is None:
|
||||||
|
job.status = JobStatus.skipped
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
text = await latest_transcript_text(session, rec.id)
|
||||||
|
if text is None:
|
||||||
|
job.status = JobStatus.skipped
|
||||||
|
await session.flush()
|
||||||
|
if rec.processing_status != ProcessingStatus.completed:
|
||||||
|
rec.processing_status = ProcessingStatus.completed
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
job.status = JobStatus.running
|
||||||
|
job.attempt = _job_try(ctx)
|
||||||
|
job.started_at = utcnow()
|
||||||
|
rec.processing_status = ProcessingStatus.processing
|
||||||
|
await session.flush()
|
||||||
|
try:
|
||||||
|
result = await provider.summarize(text, title=rec.title)
|
||||||
|
except AIError as e:
|
||||||
|
await _fail(session, rec, job, str(e), ctx, e)
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
await store_summary(session, rec, result.to_dict(), provider.name, result.model)
|
||||||
|
job.status = JobStatus.succeeded
|
||||||
|
job.finished_at = utcnow()
|
||||||
|
rec.processing_status = ProcessingStatus.completed
|
||||||
|
rec.processing_error = None
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def store_transcript(
|
||||||
|
session: AsyncSession,
|
||||||
|
rec: Recording,
|
||||||
|
text: str,
|
||||||
|
segments: list,
|
||||||
|
language: str | None,
|
||||||
|
provider_name: str,
|
||||||
|
model: str,
|
||||||
|
) -> None:
|
||||||
|
"""Insert a new auto version; a newest user-edited row wins instead and
|
||||||
|
nothing is inserted (edits are verdicts)."""
|
||||||
|
existing = (
|
||||||
|
await session.scalars(
|
||||||
|
select(Transcript)
|
||||||
|
.where(
|
||||||
|
Transcript.recording_id == rec.id,
|
||||||
|
Transcript.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Transcript.version.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if existing and existing[0].edited_by_user:
|
||||||
|
return
|
||||||
|
now = utcnow()
|
||||||
|
max_version = await session.scalar(
|
||||||
|
select(func.max(Transcript.version)).where(Transcript.recording_id == rec.id)
|
||||||
|
)
|
||||||
|
for row in existing:
|
||||||
|
row.superseded_at = now
|
||||||
|
session.add(
|
||||||
|
Transcript(
|
||||||
|
recording_id=rec.id,
|
||||||
|
version=(max_version or 0) + 1,
|
||||||
|
language=language,
|
||||||
|
provider=provider_name,
|
||||||
|
model=model,
|
||||||
|
text=text,
|
||||||
|
segments=[
|
||||||
|
{"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker}
|
||||||
|
for s in segments
|
||||||
|
],
|
||||||
|
edited_by_user=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def store_summary(
|
||||||
|
session: AsyncSession,
|
||||||
|
rec: Recording,
|
||||||
|
content: dict,
|
||||||
|
provider_name: str,
|
||||||
|
model: str,
|
||||||
|
) -> None:
|
||||||
|
existing = (
|
||||||
|
await session.scalars(
|
||||||
|
select(Summary)
|
||||||
|
.where(
|
||||||
|
Summary.recording_id == rec.id,
|
||||||
|
Summary.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Summary.version.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if existing and existing[0].edited_by_user:
|
||||||
|
return
|
||||||
|
now = utcnow()
|
||||||
|
max_version = await session.scalar(
|
||||||
|
select(func.max(Summary.version)).where(Summary.recording_id == rec.id)
|
||||||
|
)
|
||||||
|
for row in existing:
|
||||||
|
row.superseded_at = now
|
||||||
|
session.add(
|
||||||
|
Summary(
|
||||||
|
recording_id=rec.id,
|
||||||
|
version=(max_version or 0) + 1,
|
||||||
|
provider=provider_name,
|
||||||
|
model=model,
|
||||||
|
content=content,
|
||||||
|
edited_by_user=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def sweep_stale(limit: int = 100) -> int:
|
||||||
|
"""Crash recovery + transport-loss backstop: requeue jobs stuck running
|
||||||
|
or sitting queued, newest catastrophe first. Returns jobs re-enqueued."""
|
||||||
|
from shonar.db.session import session_factory
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
async with session_factory()() as session:
|
||||||
|
rows = (
|
||||||
|
await session.scalars(
|
||||||
|
select(ProcessingJob)
|
||||||
|
.where(ProcessingJob.status.in_([JobStatus.queued, JobStatus.running]))
|
||||||
|
.order_by(ProcessingJob.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for job in rows:
|
||||||
|
rec = await session.get(Recording, job.recording_id)
|
||||||
|
if rec is None or rec.deleted_at is not None:
|
||||||
|
job.status = JobStatus.skipped
|
||||||
|
continue
|
||||||
|
if job.status == JobStatus.running and job.attempt >= MAX_TRIES:
|
||||||
|
job.status = JobStatus.failed
|
||||||
|
job.error = "Worker died too many times."
|
||||||
|
job.finished_at = utcnow()
|
||||||
|
continue
|
||||||
|
job.status = JobStatus.queued
|
||||||
|
job.error = None
|
||||||
|
count += 1
|
||||||
|
await session.commit()
|
||||||
|
# Transport outside the transaction: rows are the queue, this just pokes.
|
||||||
|
async with session_factory()() as session:
|
||||||
|
rows = (
|
||||||
|
await session.scalars(
|
||||||
|
select(ProcessingJob)
|
||||||
|
.where(ProcessingJob.status == JobStatus.queued)
|
||||||
|
.order_by(ProcessingJob.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for job in rows:
|
||||||
|
await transport_enqueue(job.job_type, job.recording_id)
|
||||||
|
return count
|
||||||
|
|
@ -231,6 +231,11 @@ async def finalize(
|
||||||
us.completed_asset_id = existing_original.id
|
us.completed_asset_id = existing_original.id
|
||||||
recording.title = us.title or recording.title
|
recording.title = us.title or recording.title
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
from shonar.services import processing as _processing
|
||||||
|
|
||||||
|
# Same audio, maybe new metadata — and a failed pipeline deserves
|
||||||
|
# another attempt. Idempotent: completed work is never redone.
|
||||||
|
await _processing.enqueue_for_recording(session, recording)
|
||||||
return us, recording, existing_original
|
return us, recording, existing_original
|
||||||
|
|
||||||
# Assemble into the final object (streamed per chunk to bound memory).
|
# Assemble into the final object (streamed per chunk to bound memory).
|
||||||
|
|
@ -289,6 +294,11 @@ async def finalize(
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await storage.delete(c.storage_key)
|
await storage.delete(c.storage_key)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
from shonar.services import processing as _processing
|
||||||
|
|
||||||
|
# New audio on disk: queue whatever AI stages apply (none configured =
|
||||||
|
# ai_disabled, never an error).
|
||||||
|
await _processing.enqueue_for_recording(session, recording)
|
||||||
return us, recording, asset
|
return us, recording, asset
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
62
backend/shonar/worker.py
Normal file
62
backend/shonar/worker.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""arq worker entrypoint (M7): runs the AI pipeline tasks.
|
||||||
|
|
||||||
|
Run: arq shonar.worker.WorkerSettings
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from arq import cron
|
||||||
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
|
from shonar.core.config import get_settings
|
||||||
|
from shonar.db.session import dispose_engine, get_engine
|
||||||
|
from shonar.services import processing
|
||||||
|
|
||||||
|
logger = logging.getLogger("shonar.worker")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_transcribe(ctx: dict, recording_id: str) -> None:
|
||||||
|
await processing.run_transcribe(ctx, recording_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_summarize(ctx: dict, recording_id: str) -> None:
|
||||||
|
await processing.run_summarize(ctx, recording_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def sweep(ctx: dict) -> None: # noqa: ARG001 — arq cron signature
|
||||||
|
count = await processing.sweep_stale()
|
||||||
|
if count:
|
||||||
|
logger.info("sweep re-enqueued %d stale jobs", count)
|
||||||
|
|
||||||
|
|
||||||
|
async def startup(ctx: dict) -> None:
|
||||||
|
get_engine()
|
||||||
|
# Crash recovery before accepting new work: jobs stuck `running` and
|
||||||
|
# queued rows the transport missed go back through arq.
|
||||||
|
count = await processing.sweep_stale()
|
||||||
|
if count:
|
||||||
|
logger.info("startup sweep re-enqueued %d stale jobs", count)
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown(ctx: dict) -> None: # noqa: ARG001
|
||||||
|
await dispose_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def _redis() -> RedisSettings:
|
||||||
|
return RedisSettings.from_dsn(get_settings().redis_url)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerSettings:
|
||||||
|
functions = [run_transcribe, run_summarize, sweep]
|
||||||
|
# Transport-loss backstop beyond the startup sweep: anything still
|
||||||
|
# queued (missed enqueue, dead worker between runs) goes back through
|
||||||
|
# arq every 5 minutes. Rows are the queue; this just pokes.
|
||||||
|
cron_jobs = [cron(sweep, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55})]
|
||||||
|
on_startup = startup
|
||||||
|
on_shutdown = shutdown
|
||||||
|
redis_settings = _redis()
|
||||||
|
# Retry budget for transient provider failures; the tasks themselves
|
||||||
|
# mark jobs failed on the last try (see processing.MAX_TRIES).
|
||||||
|
max_tries = 3
|
||||||
184
backend/tests/test_ai_adapters.py
Normal file
184
backend/tests/test_ai_adapters.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"""Adapter wire-protocol tests (M7): JSON shapes in/out, error mapping.
|
||||||
|
|
||||||
|
HTTP adapters take an injectable httpx client; faster-whisper is an
|
||||||
|
optional dependency and is only exercised when installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shonar.core.config import Settings
|
||||||
|
from shonar.services import ai
|
||||||
|
from shonar.services.ai import ProviderConfigError, SummaryResult
|
||||||
|
from shonar.services.ai.ollama import OllamaProvider
|
||||||
|
from shonar.services.ai.openai_compat import OpenAICompatProvider
|
||||||
|
from shonar.services.ai.whisper_http import WhisperHttpProvider
|
||||||
|
|
||||||
|
|
||||||
|
def mock_client(handler) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
# --- factories -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_factories_none_means_skip():
|
||||||
|
s = Settings(transcription_provider="none", llm_provider="none")
|
||||||
|
assert ai.get_transcription_provider(s) is None
|
||||||
|
assert ai.get_llm_provider(s) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_factories_unknown_names_raise_config_error():
|
||||||
|
s = Settings(transcription_provider="whisper-9k")
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
ai.get_transcription_provider(s)
|
||||||
|
s = Settings(llm_provider="clippy")
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
ai.get_llm_provider(s)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factories_missing_urls_raise_config_error():
|
||||||
|
s = Settings(transcription_provider="whisper_http", transcription_base_url="")
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
ai.get_transcription_provider(s)
|
||||||
|
s = Settings(llm_provider="openai_compat", llm_base_url="http://x", llm_model="")
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
ai.get_llm_provider(s)
|
||||||
|
|
||||||
|
|
||||||
|
def test_faster_whisper_missing_dep_is_config_error():
|
||||||
|
if importlib.util.find_spec("faster_whisper") is not None:
|
||||||
|
pytest.skip("faster-whisper installed; missing-dep path N/A")
|
||||||
|
# Constructor validates eagerly so misconfiguration fails at startup,
|
||||||
|
# not on the first recording.
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
ai.get_transcription_provider(Settings(transcription_provider="faster_whisper"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- whisper_http -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def whisper_ok(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/v1/audio/transcriptions"
|
||||||
|
assert request.method == "POST"
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"text": "hello world",
|
||||||
|
"language": "en",
|
||||||
|
"segments": [{"start": 0.0, "end": 1.2, "text": "hello world"}],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_whisper_http_happy_path():
|
||||||
|
p = WhisperHttpProvider("http://stt:8000", model="small",
|
||||||
|
http_client=mock_client(whisper_ok))
|
||||||
|
res = await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||||
|
assert res.text == "hello world"
|
||||||
|
assert res.language == "en"
|
||||||
|
assert [(s.start, s.end, s.text) for s in res.segments] == [(0.0, 1.2, "hello world")]
|
||||||
|
assert res.model == "small"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_whisper_http_401_is_config_error():
|
||||||
|
async def denied(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(401, json={"detail": "nope"})
|
||||||
|
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(denied))
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_whisper_http_503_is_transient():
|
||||||
|
async def busy(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(503, text="overloaded")
|
||||||
|
from shonar.services.ai import ProviderTransientError
|
||||||
|
|
||||||
|
p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(busy))
|
||||||
|
with pytest.raises(ProviderTransientError):
|
||||||
|
await p.transcribe(b"\x00" * 16, "audio/wav")
|
||||||
|
|
||||||
|
|
||||||
|
# --- openai_compat ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def chat_ok(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/v1/chat/completions"
|
||||||
|
body = {
|
||||||
|
"short": "Standup.",
|
||||||
|
"detailed": "The team met.",
|
||||||
|
"key_points": ["a", "b"],
|
||||||
|
"decisions": ["ship"],
|
||||||
|
"action_items": [{"not": "a string"}, "call ana"],
|
||||||
|
"questions": [],
|
||||||
|
"extra_key": "ignored",
|
||||||
|
}
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": _json.dumps(body)}}]})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_compat_parses_and_sanitizes():
|
||||||
|
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||||
|
http_client=mock_client(chat_ok))
|
||||||
|
res = await p.summarize("a very long meeting transcript", title="Standup")
|
||||||
|
assert isinstance(res, SummaryResult)
|
||||||
|
assert res.short == "Standup."
|
||||||
|
assert res.action_items == ("call ana",) # non-strings dropped
|
||||||
|
assert res.model == "qwen"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_compat_partial_json_gets_defaults():
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
async def partial(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": _json.dumps({"short": "Hi."})}}]
|
||||||
|
})
|
||||||
|
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||||
|
http_client=mock_client(partial))
|
||||||
|
res = await p.summarize("hi")
|
||||||
|
assert res.short == "Hi."
|
||||||
|
assert res.detailed == "" and res.key_points == ()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_compat_non_json_is_transient():
|
||||||
|
async def garbage(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": "Sure! Here it is..."}}]
|
||||||
|
})
|
||||||
|
from shonar.services.ai import ProviderTransientError
|
||||||
|
|
||||||
|
p = OpenAICompatProvider("http://llm:8000", model="qwen",
|
||||||
|
http_client=mock_client(garbage))
|
||||||
|
with pytest.raises(ProviderTransientError):
|
||||||
|
await p.summarize("hi")
|
||||||
|
|
||||||
|
|
||||||
|
# --- ollama ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ollama_happy_path():
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
async def ok(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/api/chat"
|
||||||
|
payload = _json.loads(request.content.decode())
|
||||||
|
assert payload["format"] == "json" and payload["stream"] is False
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"message": {"content": _json.dumps({"short": "S.", "detailed": "D."})}
|
||||||
|
})
|
||||||
|
p = OllamaProvider("http://ollama:11434", model="llama3",
|
||||||
|
http_client=mock_client(ok))
|
||||||
|
res = await p.summarize("meeting notes")
|
||||||
|
assert res.short == "S." and res.model == "llama3"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ollama_404_is_config_error():
|
||||||
|
async def missing(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(404, text="model not found")
|
||||||
|
p = OllamaProvider("http://ollama:11434", model="nope",
|
||||||
|
http_client=mock_client(missing))
|
||||||
|
with pytest.raises(ProviderConfigError):
|
||||||
|
await p.summarize("hi")
|
||||||
375
backend/tests/test_ai_pipeline.py
Normal file
375
backend/tests/test_ai_pipeline.py
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
"""AI pipeline tests (M7): status flow, versioning, failure modes, endpoints.
|
||||||
|
|
||||||
|
Provider fakes stand in for real STT/LLM services (no network, no model
|
||||||
|
downloads); adapter wire-protocol tests live in test_ai_adapters.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from shonar.db import session as db_session
|
||||||
|
from shonar.db.models import (
|
||||||
|
JobStatus,
|
||||||
|
JobType,
|
||||||
|
ProcessingJob,
|
||||||
|
Transcript,
|
||||||
|
)
|
||||||
|
from shonar.services import processing
|
||||||
|
from shonar.services.ai import (
|
||||||
|
ProviderConfigError,
|
||||||
|
ProviderTransientError,
|
||||||
|
Segment,
|
||||||
|
SummaryResult,
|
||||||
|
TranscriptResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wav_bytes(payload_len: int = 64) -> bytes:
|
||||||
|
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
|
||||||
|
data = data[:payload_len]
|
||||||
|
header = (
|
||||||
|
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
||||||
|
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
|
||||||
|
+ b"data" + struct.pack("<I", len(data))
|
||||||
|
)
|
||||||
|
return header + data
|
||||||
|
|
||||||
|
|
||||||
|
AUTH = {"email": "m7@example.com", "password": "m7-test-passw0rd-123"}
|
||||||
|
|
||||||
|
|
||||||
|
async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]):
|
||||||
|
r = await client.post("/api/v1/auth/register", json={"email": email, "password": password})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_recording(client, token, client_id=None, title="M7 standup"):
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
data = wav_bytes()
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/uploads",
|
||||||
|
json={"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
|
||||||
|
"client_recording_id": client_id, "title": title},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
sid = r.json()["id"]
|
||||||
|
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
|
||||||
|
headers={**h, "content-type": "application/octet-stream"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
r = await client.post(f"/api/v1/uploads/{sid}/finalize",
|
||||||
|
json={"duration_seconds": 5.0}, headers=h)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTranscriber:
|
||||||
|
name = "fake-stt"
|
||||||
|
|
||||||
|
def __init__(self, text="hello world from the meeting", fail=None):
|
||||||
|
self.text = text
|
||||||
|
self.fail = fail
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def transcribe(self, audio, mime, *, language_hint=None):
|
||||||
|
self.calls += 1
|
||||||
|
assert len(audio) > 0 and mime == "audio/wav"
|
||||||
|
if self.fail is not None:
|
||||||
|
raise self.fail
|
||||||
|
return TranscriptResult(
|
||||||
|
text=self.text, language="en",
|
||||||
|
segments=[Segment(0.0, 1.0, self.text)], model="fake-stt-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLlm:
|
||||||
|
name = "fake-llm"
|
||||||
|
|
||||||
|
def __init__(self, fail=None):
|
||||||
|
self.fail = fail
|
||||||
|
self.seen = []
|
||||||
|
|
||||||
|
async def summarize(self, transcript, *, title=None):
|
||||||
|
self.seen.append(transcript)
|
||||||
|
if self.fail is not None:
|
||||||
|
raise self.fail
|
||||||
|
return SummaryResult(
|
||||||
|
short="Standup happened.", detailed="The team met and spoke.",
|
||||||
|
key_points=("a",), decisions=(), action_items=("ship it",),
|
||||||
|
questions=(), model="fake-llm-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET):
|
||||||
|
tprov = FakeTranscriber() if stt is _UNSET else stt
|
||||||
|
lprov = FakeLlm() if llm is _UNSET else llm
|
||||||
|
monkeypatch.setattr(processing, "get_transcription_provider", lambda settings: tprov)
|
||||||
|
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: lprov)
|
||||||
|
|
||||||
|
|
||||||
|
async def jobs_for(recording_id):
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
rows = (await s.scalars(
|
||||||
|
select(ProcessingJob).where(ProcessingJob.recording_id == uuid.UUID(recording_id))
|
||||||
|
)).all()
|
||||||
|
return {j.job_type: j for j in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# --- no AI configured --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_none_configured_marks_ai_disabled(client):
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token)
|
||||||
|
assert rec["processing_status"] == "ai_disabled"
|
||||||
|
assert await jobs_for(rec["id"]) == {}
|
||||||
|
# Status endpoints: nothing there yet, but the recording exists.
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||||
|
assert r.status_code == 404
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||||
|
assert r.status_code == 404
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||||
|
assert r.status_code == 200 and r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- happy path ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_pipeline_transcribe_then_summarize(client, monkeypatch):
|
||||||
|
stt, llm = FakeTranscriber(), FakeLlm()
|
||||||
|
use_fakes(monkeypatch, stt, llm)
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-full-1")
|
||||||
|
assert rec["processing_status"] == "queued"
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert set(jobs) == {JobType.transcribe}
|
||||||
|
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||||
|
|
||||||
|
await processing.run_transcribe({}, rec["id"])
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert jobs[JobType.transcribe].status == JobStatus.succeeded
|
||||||
|
assert set(jobs) == {JobType.transcribe, JobType.summarize}
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||||
|
assert r.json()["processing_status"] == "processing"
|
||||||
|
|
||||||
|
t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
|
||||||
|
assert t.status_code == 200, t.text
|
||||||
|
body = t.json()
|
||||||
|
assert body["text"] == "hello world from the meeting"
|
||||||
|
assert body["version"] == 1 and body["provider"] == "fake-stt"
|
||||||
|
assert body["segments"][0]["text"].startswith("hello")
|
||||||
|
assert llm.seen == []
|
||||||
|
|
||||||
|
await processing.run_summarize({}, rec["id"])
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert jobs[JobType.summarize].status == JobStatus.succeeded
|
||||||
|
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||||
|
assert s.status_code == 200, s.text
|
||||||
|
content = s.json()["content"]
|
||||||
|
assert content["short"] == "Standup happened."
|
||||||
|
assert content["action_items"] == ["ship it"]
|
||||||
|
assert set(content) == {"short", "detailed", "key_points", "decisions",
|
||||||
|
"action_items", "questions"}
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||||
|
assert r.json()["processing_status"] == "completed"
|
||||||
|
assert llm.seen == ["hello world from the meeting"]
|
||||||
|
|
||||||
|
jobs_rows = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
|
||||||
|
assert jobs_rows.status_code == 200
|
||||||
|
assert {j["job_type"] for j in jobs_rows.json()} == {"transcribe", "summarize"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enqueue_is_idempotent(client, monkeypatch):
|
||||||
|
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-idem-1")
|
||||||
|
await processing.run_transcribe({}, rec["id"])
|
||||||
|
await processing.run_summarize({}, rec["id"])
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
from shonar.db.models import Recording
|
||||||
|
|
||||||
|
row = await s.get(Recording, uuid.UUID(rec["id"]))
|
||||||
|
queued = await processing.enqueue_for_recording(s, row)
|
||||||
|
assert queued == []
|
||||||
|
n = await s.scalar(
|
||||||
|
select(func.count(ProcessingJob.id)).where(
|
||||||
|
ProcessingJob.recording_id == uuid.UUID(rec["id"]))
|
||||||
|
)
|
||||||
|
assert n == 2
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- failure modes -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transient_failure_retries_then_fails(client, monkeypatch):
|
||||||
|
stt = FakeTranscriber(fail=ProviderTransientError("stt down"))
|
||||||
|
use_fakes(monkeypatch, stt, FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-fail-1")
|
||||||
|
# First tries raise for arq retry; the attempt rolls back with the
|
||||||
|
# transaction, so the row still reads queued (arq tracks the tries).
|
||||||
|
with pytest.raises(ProviderTransientError):
|
||||||
|
await processing.run_transcribe({"job_try": 1}, rec["id"])
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||||
|
# Last try marks the job (and recording) failed with a safe message.
|
||||||
|
await processing.run_transcribe({"job_try": 3}, rec["id"])
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||||
|
assert jobs[JobType.transcribe].error == "stt down"
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||||
|
assert r.json()["processing_status"] == "failed"
|
||||||
|
assert r.json()["processing_error"] == "stt down"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_error_fails_fast(client, monkeypatch):
|
||||||
|
stt = FakeTranscriber(fail=ProviderConfigError("bad credentials"))
|
||||||
|
use_fakes(monkeypatch, stt, FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-cfg-1")
|
||||||
|
await processing.run_transcribe({"job_try": 1}, rec["id"]) # no raise
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||||
|
assert stt.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- versioning -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rerun_supersedes_auto_but_not_user_edits(client, monkeypatch):
|
||||||
|
use_fakes(monkeypatch, FakeTranscriber("v1 text"), FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-ver-1")
|
||||||
|
await processing.run_transcribe({}, rec["id"])
|
||||||
|
rid = uuid.UUID(rec["id"])
|
||||||
|
|
||||||
|
async def versions():
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
rows = (await s.scalars(
|
||||||
|
select(Transcript).where(Transcript.recording_id == rid)
|
||||||
|
.order_by(Transcript.version))).all()
|
||||||
|
return [(r.version, r.text, r.superseded_at is not None, r.edited_by_user)
|
||||||
|
for r in rows]
|
||||||
|
|
||||||
|
assert await versions() == [(1, "v1 text", False, False)]
|
||||||
|
|
||||||
|
# Queue another transcription run manually (re-entry path).
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
from shonar.db.models import Recording
|
||||||
|
|
||||||
|
row = await s.get(Recording, rid)
|
||||||
|
await processing.enqueue_for_recording(s, row)
|
||||||
|
await s.commit()
|
||||||
|
use_fakes(monkeypatch, FakeTranscriber("v2 text"), FakeLlm())
|
||||||
|
await processing.run_transcribe({}, rec["id"])
|
||||||
|
assert await versions() == [(1, "v1 text", True, False), (2, "v2 text", False, False)]
|
||||||
|
|
||||||
|
# A user edit wins: the next auto run inserts nothing.
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
v2 = await s.scalar(
|
||||||
|
select(Transcript).where(Transcript.recording_id == rid,
|
||||||
|
Transcript.version == 2))
|
||||||
|
v2.edited_by_user = True
|
||||||
|
v2.text = "user corrected text"
|
||||||
|
from shonar.db.models import Recording
|
||||||
|
|
||||||
|
row = await s.get(Recording, rid)
|
||||||
|
await processing.enqueue_for_recording(s, row)
|
||||||
|
await s.commit()
|
||||||
|
use_fakes(monkeypatch, FakeTranscriber("v3 text"), FakeLlm())
|
||||||
|
await processing.run_transcribe({}, rec["id"])
|
||||||
|
got = await versions()
|
||||||
|
assert len(got) == 2 and got[1][1] == "user corrected text"
|
||||||
|
|
||||||
|
|
||||||
|
# --- llm-only and skips ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_llm_only_without_transcript_queues_nothing(client, monkeypatch):
|
||||||
|
use_fakes(monkeypatch, None, FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-llm-1")
|
||||||
|
assert rec["processing_status"] == "uploaded"
|
||||||
|
assert await jobs_for(rec["id"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_llm_only_with_manual_transcript_summarizes(client, monkeypatch):
|
||||||
|
use_fakes(monkeypatch, None, FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-llm-2")
|
||||||
|
rid = uuid.UUID(rec["id"])
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
from shonar.db.models import Recording
|
||||||
|
|
||||||
|
row = await s.get(Recording, rid)
|
||||||
|
s.add(Transcript(recording_id=rid, version=1, provider="manual",
|
||||||
|
text="handwritten notes", edited_by_user=True))
|
||||||
|
await s.flush()
|
||||||
|
queued = await processing.enqueue_for_recording(s, row)
|
||||||
|
assert queued == [JobType.summarize]
|
||||||
|
await s.commit()
|
||||||
|
await processing.run_summarize({}, rec["id"])
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h)
|
||||||
|
assert s.status_code == 200
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||||
|
assert r.json()["processing_status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- sweep ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sweep_requeues_stale_jobs(client, monkeypatch):
|
||||||
|
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-sweep-1")
|
||||||
|
rid = uuid.UUID(rec["id"])
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
from shonar.db.models import Recording
|
||||||
|
|
||||||
|
row = await s.get(Recording, rid)
|
||||||
|
# Simulate a crashed worker + a lost transport, respectively.
|
||||||
|
await processing.reset_or_create(s, row, JobType.transcribe)
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
jobs[JobType.transcribe].status = JobStatus.running
|
||||||
|
await s.commit()
|
||||||
|
# reset_or_create leaves running rows alone, so force the second shape:
|
||||||
|
async with db_session._session_factory() as s:
|
||||||
|
extra = ProcessingJob(recording_id=rid, job_type=JobType.summarize,
|
||||||
|
status=JobStatus.queued)
|
||||||
|
s.add(extra)
|
||||||
|
await s.commit()
|
||||||
|
count = await processing.sweep_stale()
|
||||||
|
assert count == 2
|
||||||
|
jobs = await jobs_for(rec["id"])
|
||||||
|
assert jobs[JobType.transcribe].status == JobStatus.queued
|
||||||
|
assert jobs[JobType.summarize].status == JobStatus.queued
|
||||||
|
|
||||||
|
|
||||||
|
# --- ownership ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ai_endpoints_enforce_ownership(client, monkeypatch):
|
||||||
|
use_fakes(monkeypatch, FakeTranscriber(), FakeLlm())
|
||||||
|
token = await user_tokens(client)
|
||||||
|
rec = await upload_recording(client, token, client_id="m7-own-1")
|
||||||
|
await processing.run_transcribe({}, rec["id"])
|
||||||
|
other = await user_tokens(client, email="m7-other@example.com",
|
||||||
|
password="m7-test-passw0rd-456")
|
||||||
|
h = {"Authorization": f"Bearer {other}"}
|
||||||
|
for path in ("transcript", "summary", "jobs"):
|
||||||
|
r = await client.get(f"/api/v1/recordings/{rec['id']}/{path}", headers=h)
|
||||||
|
assert r.status_code == 404, path
|
||||||
35
backend/tests/test_provider_info.py
Normal file
35
backend/tests/test_provider_info.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""provider-info handshake tests (P2/P3 client probing depends on this)."""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provider_info_identifies_shonar(client):
|
||||||
|
r = await client.get("/api/v1/provider-info")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["kind"] == "shonar"
|
||||||
|
assert body["api_version"] == "v1"
|
||||||
|
caps = body["capabilities"]
|
||||||
|
assert caps["chunked_upload"] is True
|
||||||
|
assert caps["account_deletion"] is True
|
||||||
|
# test env configures no AI providers -> flags must be false
|
||||||
|
assert caps["server_transcription"] is False
|
||||||
|
assert caps["server_summary"] is False
|
||||||
|
assert body["storage_backend"] in ("local", "s3")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provider_info_leaks_no_paths_or_secrets(client):
|
||||||
|
r = await client.get("/api/v1/provider-info")
|
||||||
|
body = r.json()
|
||||||
|
# every string value must be a bare identifier, never a filesystem path
|
||||||
|
def walk(v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
assert not v.startswith("/"), f"path-like value in handshake: {v!r}"
|
||||||
|
low = v.lower()
|
||||||
|
for banned in ("secret", "token", "password", "key"):
|
||||||
|
assert banned not in low
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
for x in v.values():
|
||||||
|
walk(x)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
for x in v:
|
||||||
|
walk(x)
|
||||||
|
walk(body)
|
||||||
|
|
@ -72,7 +72,8 @@ async def test_upload_happy_path_creates_recording(client):
|
||||||
rec = r.json()
|
rec = r.json()
|
||||||
assert rec["title"] == "Standup"
|
assert rec["title"] == "Standup"
|
||||||
assert rec["has_audio"] is True
|
assert rec["has_audio"] is True
|
||||||
assert rec["processing_status"] == "uploaded"
|
# M7: with no AI configured the pipeline marks audio-only explicitly.
|
||||||
|
assert rec["processing_status"] == "ai_disabled"
|
||||||
assert rec["duration_seconds"] == 12.5
|
assert rec["duration_seconds"] == 12.5
|
||||||
# No storage keys or internals leak.
|
# No storage keys or internals leak.
|
||||||
assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "")
|
assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "")
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ updated in the same commit as the work it describes.
|
||||||
| M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done |
|
| M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done |
|
||||||
| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done |
|
| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done |
|
||||||
| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done |
|
| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done |
|
||||||
| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO |
|
| M3 | Android: server URL config, login, token persistence + auto-refresh | implemented via P3 `CustomShonarProvider` (Keystore token store, transparent refresh, login UI in provider selection); on-device verification pending |
|
||||||
| S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done |
|
| S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done |
|
||||||
| ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** |
|
| ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** |
|
||||||
| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO |
|
| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | implemented; on-device verification pending |
|
||||||
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO |
|
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | done — `SyncWorker` + `SyncScheduler` (15-min periodic, one-shot on startup/constraint change, backoff on retry), Context-free `SyncDrain` engine (drains QUEUED + due ERROR incl. P7 leftovers, exponential backoff 1m–1h, cancel reverts to QUEUED), pause as resting state; full suite 167 green. On-device behaviour pending |
|
||||||
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO |
|
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | partial; local list, playback, and delete implemented |
|
||||||
| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | TODO |
|
| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | done — provider protocols + 4 adapters (faster-whisper lazy optional), versioned transcripts/summaries (user edits win), arq worker (`run_transcribe`/`run_summarize` + startup/5-min sweep, max 3 tries), transcript/summary/jobs endpoints, `none` means skipped; 50 backend tests green, ruff clean |
|
||||||
| M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO |
|
| M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO |
|
||||||
| M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) |
|
| M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) |
|
||||||
| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO |
|
| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO |
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,11 @@ provider API first, local wipe second.
|
||||||
|
|
||||||
## 7. Start9 / Umbrel / custom adapters
|
## 7. Start9 / Umbrel / custom adapters
|
||||||
|
|
||||||
|
> Status: P6a shipped the generic path — Start9/Umbrel cards take a service
|
||||||
|
> URL and auto-detect Nextcloud vs SHONAR, handing off to those providers.
|
||||||
|
> The platform-RPC discovery below, multi-service picking, and Tor onion
|
||||||
|
> access (Orbot SOCKS) are deferred to P6b.
|
||||||
|
|
||||||
- **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is
|
- **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is
|
||||||
"platform probe + service binding": a `PlatformProbe` (Start9: Server API
|
"platform probe + service binding": a `PlatformProbe` (Start9: Server API
|
||||||
over its RPC; Umbrel: its app manifest endpoints, where available)
|
over its RPC; Umbrel: its app manifest endpoints, where available)
|
||||||
|
|
@ -182,11 +187,12 @@ provider API first, local wipe second.
|
||||||
| P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green |
|
| P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green |
|
||||||
| P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) |
|
| P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) |
|
||||||
| P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device |
|
| P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device |
|
||||||
| P3 | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | backend + app integration |
|
| P3 (done) | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | contract suite (10 shared + 12 custom) + 9 auth tests vs MockWebServer fixtures mirroring `schemas_*.py`; backend pytest 28 green on live Postgres; `assembleDebug` clean |
|
||||||
| P4 | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking | contract suite + a real Nextcloud instance |
|
| P4 (done) | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking — plus `FolderSyncProvider` (sync-folder for Syncthing etc., requested alongside) | contract suite (10 shared + 6 custom) + 6 auth + 4 protocol tests vs MockWebServer fake; DAV/chunking-v2 protocol verified live against Nextcloud 34 (MKCOL/PUT/MOVE-assemble/PROPFIND/OCS/login-v2 via curl); full suite 120 green, `assembleDebug` clean. On-device browser-approval tap-through still pending |
|
||||||
| P5 | TLS TOFU pinning + redaction/logging + leak tests | cert fixtures |
|
| P5 (done) | TLS TOFU pinning + redaction/logging + leak tests | TOFU trust manager (system-first, per-host DER pins in secure store, explicit approval UI with fingerprint + issuer + validity) + redacting logger gated by `log_http_bodies` (transcript/sidecar bodies never log, in any mode) + HeldCertificate TLS fixtures; full suite 138 green, `assembleDebug` clean. Self-signed hosts now connect after one approval; hostname verification stays strict |
|
||||||
| P6 | Platform probes for Start9/Umbrel + service-binding UX | probe fakes |
|
| P6a (done) | Generic hosted setup: Start9/Umbrel cards take a service URL, auto-detect Nextcloud vs SHONAR, hand off to the existing provider flows; the platform is an entry path, the persisted provider is the protocol | routing matrix unit tests (`routeHosted`); full suite + `assembleDebug` |
|
||||||
| P7 | Provider switching w/ migration prompts, account deletion, revocation | e2e |
|
| P6b (deferred) | Platform RPC auto-discovery (Start9 Server API, Umbrel manifests), multi-service picker, Tor onion access via Orbot SOCKS | probe fakes + on-device, when needed |
|
||||||
|
| P7 (done) | Provider switching w/ migration prompts, account deletion, revocation | Room v2 provider slot per recording (origin + remote ref + sync state, honest migration) + "Where is my data?" screen (summary, reconnect, disconnect+revoke, delete account) + foreground migrate-now uploader (bounded: cancellable, one attempt/file, upload-only) + switch-time prompt (upload / keep / forget links); migration matrix + slots + converter tests, full suite 156 green, `assembleDebug` clean. Background/scheduled sync stays M5; pull-down and old-remote wipe are explicit non-goals (a future wipe is safe: everything lives under `SHONAR/` prefixes) |
|
||||||
|
|
||||||
Recording engine (M4) and playback (M6) proceed independently on top of the
|
Recording engine (M4) and playback (M6) proceed independently on top of the
|
||||||
same Room model; provider work is orthogonal.
|
same Room model; provider work is orthogonal.
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
|
|
||||||
echo "==> backend deps"
|
echo "==> backend deps"
|
||||||
cd backend
|
cd backend
|
||||||
uv venv .venv 2>/dev/null || true
|
if command -v uv >/dev/null 2>&1; then
|
||||||
uv pip install -e ".[dev]"
|
uv venv .venv 2>/dev/null || true
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
else
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install -e ".[dev]"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> test database"
|
echo "==> test database"
|
||||||
docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \
|
docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,72 @@
|
||||||
"title": "LogoutRequest",
|
"title": "LogoutRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ProcessingJobOut": {
|
||||||
|
"properties": {
|
||||||
|
"attempt": {
|
||||||
|
"title": "Attempt",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Error"
|
||||||
|
},
|
||||||
|
"finished_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Finished At"
|
||||||
|
},
|
||||||
|
"job_type": {
|
||||||
|
"title": "Job Type",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"max_attempts": {
|
||||||
|
"title": "Max Attempts",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Started At"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "Status",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"job_type",
|
||||||
|
"status",
|
||||||
|
"attempt",
|
||||||
|
"max_attempts",
|
||||||
|
"error",
|
||||||
|
"started_at",
|
||||||
|
"finished_at"
|
||||||
|
],
|
||||||
|
"title": "ProcessingJobOut",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"RecordingFinalize": {
|
"RecordingFinalize": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"duration_seconds": {
|
"duration_seconds": {
|
||||||
|
|
@ -475,6 +541,93 @@
|
||||||
"title": "RegisterRequest",
|
"title": "RegisterRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SegmentOut": {
|
||||||
|
"properties": {
|
||||||
|
"end": {
|
||||||
|
"title": "End",
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"speaker": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Speaker"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "Start",
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"title": "Text",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"start",
|
||||||
|
"end",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"title": "SegmentOut",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SummaryOut": {
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Content",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"edited_by_user": {
|
||||||
|
"title": "Edited By User",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Model"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"title": "Provider",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Updated At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"title": "Version",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"provider",
|
||||||
|
"model",
|
||||||
|
"content",
|
||||||
|
"edited_by_user",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
"title": "SummaryOut",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TokenPair": {
|
"TokenPair": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"access_token": {
|
"access_token": {
|
||||||
|
|
@ -515,6 +668,78 @@
|
||||||
"title": "TokenPair",
|
"title": "TokenPair",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TranscriptOut": {
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"edited_by_user": {
|
||||||
|
"title": "Edited By User",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Language"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Model"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"title": "Provider",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"segments": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SegmentOut"
|
||||||
|
},
|
||||||
|
"title": "Segments",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"title": "Text",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Updated At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"title": "Version",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"language",
|
||||||
|
"provider",
|
||||||
|
"model",
|
||||||
|
"text",
|
||||||
|
"segments",
|
||||||
|
"edited_by_user",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
"title": "TranscriptOut",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"UploadSessionCreate": {
|
"UploadSessionCreate": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"client_recording_id": {
|
"client_recording_id": {
|
||||||
|
|
@ -1093,6 +1318,30 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/provider-info": {
|
||||||
|
"get": {
|
||||||
|
"description": "Static, unauthenticated deployment identity for client probing.",
|
||||||
|
"operationId": "provider_info_api_v1_provider_info_get",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Response Provider Info Api V1 Provider Info Get",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Provider Info",
|
||||||
|
"tags": [
|
||||||
|
"provider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/readyz": {
|
"/api/v1/readyz": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.",
|
"description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.",
|
||||||
|
|
@ -1407,6 +1656,157 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/recordings/{recording_id}/jobs": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "list_jobs_api_v1_recordings__recording_id__jobs_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "recording_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Recording Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ProcessingJobOut"
|
||||||
|
},
|
||||||
|
"title": "Response List Jobs Api V1 Recordings Recording Id Jobs Get",
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"HTTPBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "List Jobs",
|
||||||
|
"tags": [
|
||||||
|
"uploads",
|
||||||
|
"recordings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/recordings/{recording_id}/summary": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_summary_api_v1_recordings__recording_id__summary_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "recording_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Recording Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SummaryOut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"HTTPBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Get Summary",
|
||||||
|
"tags": [
|
||||||
|
"uploads",
|
||||||
|
"recordings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/recordings/{recording_id}/transcript": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_transcript_api_v1_recordings__recording_id__transcript_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "recording_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Recording Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TranscriptOut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"HTTPBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Get Transcript",
|
||||||
|
"tags": [
|
||||||
|
"uploads",
|
||||||
|
"recordings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/system/status": {
|
"/api/v1/system/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.",
|
"description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""Worker entrypoint: runs the arq queue on the backend image.
|
"""Worker entrypoint: runs the arq queue on the backend image.
|
||||||
|
|
||||||
Pipeline tasks land with milestone M7; until then the worker image runs an
|
Run (from `backend/`, venv active):
|
||||||
idle loop so `docker compose up` is complete and future-proof.
|
|
||||||
TODO(M7): implement sonar/processing/tasks.py and wire arq.WorkerSettings.
|
arq shonar.worker.WorkerSettings
|
||||||
|
|
||||||
|
Needs Redis (`SHONAR_REDIS_URL`, wired automatically in compose) and the
|
||||||
|
same `SHONAR_*` env as the API — the worker reads AI provider config from
|
||||||
|
the same variables, so transcription/summary behavior matches what
|
||||||
|
`/api/v1/provider-info` advertises.
|
||||||
|
|
||||||
|
What it runs (M7):
|
||||||
|
- `run_transcribe(recording_id)` — original audio through the configured
|
||||||
|
transcription provider, stored versioned (user edits win, never touched).
|
||||||
|
- `run_summarize(recording_id)` — latest transcript through the LLM into
|
||||||
|
the structured summary shape.
|
||||||
|
- `sweep` on startup + every 5 min — crash recovery and transport-loss
|
||||||
|
backstop: stuck `running` and orphaned `queued` rows go back through arq.
|
||||||
|
|
||||||
|
With no AI configured the pipeline marks recordings `ai_disabled` and the
|
||||||
|
worker idles: recording, sync, and playback never need it.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue