diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9bd311e..a7e257c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,43 +19,10 @@ kotlin { sourceSets { main { kotlin { - // Portable shared sources vendored into this project (formerly - // compiled in place from the sibling Android repo). Android-only - // files excluded below. + // Portable provider/recording sources vendored into this + // project (the Android-only files were removed; this app is + // standalone desktop). srcDir("../shared") - exclude( - "com/shonar/ShonarApplication.kt", - "com/shonar/MainActivity.kt", - // settings store impls are Android (DataStore/Keystore); - // desktop provides its own (desktopSettingsStore.kt). - "com/shonar/settings/SettingsStore.kt", - // recording: only AiContent.kt is portable. - "com/shonar/recording/MigrationRunner.kt", - "com/shonar/recording/RecordingDao.kt", - "com/shonar/recording/RecordingEntity.kt", - "com/shonar/recording/RecordingNotificationHelper.kt", - "com/shonar/recording/PlaybackController.kt", - "com/shonar/recording/RecordingPermissionHelper.kt", - "com/shonar/recording/RecordingRepository.kt", - "com/shonar/recording/RecordingService.kt", - "com/shonar/recording/RecordingStateStore.kt", - "com/shonar/recording/ShonarDatabase.kt", - "com/shonar/recording/SyncDrain.kt", - "com/shonar/recording/SyncSlots.kt", - "com/shonar/recording/SyncStateConverter.kt", - "com/shonar/recording/SyncWorker.kt", - // widget + rename-after-save UI: Android-only (AppWidget, - // ShonarApplication references). - "com/shonar/widget/**", - "com/shonar/ui/rename/**", - // ui: only ui/folder/FolderList.kt is portable. - "com/shonar/ui/detail/**", - "com/shonar/ui/home/**", - "com/shonar/ui/provider/**", - "com/shonar/ui/settings/**", - "com/shonar/ui/theme/**", - "com/shonar/ui/folder/FolderBrowserScreen.kt", - ) srcDir("src/main/kotlin") } } diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index aeaa9d0..9b6e9d7 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -34,7 +34,7 @@ import okhttp3.OkHttpClient /** * Desktop app state (Stage 3). Plain class + StateFlows — no ViewModel * framework. Talks to the SHONAR backend through the *shared* - * [CustomShonarProvider] (same files the Android app compiles). + * [CustomShonarProvider] (vendored portable sources in ../shared). */ class DesktopState(private val appDir: File = defaultAppDir()) { diff --git a/app/src/main/kotlin/com/shonar/desktop/Theme.kt b/app/src/main/kotlin/com/shonar/desktop/Theme.kt index d58c945..dc4dc16 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Theme.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Theme.kt @@ -20,10 +20,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** - * SHONAR Desktop theme — the Android companion's "deep-sea sonar" palette, - * copied exactly from android ui/theme/Theme.kt dark scheme: dark navy + - * teal ping. Same seven role overrides the phone app sets; everything else - * falls through to Material3 defaults, same as Android. + * SHONAR Desktop theme — the "deep-sea sonar" palette: dark navy + + * teal ping. Seven role overrides; everything else falls through to + * Material3 defaults. * * Status semantics in the library list map to scheme roles: * done -> primary (teal), failed -> error (red), queued/running -> @@ -52,7 +51,7 @@ fun ShonarTheme(content: @Composable () -> Unit) { } /** - * The SHONAR sonar-ping logo — same geometry as the Android launcher's + * The SHONAR sonar-ping logo — same geometry as the brand's launcher * ic_launcher_foreground.xml: a center dot with quarter arcs ringing out * (teal upper-right, teal-dark lower-left), 108-unit viewport. */ diff --git a/shared/com/shonar/MainActivity.kt b/shared/com/shonar/MainActivity.kt deleted file mode 100644 index 1349af4..0000000 --- a/shared/com/shonar/MainActivity.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.shonar - -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.Modifier -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.shonar.recording.RecordingFiles -import com.shonar.recording.RecordingService -import com.shonar.recording.RenamePendingHolder -import com.shonar.ui.detail.DetailsScreen -import com.shonar.ui.folder.FolderBrowserScreen -import com.shonar.ui.home.HomeScreen -import com.shonar.ui.provider.ProviderSelectionScreen -import com.shonar.ui.provider.StorageScreen -import com.shonar.ui.rename.RenameAfterSaveDialog -import com.shonar.ui.settings.SettingsScreen -import com.shonar.ui.theme.ShonarTheme - -class MainActivity : ComponentActivity() { - - private val notifPermLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { } - - /** Recording id awaiting rename after a service SAVE. Null = no sheet. */ - private val pendingRename = mutableStateOf(null) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (android.os.Build.VERSION.SDK_INT >= 33 && - androidx.core.content.ContextCompat.checkSelfPermission( - this, android.Manifest.permission.POST_NOTIFICATIONS, - ) != android.content.pm.PackageManager.PERMISSION_GRANTED - ) { - runCatching { notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } - } - routeIntent(intent) - setContent { - ShonarTheme { - val nav = rememberNavController() - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - NavHost(navController = nav, startDestination = "home") { - composable("home") { - HomeScreen( - onOpenSettings = { nav.navigate("settings") }, - onOpenStorage = { nav.navigate("storage") }, - onOpenDetail = { id -> nav.navigate("detail/$id") }, - ) - } - composable( - "detail/{id}", - arguments = listOf(navArgument("id") { type = androidx.navigation.NavType.StringType }), - ) { backStack -> - DetailsScreen( - recordingId = backStack.arguments?.getString("id").orEmpty(), - onBack = { nav.popBackStack() }, - ) - } - composable("storage") { - StorageScreen( - onBack = { nav.popBackStack() }, - onSwitchProvider = { nav.navigate("provider") }, - ) - } - composable("provider") { entry -> - val pick by entry.savedStateHandle - .getStateFlow("picked_path", null) - .collectAsState() - ProviderSelectionScreen( - onDone = { nav.popBackStack() }, - onBack = { nav.popBackStack() }, - browsePick = pick, - onPickConsumed = { - entry.savedStateHandle.remove("picked_path") - }, - onBrowse = { nav.navigate("folderBrowser") }, - ) - } - composable("folderBrowser") { - FolderBrowserScreen( - onPick = { path -> - nav.previousBackStackEntry - ?.savedStateHandle - ?.set("picked_path", path) - nav.popBackStack() - }, - onBack = { nav.popBackStack() }, - ) - } - composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } - } - - // Post-save rename sheet: opens automatically after Save. - // Cancel keeps the file under its default name. - val renameId = pendingRename.value - if (renameId != null) { - val app = applicationContext as ShonarApplication - val recordings by app.recordingRepository.recordings - .collectAsState(initial = emptyList()) - val row = recordings.firstOrNull { it.id == renameId } - val defaultName = row?.title - ?: RecordingFiles.defaultDisplayName(System.currentTimeMillis()) - var err by androidx.compose.runtime.remember(renameId) { - androidx.compose.runtime.mutableStateOf(null) - } - val vm: com.shonar.ui.rename.RenameAfterSaveViewModel = - androidx.lifecycle.viewmodel.compose.viewModel( - factory = object : androidx.lifecycle.ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: Class, - ): T = com.shonar.ui.rename.RenameAfterSaveViewModel(app) as T - }, - ) - RenameAfterSaveDialog( - defaultName = defaultName, - error = err, - onConfirm = { name -> - val stem = RecordingFiles.sanitizeStem(name) - if (stem == null) { - err = "Enter a valid name." - } else { - vm.confirm(renameId, stem) { pendingRename.value = null } - } - }, - onCancel = { pendingRename.value = null }, - ) - } - } - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - routeIntent(intent) - } - - private fun routeIntent(intent: Intent?) { - // Widget without RECORD_AUDIO routes here to request permission first. - if (intent?.action == RecordingService.ACTION_START) { - if (androidx.core.content.ContextCompat.checkSelfPermission( - this, android.Manifest.permission.RECORD_AUDIO, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - ) { - RecordingService.command(this, RecordingService.ACTION_START) - } - intent.action = Intent.ACTION_MAIN - } - val renameId = intent?.takeIf { it.action == RecordingService.ACTION_RENAME } - ?.getStringExtra(RecordingService.EXTRA_RECORDING_ID) - ?: RenamePendingHolder.pendingRenameId - if (intent?.action == RecordingService.ACTION_RENAME) { - RenamePendingHolder.pendingRenameId = null - intent.action = Intent.ACTION_MAIN - } - if (renameId != null) pendingRename.value = renameId - } -} diff --git a/shared/com/shonar/ShonarApplication.kt b/shared/com/shonar/ShonarApplication.kt deleted file mode 100644 index 14c8620..0000000 --- a/shared/com/shonar/ShonarApplication.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.shonar - -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.SecureSettingsStore -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() { - - 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(filesDir, database.recordingDao()) { - // New recordings belong in the picked folder (local-only - // provider), or the app-private library by default / for - // server-backed setups. Never throws — null means default. - runCatching { - settingsManager.ensureLoaded() - val pid = settingsManager.string( - com.shonar.settings.BuiltInSettings.PROVIDER_ID - ).ifBlank { "local-only" } - (providerRegistry.provider(pid) as? com.shonar.provider.LocalOnlyProvider) - ?.currentRoot() - }.getOrNull() - } - } - - val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) } - - val settingsManager: SettingsManager by lazy { - SettingsManager( - store = DataStoreSettingsStore(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, - sink = { msg -> android.util.Log.d("ShonarNet", msg) }, - ), - ) - } - - override fun onCreate() { - super.onCreate() - appScope.launch { - settingsManager.ensureLoaded() - // TOFU pins must be in cache before any TLS handshake needs them. - tofu.refresh() - // Point new recordings at the picked folder (if any). - runCatching { recordingRepository.refreshRoot() } - 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? = 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) - ) - } -} diff --git a/shared/com/shonar/provider/CustomShonarProvider.kt b/shared/com/shonar/provider/CustomShonarProvider.kt index ec38a05..05a7500 100644 --- a/shared/com/shonar/provider/CustomShonarProvider.kt +++ b/shared/com/shonar/provider/CustomShonarProvider.kt @@ -40,10 +40,10 @@ import okhttp3.Response * refresh, and concurrent 401s serialize on [refreshMutex] — two parallel * refreshes would look like token reuse and burn the whole family. * - * Sidecars: file cache under [sidecarRoot] keyed by remote recording id - * (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs) - * goes through the real M7 endpoints instead — see [fetchTranscript] and - * friends, consumed by the M8 details screen. + * Sidecars: file cache under [sidecarRoot] keyed by remote recording id. + * AI content (transcript/summary/jobs) goes through the real M7 + * endpoints instead — see [fetchTranscript] and friends, consumed by + * the details screen. * * Cancellation safety: a cancelled upload leaves an open server session * with some chunks stored — invisible until finalize, resumable via the @@ -56,8 +56,8 @@ class CustomShonarProvider( private val client: OkHttpClient = defaultClient(), private val handshake: ShonarHandshake = ShonarHandshake(), /** Identifies this client to the server (login device_name/platform). */ - private val deviceName: String = "SHONAR Android", - private val platform: String = "android", + private val deviceName: String = "SHONAR Desktop", + private val platform: String = "desktop", ) : ShonarProvider { override val descriptor = ProviderDescriptor( diff --git a/shared/com/shonar/provider/FolderSyncProvider.kt b/shared/com/shonar/provider/FolderSyncProvider.kt deleted file mode 100644 index eb002d7..0000000 --- a/shared/com/shonar/provider/FolderSyncProvider.kt +++ /dev/null @@ -1,194 +0,0 @@ -package com.shonar.provider - -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext - -/** - * P4: sync-folder provider — "bring your own sync". The app reads and - * writes plain files under a user-chosen directory; an external tool - * (Syncthing, the Nextcloud desktop client, rsync, …) moves those bytes - * between devices. The app never talks to a network for this provider — - * provable by construction (no HTTP imports in this file). - * - * The on-disk layout is identical to [LocalOnlyProvider] (`audio/{id}`, - * `sidecars/…`), so switching between local-only and a sync folder is a - * copy, not a migration, and anything already syncing the folder picks - * the recordings up with no special handling. - * - * Two deliberate differences from local-only: - * - [connect] validates the directory (must exist, be a directory, be - * readable AND writable) and refuses anything else. A typo must be an - * error, never a silently created folder somewhere surprising. Paths - * escaping via `..` are rejected for the same reason. - * - [deleteAccountAndData] NEVER deletes the folder's contents. That - * directory belongs to the user and their sync tool, not to the app — - * forgetting the path is the whole operation. - */ -class FolderSyncProvider( - private val pathStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ID, - displayName = "Sync folder", - capabilities = setOf(), // the sync tool owns the protocol, not us - ) - - private val _authState = MutableStateFlow(AuthState.DISCONNECTED) - override val authState: StateFlow = _authState - - private suspend fun storedRoot(): File? { - val raw = pathStore.getString(KEY_ROOT) ?: return null - return File(raw) - } - - private fun checkDir(dir: File): File { - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) throw ProviderError.InvalidUrl( - "Folder does not exist: ${dir.path}. Create it (or let Syncthing create it) first." - ) - if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}") - if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl( - "Folder is not readable and writable: ${dir.path}" - ) - return dir - } - - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = - ProbeResult.Incompatible // no server involved — nothing to probe - - override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { - val folder = credential as? ProviderCredential.FolderPath - ?: throw ProviderError.InvalidUrl( - "Sync folder needs a folder path to sync through" - ) - val dir = checkDir(File(folder.path)) - pathStore.putString(KEY_ROOT, dir.canonicalPath) - _authState.value = AuthState.CONNECTED - } - - /** - * Create the folder, then connect. Safety rules (a typo must never - * spray directories across storage): - * - no `..` segments, never a blank path; - * - the direct parent must already exist, be a directory, and be - * writable — only the final segment is ever created; - * - when the folder already exists this is just [connect]. - */ - suspend fun createRoot(rawPath: String) = withContext(Dispatchers.IO) { - val trimmed = rawPath.trim() - if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty") - val dir = File(trimmed) - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) { - val parent = dir.absoluteFile.parentFile - ?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed") - if (!parent.isDirectory) throw ProviderError.InvalidUrl( - "Parent is not a folder: ${parent.path}" - ) - if (!parent.canWrite()) throw ProviderError.InvalidUrl( - "Parent folder is not writable: ${parent.path}" - ) - if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl( - "Could not create folder: ${dir.path}" - ) - } - val checked = checkDir(dir) - pathStore.putString(KEY_ROOT, checked.canonicalPath) - _authState.value = AuthState.CONNECTED - } - - override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { - val root = storedRoot() - if (root == null) { - _authState.value = AuthState.DISCONNECTED - return@withContext _authState.value - } - runCatching { checkDir(root) } - .onSuccess { _authState.value = AuthState.CONNECTED } - .onFailure { _authState.value = AuthState.DISCONNECTED } - _authState.value - } - - override suspend fun disconnect(revokeOnServer: Boolean) { - // Nothing remote to revoke; the path is kept so reconnect is one tap. - _authState.value = AuthState.DISCONNECTED - } - - override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) { - // Forget the folder. The files stay — they belong to the user and - // their sync tool, and deleting someone's Syncthing folder because - // they tapped "disconnect" would be unforgivable. - pathStore.remove(KEY_ROOT) - _authState.value = AuthState.DISCONNECTED - } - - // ---- storage: delegate with rewritten identity -------------------------- - - private suspend fun root(): File { - if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected() - return storedRoot()?.let { checkDir(it) } ?: throw ProviderError.NotConnected() - } - - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = - withContext(Dispatchers.IO) { - val ref = LocalOnlyProvider(root()).upload(draft, onProgress) - ref.copy(providerId = ID) - } - - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = - withContext(Dispatchers.IO) { - LocalOnlyProvider(root()).download(ref.copy(providerId = LocalOnlyProvider.ID), dest, onProgress) - } - - override suspend fun delete(ref: RemoteRef) = withContext(Dispatchers.IO) { - LocalOnlyProvider(root()).delete(ref.copy(providerId = LocalOnlyProvider.ID)) - } - - override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { - val page = LocalOnlyProvider(root()).list(cursor) - Page( - page.items.map { it.copy(ref = it.ref.copy(providerId = ID)) }, - page.nextCursor, - ) - } - - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = - withContext(Dispatchers.IO) { - LocalOnlyProvider(root()) - .putSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind, bytes) - } - - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = - withContext(Dispatchers.IO) { - LocalOnlyProvider(root()) - .getSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind) - } - - override suspend fun storageLocationSummary(): StorageLocation = - withContext(Dispatchers.IO) { - val r = root() - val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() - StorageLocation( - headline = "Sync folder", - detail = "${audio.size} recordings — synced by your sync tool, not by this app.", - syncedCount = 0, - localOnlyCount = audio.size, - bytesUsed = audio.sumOf { it.length() }, - path = r.path, - ) - } - - companion object { - const val ID = "sync-folder" - const val KEY_ROOT = "provider.sync-folder.root" - } -} diff --git a/shared/com/shonar/provider/LocalOnlyProvider.kt b/shared/com/shonar/provider/LocalOnlyProvider.kt deleted file mode 100644 index 2e2a3d0..0000000 --- a/shared/com/shonar/provider/LocalOnlyProvider.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.shonar.provider - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.io.File - -/** - * Local-only provider: everything stays in app-private storage. First-class - * so the app has no "no server" special case. No networking code paths at - * all — provable by construction (this file imports no HTTP library). - * - * The storage root is the app-private [defaultRoot], unless the user picked - * their own folder ([connect]/[createRoot] with a [ProviderCredential.FolderPath]): - * then files live there and [deleteAccountAndData] only forgets the path — - * a user folder is never deleted by this app. - */ -class LocalOnlyProvider( - private val defaultRoot: File, - private val pathStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), - private val rootKey: String = KEY_ROOT, -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ID, - displayName = "Local-only storage", - capabilities = setOf(), // no chunked protocol needed; no server AI - ) - - private val _authState = MutableStateFlow(AuthState.CONNECTED) - override val authState: StateFlow = _authState - - private suspend fun storedRoot(): File? { - val raw = pathStore.getString(rootKey) ?: return null - return File(raw).takeIf { it.exists() } - } - - /** App-private dir by default; the user's own folder once picked. */ - private suspend fun effectiveRoot(): File = storedRoot() ?: defaultRoot - - /** Where new recordings belong right now. Read by the recording repository. */ - suspend fun currentRoot(): File = effectiveRoot() - - private fun checkDir(dir: File): File { - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) throw ProviderError.InvalidUrl( - "Folder does not exist: ${dir.path}." - ) - if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}") - if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl( - "Folder is not readable and writable: ${dir.path}" - ) - return dir - } - - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = - ProbeResult.Incompatible // local-only never talks to servers - - override suspend fun connect(credential: ProviderCredential) { - when (credential) { - ProviderCredential.None -> _authState.value = AuthState.CONNECTED - is ProviderCredential.FolderPath -> { - val dir = checkDir(File(credential.path)) - pathStore.putString(rootKey, dir.canonicalPath) - _authState.value = AuthState.CONNECTED - } - else -> throw ProviderError.InvalidUrl( - "Local-only storage takes no credentials" - ) - } - } - - /** - * Use the app-private folder again (forget a previously picked folder; - * its files stay where they are). - */ - suspend fun useDefaultRoot() { - pathStore.remove(rootKey) - _authState.value = AuthState.CONNECTED - } - - /** - * Create the folder, then use it. Same safety rules as the sync - * folder: only the final segment is ever created, the parent must - * already exist, no `..`. When it exists this is just [connect]. - */ - suspend fun createRoot(rawPath: String) { - val trimmed = rawPath.trim() - if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty") - val dir = File(trimmed) - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) { - val parent = dir.absoluteFile.parentFile - ?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed") - if (!parent.isDirectory) throw ProviderError.InvalidUrl( - "Parent is not a folder: ${parent.path}" - ) - if (!parent.canWrite()) throw ProviderError.InvalidUrl( - "Parent folder is not writable: ${parent.path}" - ) - if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl( - "Could not create folder: ${dir.path}" - ) - } - val checked = checkDir(dir) - pathStore.putString(rootKey, checked.canonicalPath) - _authState.value = AuthState.CONNECTED - } - - override suspend fun reconnect(): AuthState { - // A picked folder may have been deleted out from under us; fall - // back to the app-private dir rather than stranding recordings. - _authState.value = AuthState.CONNECTED - return _authState.value - } - - override suspend fun disconnect(revokeOnServer: Boolean) { - // Nothing to revoke; DISCONNECTED means "user left local mode" — the - // sync layer treats it as paused, files remain on disk. - _authState.value = AuthState.DISCONNECTED - } - - override suspend fun deleteAccountAndData() { - if (storedRoot() != null) { - // A user-picked folder belongs to the user — forget it, never - // delete it. - pathStore.remove(rootKey) - } else if (defaultRoot.exists()) { - defaultRoot.deleteRecursively() - } - _authState.value = AuthState.DISCONNECTED - } - - // ---- storage ----------------------------------------------------------- - - private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key) - private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) = - File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}") - - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { - val key = "audio/${draft.id}" - val dest = File(effectiveRoot(), key) - dest.parentFile?.mkdirs() - // "Upload" locally = copy; report progress in slices so UI behaves uniformly - draft.sourceFile.inputStream().use { input -> - dest.outputStream().use { output -> - val buf = ByteArray(64 * 1024) - val total = draft.sizeBytes.coerceAtLeast(1) - var written = 0L - while (true) { - val n = input.read(buf) - if (n < 0) break - output.write(buf, 0, n) - written += n - onProgress((written.toFloat() / total).coerceIn(0f, 1f)) - } - } - } - return RemoteRef(ID, key, etag = "sha256:" + dest.sha256Hex(), sizeBytes = dest.length()) - } - - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) { - val src = audioFile(ref) - if (!src.exists()) throw ProviderError.NotFound(ref.key) - src.copyTo(dest, overwrite = true) - onProgress(1f) - } - - override suspend fun delete(ref: RemoteRef) { - audioFile(ref).delete() - File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively() - } - - override suspend fun list(cursor: String?): Page { - val audioRoot = File(effectiveRoot(), "audio") - val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList() - // single page; no paging for local storage - val items = files.map { f -> - RemoteRecording( - ref = RemoteRef(ID, "audio/${f.name}", etag = "sha256:" + f.sha256Hex(), sizeBytes = f.length()), - title = f.nameWithoutExtension, - createdAtEpochMs = f.lastModified(), - durationMs = 0, // duration is tracked in Room, not on disk - mime = "application/octet-stream", - ) - } - return Page(items, nextCursor = null) - } - - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) { - val f = sidecarFile(ref, kind) - f.parentFile?.mkdirs() - f.writeBytes(bytes) - } - - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? { - val f = sidecarFile(ref, kind) - return if (f.exists()) f.readBytes() else null - } - - override suspend fun storageLocationSummary(): StorageLocation { - val raw = pathStore.getString(rootKey) - val custom = storedRoot() - if (raw != null && custom == null) { - return StorageLocation( - headline = "Folder unavailable", - detail = "Your picked folder is gone or unreadable. " + - "Reconnect storage or pick again — new recordings use the app folder meanwhile.", - syncedCount = 0, - localOnlyCount = 0, - bytesUsed = 0, - path = raw, - ) - } - val r = custom ?: defaultRoot - val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() - return StorageLocation( - headline = if (custom != null) "Your folder" else "On this device only", - detail = if (custom != null) "Recordings stay in your folder and never leave your phone." - else "Recordings and transcripts never leave your phone.", - syncedCount = 0, - localOnlyCount = audio.size, - bytesUsed = audio.sumOf { it.length() }, - path = r.absolutePath, - ) - } - - companion object { - const val ID = "local-only" - const val KEY_ROOT = "provider.local-only.root" - - private fun File.sha256Hex(): String { - val md = java.security.MessageDigest.getInstance("SHA-256") - inputStream().use { inn -> - val buf = ByteArray(64 * 1024) - while (true) { - val n = inn.read(buf) - if (n < 0) break - md.update(buf, 0, n) - } - } - return md.digest().joinToString("") { "%02x".format(it) } - } - } -} diff --git a/shared/com/shonar/provider/NextcloudAuth.kt b/shared/com/shonar/provider/NextcloudAuth.kt deleted file mode 100644 index 9750e75..0000000 --- a/shared/com/shonar/provider/NextcloudAuth.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.shonar.provider - -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONObject - -/** - * Nextcloud server identification + login flow v2 (docs/server-providers.md - * §3, §6). No credentials are ever sent here: [startLogin] is anonymous, - * [poll] carries only the one-time poll token. - * - * Login flow v2 (official, supported): - * 1. POST {base}/index.php/login/v2 -> {poll:{token,endpoint}, login:url} - * 2. user approves {login} in a browser (server-owned consent screen) - * 3. POST {poll.endpoint} {"token":...} -> 404 while pending, - * 200 {server, loginName, appPassword} once approved - */ -class NextcloudAuth( - private val client: OkHttpClient = defaultClient(), - private val tofu: TofuManager? = null, -) { - - /** Is there a Nextcloud at [url]? Read-only, no credentials. */ - suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) { - val req = Request.Builder().url(url.origin + "/status.php").get().build() - try { - client.newCall(req).execute().use { resp -> - if (resp.code != 200) return@withContext ProbeResult.Incompatible - val body = JSONObject(resp.body?.string().orEmpty()) - // status.php: {productname, versionstring, ...}. Some forks - // report "Nextcloud" with different casing — accept any. - val product = body.optString("productname", "") - if (!product.equals("nextcloud", ignoreCase = true)) { - return@withContext ProbeResult.Incompatible - } - ProbeResult.Compatible( - descriptor = ProviderDescriptor( - id = ProviderRegistry.NEXTCLOUD_ID, - displayName = "Nextcloud", - isDefault = true, - capabilities = setOf( - ProviderDescriptor.Capability.CHUNKED_UPLOAD, - ProviderDescriptor.Capability.QUOTA_INFO, - ), - ), - serverName = product, - version = body.optString("versionstring", "unknown"), - ) - } - } catch (e: javax.net.ssl.SSLHandshakeException) { - ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown") - } catch (e: javax.net.ssl.SSLPeerUnverifiedException) { - ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown") - } catch (e: Exception) { - ProbeResult.NetworkError(e.javaClass.simpleName) - } - } - - /** Begins login flow v2. Returns the browser URL + poll handle. */ - suspend fun startLogin(url: ServerUrl): LoginFlowSession = withContext(Dispatchers.IO) { - val req = Request.Builder().url(url.origin + "/index.php/login/v2") - .post(ByteArray(0).toRequestBody(null)).build() - try { - client.newCall(req).execute().use { resp -> - if (resp.code != 200) { - throw ProviderError.Transient( - "Server refused the login request (HTTP ${resp.code}). " + - "Is this a Nextcloud?" - ) - } - val body = JSONObject(resp.body?.string().orEmpty()) - val poll = body.optJSONObject("poll") - val login = body.optString("login", "") - val token = poll?.optString("token", "").orEmpty() - val endpoint = poll?.optString("endpoint", "").orEmpty() - if (login.isBlank() || token.isBlank() || endpoint.isBlank()) { - throw ProviderError.Transient("Server gave an incomplete login response") - } - LoginFlowSession( - baseUrl = url.origin, - loginUrl = login, - pollToken = token, - pollEndpoint = endpoint, - ) - } - } catch (e: ProviderError) { - throw e - } catch (e: Exception) { - throw ProviderError.Transient("Could not reach the server (${e.javaClass.simpleName})") - } - } - - /** One poll attempt. Call repeatedly until [PollResult.Approved]. */ - suspend fun poll(flow: LoginFlowSession): PollResult = withContext(Dispatchers.IO) { - val json = """{"token":"${flow.pollToken}"}""" - val req = Request.Builder().url(flow.pollEndpoint) - .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() - try { - client.newCall(req).execute().use { resp -> - when (resp.code) { - 200 -> { - val body = JSONObject(resp.body?.string().orEmpty()) - val server = body.optString("server", flow.baseUrl) - val name = body.optString("loginName", "") - val pass = body.optString("appPassword", "") - if (name.isBlank() || pass.isBlank()) { - return@withContext PollResult.Failed("Server approved but sent no credentials") - } - PollResult.Approved( - ProviderCredential.AppPassword( - accountLabel = "$name@${baseHost(server)}", - loginUrl = server, - user = name, - password = pass, - ) - ) - } - 404 -> PollResult.Pending - else -> PollResult.Failed("Login poll failed (HTTP ${resp.code})") - } - } - } catch (e: Exception) { - PollResult.Failed("Login poll failed (${e.javaClass.simpleName})") - } - } - - companion object { - fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(20, TimeUnit.SECONDS) - .followRedirects(false) - .build() - - private fun baseHost(server: String): String = - runCatching { java.net.URI(server).host }.getOrNull() ?: server - } -} - -/** Browser URL + one-time poll handle from [NextcloudAuth.startLogin]. */ -data class LoginFlowSession( - val baseUrl: String, - val loginUrl: String, - val pollToken: String, - val pollEndpoint: String, -) - -/** One poll attempt's outcome. The token itself never appears in messages. */ -sealed class PollResult { - data object Pending : PollResult() - data class Approved(val credential: ProviderCredential.AppPassword) : PollResult() - data class Failed(val reason: String) : PollResult() -} diff --git a/shared/com/shonar/provider/NextcloudAuthStore.kt b/shared/com/shonar/provider/NextcloudAuthStore.kt deleted file mode 100644 index 572cd45..0000000 --- a/shared/com/shonar/provider/NextcloudAuthStore.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.shonar.provider - -import com.shonar.settings.SettingsStore - -/** - * Secure persistence for the Nextcloud session: server origin, DAV user id, - * display name, and the app password from login flow v2. The user's *normal* - * account password is never requested, typed, or stored — only the - * server-issued app password lives here (docs/server-providers.md §3). - */ -class NextcloudAuthStore(private val secure: SettingsStore) { - - suspend fun save(session: NcSession) { - secure.putString(KEY_BASE_URL, session.baseUrl) - secure.putString(KEY_USER_ID, session.userId) - secure.putString(KEY_USERNAME, session.username) - secure.putString(KEY_APP_PASSWORD, session.appPassword) - } - - suspend fun load(): NcSession? { - val base = secure.getString(KEY_BASE_URL) ?: return null - val pass = secure.getString(KEY_APP_PASSWORD) ?: return null - return NcSession( - baseUrl = base, - userId = secure.getString(KEY_USER_ID).orEmpty(), - username = secure.getString(KEY_USERNAME).orEmpty(), - appPassword = pass, - ) - } - - suspend fun clear() { - secure.remove(KEY_BASE_URL) - secure.remove(KEY_USER_ID) - secure.remove(KEY_USERNAME) - secure.remove(KEY_APP_PASSWORD) - } - - companion object { - private const val PREFIX = "provider.nextcloud." - const val KEY_BASE_URL = PREFIX + "base_url" - const val KEY_USER_ID = PREFIX + "user_id" - const val KEY_USERNAME = PREFIX + "username" - const val KEY_APP_PASSWORD = PREFIX + "app_password" - } -} - -/** Authenticated Nextcloud session. Never logged (see toString). */ -data class NcSession( - val baseUrl: String, - val userId: String, // DAV path user (OCS `id`, not the display name) - val username: String, // human hint only - val appPassword: String, -) { - override fun toString(): String = "NcSession(server=$baseUrl, user=$username, [redacted])" -} diff --git a/shared/com/shonar/provider/NextcloudProvider.kt b/shared/com/shonar/provider/NextcloudProvider.kt deleted file mode 100644 index 5ab2e9a..0000000 --- a/shared/com/shonar/provider/NextcloudProvider.kt +++ /dev/null @@ -1,644 +0,0 @@ -package com.shonar.provider - -import java.io.File -import java.time.format.DateTimeFormatter -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import org.json.JSONObject - -/** - * P4: Nextcloud provider — the product default. Talks only official, - * supported endpoints (docs/server-providers.md §6): - * - * - GET {base}/status.php (probe) - * - login flow v2 (see [NextcloudAuth]) - * - GET {base}/ocs/v2.php/cloud/user (identity + quota) - * - DELETE {base}/ocs/v2.php/core/apppassword (revoke own app password) - * - WebDAV {base}/remote.php/dav/files/{user}/… (PROPFIND/GET/PUT/MKCOL/MOVE/DELETE) - * - Chunked upload v2 {base}/remote.php/dav/uploads/{user}/{transfer}/ - * (MKCOL, PUT chunks 00001..N, MOVE {transfer}/.file -> destination) - * - * Layout: `SHONAR/audio/{uuid}.m4a`, `SHONAR/sidecars/{uuid}/{kind}.json`. - * Originals are never overwritten by processing artifacts — uploads with - * the same draft id MOVE onto the same key (idempotent replace). - * - * Chunk naming follows the developer manual: chunks are numbered 1..10000 - * and assembled in name order, so names are zero-padded to 5 digits - * ("00001".."10000") to keep lexical order == numeric order. Chunk size - * defaults to 16 MiB — the server requires 5 MiB..5 GiB per chunk (last - * chunk exempt), so never lower the default for production use; the - * constructor parameter exists for tests only. - * - * Transfer ids are deterministic per recording (`shonar-{draft.id}`), so a - * killed upload resumes by PROPFIND-ing the transfer folder and skipping - * present chunks — including across process restarts. - */ -class NextcloudProvider( - private val auth: NextcloudAuthStore, - private val client: OkHttpClient = NextcloudAuth.defaultClient(), - private val loginFlow: NextcloudAuth = NextcloudAuth(client), - private val chunkSizeBytes: Long = 16 * 1024 * 1024, -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ProviderRegistry.NEXTCLOUD_ID, - displayName = "Nextcloud", - isDefault = true, - capabilities = setOf( - ProviderDescriptor.Capability.CHUNKED_UPLOAD, - ProviderDescriptor.Capability.QUOTA_INFO, - ), - ) - - private val _authState = MutableStateFlow(AuthState.DISCONNECTED) - override val authState: StateFlow = _authState - - // ---- 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 = 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 { - 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 = - withContext(Dispatchers.IO) { - val body = """ -""" - 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 { - if (xml.isBlank()) return emptyList() - val out = mutableListOf() - 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) - } - } - } -} diff --git a/shared/com/shonar/provider/ProviderRegistry.kt b/shared/com/shonar/provider/ProviderRegistry.kt index d11895b..6d8d504 100644 --- a/shared/com/shonar/provider/ProviderRegistry.kt +++ b/shared/com/shonar/provider/ProviderRegistry.kt @@ -1,96 +1,11 @@ package com.shonar.provider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - /** - * Maps provider ids to implementations. The active provider is whichever the - * user selected during setup; switching is just changing this id — the app - * never branches on concrete provider classes. + * Provider ids. The desktop app talks to exactly one service — the local + * SHONAR engine — so this is just the id constant its RemoteRefs carry. + * (The phone's multi-provider registry — Nextcloud, local folder, sync + * folder — was removed; the app is standalone desktop.) */ -class ProviderRegistry( - private val factories: Map ShonarProvider>, - /** Builds every provider HTTP client (TOFU trust + redacting logger). */ - val tls: TlsPolicy = defaultTls(), -) { - - private val _activeId = MutableStateFlow(LocalOnlyProvider.ID) - val activeId: StateFlow = _activeId - - /** Nextcloud is the product default once its factory registers (P4). */ - val defaultProviderId: String - get() = factories.keys.sorted().let { ids -> - ids.firstOrNull { it == NEXTCLOUD_ID } ?: ids.firstOrNull() ?: LocalOnlyProvider.ID - } - - fun provider(id: String): ShonarProvider = - factories[id]?.invoke() ?: throw ProviderError.InvalidUrl("Unknown provider: $id") - - val active: ShonarProvider get() = provider(_activeId.value) - - /** Selecting a provider does not touch existing local data. */ - fun select(id: String): ShonarProvider { - if (id !in factories) throw ProviderError.InvalidUrl("Unknown provider: $id") - _activeId.value = id - return provider(id) - } - - fun available(): List = - factories.keys.map { provider(it).descriptor } - - companion object { - const val NEXTCLOUD_ID = "nextcloud" - const val CUSTOM_SHONAR_ID = "custom-shonar" - const val SYNC_FOLDER_ID = FolderSyncProvider.ID - - /** Behaviour-identical default: system trust, logging off, no pins. */ - fun defaultTls(sink: (String) -> Unit = {}): TlsPolicy = TlsPolicy( - tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), - sink = sink, - ) - - /** - * Production factory map. P1 registered local-only, P3 the custom - * SHONAR server, P4 Nextcloud + the sync folder. Network providers - * are single shared instances (their sessions live in the secure - * store, not in the object) so connect/reconnect state survives - * `provider(id)` calls. P5: every HTTP client comes from [tlsPolicy]. - */ - fun withDefaults( - appFilesDir: java.io.File, - secureStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), - plainStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), - tlsPolicy: TlsPolicy = defaultTls(), - ): ProviderRegistry { - val custom = CustomShonarProvider( - auth = ShonarAuthStore(secureStore), - sidecarRoot = java.io.File(appFilesDir, "shonar-sidecars"), - client = tlsPolicy.apiClient(), - handshake = ShonarHandshake(tlsPolicy.probeClient(), tlsPolicy.tofu), - ) - val nextcloud = NextcloudProvider( - auth = NextcloudAuthStore(secureStore), - client = tlsPolicy.nextcloudClient(), - loginFlow = NextcloudAuth(tlsPolicy.nextcloudClient(), tlsPolicy.tofu), - ) - val folder = FolderSyncProvider(pathStore = plainStore) - return ProviderRegistry( - mapOf( - LocalOnlyProvider.ID to { - LocalOnlyProvider( - java.io.File(appFilesDir, "shonar-local"), - pathStore = plainStore, - ) - }, - CUSTOM_SHONAR_ID to { custom }, - NEXTCLOUD_ID to { nextcloud }, - SYNC_FOLDER_ID to { folder }, - // P6: start9 / umbrel platform probes - ), - tls = tlsPolicy, - ) - } - } +object ProviderRegistry { + const val CUSTOM_SHONAR_ID = "custom-shonar" } diff --git a/shared/com/shonar/provider/ShonarHandshake.kt b/shared/com/shonar/provider/ShonarHandshake.kt index 522095a..64b1c5e 100644 --- a/shared/com/shonar/provider/ShonarHandshake.kt +++ b/shared/com/shonar/provider/ShonarHandshake.kt @@ -21,7 +21,6 @@ import javax.net.ssl.SSLHandshakeException */ class ShonarHandshake( private val client: OkHttpClient = defaultClient(), - private val tofu: TofuManager? = null, ) { suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) { @@ -74,12 +73,11 @@ class ShonarHandshake( } /** - * SPKI SHA-256 recorded by the TOFU trust manager during the failed - * handshake, or "unknown" when nothing was captured (plain HTTP, - * pre-handshake failure, or no TOFU manager wired). + * SPKI SHA-256 captured during the failed handshake. The desktop talks + * to a local engine over plain HTTP, so this is a placeholder kept for + * the ProbeResult contract. */ - private fun fingerprintFor(host: String): String = - tofu?.failureFor(host)?.spkiHex ?: "unknown" + private fun fingerprintFor(host: String): String = "unknown" companion object { fun defaultClient(): OkHttpClient = OkHttpClient.Builder() diff --git a/shared/com/shonar/provider/SyncState.kt b/shared/com/shonar/provider/SyncState.kt deleted file mode 100644 index 02cda21..0000000 --- a/shared/com/shonar/provider/SyncState.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.shonar.provider - -/** - * Sync lifecycle for one local recording against the active provider - * (docs/server-providers.md §2). P3 defines the vocabulary and the legal - * transitions; the WorkManager driver that moves recordings through it is - * M5 — until then uploads go through [ShonarProvider.upload] directly and - * land in UPLOADED. - * - * Pausing/canceling is a state, not a job kill: QUEUED and ERROR are stable - * resting states a later run resumes from. - */ -enum class SyncState { - LOCAL_ONLY, // provider is local-only, or user never enabled sync - QUEUED, // waiting for constraints (network, Wi-Fi-only, charging-only) - UPLOADING, // bytes in flight (resumable via the provider's session) - UPLOADED, // audio on the server; sidecars may still be pending - SYNCED, // audio + all sidecars confirmed server-side - ERROR, // failed; [SyncStatus.reasonCode] says why, [SyncStatus.retryAtEpochMs] when -} - -data class SyncStatus( - val state: SyncState, - val retryAtEpochMs: Long? = null, - val reasonCode: String? = null, -) - -/** - * Legal transitions. Anything not listed here is a programming error, not - * a state the UI should ever render. - */ -fun SyncStatus.canTransitionTo(next: SyncState): Boolean = when (state) { - SyncState.LOCAL_ONLY -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY - SyncState.QUEUED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY || - next == SyncState.ERROR - SyncState.UPLOADING -> next == SyncState.UPLOADED || next == SyncState.QUEUED || - next == SyncState.ERROR || next == SyncState.LOCAL_ONLY - SyncState.UPLOADED -> next == SyncState.SYNCED || next == SyncState.UPLOADING || - next == SyncState.ERROR || next == SyncState.LOCAL_ONLY - SyncState.SYNCED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY - SyncState.ERROR -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY -} diff --git a/shared/com/shonar/provider/TlsPolicy.kt b/shared/com/shonar/provider/TlsPolicy.kt deleted file mode 100644 index 73eff4f..0000000 --- a/shared/com/shonar/provider/TlsPolicy.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.shonar.provider - -import java.util.concurrent.TimeUnit -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Response - -/** - * P5: builds every provider HTTP client with the TOFU trust manager and the - * redacting logger (docs/server-providers.md §4). One place, so no call - * site can accidentally build a client that skips either. - * - * The log [sink] is an explicit constructor argument (no platform default) - * so this file compiles on Android and JVM desktop alike; each platform - * passes its own sink (Logcat vs stdout). - */ -class TlsPolicy( - val tofu: TofuManager, - private val bodiesEnabled: () -> Boolean = { false }, - private val sink: (String) -> Unit, -) { - fun newClient( - connectTimeoutS: Long, - readTimeoutS: Long, - writeTimeoutS: Long = readTimeoutS, - followRedirects: Boolean = true, - ): OkHttpClient { - val tm = tofu.trustManager - val sslContext = javax.net.ssl.SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(tm), null) - return OkHttpClient.Builder() - .sslSocketFactory(sslContext.socketFactory, tm) - .connectTimeout(connectTimeoutS, TimeUnit.SECONDS) - .readTimeout(readTimeoutS, TimeUnit.SECONDS) - .writeTimeout(writeTimeoutS, TimeUnit.SECONDS) - .followRedirects(followRedirects) - .addInterceptor(RedactingLogger(bodiesEnabled, sink)) - .build() - } - - /** P3-era API shape (15/60/60s). */ - fun apiClient(): OkHttpClient = newClient(15, 60, 60) - - /** Short probing shape (10/15s, no redirects). */ - fun probeClient(): OkHttpClient = newClient(10, 15, 15, followRedirects = false) - - /** Nextcloud shape (10/20/60s, no redirects). */ - fun nextcloudClient(): OkHttpClient = newClient(10, 20, 60, followRedirects = false) -} - -/** - * Logging is OFF by default; when the `log_http_bodies` debug setting is - * on, requests log in redacted form. Invariants (leak-tested): - * - Authorization / Cookie / Set-Cookie headers are never logged. - * - bodies log only for JSON/XML/text under [MAX_BODY] bytes; audio and - * other binary bodies never log. - * - token-shaped JSON values (`…token…`, `password`, `appPassword`) are - * masked, and Basic credentials are masked, even inside bodies. - */ -class RedactingLogger( - private val bodiesEnabled: () -> Boolean, - private val sink: (String) -> Unit, -) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val sensitive = chain.request().header(SENSITIVE_BODY) != null - val req = chain.request().newBuilder().removeHeader(SENSITIVE_BODY).build() - if (!bodiesEnabled()) return chain.proceed(req) - val t0 = System.currentTimeMillis() - val reqBody = req.body - val reqLen = reqBody?.contentLength()?.takeIf { it >= 0 } - sink("→ ${req.method} ${req.url.host}${req.url.encodedPath} body=${reqLen?.let { "$it B" } ?: "?"}") - for (i in 0 until req.headers.size) { - val name = req.headers.name(i) - sink(" $name: ${if (isSensitiveHeader(name)) "[redacted]" else req.headers.value(i)}") - } - if (sensitive) { - sink(" request-body: [sensitive, not logged]") - } else { - logRequestBody(reqBody?.contentType()?.toString(), reqBody) - } - try { - val resp = chain.proceed(req) - val ms = System.currentTimeMillis() - t0 - sink("← ${resp.code} ${req.url.encodedPath} (${ms}ms)") - if (sensitive) { - sink(" response-body: [sensitive, not logged]") - return resp - } - val peek = resp.peekBody(MAX_BODY + 1) - logBody( - " response", - peek.contentType()?.toString(), - peek.bytes().toList(), - ) - return resp - } catch (e: Exception) { - sink("✕ ${req.url.encodedPath} failed (${e.javaClass.simpleName})") - throw e - } - } - - /** - * Request bodies need a size gate BEFORE reading: uploads are file - * bodies that must never be buffered just to log a prefix of them. - */ - private fun logRequestBody(contentType: String?, body: okhttp3.RequestBody?) { - if (body == null) { - sink(" request-body: none") - return - } - val len = try { - body.contentLength() - } catch (e: Exception) { - -1L - } - if (len < 0 || len > MAX_BODY) { - val what = if (len < 0) "streaming" else "$len bytes" - sink(" request-body: [$what, not logged]") - return - } - val bytes = try { - val buf = okio.Buffer() - body.writeTo(buf) - buf.readByteArray().toList() - } catch (e: Exception) { - null - } - logBody(" request", contentType, bytes) - } - - private fun logBody(prefix: String, contentType: String?, bytes: List?) { - 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 - } - } -} diff --git a/shared/com/shonar/provider/TofuTrust.kt b/shared/com/shonar/provider/TofuTrust.kt deleted file mode 100644 index f684b10..0000000 --- a/shared/com/shonar/provider/TofuTrust.kt +++ /dev/null @@ -1,287 +0,0 @@ -package com.shonar.provider - -import com.shonar.settings.SettingsStore -import java.net.Socket -import java.security.MessageDigest -import java.security.cert.CertificateException -import java.security.cert.X509Certificate -import java.util.concurrent.ConcurrentHashMap -import javax.net.ssl.SSLEngine -import javax.net.ssl.SSLSession -import javax.net.ssl.X509ExtendedTrustManager -import javax.net.ssl.X509TrustManager - -/** - * P5: trust-on-first-use (TOFU) for self-signed LAN servers - * (docs/server-providers.md §4). - * - * Policy, enforced by construction: - * - system CAs are always tried first; TOFU pins are a fallback, never a - * replacement. A host that later gets a real certificate just works. - * - pins are per-host: an approved cert for `nas.local` is trusted ONLY - * when `nas.local` presents it. Byte-equality on the leaf DER, so a - * rotation needs a fresh approval (correct TOFU semantics). - * - nothing is ever trusted silently: the first failure only RECORDS the - * chain, and trust requires an explicit [TofuManager.approve] call from - * a UI that showed the fingerprint. - * - the normal TLS hostname verifier stays strict; TOFU covers unknown - * CAs, not name mismatches. - */ -class TofuStore(private val secure: SettingsStore) { - - suspend fun addPin(host: String, derBase64: String) { - val pins = pins(host).toMutableSet() - pins += derBase64 - secure.putString(pinKey(host), org.json.JSONArray(pins.toList()).toString()) - val hosts = hosts().toMutableSet() - if (hosts.add(host.lowercase())) { - secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString()) - } - } - - suspend fun pins(host: String): Set { - 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 { - 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, 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, authType: String, socket: Socket, - ) { - system.checkClientTrusted(chain, authType) - } - - override fun checkClientTrusted( - chain: Array, authType: String, engine: SSLEngine, - ) { - system.checkClientTrusted(chain, authType) - } - - override fun checkServerTrusted(chain: Array, authType: String) { - checkServerTrusted(chain, authType, host = null) - } - - override fun checkServerTrusted( - chain: Array, 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, 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, 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 = 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>() - private val failures = ConcurrentHashMap() - - 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>() - 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 = 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().first() - } - } -} diff --git a/shared/com/shonar/recording/MigrationRunner.kt b/shared/com/shonar/recording/MigrationRunner.kt deleted file mode 100644 index 89541e2..0000000 --- a/shared/com/shonar/recording/MigrationRunner.kt +++ /dev/null @@ -1,89 +0,0 @@ -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, 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() - 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 - } - } -} diff --git a/shared/com/shonar/recording/PlaybackController.kt b/shared/com/shonar/recording/PlaybackController.kt deleted file mode 100644 index f45b32a..0000000 --- a/shared/com/shonar/recording/PlaybackController.kt +++ /dev/null @@ -1,328 +0,0 @@ -package com.shonar.recording - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.media.MediaPlayer -import android.os.Build -import androidx.core.app.NotificationCompat -import com.shonar.MainActivity -import com.shonar.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -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.isActive -import kotlinx.coroutines.launch - -/** - * The single audio player for the whole app. Home rows and the details - * screen share it, so what you hear is always what the UI shows — no - * phantom playback with no stop control. - * - * - One MediaPlayer at a time; a generation counter ignores stale async - * callbacks (tap A while B is still preparing, etc.). - * - Position polling (250ms) and speed live here; both screens bind it. - * - An ongoing "Now playing" notification carries a Stop action, so audio - * can always be silenced from the shade even with the app closed. - * - Audio-focus loss (call, assistant) pauses; never auto-resumes. - */ -class PlaybackController(private val app: Context) { - - data class State( - val id: String, - val title: String, - val playing: Boolean, - val positionMs: Long, - val durationMs: Long, - val speed: Float, - val error: String? = null, - ) - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val _state = MutableStateFlow(null) - val state: StateFlow = _state.asStateFlow() - - private var player: MediaPlayer? = null - private var generation = 0 - private var poll: Job? = null - private var audioManager: AudioManager? = null - private var focusRequest: AudioFocusRequest? = null - - companion object { - const val ACTION_STOP = "com.shonar.playback.STOP" - const val CHANNEL_ID = "shonar_playback" - const val NOTIFICATION_ID = 1002 - val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f) - - fun stopIntent(ctx: Context): PendingIntent { - val i = Intent(ctx, PlaybackStopReceiver::class.java).setAction(ACTION_STOP) - return PendingIntent.getBroadcast( - ctx, 61, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } - } - - /** Play/pause toggle for [id]. Anything else playing stops first. */ - fun toggle(id: String, title: String, filePath: String) { - val cur = _state.value - if (cur != null && cur.id == id) { - val p = player - if (p != null && cur.playing) { - pauseLocked() - } else if (p != null) { - runCatching { applySpeed(p, cur.speed); p.start() } - _state.value = cur.copy(playing = true, error = null) - startPolling() - pushNotification() - } - return - } - startNew(id, title, filePath, cur?.speed ?: 1f) - } - - fun seekTo(positionMs: Long) { - val cur = _state.value ?: return - val clamped = positionMs.coerceIn(0, cur.durationMs.coerceAtLeast(1)) - runCatching { player?.seekTo(clamped.toInt()) } - _state.value = cur.copy(positionMs = clamped) - } - - fun cycleSpeed() { - val cur = _state.value ?: return - val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size] - player?.let { applySpeed(it, next) } - _state.value = cur.copy(speed = next) - } - - fun stop() { - generation++ - poll?.cancel() - poll = null - runCatching { - player?.apply { - runCatching { stop() } - release() - } - } - player = null - abandonFocus() - _state.value = null - cancelNotification() - } - - /** Call when the owning process goes down for good (tests). */ - fun release() { - stop() - scope.cancel() - } - - // ---- internals -------------------------------------------------------- - - private fun startNew(id: String, title: String, filePath: String, speed: Float) { - val gen = ++generation - poll?.cancel() - poll = null - runCatching { player?.release() } - player = null - val mp: MediaPlayer - try { - mp = MediaPlayer().apply { - setDataSource(filePath) - setOnPreparedListener { prepared -> - if (gen != generation) { - runCatching { prepared.release() } - return@setOnPreparedListener - } - requestFocus() - applySpeed(prepared, speed) - runCatching { prepared.start() } - val dur = runCatching { prepared.duration.toLong() }.getOrDefault(1L) - .coerceAtLeast(1) - _state.value = State(id, title, playing = true, 0, dur, speed) - startPolling() - pushNotification() - } - setOnCompletionListener { - if (gen != generation) { - runCatching { it.release() } - return@setOnCompletionListener - } - stop() - } - setOnErrorListener { _, _, _ -> - if (gen == generation) { - _state.value = State(id, title, false, 0, 1, speed, "Could not play this file.") - cancelNotification() - } - true - } - prepareAsync() - } - } catch (_: Exception) { - if (gen == generation) { - _state.value = State(id, title, false, 0, 1, speed, "Could not play this file.") - } - return - } - player = mp - // Tentative row state so a second tap lands on this session, not a new one. - _state.value = State(id, title, false, 0, 1, speed) - } - - private fun pauseLocked() { - val cur = _state.value ?: return - val p = player ?: return - runCatching { if (p.isPlaying) p.pause() } - poll?.cancel() - poll = null - _state.value = cur.copy(playing = false, positionMs = currentPosition()) - pushNotification() - } - - private fun currentPosition(): Long = - runCatching { player?.currentPosition?.toLong() }.getOrNull() - ?: _state.value?.positionMs ?: 0L - - private fun applySpeed(p: MediaPlayer, speed: Float) { - runCatching { - val params = if (Build.VERSION.SDK_INT >= 23) { - p.playbackParams ?: android.media.PlaybackParams() - } else { - return - } - p.playbackParams = params.apply { this.speed = speed } - } - } - - private fun startPolling() { - poll?.cancel() - poll = scope.launch { - while (isActive) { - delay(250) - val p = player ?: break - if (!runCatching { p.isPlaying }.getOrDefault(false)) break - val cur = _state.value ?: break - _state.value = cur.copy( - positionMs = runCatching { p.currentPosition.toLong() }.getOrDefault(cur.positionMs), - durationMs = runCatching { p.duration.toLong() }.getOrDefault(cur.durationMs).coerceAtLeast(1), - ) - } - } - } - - private val focusListener = AudioManager.OnAudioFocusChangeListener { change -> - when (change) { - AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseLocked() - else -> Unit - } - } - - private fun requestFocus() { - val am = app.getSystemService(AudioManager::class.java) ?: return - audioManager = am - if (Build.VERSION.SDK_INT >= 26) { - val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - .setOnAudioFocusChangeListener(focusListener) - .build() - focusRequest = req - runCatching { am.requestAudioFocus(req) } - } else { - @Suppress("DEPRECATION") - runCatching { am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) } - } - } - - private fun abandonFocus() { - val am = audioManager ?: return - if (Build.VERSION.SDK_INT >= 26) { - focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } } - focusRequest = null - } else { - @Suppress("DEPRECATION") - runCatching { am.abandonAudioFocus(focusListener) } - } - audioManager = null - } - - // ---- notification ----------------------------------------------------- - - private fun notifications(): NotificationManager = - app.getSystemService(NotificationManager::class.java) - - private fun ensureChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val mgr = notifications() - if (runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() == null) { - mgr.createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW) - ) - } - } - - private fun pushNotification() { - val s = _state.value ?: return cancelNotification() - ensureChannel() - val openApp = PendingIntent.getActivity( - app, 60, - Intent(app, MainActivity::class.java) - .setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - val notif: Notification = NotificationCompat.Builder(app, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_mic) - .setContentTitle(if (s.playing) "Playing" else "Paused") - .setContentText(s.title) - .setOngoing(s.playing) - .setOnlyAlertOnce(true) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(openApp) - .setShowWhen(false) - .addAction(R.drawable.ic_notif_close, "Stop", stopIntent(app)) - .build() - runCatching { notifications().notify(NOTIFICATION_ID, notif) } - } - - private fun cancelNotification() { - runCatching { notifications().cancel(NOTIFICATION_ID) } - } -} - -/** Shade "Stop" button: silences playback even with the app closed. */ -class PlaybackStopReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != PlaybackController.ACTION_STOP) return - val app = context.applicationContext - // Route through the shared controller when the process is alive… - runCatching { (app as? PlaybackOwner)?.playback?.stop() } - // …and always clear the notification (covers a restarted process - // holding no player while the card is still posted). - runCatching { - (app.getSystemService(NotificationManager::class.java) as NotificationManager) - .cancel(PlaybackController.NOTIFICATION_ID) - } - } -} - -/** Implemented by [com.shonar.ShonarApplication]: one controller per process. */ -interface PlaybackOwner { - val playback: PlaybackController -} diff --git a/shared/com/shonar/recording/RecordingDao.kt b/shared/com/shonar/recording/RecordingDao.kt deleted file mode 100644 index 22a86ad..0000000 --- a/shared/com/shonar/recording/RecordingDao.kt +++ /dev/null @@ -1,29 +0,0 @@ -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> - - @Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC") - suspend fun getAll(): List - - @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) -} diff --git a/shared/com/shonar/recording/RecordingEntity.kt b/shared/com/shonar/recording/RecordingEntity.kt deleted file mode 100644 index aa8ac3d..0000000 --- a/shared/com/shonar/recording/RecordingEntity.kt +++ /dev/null @@ -1,38 +0,0 @@ -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, -) diff --git a/shared/com/shonar/recording/RecordingFiles.kt b/shared/com/shonar/recording/RecordingFiles.kt deleted file mode 100644 index dfa7b92..0000000 --- a/shared/com/shonar/recording/RecordingFiles.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.shonar.recording - -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * All filename policy in one place. Storage choice: app-specific - * `filesDir` (default) or the user-picked folder (local-only provider). - * MediaStore is intentionally NOT used: recordings must survive uninstall - * control, stay off the shared music library, and never need - * READ_MEDIA_AUDIO for our own files. Import scan in - * [RecordingRepository.importExistingFiles] adopts externally added files. - */ -object RecordingFiles { - const val EXT = "m4a" - const val MIME = "audio/mp4" - private const val MAX_STEM = 120 - - fun defaultDisplayName(nowMs: Long = System.currentTimeMillis()): String { - val s = SimpleDateFormat("yyyy-MM-dd HH-mm", Locale.US).format(Date(nowMs)) - return "Shonar Recording - $s" - } - - /** Null = blank / "." / ".." / nothing usable after cleaning. */ - fun sanitizeStem(raw: String): String? { - var stem = raw.trim() - .replace(Regex("[/\\\\]"), "_") - .replace(Regex("\\p{Cntrl}"), "") - .replace(Regex("\\s+"), " ").trim() - // Windows-hostile + MediaStore-hostile chars. - stem = stem.replace(Regex("[<>:\"|?*]"), "_").trim() - // No leading dots (hidden files) / trailing dots-spaces (Windows trim). - stem = stem.trim('.', ' ', '\t').trim() - if (stem.isEmpty() || stem == "." || stem == "..") return null - if (stem.length > MAX_STEM) stem = stem.take(MAX_STEM).trimEnd() - return stem.ifEmpty { null } - } - - /** Never overwrite: "Name.m4a", "Name (2).m4a", ... */ - fun uniqueFinalFile(dir: File, stem: String, ext: String = EXT): File { - var target = File(dir, "$stem.$ext") - var n = 1 - while (target.exists()) { - n++ - target = File(dir, "$stem ($n).$ext") - } - return target - } - - /** Sweep crashed sessions: files in .in-progress older than 24h or orphaned. */ - fun sweepOrphanedTempFiles(libraryRoot: File, validId: String?): Int { - val tmp = File(libraryRoot, ".in-progress") - if (!tmp.isDirectory) return 0 - var removed = 0 - val cutoff = System.currentTimeMillis() - 24 * 3600 * 1000L - tmp.listFiles()?.forEach { f -> - if (!f.isFile) return@forEach - val orphan = validId == null || f.nameWithoutExtension != validId - if (orphan && f.lastModified() < cutoff) { - if (f.delete()) removed++ - } - } - return removed - } -} diff --git a/shared/com/shonar/recording/RecordingNotificationHelper.kt b/shared/com/shonar/recording/RecordingNotificationHelper.kt deleted file mode 100644 index 31b40a2..0000000 --- a/shared/com/shonar/recording/RecordingNotificationHelper.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.shonar.recording - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.widget.RemoteViews -import androidx.core.app.NotificationCompat -import com.shonar.MainActivity -import com.shonar.R - -/** - * Single custom card — Image 1 is the ONLY view. - * mic | "Recording" + filename | big timer, then X / pause / check pills. - * Same RemoteViews for collapsed AND expanded, so there is no duality: - * the default view already has full controls. - * Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never - * the dark media-player template. - * - * LOCKED 2026-09-14 (user-verified on-device): do NOT revert build() to - * system MediaStyle — that renders the small "SHONAR • SHONAR" text row - * with truncated filename and hides the big timer. Any layout change here - * must keep the custom RemoteViews card + big timer, and must keep - * RecordingNotificationLayoutTest green. - */ -object RecordingNotificationHelper { - const val CHANNEL_ID = "shonar_recording_ongoing" - private const val LEGACY_CHANNEL_ID = "shonar_recording" - const val NOTIFICATION_ID = 1001 - - fun ensureChannel(ctx: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val mgr = ctx.getSystemService(NotificationManager::class.java) - runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) } - val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() - if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH || - existing.sound != null - ) { - runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) } - } - mgr.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - ctx.getString(R.string.notif_channel_recording), - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = ctx.getString(R.string.notif_channel_recording_desc) - setSound(null, null) - enableVibration(false) - }, - ) - } - - private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent { - val i = Intent(ctx, RecordingService::class.java).setAction(action) - return PendingIntent.getService( - ctx, req, i, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } - - @JvmOverloads - fun build( - ctx: Context, - phase: RecordingSnapshot.Phase, - elapsedMs: Long, - fileName: String = "Shonar", - confirmingDiscard: Boolean = false, - ): Notification { - val openApp = PendingIntent.getActivity( - ctx, 10, - Intent(ctx, MainActivity::class.java) - .setAction(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - if (confirmingDiscard) return buildConfirm(ctx, openApp) - val paused = phase == RecordingSnapshot.Phase.PAUSED - val elapsed = formatElapsed(elapsedMs) - - val card = RemoteViews(ctx.packageName, R.layout.notification_recording) - card.setTextViewText(R.id.notif_title, "Recording") - card.setTextViewText(R.id.notif_subtitle, fileName) - card.setTextViewText(R.id.notif_timer, elapsed) - card.setImageViewResource( - R.id.notif_btn_pause, - if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, - ) - card.setOnClickPendingIntent( - R.id.notif_btn_discard, - svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21), - ) - card.setOnClickPendingIntent( - R.id.notif_btn_pause, - svcIntent( - ctx, - if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22, - ), - ) - card.setOnClickPendingIntent( - R.id.notif_btn_save, - svcIntent(ctx, RecordingService.ACTION_SAVE, 24), - ) - card.setOnClickPendingIntent(R.id.notif_icon, openApp) - - return NotificationCompat.Builder(ctx, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_mic) - .setContentTitle("Recording") - .setContentText("$fileName • $elapsed") - .setSubText("SHONAR") - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(openApp) - .setShowWhen(false) - .setPriority(NotificationCompat.PRIORITY_HIGH) - // Fallback actions (Android Auto, watches, if custom is ever stripped). - .addAction( - R.drawable.ic_notif_close, "Discard", - svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21), - ) - .addAction( - if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, - if (paused) "Resume" else "Pause", - svcIntent( - ctx, - if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22, - ), - ) - .addAction( - R.drawable.ic_notif_check, "Save", - svcIntent(ctx, RecordingService.ACTION_SAVE, 24), - ) - // Same card for collapsed + big: no expanded duality. - .setCustomContentView(card) - .setCustomBigContentView(card) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) - .build() - } - - /** "Delete current recording? No / Yes" — same single-card pattern. */ - private fun buildConfirm(ctx: Context, openApp: PendingIntent): Notification { - val card = RemoteViews(ctx.packageName, R.layout.notification_confirm_delete) - card.setOnClickPendingIntent( - R.id.notif_btn_no, svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 41), - ) - card.setOnClickPendingIntent( - R.id.notif_btn_yes, svcIntent(ctx, RecordingService.ACTION_CANCEL, 42), - ) - card.setOnClickPendingIntent(R.id.notif_icon, openApp) - return NotificationCompat.Builder(ctx, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_mic) - .setContentTitle("Delete current recording?") - .setContentText("Shonar") - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(openApp) - .setShowWhen(false) - .setCustomContentView(card) - .setCustomBigContentView(card) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .addAction( - R.drawable.ic_notif_close, "No", - svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 43), - ) - .addAction( - R.drawable.ic_notif_check, "Yes", - svcIntent(ctx, RecordingService.ACTION_CANCEL, 44), - ) - .build() - } - - fun formatElapsed(ms: Long): String { - val s = (ms / 1000).coerceAtLeast(0) - val h = s / 3600 - val m = (s % 3600) / 60 - val sec = s % 60 - return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec) - } -} diff --git a/shared/com/shonar/recording/RecordingPermissionHelper.kt b/shared/com/shonar/recording/RecordingPermissionHelper.kt deleted file mode 100644 index 1203d32..0000000 --- a/shared/com/shonar/recording/RecordingPermissionHelper.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.shonar.recording - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat - -/** - * Version matrix (12/13/14+): - * - RECORD_AUDIO: runtime on all versions, required before MediaRecorder. - * - POST_NOTIFICATIONS (API 33+): runtime; without it the foreground - * service + widget still work, but the notification is suppressed by - * the system. We keep recording + show in-app/widget state. - * - FOREGROUND_SERVICE (API 28+ manifest) + FOREGROUND_SERVICE_MICROPHONE - * (API 30+ manifest, enforced 34+): no runtime grant; declare in - * manifest + pass microphone FGS type at startForeground(). - * - Background start (API 31+): a widget tap (system-bound PendingIntent) - * is an exempted FGS start; an in-app tap uses startForegroundService. - * - Microphone-in-use indicator (API 29+ green dot) is system-owned. - */ -object RecordingPermissionHelper { - fun hasRecordAudio(c: Context): Boolean = - ContextCompat.checkSelfPermission(c, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - fun hasPostNotifications(c: Context): Boolean { - if (Build.VERSION.SDK_INT < 33) return true - return ContextCompat.checkSelfPermission(c, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED - } - - fun requiredMissing(c: Context): List { - val out = mutableListOf() - if (!hasRecordAudio(c)) out += Manifest.permission.RECORD_AUDIO - if (Build.VERSION.SDK_INT >= 33 && !hasPostNotifications(c)) { - out += Manifest.permission.POST_NOTIFICATIONS - } - return out - } -} diff --git a/shared/com/shonar/recording/RecordingRepository.kt b/shared/com/shonar/recording/RecordingRepository.kt deleted file mode 100644 index 66c0a54..0000000 --- a/shared/com/shonar/recording/RecordingRepository.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.shonar.recording - -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( - appFilesDir: File, - private val dao: RecordingDao, - /** - * Where new recordings go. Defaults to the app-private library; the app - * wires the active local provider's picked folder here. Cached in - * [libraryRoot] (refreshed via [refreshRoot]) so the recording service — - * which must hand MediaRecorder a path synchronously — never suspends. - */ - private val rootResolver: (suspend () -> File?)? = null, -) { - private val slots = SyncSlots(dao) - private val sandboxDir: File = appFilesDir - private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() } - - @Volatile private var libraryRoot: File = legacyDir - private val recordingsDir: File get() = libraryRoot - private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() } - - val recordings: Flow> = dao.observeAll() - - fun newRecordingId(): String = UUID.randomUUID().toString() - - /** Re-read the destination (call at startup and after a location change). */ - suspend fun refreshRoot() { - libraryRoot = runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir - libraryRoot.mkdirs() - importExistingFiles() - } - - /** - * Adopt audio files already sitting in the library folder (put there by - * another app, USB, …). Flat scan, top level only; dotfiles and the - * in-progress dir are skipped. Files already tracked by path are left - * alone — renaming a file re-imports it as new, which is honest. - * Nothing is copied, moved, or modified. - */ - suspend fun importExistingFiles() { - val root = libraryRoot - val known = dao.getAll().mapNotNullTo(mutableSetOf()) { - runCatching { File(it.filePath).canonicalPath }.getOrNull() - } - val files = root.listFiles() - ?.filter { f -> - f.isFile && !f.name.startsWith(".") && - f.extension.lowercase() in AUDIO_MIME_BY_EXTENSION && - runCatching { f.canonicalPath }.getOrNull()?.let { it !in known } == true - } - ?.sortedBy { it.name.lowercase() } - .orEmpty() - for (f in files) { - val ext = f.extension.lowercase() - dao.insert( - RecordingEntity( - id = UUID.randomUUID().toString(), - title = f.nameWithoutExtension, - createdAtEpochMs = f.lastModified().takeIf { it > 0 } - ?: System.currentTimeMillis(), - durationMs = audioDurationMs(f).coerceAtLeast(1L), - filePath = f.absolutePath, - mimeType = AUDIO_MIME_BY_EXTENSION[ext] ?: "audio/mp4", - sizeBytes = f.length(), - ), - ) - known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath - } - } - - private fun audioDurationMs(f: File): Long = runCatching { - android.media.MediaMetadataRetriever().use { r -> - r.setDataSource(f.absolutePath) - r.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION) - ?.toLongOrNull() ?: 0L - } - }.getOrDefault(0L) - - 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(), - ), - ) - } - - /** - * Rename the library entry AND its audio file (same folder, extension - * kept). A colliding name gets a " (2)" suffix — nothing is ever - * overwritten. Throws on a blank/invalid name or a missing file. - */ - suspend fun rename(recording: RecordingEntity, newTitle: String): RecordingEntity { - val stem = sanitizeFileStem(newTitle) - ?: throw IllegalArgumentException("Not a valid name.") - val current = File(recording.filePath) - require(current.isFile) { "Original file is gone." } - val parent = current.parentFile - ?: throw IllegalArgumentException("Cannot rename here.") - val ext = current.extension.ifBlank { "m4a" } - var target = File(parent, "$stem.$ext") - var n = 1 - while (target.exists() && target.canonicalPath != current.canonicalPath) { - n++ - target = File(parent, "$stem ($n).$ext") - } - if (target.canonicalPath != current.canonicalPath && !current.renameTo(target)) { - throw java.io.IOException("Could not rename the file.") - } - val updated = recording.copy( - title = target.nameWithoutExtension, - filePath = target.absolutePath, - sizeBytes = target.length(), - ) - dao.update(updated) - return updated - } - - suspend fun delete(recording: RecordingEntity) { - val canonicalFile = File(recording.filePath).canonicalFile - // Deletable when it lives under the app sandbox or the current - // library folder (older clips may predate a location change). - val allowed = listOf(sandboxDir.canonicalFile, libraryRoot.canonicalFile) - require(allowed.any { canonicalFile.toPath().startsWith(it.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) - - companion object { - /** Extensions adopted by [importExistingFiles] (mirrors backend magic check). */ - val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4", - "mp4" to "audio/mp4", - "aac" to "audio/aac", - "wav" to "audio/wav", - "ogg" to "audio/ogg", - "opus" to "audio/ogg", - "webm" to "audio/webm", - "mp3" to "audio/mpeg", - ) - - /** - * Clean a user-typed name into a safe single file stem, or null when - * it has nothing usable. Pure — unit-tested. - */ - fun sanitizeFileStem(raw: String): String? { - var stem = raw.trim().replace(Regex("[/\\\\]"), "_") - .replace(Regex("\\p{Cntrl}"), "") - .replace(Regex("\\s+"), " ").trim() - if (stem.isEmpty() || stem == "." || stem == "..") return null - if (stem.length > 120) stem = stem.take(120).trimEnd() - return stem.ifEmpty { null } - } - } -} diff --git a/shared/com/shonar/recording/RecordingService.kt b/shared/com/shonar/recording/RecordingService.kt deleted file mode 100644 index 23b01b0..0000000 --- a/shared/com/shonar/recording/RecordingService.kt +++ /dev/null @@ -1,552 +0,0 @@ -package com.shonar.recording - -import android.Manifest -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.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.media.MediaRecorder -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import android.os.SystemClock -import android.telephony.PhoneStateListener -import android.telephony.TelephonyCallback -import android.telephony.TelephonyManager -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import com.shonar.ShonarApplication -import com.shonar.widget.ShonarWidgetUpdater -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -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.isActive -import kotlinx.coroutines.launch - -/** - * Foreground microphone owner. Single-flight: START while active is a - * no-op (prevents duplicate sessions from rapid widget double-taps). - * - * Commands: START / PAUSE / RESUME / SAVE / CANCEL / DELETE. - * SAVE == stop+finalize+open rename. CANCEL == stop+discard temp. - * DELETE == CANCEL (separate action for UX; same safe discard). - * - * Reliability: - * - MediaRecorder (not AudioRecord): container-managed AAC/M4A, far fewer - * codec/buffer bugs across OEMs; pause()/resume() is API 24+. - * - Partial WakeLock (10 min timeout) prevents CPU sleep mid-take. - * - Audio focus loss (call, assistant) auto-pauses;coming back does NOT - * auto-resume (user decides — avoids surprise hot-mic). - * - Mic in use by another app / permission revoked mid-take -> keep the - * temp file, drop to IDLE with state preserved for recovery, never - * delete silently. - */ -class RecordingService : Service() { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var recorder: MediaRecorder? = null - private var wake: PowerManager.WakeLock? = null - private var audioManager: AudioManager? = null - private var focusRequest: AudioFocusRequest? = null - private var telephony: TelephonyManager? = null - private var callCallback: TelephonyCallback? = null - private var phoneListener: PhoneStateListener? = null - - private var currentId: String? = null - private var currentTemp: File? = null - private var createdAtMs: Long = 0L - private var accumulatedMs = 0L - private var resumedAtElapsed = 0L - private var ticker: Job? = null - - private lateinit var stateStore: RecordingStateStore - - override fun onCreate() { - super.onCreate() - stateStore = RecordingStateStore(this) - RecordingNotificationHelper.ensureChannel(this) - audioManager = getSystemService(AudioManager::class.java) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - scope.launch { recoverIfNeeded() } - when (intent?.action) { - ACTION_START -> startRecording() - ACTION_PAUSE -> pauseRecording() - ACTION_RESUME -> resumeRecording() - ACTION_SAVE -> saveRecording() - ACTION_DISCARD_REQUEST -> requestDiscardConfirm() - ACTION_DISCARD_CANCEL -> cancelDiscardConfirm() - ACTION_CANCEL, ACTION_DELETE -> discardRecording() - ACTION_STOP -> saveRecording() // legacy single Stop -> Save - } - return START_NOT_STICKY - } - - // ---------- public API used by widget / ViewModel ---------- - - private fun startRecording() { - if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return // no duplicates - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != - PackageManager.PERMISSION_GRANTED - ) { - // Permission lost: surface IDLE so widget shows Record again. - broadcast() - stopSelf() - return - } - val app = application as ShonarApplication - val id = app.recordingRepository.newRecordingId() - val temp = app.recordingRepository.tempFile(id) - runCatching { temp.parentFile?.mkdirs() } - - try { - createdAtMs = System.currentTimeMillis() - val notif = RecordingNotificationHelper.build( - this, RecordingSnapshot.Phase.RECORDING, 0L, - displayFileName(), - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - RecordingNotificationHelper.NOTIFICATION_ID, notif, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) - } else { - startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif) - } - } catch (e: Exception) { - // API 31+ background-start race: bail cleanly, widget stays idle. - android.util.Log.w("ShonarRec", "FGS start denied", e) - broadcast() - stopSelf() - return - } - - try { - recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply { - setAudioSource(MediaRecorder.AudioSource.MIC) - setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - setAudioEncodingBitRate(128_000) - setAudioSamplingRate(44_100) - setOutputFile(temp.absolutePath) - prepare() - start() - } - } catch (e: Exception) { - // Mic busy (another app) or HW error: keep temp (0 bytes -> sweep - // later), release, go IDLE. Never crash the widget tap. - android.util.Log.w("ShonarRec", "mic unavailable", e) - recorder?.release() - recorder = null - runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() } - stopForeground(STOP_FOREGROUND_REMOVE) - broadcast() - stopSelf() - return - } - - currentId = id - currentTemp = temp - accumulatedMs = 0L - resumedAtElapsed = SystemClock.elapsedRealtime() - acquireWake() - requestFocus() - watchCalls() - setPhase(RecordingSnapshot.Phase.RECORDING) - scope.launch { - stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs) - } - startTicker() - } - - private fun pauseRecording() { - if (_snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return - try { - if (Build.VERSION.SDK_INT >= 24) recorder?.pause() - else return // pre-24 has no pause: keep recording (button hidden there) - } catch (e: Exception) { - android.util.Log.w("ShonarRec", "pause failed", e) - return - } - accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed - setPhase(RecordingSnapshot.Phase.PAUSED) - scope.launch { - currentId?.let { - stateStore.save(it, RecordingPhase.PAUSED, accumulatedMs, resumedAtElapsed, createdAtMs) - } - } - } - - private fun resumeRecording() { - if (_snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != - PackageManager.PERMISSION_GRANTED - ) return // stay paused; user must re-grant - try { - if (Build.VERSION.SDK_INT >= 24) recorder?.resume() - } catch (e: Exception) { - android.util.Log.w("ShonarRec", "resume failed", e) - return - } - resumedAtElapsed = SystemClock.elapsedRealtime() - setPhase(RecordingSnapshot.Phase.RECORDING) - scope.launch { - currentId?.let { - stateStore.save(it, RecordingPhase.RECORDING, accumulatedMs, resumedAtElapsed, createdAtMs) - } - } - } - - /** Stop + finalize -> Room. Rename UI opens from MainActivity deep-link. */ - private fun saveRecording() { - val id = currentId - val temp = currentTemp - if (_snapshot.value.phase == RecordingSnapshot.Phase.IDLE || id == null || temp == null) { - stopSelf() - return - } - val duration = elapsedMs() - shutdownRecorder(releaseOnly = false, keepTemp = true) - ticker?.cancel() - ticker = null - setPhase(RecordingSnapshot.Phase.IDLE) - scope.launch(Dispatchers.IO) { - var savedId: String? = null - try { - val app = application as ShonarApplication - if (temp.exists() && temp.length() > 0) { - app.recordingRepository.finish(id, duration, temp, createdAtMs) - // Apply default display name "Shonar Recording - yyyy-MM-dd HH-mm" - // so the rename sheet has something meaningful to edit. - val row = app.database.recordingDao().getById(id) - if (row != null) { - val def = RecordingFiles.defaultDisplayName(createdAtMs) - val stem = RecordingFiles.sanitizeStem(def) ?: id - val dir = java.io.File(row.filePath).parentFile - if (dir != null) { - val target = RecordingFiles.uniqueFinalFile(dir, stem) - val cur = java.io.File(row.filePath) - if (cur.canonicalPath != target.canonicalPath) { - if (cur.renameTo(target)) { - app.database.recordingDao().update( - row.copy( - title = target.nameWithoutExtension, - filePath = target.absolutePath, - sizeBytes = target.length(), - ), - ) - } - } else { - app.database.recordingDao().update(row.copy(title = stem)) - } - } - } - savedId = id - } else { - runCatching { temp.delete() } - } - } catch (e: Exception) { - android.util.Log.w("ShonarRec", "finish failed", e) - runCatching { temp.delete() } - } finally { - stateStore.clear() - ShonarWidgetUpdater.refresh(applicationContext) - stopForeground(STOP_FOREGROUND_REMOVE) - // Open rename automatically (Android forbids silent activity - // launch from background — notification tap / full-screen - // intent path via MainActivity is the compliant route). - if (savedId != null) RenamePendingHolder.pendingRenameId = savedId - val open = Intent(applicationContext, com.shonar.MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - .setAction(ACTION_RENAME) - .putExtra(EXTRA_RECORDING_ID, savedId) - // From a service context startActivity is restricted on - // API 29+; MainActivity.onNewIntent also picks up the rename - // via RenamePendingHolder, so a best-effort launch here + a - // high-priority "Tap to rename" notification fallback covers it. - runCatching { startActivity(open) } - stopSelf() - } - } - } - - private fun discardRecording() { - val temp = currentTemp - shutdownRecorder(releaseOnly = false, keepTemp = false) - ticker?.cancel() - ticker = null - runCatching { temp?.delete() } - setPhase(RecordingSnapshot.Phase.IDLE) - scope.launch { - stateStore.clear() - ShonarWidgetUpdater.refresh(applicationContext) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } - - // ---------- internals ---------- - - 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 setPhase(p: RecordingSnapshot.Phase) { - _snapshot.value = RecordingSnapshot(p, currentId, elapsedMs(), confirmingDiscard = false) - pushNotification() - ShonarWidgetUpdater.refresh(this) - } - - /** X tap: keep recording running, swap card to "Delete current recording? No/Yes". */ - private fun requestDiscardConfirm() { - val s = _snapshot.value - if (s.phase == RecordingSnapshot.Phase.IDLE || s.confirmingDiscard) return - _snapshot.value = s.copy(confirmingDiscard = true) - pushNotification() - } - - /** "No": back to the media-card, recording untouched. */ - private fun cancelDiscardConfirm() { - val s = _snapshot.value - if (!s.confirmingDiscard) return - _snapshot.value = s.copy(confirmingDiscard = false) - pushNotification() - } - - private fun broadcast() { - ShonarWidgetUpdater.refresh(this) - } - - private fun pushNotification() { - val s = _snapshot.value - if (s.phase == RecordingSnapshot.Phase.IDLE) return - val nm = getSystemService(android.app.NotificationManager::class.java) - runCatching { - nm.notify( - RecordingNotificationHelper.NOTIFICATION_ID, - RecordingNotificationHelper.build( - this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard, - ), - ) - } - } - - private fun displayFileName(): String { - val base = RecordingFiles.defaultDisplayName(createdAtMs.takeIf { it > 0 } ?: System.currentTimeMillis()) - return "$base.m4a" - } - - private fun startTicker() { - ticker?.cancel() - ticker = scope.launch { - while (isActive) { - delay(1000) - val s = _snapshot.value - if (s.phase == RecordingSnapshot.Phase.IDLE) break - _snapshot.value = s.copy(elapsedMs = elapsedMs()) - pushNotification() - ShonarWidgetUpdater.refresh(this@RecordingService) - } - } - } - - /** Process-death recovery: re-read journal; stale temp without a live - * recorder is left for the next startup sweep (never auto-deleted - * under 24h — user may still want it). */ - private suspend fun recoverIfNeeded() { - // No-op while a live session runs in this process. - if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return - } - - private fun shutdownRecorder(releaseOnly: Boolean, keepTemp: Boolean) { - try { - recorder?.apply { - runCatching { if (!releaseOnly) stop() } - runCatching { reset() } - release() - } - } catch (_: Exception) { - if (!keepTemp) runCatching { currentTemp?.delete() } - } finally { - recorder = null - } - releaseWake() - abandonFocus() - unwatchCalls() - currentId = null - currentTemp = null - } - - private fun acquireWake() { - val pm = getSystemService(PowerManager::class.java) - wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SHONAR:rec").apply { - setReferenceCounted(false) - acquire(10 * 60 * 1000L) - } - } - - private fun releaseWake() { - runCatching { wake?.takeIf { it.isHeld }?.release() } - wake = null - } - - private val focusListener = AudioManager.OnAudioFocusChangeListener { change -> - when (change) { - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - // Incoming call / assistant: pause, never discard. - if (_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING) pauseRecording() - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> Unit // mic has no duck - AudioManager.AUDIOFOCUS_GAIN -> Unit // no auto-resume (explicit UX) - } - } - - private fun requestFocus() { - val am = audioManager ?: return - if (Build.VERSION.SDK_INT >= 26) { - val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build(), - ) - .setOnAudioFocusChangeListener(focusListener) - .build() - focusRequest = req - runCatching { am.requestAudioFocus(req) } - } else { - @Suppress("DEPRECATION") - runCatching { - am.requestAudioFocus( - focusListener, AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, - ) - } - } - } - - private fun abandonFocus() { - val am = audioManager ?: return - if (Build.VERSION.SDK_INT >= 26) { - focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } } - focusRequest = null - } else { - @Suppress("DEPRECATION") - runCatching { am.abandonAudioFocus(focusListener) } - } - } - - private fun watchCalls() { - val tm = getSystemService(TelephonyManager::class.java) ?: return - telephony = tm - if (Build.VERSION.SDK_INT >= 31) { - val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener { - override fun onCallStateChanged(state: Int) { - if (state != TelephonyManager.CALL_STATE_IDLE && - _snapshot.value.phase == RecordingSnapshot.Phase.RECORDING - ) pauseRecording() - } - } - callCallback = cb - runCatching { tm.registerTelephonyCallback(mainExecutor, cb) } - } else { - @Suppress("DEPRECATION") - val l = object : PhoneStateListener() { - @Deprecated("compat") - override fun onCallStateChanged(state: Int, number: String?) { - if (state != TelephonyManager.CALL_STATE_IDLE && - _snapshot.value.phase == RecordingSnapshot.Phase.RECORDING - ) pauseRecording() - } - } - phoneListener = l - @Suppress("DEPRECATION") - runCatching { tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE) } - } - } - - private fun unwatchCalls() { - telephony?.let { tm -> - if (Build.VERSION.SDK_INT >= 31) { - callCallback?.let { runCatching { tm.unregisterTelephonyCallback(it) } } - callCallback = null - } else { - phoneListener?.let { - @Suppress("DEPRECATION") - runCatching { tm.listen(it, PhoneStateListener.LISTEN_NONE) } - phoneListener = null - } - } - } - telephony = null - } - - override fun onDestroy() { - // Crash/force-stop with an active take: recorder is dead but the temp - // file + journal entry survive for the next launch sweep. Only delete - // a 0-byte temp here; anything with audio is NEVER silently dropped. - if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) { - val tmp = currentTemp - shutdownRecorder(releaseOnly = true, keepTemp = true) - if (tmp != null && tmp.exists() && tmp.length() == 0L) { - runCatching { tmp.delete() } - } - _snapshot.value = RecordingSnapshot() - ShonarWidgetUpdater.refresh(this) - } - scope.cancel() - super.onDestroy() - } - - override fun onBind(intent: Intent?): IBinder? = null - - companion object { - 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_SAVE = "com.shonar.recording.SAVE" - const val ACTION_CANCEL = "com.shonar.recording.CANCEL" - const val ACTION_DELETE = "com.shonar.recording.DELETE" - /** X tap -> show "Delete current recording? No/Yes" (recording keeps running). */ - const val ACTION_DISCARD_REQUEST = "com.shonar.recording.DISCARD_REQUEST" - /** "No" -> back to media-card, recording untouched. */ - const val ACTION_DISCARD_CANCEL = "com.shonar.recording.DISCARD_CANCEL" - const val ACTION_STOP = "com.shonar.recording.STOP" // legacy alias -> SAVE - const val ACTION_RENAME = "com.shonar.recording.RENAME" - const val EXTRA_RECORDING_ID = "recording_id" - - private val _snapshot = MutableStateFlow(RecordingSnapshot()) - val snapshot: StateFlow = _snapshot.asStateFlow() - - fun command(context: Context, action: String) { - val intent = Intent(context, RecordingService::class.java).setAction(action) - if (action == ACTION_START) { - ContextCompat.startForegroundService(context, intent) - } else { - context.startService(intent) - } - } - } -} - -/** Handoff for the post-save rename sheet (process-safe via intent extra too). */ -object RenamePendingHolder { - @Volatile var pendingRenameId: String? = null -} diff --git a/shared/com/shonar/recording/RecordingSession.kt b/shared/com/shonar/recording/RecordingSession.kt deleted file mode 100644 index 35b356b..0000000 --- a/shared/com/shonar/recording/RecordingSession.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.shonar.recording - -/** - * Single source of truth for the in-progress recording session. - * Persists across process death via [RecordingStateStore]; mirrored to - * the widget + notification. - */ -enum class RecordingPhase { IDLE, RECORDING, PAUSED } - -sealed interface RecordingCommand { - data object Start : RecordingCommand - data object Pause : RecordingCommand - data object Resume : RecordingCommand - /** Stop, finalize temp file -> Room, then open rename. */ - data object Save : RecordingCommand - /** Stop + delete temp file, no Room row. */ - data object Cancel : RecordingCommand - /** Delete = Cancel alias kept for notification UX (per spec). */ - data object Delete : RecordingCommand -} - -/** Snapshot observed by UI / widget. Kept compatible with existing code. */ -data class RecordingSnapshot( - val phase: Phase = Phase.IDLE, - val recordingId: String? = null, - val elapsedMs: Long = 0L, - /** True while the notification shows "Delete current recording? No/Yes". */ - val confirmingDiscard: Boolean = false, -) { - // Legacy alias so existing HomeScreen code keeps compiling. - enum class Phase { IDLE, RECORDING, PAUSED } - - fun toPhase(): RecordingPhase = when (phase) { - Phase.IDLE -> RecordingPhase.IDLE - Phase.RECORDING -> RecordingPhase.RECORDING - Phase.PAUSED -> RecordingPhase.PAUSED - } -} - -fun RecordingPhase.toSnapshotPhase(): RecordingSnapshot.Phase = when (this) { - RecordingPhase.IDLE -> RecordingSnapshot.Phase.IDLE - RecordingPhase.RECORDING -> RecordingSnapshot.Phase.RECORDING - RecordingPhase.PAUSED -> RecordingSnapshot.Phase.PAUSED -} diff --git a/shared/com/shonar/recording/RecordingStateStore.kt b/shared/com/shonar/recording/RecordingStateStore.kt deleted file mode 100644 index 0ce580d..0000000 --- a/shared/com/shonar/recording/RecordingStateStore.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.shonar.recording - -import android.content.Context -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -private val Context.sessionStore by preferencesDataStore("recording_session") - -/** - * Crash-safe session journal. The service writes before/after every - * transition; on reboot / process death we either resume UI state or - * sweep an orphaned temp file. Small, synchronous, no Room needed here - * because this must work even when the DB is closed. - */ -class RecordingStateStore(private val context: Context) { - private val ID = stringPreferencesKey("session_id") - private val PHASE = stringPreferencesKey("session_phase") - private val ACCUM = longPreferencesKey("session_accum_ms") - private val STARTED_AT = longPreferencesKey("session_started_at_ms") - private val CREATED_AT = longPreferencesKey("session_created_at_ms") - - data class Persisted( - val id: String, - val phase: RecordingPhase, - val accumulatedMs: Long, - val resumedAtElapsedMs: Long, - val createdAtMs: Long, - ) - - suspend fun save( - id: String, - phase: RecordingPhase, - accumulatedMs: Long, - resumedAtElapsedMs: Long, - createdAtMs: Long, - ) { - context.sessionStore.edit { p -> - p[ID] = id - p[PHASE] = phase.name - p[ACCUM] = accumulatedMs - p[STARTED_AT] = resumedAtElapsedMs - p[CREATED_AT] = createdAtMs - } - } - - suspend fun load(): Persisted? { - val p = context.sessionStore.data.map { it }.first() - val id = p[ID] ?: return null - val phase = runCatching { RecordingPhase.valueOf(p[PHASE] ?: "IDLE") } - .getOrDefault(RecordingPhase.IDLE) - if (phase == RecordingPhase.IDLE) return null - return Persisted( - id = id, - phase = phase, - accumulatedMs = p[ACCUM] ?: 0L, - resumedAtElapsedMs = p[STARTED_AT] ?: 0L, - createdAtMs = p[CREATED_AT] ?: System.currentTimeMillis(), - ) - } - - suspend fun clear() { - context.sessionStore.edit { it.clear() } - } -} diff --git a/shared/com/shonar/recording/ShonarDatabase.kt b/shared/com/shonar/recording/ShonarDatabase.kt deleted file mode 100644 index 9e4c82e..0000000 --- a/shared/com/shonar/recording/ShonarDatabase.kt +++ /dev/null @@ -1,41 +0,0 @@ -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") - } -} diff --git a/shared/com/shonar/recording/SyncDrain.kt b/shared/com/shonar/recording/SyncDrain.kt deleted file mode 100644 index 7b36682..0000000 --- a/shared/com/shonar/recording/SyncDrain.kt +++ /dev/null @@ -1,94 +0,0 @@ -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, 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() - 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) diff --git a/shared/com/shonar/recording/SyncSlots.kt b/shared/com/shonar/recording/SyncSlots.kt deleted file mode 100644 index 67543ef..0000000 --- a/shared/com/shonar/recording/SyncSlots.kt +++ /dev/null @@ -1,116 +0,0 @@ -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 = 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 = 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) -} diff --git a/shared/com/shonar/recording/SyncStateConverter.kt b/shared/com/shonar/recording/SyncStateConverter.kt deleted file mode 100644 index 381a75f..0000000 --- a/shared/com/shonar/recording/SyncStateConverter.kt +++ /dev/null @@ -1,16 +0,0 @@ -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) -} diff --git a/shared/com/shonar/recording/SyncWorker.kt b/shared/com/shonar/recording/SyncWorker.kt deleted file mode 100644 index 1553dd4..0000000 --- a/shared/com/shonar/recording/SyncWorker.kt +++ /dev/null @@ -1,141 +0,0 @@ -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() - .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(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)) - } - } - } -} diff --git a/shared/com/shonar/settings/BuiltInSettings.kt b/shared/com/shonar/settings/BuiltInSettings.kt deleted file mode 100644 index d88e5e0..0000000 --- a/shared/com/shonar/settings/BuiltInSettings.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.shonar.settings - -/** - * Built-in settings. These ship with the app; users may add more at runtime. - * - * NOTE: Home Assistant settings were removed from the default configuration - * (product scope change) and preserved on branch `deferred/home-assistant` - * under `deferred/home-assistant/`. - */ -object BuiltInSettings { - - const val CAT_GENERAL = "General" - const val CAT_APPEARANCE = "Appearance" - const val CAT_NETWORK = "Network" - const val CAT_ADVANCED = "Advanced" - - // ids other code depends on (single source of truth) - 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 = listOf( - // --- General ------------------------------------------------------- - SettingDefinition( - id = "default_recording_title_format", - name = "Default recording title", - description = "Used for new recordings until you rename them.", - category = CAT_GENERAL, - type = SettingType.SELECT, - choices = listOf("Date and time", "Recording ", "Untitled"), - defaultJson = "\"Date and time\"", - ), - SettingDefinition( - id = "consent_notice_seen", - name = "Recording consent notice acknowledged", - description = "You confirmed you will comply with local " + - "recording-consent laws.", - category = CAT_GENERAL, - type = SettingType.BOOLEAN, - defaultJson = "false", - editable = true, - ), - // --- Appearance ------------------------------------------------------- - SettingDefinition( - id = "theme_mode", - name = "Theme", - description = "Dark, light, or follow the system.", - category = CAT_APPEARANCE, - type = SettingType.SELECT, - choices = listOf("System", "Light", "Dark"), - defaultJson = "\"System\"", - ), - SettingDefinition( - id = "accent_color", - name = "Accent color", - category = CAT_APPEARANCE, - type = SettingType.COLOR, - 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 ------------------------------------------------------------- - SettingDefinition( - id = "wifi_only_upload", - name = "Upload on Wi-Fi only", - description = "Applies once a sync provider is connected (next milestone).", - category = CAT_NETWORK, - type = SettingType.BOOLEAN, - defaultJson = "true", - ), - SettingDefinition( - id = "charging_only_upload", - name = "Upload only while charging", - description = "Applies once a sync provider is connected (next milestone).", - category = CAT_NETWORK, - type = SettingType.BOOLEAN, - defaultJson = "false", - ), - // --- Advanced ------------------------------------------------------------ - SettingDefinition( - id = "log_http_bodies", - name = "Log request/response bodies (debug)", - description = "Never enabled by default. May write sensitive data " + - "to logcat; for debugging only.", - category = CAT_ADVANCED, - type = SettingType.BOOLEAN, - defaultJson = "false", - ), - SettingDefinition( - id = "app_version", - name = "App version", - category = CAT_ADVANCED, - type = SettingType.STRING, - defaultJson = "\"0.1.0\"", - editable = false, - ), - ) -} - diff --git a/shared/com/shonar/settings/SettingDefinition.kt b/shared/com/shonar/settings/SettingDefinition.kt deleted file mode 100644 index 275adbb..0000000 --- a/shared/com/shonar/settings/SettingDefinition.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.shonar.settings - -import kotlinx.serialization.Serializable - -/** All setting value types the app can render and validate. */ -@Serializable -enum class SettingType { - BOOLEAN, STRING, NUMBER, SELECT, MULTI_SELECT, COLOR, URL, SECRET -} - -/** - * A single settings entry. Definitions may come from the built-in registry - * or be created by the user at runtime (custom settings) — no code changes - * required to add one. - */ -@Serializable -data class SettingDefinition( - val id: String, - val name: String, - val description: String = "", - val category: String, - val type: SettingType, - val defaultJson: String, // value encoded as JSON per type - val min: Double? = null, // NUMBER - val max: Double? = null, // NUMBER - val choices: List = emptyList(), // SELECT / MULTI_SELECT - val editable: Boolean = true, // false = informational only - val sensitive: Boolean = false, // stored in secure storage, masked UI - val requiresRestart: Boolean = false, - /** Simple conditional visibility: id of another boolean setting. */ - val visibleIfSettingId: String? = null, - /** True when the user created this (vs built-in); user settings can be deleted. */ - val custom: Boolean = false, -) - -/** Validation failure with a user-facing message. */ -class SettingValidationException(message: String) : Exception(message) diff --git a/shared/com/shonar/settings/SettingsManager.kt b/shared/com/shonar/settings/SettingsManager.kt deleted file mode 100644 index 00c528b..0000000 --- a/shared/com/shonar/settings/SettingsManager.kt +++ /dev/null @@ -1,333 +0,0 @@ -package com.shonar.settings - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -/** Result of importing settings: applied ids + rejected entries with reasons. */ -data class ImportResult(val applied: List, val rejected: Map) - -/** - * Central settings engine. Definitions are data, not code: built-ins come - * from [BuiltInSettings], user-created definitions persist alongside values. - * - * Values are stored as JSON strings (per [SettingDefinition.type]). - * Sensitive values go to [secureStore]; everything else to [store]. - */ -class SettingsManager( - private val store: SettingsStore, - private val secureStore: SettingsStore, -) { - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private val definitionsFlow = MutableStateFlow>(emptyList()) - val definitions: StateFlow> = definitionsFlow.asStateFlow() - - /** Bumped on any value change so ViewModels can re-read. */ - private val valuesVersion = MutableStateFlow(0L) - val valuesChanged: StateFlow = valuesVersion.asStateFlow() - - private var loaded = false - - suspend fun ensureLoaded() { - if (loaded) return - loaded = true - val builtIns = BuiltInSettings.all - val customJson = store.getString(CUSTOM_DEFS_KEY) - val customs = customJson?.let { - runCatching { json.decodeFromString>(it) }.getOrDefault(emptyList()) - }.orEmpty() - // Built-ins win on id collision; custom defs can shadow defaults only - // for their own ids. - definitionsFlow.value = builtIns + customs.filter { c -> builtIns.none { it.id == c.id } } - } - - private fun storeFor(def: SettingDefinition): SettingsStore = - if (def.sensitive) secureStore else store - - suspend fun getString(id: String): String? { - val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return null - return storeFor(def).getString(valueKey(id)) - } - - /** Typed read with default fallback. */ - suspend fun value(id: String): JsonElement? { - val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return null - val raw = storeFor(def).getString(valueKey(id)) ?: return json.parseToJsonElement(def.defaultJson) - return runCatching { json.parseToJsonElement(raw) }.getOrElse { - json.parseToJsonElement(def.defaultJson) - } - } - - suspend fun bool(id: String): Boolean = - (value(id) as? kotlinx.serialization.json.JsonPrimitive)?.booleanOrNull ?: false - - suspend fun string(id: String): String = - (value(id) as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" - - suspend fun double(id: String): Double = - (value(id) as? kotlinx.serialization.json.JsonPrimitive)?.doubleOrNull ?: 0.0 - - suspend fun stringList(id: String): List = - runCatching { value(id)?.jsonArray?.map { it.jsonPrimitive.content } }.getOrNull() - ?: emptyList() - - /** Validate + persist. Throws [SettingValidationException] on bad input. */ - suspend fun setValue(id: String, jsonValue: String) { - val def = definitionsFlow.value.firstOrNull { it.id == id } - ?: throw SettingValidationException("Unknown setting: $id") - if (!def.editable) throw SettingValidationException("This setting is not editable.") - validate(def, jsonValue) - storeFor(def).putString(valueKey(id), jsonValue) - valuesVersion.value += 1 - } - - suspend fun reset(id: String) { - val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return - if (!def.editable) throw SettingValidationException("This setting is not editable.") - storeFor(def).remove(valueKey(id)) - valuesVersion.value += 1 - } - - fun validate(def: SettingDefinition, jsonValue: String) { - val el = runCatching { json.parseToJsonElement(jsonValue) } - .getOrElse { throw SettingValidationException("Invalid value encoding.") } - val p = el as? kotlinx.serialization.json.JsonPrimitive - when (def.type) { - SettingType.BOOLEAN -> { - if (p == null || p.booleanOrNull == null) - throw SettingValidationException("Expected true or false.") - } - SettingType.STRING, SettingType.SECRET -> { - if (p == null || !p.isString) - throw SettingValidationException("Expected text.") - if (def.type == SettingType.SECRET && p.content.isBlank()) - throw SettingValidationException("Value cannot be empty.") - } - SettingType.NUMBER -> { - val d = p?.doubleOrNull - ?: throw SettingValidationException("Expected a number.") - if (def.min != null && d < def.min) - throw SettingValidationException("Must be at least ${fmt(def.min)}.") - if (def.max != null && d > def.max) - throw SettingValidationException("Must be at most ${fmt(def.max)}.") - } - SettingType.SELECT -> { - val v = p?.content ?: throw SettingValidationException("Expected text.") - if (v !in def.choices) - throw SettingValidationException("Choose one of: ${def.choices.joinToString()}") - } - SettingType.MULTI_SELECT -> { - val items = runCatching { el.jsonArray.map { it.jsonPrimitive.content } } - .getOrElse { throw SettingValidationException("Expected a list.") } - val bad = items.filter { it !in def.choices } - if (bad.isNotEmpty()) - throw SettingValidationException("Unknown options: ${bad.joinToString()}") - } - SettingType.COLOR -> { - val v = p?.content ?: throw SettingValidationException("Expected a color.") - if (!COLOR_REGEX.matches(v)) - throw SettingValidationException("Use #RRGGBB or #AARRGGBB.") - } - SettingType.URL -> { - val v = p?.content ?: throw SettingValidationException("Expected a URL.") - validateUrlOrThrow(v) - } - } - } - - // --- custom setting CRUD ------------------------------------------------ - - suspend fun addCustom(def: SettingDefinition) { - validateId(def.id) - if (definitionsFlow.value.any { it.id == def.id }) - throw SettingValidationException("A setting with id \"${def.id}\" already exists.") - if (def.name.isBlank()) throw SettingValidationException("Display name is required.") - // Default must validate against the definition itself. - validate(def, def.defaultJson) - val updated = definitionsFlow.value + def.copy(custom = true, sensitive = def.sensitive) - persistDefinitions(updated) - } - - suspend fun updateCustom(def: SettingDefinition) { - val existing = definitionsFlow.value.firstOrNull { it.id == def.id } - ?: throw SettingValidationException("Setting not found.") - if (!existing.custom) throw SettingValidationException("Built-in settings cannot be edited.") - validate(def, def.defaultJson) - val updated = definitionsFlow.value.map { - if (it.id == def.id) def.copy(custom = true) else it - } - persistDefinitions(updated) - // Current value may no longer pass the new definition; drop it if not. - currentRaw(def.id)?.let { raw -> - runCatching { validate(def, raw) }.onFailure { reset(def.id) } - } - } - - suspend fun deleteCustom(id: String) { - val def = definitionsFlow.value.firstOrNull { it.id == id } - ?: throw SettingValidationException("Setting not found.") - if (!def.custom) throw SettingValidationException("Built-in settings cannot be deleted.") - storeFor(def).remove(valueKey(id)) - persistDefinitions(definitionsFlow.value.filterNot { it.id == id }) - } - - private suspend fun currentRaw(id: String): String? { - val def = definitionsFlow.value.firstOrNull { it.id == id } ?: return null - return storeFor(def).getString(valueKey(id)) - } - - private suspend fun persistDefinitions(all: List) { - val customs = all.filter { it.custom } - store.putString(CUSTOM_DEFS_KEY, json.encodeToString(customs)) - definitionsFlow.value = BuiltInSettings.all + - customs.filter { c -> BuiltInSettings.all.none { it.id == c.id } } - valuesVersion.value += 1 - } - - // --- export / import ----------------------------------------------------- - - /** JSON export. Sensitive values are included ONLY when asked. */ - suspend fun exportJson(includeSecrets: Boolean): String { - val settingsObj = buildJsonObject { - for (def in definitionsFlow.value) { - val raw = if (def.sensitive && !includeSecrets) null - else storeFor(def).getString(valueKey(def.id)) - // Export only values the user actually set (differ from the - // default). Untouched built-ins and empty secrets stay out. - if (raw != null && raw != def.defaultJson) { - runCatching { put(def.id, json.parseToJsonElement(raw)) } - } - } - } - val customs = definitionsFlow.value.filter { it.custom } - val obj = buildJsonObject { - put("schemaVersion", 1) - put("app", "SHONAR") - put("customDefinitions", json.encodeToJsonElement(customs)) - put("settings", settingsObj) - } - return obj.toString() - } - - /** - * Import validates every entry against known definitions BEFORE - * applying any of them (all-or-nothing). Unknown ids are rejected, not - * silently created. - */ - suspend fun importJson(text: String): ImportResult { - val root = runCatching { json.parseToJsonElement(text).jsonObject } - .getOrElse { throw SettingValidationException("Not a valid settings JSON file.") } - if (root["app"]?.jsonPrimitive?.content != "SHONAR") - throw SettingValidationException("Not a SHONAR settings file.") - val settingsObj = root["settings"]?.jsonObject - ?: throw SettingValidationException("Missing \"settings\" object.") - - // Custom definitions travel with the export so imports work on a - // fresh install. Validate them first; unknown/invalid ones are - // reported, and their values (if any) are rejected with them. - val pendingDefs = mutableListOf() - val defsById = definitionsFlow.value.associateBy { it.id }.toMutableMap() - val rejected = mutableMapOf() - root["customDefinitions"]?.let { el -> - val defs = runCatching { - json.decodeFromJsonElement>(el) - }.getOrElse { emptyList() } - for (d in defs) { - runCatching { - if (!ID_REGEX.matches(d.id)) { - throw SettingValidationException("bad id") - } - validate(d, d.defaultJson) - }.onFailure { rejected["def.${d.id}"] = it.message ?: "invalid definition" } - .onSuccess { - if (d.id !in defsById) { - pendingDefs += d.copy(custom = true) - defsById[d.id] = d.copy(custom = true) - } - } - } - } - - val pending = mutableListOf>() - for ((id, el) in settingsObj) { - val def = defsById[id] - if (def == null) { - rejected[id] = "Unknown setting id" - continue - } - if (!def.editable) { - rejected[id] = "Not editable" - continue - } - val raw = el.toString() - runCatching { validate(def, raw) } - .onFailure { rejected[id] = it.message ?: "Invalid" } - .onSuccess { pending += def to raw } - } - if (rejected.isEmpty()) { - if (pendingDefs.isNotEmpty()) { - val merged = definitionsFlow.value + pendingDefs - store.putString(CUSTOM_DEFS_KEY, - json.encodeToString(merged.filter { it.custom })) - definitionsFlow.value = BuiltInSettings.all + merged.filter { it.custom } - } - pending.forEach { (def, raw) -> storeFor(def).putString(valueKey(def.id), raw) } - valuesVersion.value += 1 - } - return ImportResult( - applied = if (rejected.isEmpty()) pending.map { it.first.id } else emptyList(), - rejected = rejected, - ) - } - - private fun valueKey(id: String) = "value.$id" - - private fun validateId(id: String) { - if (!ID_REGEX.matches(id)) - throw SettingValidationException( - "id must be lowercase letters, digits and underscores (max 64)." - ) - } - - private fun fmt(d: Double): String = - if (d == d.toLong().toDouble()) d.toLong().toString() else d.toString() - - companion object { - const val CUSTOM_DEFS_KEY = "custom_definitions" - val ID_REGEX = Regex("^[a-z0-9_]{1,64}$") - val COLOR_REGEX = Regex("^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") - - /** URL validation: http(s) only, parseable host, no credentials, - * optional port. Rejects javascript:, file:, etc. */ - fun validateUrlOrThrow(v: String) { - val trimmed = v.trim().trimEnd('/') - if (trimmed.isBlank()) throw SettingValidationException("URL cannot be empty.") - val uri = runCatching { java.net.URI(trimmed) } - .getOrElse { throw SettingValidationException("Not a valid URL.") } - if (uri.scheme != "http" && uri.scheme != "https") - throw SettingValidationException("Only http and https URLs are allowed.") - if (uri.host.isNullOrBlank()) - throw SettingValidationException("URL must include a host.") - if (!uri.userInfo.isNullOrBlank()) - throw SettingValidationException("URL credentials are not allowed; use the token field.") - if (uri.port != -1 && (uri.port < 1 || uri.port > 65535)) - throw SettingValidationException("Port out of range.") - } - } -} diff --git a/shared/com/shonar/settings/SettingsStore.kt b/shared/com/shonar/settings/SettingsStore.kt deleted file mode 100644 index ea6fb41..0000000 --- a/shared/com/shonar/settings/SettingsStore.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.shonar.settings - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.flow.first -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Key/value persistence abstraction. Two implementations: - * - [DataStoreSettingsStore] for ordinary settings - * - [SecureSettingsStore] (EncryptedSharedPreferences, AndroidKeyStore- - * backed) for anything marked sensitive. Secrets never touch the - * unencrypted store. - */ -interface SettingsStore { - suspend fun getString(key: String): String? - suspend fun putString(key: String, value: String) - suspend fun remove(key: String) - suspend fun keys(): Set - - /** Emits whenever any value changes (for reactive UI). */ - val changes: StateFlow -} - -private val Context.settingsDataStore: DataStore by preferencesDataStore( - name = "shonar_settings" -) - -class DataStoreSettingsStore(private val context: Context) : SettingsStore { - private val changeFlow = MutableStateFlow(0L) - override val changes: StateFlow = changeFlow.asStateFlow() - - private val store get() = context.settingsDataStore - - override suspend fun getString(key: String): String? = - store.data.first()[stringPreferencesKey(key)] - - override suspend fun putString(key: String, value: String) { - store.edit { it[stringPreferencesKey(key)] = value } - changeFlow.value += 1 - } - - override suspend fun remove(key: String) { - store.edit { it.remove(stringPreferencesKey(key)) } - changeFlow.value += 1 - } - - override suspend fun keys(): Set = - store.data.first().asMap().keys.map { it.name }.toSet() -} - -class SecureSettingsStore(context: Context) : SettingsStore { - private val prefs by lazy { - val masterKey = MasterKey.Builder(context, "shonar_secrets_master") - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context, - "shonar_secrets", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - - private val changeFlow = MutableStateFlow(0L) - override val changes: StateFlow = changeFlow.asStateFlow() - - override suspend fun getString(key: String): String? = prefs.getString(key, null) - - override suspend fun putString(key: String, value: String) { - prefs.edit().putString(key, value).apply() - changeFlow.value += 1 - } - - override suspend fun remove(key: String) { - prefs.edit().remove(key).apply() - changeFlow.value += 1 - } - - override suspend fun keys(): Set = prefs.all.keys -} - -/** In-memory store: used by unit tests. */ -class InMemorySettingsStore : SettingsStore { - private val map = mutableMapOf() - private val changeFlow = MutableStateFlow(0L) - override val changes: StateFlow = changeFlow.asStateFlow() - - override suspend fun getString(key: String): String? = map[key] - override suspend fun putString(key: String, value: String) { - map[key] = value - changeFlow.value += 1 - } - - override suspend fun remove(key: String) { - map.remove(key) - changeFlow.value += 1 - } - - override suspend fun keys(): Set = map.keys.toSet() -} diff --git a/shared/com/shonar/ui/detail/DetailsScreen.kt b/shared/com/shonar/ui/detail/DetailsScreen.kt deleted file mode 100644 index ccc3630..0000000 --- a/shared/com/shonar/ui/detail/DetailsScreen.kt +++ /dev/null @@ -1,507 +0,0 @@ -package com.shonar.ui.detail - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.shonar.ShonarApplication -import com.shonar.recording.SUMMARY_LIST_KEYS -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData -import com.shonar.recording.activeSegmentIndex - -/** - * M8 recording details: playback synced to the transcript, summary with - * action items, processing status, and editing. Everything server-side - * degrades to an honest empty state — playback and rename always work. - */ -@Composable -fun DetailsScreen(recordingId: String, onBack: () -> Unit) { - val context = LocalContext.current - val app = context.applicationContext as ShonarApplication - val vm: DetailsViewModel = viewModel(factory = DetailsViewModel.factory(app, recordingId)) - val uiState by vm.state.collectAsState() - - Scaffold { padding -> - Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - when (val s = uiState) { - is DetailsViewModel.UiState.ReadyState -> TitleRow( - title = s.ready.recording.title, - onRename = vm::rename, - ) - else -> Text("Details", style = MaterialTheme.typography.titleLarge) - } - } - when (val s = uiState) { - DetailsViewModel.UiState.Loading -> LoadingRow() - is DetailsViewModel.UiState.Error -> Text( - s.message, color = MaterialTheme.colorScheme.error - ) - is DetailsViewModel.UiState.ReadyState -> ReadyBody(vm = vm, ready = s.ready) - } - } - } -} - -@Composable -private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) { - var editing by remember { mutableStateOf(false) } - var draft by remember(title) { mutableStateOf(title) } - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - title, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { editing = true }) { - Icon(Icons.Filled.Edit, contentDescription = "Rename") - } - } - if (editing) { - AlertDialog( - onDismissRequest = { editing = false }, - title = { Text("Rename recording") }, - text = { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton(onClick = { onRename(draft); editing = false }) { Text("Save") } - }, - dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, - ) - } -} - -@Composable -private fun ColumnScope.ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) { - var tab by remember { mutableIntStateOf(0) } - PlaybackCard(ready = ready, onToggle = vm::togglePlayback, - onSeek = vm::seekTo, onSpeed = vm::cycleSpeed) - ready.playbackError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) - } - TabRow(selectedTabIndex = tab) { - Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Transcript") }) - Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Summary") }) - Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Status") }) - } - when (tab) { - 0 -> TranscriptTab( - transcript = ready.transcript, - ai = ready.ai, - positionMs = ready.positionMs, - canEdit = ready.remoteId != null, - busy = ready.busy, - saveError = ready.saveError, - onSeek = vm::seekTo, - onSave = vm::saveTranscript, - onDismissError = vm::dismissSaveError, - ) - 1 -> SummaryTab( - summary = ready.summary, - ai = ready.ai, - canEdit = ready.remoteId != null, - busy = ready.busy, - saveError = ready.saveError, - onSave = vm::saveSummary, - onDismissError = vm::dismissSaveError, - ) - else -> StatusTab(ready = ready) - } -} - -@Composable -private fun PlaybackCard( - ready: DetailsViewModel.Ready, - onToggle: () -> Unit, - onSeek: (Long) -> Unit, - onSpeed: () -> Unit, -) { - Card(Modifier.fillMaxWidth()) { - Row( - Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onToggle) { - Icon( - if (ready.playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (ready.playing) "Pause" else "Play", - ) - } - Column(Modifier.weight(1f)) { - Slider( - value = ready.positionMs.toFloat(), - onValueChange = { onSeek(it.toLong()) }, - valueRange = 0f..ready.durationMs.coerceAtLeast(1).toFloat(), - ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(formatMs(ready.positionMs), style = MaterialTheme.typography.bodySmall) - Text(formatMs(ready.durationMs), style = MaterialTheme.typography.bodySmall) - } - } - TextButton(onClick = onSpeed) { Text("${ready.speed}×") } - } - } -} - -@Composable -private fun ColumnScope.TranscriptTab( - transcript: TranscriptData?, - ai: DetailsViewModel.AiState, - positionMs: Long, - canEdit: Boolean, - busy: String?, - saveError: String?, - onSeek: (Long) -> Unit, - onSave: (String) -> Unit, - onDismissError: () -> Unit, -) { - var editing by remember { mutableStateOf(false) } - when { - transcript == null -> AiEmptyState(ai, what = "transcript") - transcript.segments.isEmpty() -> { - Text(transcript.text.ifBlank { "Transcript is empty." }) - EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) - } - else -> { - val active = activeSegmentIndex(transcript.segments, positionMs) - val listState = rememberLazyListState() - LaunchedEffect(active) { - if (active >= 0) listState.animateScrollToItem(active) - } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - (if (transcript.editedByUser) "Edited by you" else "Auto transcript") + - " · v${transcript.version}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) - } - LazyColumn( - Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(transcript.segments, key = { i, s -> "$i-${s.startSec}" }) { i, seg -> - val isActive = i == active - Card( - colors = if (isActive) { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - } else { - CardDefaults.cardColors() - }, - modifier = Modifier.fillMaxWidth() - .clickable { onSeek((seg.startSec * 1000).toLong()) }, - ) { - Column(Modifier.padding(10.dp)) { - Text( - formatMs((seg.startSec * 1000).toLong()) + - (seg.speaker?.let { " · $it" } ?: ""), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(seg.text, style = MaterialTheme.typography.bodyMedium) - } - } - } - } - } - } - saveError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.clickable { onDismissError() }) - } - if (editing && transcript != null) { - var draft by remember(transcript.version) { mutableStateOf(transcript.text) } - AlertDialog( - onDismissRequest = { editing = false }, - title = { Text("Edit transcript") }, - text = { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth().height(220.dp), - ) - }, - confirmButton = { - TextButton( - onClick = { onSave(draft); editing = false }, - enabled = draft.isNotBlank(), - ) { Text("Save") } - }, - dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, - ) - } -} - -@Composable -private fun ColumnScope.SummaryTab( - summary: SummaryData?, - ai: DetailsViewModel.AiState, - canEdit: Boolean, - busy: String?, - saveError: String?, - onSave: (Map) -> Unit, - onDismissError: () -> Unit, -) { - var editing by remember { mutableStateOf(false) } - if (summary == null) { - AiEmptyState(ai, what = "summary") - return - } - LazyColumn( - Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - (if (summary.editedByUser) "Edited by you" else "AI summary") + - " · v${summary.version}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) - } - } - if (summary.short.isNotBlank()) { - item { - SummarySection(title = "Summary", body = summary.short) - } - } - if (summary.detailed.isNotBlank()) { - item { - SummarySection(title = "Details", body = summary.detailed) - } - } - SUMMARY_LIST_KEYS.forEach { key -> - val items = summary.list(key) - if (items.isNotEmpty()) { - item { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - key.replace('_', ' ').replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleSmall, - ) - items.forEach { Bullet(it) } - } - } - } - } - } - saveError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.clickable { onDismissError() }) - } - if (editing) { - SummaryEditDialog(summary = summary, onDismiss = { editing = false }, onSave = { - onSave(it); editing = false - }) - } -} - -@Composable -private fun SummaryEditDialog( - summary: SummaryData, - onDismiss: () -> Unit, - onSave: (Map) -> Unit, -) { - var short by remember { mutableStateOf(summary.short) } - var detailed by remember { mutableStateOf(summary.detailed) } - var actionItems by remember { mutableStateOf(summary.list("action_items").joinToString("\n")) } - var keyPoints by remember { mutableStateOf(summary.list("key_points").joinToString("\n")) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Edit summary") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(value = short, onValueChange = { short = it }, - label = { Text("Short") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = detailed, onValueChange = { detailed = it }, - label = { Text("Detailed") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = actionItems, onValueChange = { actionItems = it }, - label = { Text("Action items (one per line)") }, - modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = keyPoints, onValueChange = { keyPoints = it }, - label = { Text("Key points (one per line)") }, - modifier = Modifier.fillMaxWidth()) - } - }, - confirmButton = { - TextButton(onClick = { - val content = summary.content.toMutableMap() - content["short"] = short - content["detailed"] = detailed - content["action_items"] = linesOf(actionItems) - content["key_points"] = linesOf(keyPoints) - onSave(content) - }) { Text("Save") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -private fun linesOf(raw: String): List = - raw.lines().map { it.trim() }.filter { it.isNotEmpty() } - -@Composable -private fun StatusTab(ready: DetailsViewModel.Ready) { - val jobs = (ready.ai as? DetailsViewModel.AiState.Ready)?.jobs.orEmpty() - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Sync: ${ready.recording.syncState}" + - (ready.recording.syncReason?.let { " ($it)" } ?: "")) - if (jobs.isEmpty()) { - Text("No processing jobs reported.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - jobs.forEach { job -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(10.dp)) { - Text("${job.jobType}: ${job.status}", - style = MaterialTheme.typography.titleSmall) - if (job.error != null) { - Text(job.error, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error) - } else { - Text("attempt ${job.attempt}/${job.maxAttempts}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } - } - } -} - -@Composable -private fun AiEmptyState(ai: DetailsViewModel.AiState, what: String) { - when (ai) { - DetailsViewModel.AiState.Loading -> LoadingRow() - is DetailsViewModel.AiState.Unavailable -> Text( - ai.reason.ifBlank { "No $what yet." }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - is DetailsViewModel.AiState.Error -> Text( - ai.message, - color = MaterialTheme.colorScheme.error, - ) - is DetailsViewModel.AiState.Ready -> Text( - "No $what yet — processing may still be running. Check Status.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.height(4.dp)) -} - -@Composable -private fun EditRow(canEdit: Boolean, busy: String?, onEdit: () -> Unit) { - if (!canEdit) return - if (busy != null) { - Text(busy, style = MaterialTheme.typography.labelMedium) - } else { - TextButton(onClick = onEdit) { Text("Edit") } - } -} - -@Composable -private fun SummarySection(title: String, body: String) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall) - Text(body, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun Bullet(text: String) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("•", style = MaterialTheme.typography.bodyMedium) - Text(text, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun LoadingRow() { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { CircularProgressIndicator() } -} - -private fun formatMs(ms: Long): String { - val total = (ms / 1000).coerceAtLeast(0) - return "%d:%02d".format(total / 60, total % 60) -} diff --git a/shared/com/shonar/ui/detail/DetailsViewModel.kt b/shared/com/shonar/ui/detail/DetailsViewModel.kt deleted file mode 100644 index 5388d34..0000000 --- a/shared/com/shonar/ui/detail/DetailsViewModel.kt +++ /dev/null @@ -1,356 +0,0 @@ -package com.shonar.ui.detail - -import android.media.MediaPlayer -import android.media.PlaybackParams -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.shonar.ShonarApplication -import com.shonar.provider.AuthState -import com.shonar.provider.CustomShonarProvider -import com.shonar.provider.ProviderError -import com.shonar.provider.ProviderRegistry -import com.shonar.recording.JobInfo -import com.shonar.recording.RecordingEntity -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData -import com.shonar.recording.parseJobs -import com.shonar.recording.parseSummary -import com.shonar.recording.parseTranscript -import com.shonar.recording.recordingPatchPayload -import com.shonar.recording.summaryUpdatePayload -import com.shonar.recording.transcriptUpdatePayload -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** - * M8 details screen: local playback (seek + speed) with the server transcript - * synced to the playhead, summary/action items, processing jobs, and editing. - * - * AI content lives on the custom SHONAR server (M7 endpoints) and is - * addressable only after upload — the local row's [RecordingEntity.remoteKey] - * is the server recording id. Anything else (local-only, not yet synced, - * other providers) gets an honest [AiState.Unavailable] instead of a - * spinner: playback and title/notes editing always work offline. - */ -class DetailsViewModel( - private val app: ShonarApplication, - private val recordingId: String, -) : ViewModel() { - - sealed interface AiState { - data object Loading : AiState - data class Ready(val jobs: List) : AiState - /** No transcript/summary yet, or AI not applicable here. */ - data class Unavailable(val reason: String) : AiState - data class Error(val message: String) : AiState - } - - data class Ready( - val recording: RecordingEntity, - val playing: Boolean = false, - val positionMs: Long = 0, - val durationMs: Long = 0, - val speed: Float = 1f, - val playbackError: String? = null, - val transcript: TranscriptData? = null, - val summary: SummaryData? = null, - val ai: AiState = AiState.Loading, - /** Null remote id = editing goes to Room only; non-null = also PUT/PATCH server. */ - val remoteId: String? = null, - val busy: String? = null, - val saveError: String? = null, - ) - - sealed interface UiState { - data object Loading : UiState - data class Error(val message: String) : UiState - data class ReadyState(val ready: Ready) : UiState - } - - private val dao = app.database.recordingDao() - private val registry = app.providerRegistry - private val settings = app.settingsManager - - private val _state = MutableStateFlow(UiState.Loading) - val state: StateFlow = _state.asStateFlow() - - private var player: MediaPlayer? = null - private var pollJob: Job? = null - - companion object { - val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f) - - fun factory(app: ShonarApplication, recordingId: String): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - DetailsViewModel(app, recordingId) as T - } - } - - init { - refresh() - } - - fun refresh() { - viewModelScope.launch { - _state.value = UiState.Loading - val entity = dao.getById(recordingId) - if (entity == null) { - _state.value = UiState.Error("Recording not found.") - return@launch - } - val ready = Ready(recording = entity, durationMs = entity.durationMs) - _state.value = UiState.ReadyState(ready) - loadAi(entity) - } - } - - // ---- AI content ---------------------------------------------------------- - - private suspend fun loadAi(entity: RecordingEntity) { - val cur = current() ?: return - if (entity.originProviderId != ProviderRegistry.CUSTOM_SHONAR_ID || entity.remoteKey == null) { - val reason = if (entity.remoteKey == null && - entity.originProviderId == ProviderRegistry.CUSTOM_SHONAR_ID - ) { - "Not uploaded yet — transcript and summary appear after sync + processing." - } else { - "AI transcript and summary live on a SHONAR server — " + - "connect one and sync this recording to see them here." - } - _state.value = UiState.ReadyState(cur.copy(ai = AiState.Unavailable(reason))) - return - } - val provider = registry.provider(entity.originProviderId) as? CustomShonarProvider - if (provider == null) { - _state.value = UiState.ReadyState( - cur.copy(ai = AiState.Unavailable("This provider has no AI content.")) - ) - return - } - // Best effort: stored tokens may have expired while the app was away. - if (provider.authState.value != AuthState.CONNECTED) { - runCatching { provider.reconnect() } - } - if (provider.authState.value != AuthState.CONNECTED) { - _state.value = UiState.ReadyState( - cur.copy(ai = AiState.Error("Not connected — reconnect, then reopen this recording.")) - ) - return - } - val remoteId = entity.remoteKey - val transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } - .getOrElse { return setAiError(cur, "Could not load transcript.") } - val summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } - .getOrElse { return setAiError(cur, "Could not load summary.") } - val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrDefault(emptyList()) - _state.value = UiState.ReadyState( - cur.copy( - transcript = transcript, - summary = summary, - ai = AiState.Ready(jobs), - remoteId = remoteId, - ) - ) - } - - private fun setAiError(cur: Ready, message: String) { - _state.value = UiState.ReadyState(cur.copy(ai = AiState.Error(message))) - } - - fun saveTranscript(text: String) { - val cur = current() ?: return - val remoteId = cur.remoteId ?: return - val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider - ?: return - viewModelScope.launch { - _state.value = UiState.ReadyState(cur.copy(busy = "Saving transcript…", saveError = null)) - val result = runCatching { - provider.updateTranscript(remoteId, transcriptUpdatePayload(text.trim())) - } - val parsed = result.getOrNull()?.let { parseTranscript(it) } - if (parsed != null) { - _state.value = UiState.ReadyState(current()!!.copy(transcript = parsed, busy = null)) - } else { - _state.value = UiState.ReadyState( - current()!!.copy(busy = null, saveError = "Save failed — kept your text, try again.") - ) - } - } - } - - fun saveSummary(content: Map) { - val cur = current() ?: return - val remoteId = cur.remoteId ?: return - val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider - ?: return - viewModelScope.launch { - _state.value = UiState.ReadyState(cur.copy(busy = "Saving summary…", saveError = null)) - val result = runCatching { - provider.updateSummary(remoteId, summaryUpdatePayload(content)) - } - val parsed = result.getOrNull()?.let { parseSummary(it) } - if (parsed != null) { - _state.value = UiState.ReadyState(current()!!.copy(summary = parsed, busy = null)) - } else { - _state.value = UiState.ReadyState( - current()!!.copy(busy = null, saveError = "Save failed — try again.") - ) - } - } - } - - fun rename(title: String) { - val cur = current() ?: return - val trimmed = title.trim() - if (trimmed.isEmpty() || trimmed == cur.recording.title) return - viewModelScope.launch { - // Renames the library entry AND its audio file. - val updated = runCatching { - app.recordingRepository.rename(cur.recording, trimmed) - }.getOrElse { - _state.value = UiState.ReadyState( - cur.copy(saveError = it.message ?: "Rename failed.") - ) - return@launch - } - _state.value = UiState.ReadyState( - (current() ?: cur).copy(recording = updated) - ) - val remoteId = (current() ?: cur).remoteId ?: return@launch - val provider = registry.provider(updated.originProviderId) as? CustomShonarProvider - ?: return@launch - runCatching { - provider.patchRecording(remoteId, recordingPatchPayload(title = updated.title)) - } - } - } - - fun dismissSaveError() { - val cur = current() ?: return - _state.value = UiState.ReadyState(cur.copy(saveError = null)) - } - - // ---- playback ------------------------------------------------------------ - - fun togglePlayback() { - val cur = current() ?: return - val p = player - if (p != null) { - if (p.isPlaying) { - p.pause() - stopPolling() - _state.value = UiState.ReadyState(cur.copy(playing = false, positionMs = p.currentPosition.toLong())) - } else { - applySpeed(p, cur.speed) - p.start() - startPolling() - _state.value = UiState.ReadyState(cur.copy(playing = true)) - } - return - } - viewModelScope.launch { - val fresh = (current() ?: return@launch).copy(playbackError = null) - _state.value = UiState.ReadyState(fresh) - val mp = runCatching { - MediaPlayer().apply { - setDataSource(fresh.recording.filePath) - setOnPreparedListener { prepared -> - applySpeed(prepared, fresh.speed) - prepared.start() - startPolling() - _state.value = UiState.ReadyState( - (current() ?: fresh).copy( - playing = true, - durationMs = prepared.duration.toLong().coerceAtLeast(1), - ) - ) - } - setOnCompletionListener { - stopPolling() - _state.value = UiState.ReadyState( - (current() ?: fresh).copy(playing = false, positionMs = 0) - ) - it.seekTo(0) - } - setOnErrorListener { _, _, _ -> - stopPolling() - _state.value = UiState.ReadyState( - (current() ?: fresh).copy( - playing = false, - playbackError = "Could not play this file.", - ) - ) - true - } - prepareAsync() - } - }.getOrNull() - if (mp == null) { - _state.value = UiState.ReadyState(fresh.copy(playbackError = "Could not play this file.")) - } else { - player = mp - } - } - } - - fun seekTo(positionMs: Long) { - val cur = current() ?: return - val duration = cur.durationMs.coerceAtLeast(1) - val clamped = positionMs.coerceIn(0, duration) - player?.seekTo(clamped.toInt()) - _state.value = UiState.ReadyState(cur.copy(positionMs = clamped)) - } - - fun cycleSpeed() { - val cur = current() ?: return - val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size] - player?.let { applySpeed(it, next) } - _state.value = UiState.ReadyState(cur.copy(speed = next)) - } - - private fun applySpeed(p: MediaPlayer, speed: Float) { - runCatching { - p.playbackParams = (p.playbackParams ?: PlaybackParams()).apply { this.speed = speed } - } - } - - private fun startPolling() { - pollJob?.cancel() - pollJob = viewModelScope.launch { - while (true) { - delay(250) - val p = player ?: break - if (!p.isPlaying) break - val cur = current() ?: break - _state.value = UiState.ReadyState( - cur.copy( - positionMs = p.currentPosition.toLong(), - durationMs = runCatching { p.duration.toLong() } - .getOrDefault(cur.durationMs).coerceAtLeast(1), - ) - ) - } - } - } - - private fun stopPolling() { - pollJob?.cancel() - pollJob = null - } - - private fun current(): Ready? = (_state.value as? UiState.ReadyState)?.ready - - override fun onCleared() { - stopPolling() - player?.release() - player = null - super.onCleared() - } -} diff --git a/shared/com/shonar/ui/folder/FolderBrowserScreen.kt b/shared/com/shonar/ui/folder/FolderBrowserScreen.kt deleted file mode 100644 index 08adbac..0000000 --- a/shared/com/shonar/ui/folder/FolderBrowserScreen.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.shonar.ui.folder - -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.CreateNewFolder -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -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.runtime.Composable -import androidx.compose.runtime.DisposableEffect -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.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import java.io.File - -/** - * Pick one of your own folders: browse shared storage, create a folder - * where you want it, and hand the path back. The caller (provider setup) - * still runs its own validation — the browser never writes recordings. - */ -@Composable -fun FolderBrowserScreen( - onPick: (String) -> Unit, - onBack: () -> Unit, -) { - val context = LocalContext.current - val lifecycle = LocalLifecycleOwner.current.lifecycle - - fun hasAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == - PackageManager.PERMISSION_GRANTED - } - - var access by remember { mutableStateOf(hasAccess()) } - // Re-check when returning from the Settings grant screen. - DisposableEffect(lifecycle) { - val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) access = hasAccess() - } - lifecycle.addObserver(obs) - onDispose { lifecycle.removeObserver(obs) } - } - - val settingsLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { access = hasAccess() } - val legacyPermission = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> if (granted) access = true } - - fun requestAccess() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - settingsLauncher.launch( - Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - Uri.parse("package:${context.packageName}"), - ) - ) - } else { - legacyPermission.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - - Scaffold { padding -> - Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Text("Choose a folder", style = MaterialTheme.typography.titleLarge) - } - if (!access) { - Text( - "SHONAR needs file access so you can browse your own " + - "folders. Only the folder you pick is ever used.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = ::requestAccess, modifier = Modifier.fillMaxWidth()) { - Text("Grant file access") - } - } else { - BrowserBody(onPick = onPick) - } - } - } -} - -@Composable -private fun ColumnScope.BrowserBody(onPick: (String) -> Unit) { - val start = Environment.getExternalStorageDirectory()?.path ?: "/storage/emulated/0" - var current by remember { mutableStateOf(File(start)) } - var entries by remember { mutableStateOf(listChildDirs(current)) } - var error by remember { mutableStateOf(null) } - var showCreate by remember { mutableStateOf(false) } - - fun reload(dir: File = current) { - current = dir - error = null - entries = listChildDirs(dir) - if (!dir.canRead()) error = "Cannot read this folder." - } - - Text( - current.absolutePath, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton( - onClick = { current.parentFile?.let { reload(it) } }, - enabled = current.parentFile != null, - modifier = Modifier.weight(1f), - ) { Text("Up") } - OutlinedButton( - onClick = { showCreate = true }, - modifier = Modifier.weight(1f), - ) { - Icon(Icons.Filled.CreateNewFolder, contentDescription = null) - Text(" New folder") - } - } - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) - } - LazyColumn( - Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(entries, key = { it.path }) { entry -> - Row( - Modifier.fillMaxWidth().padding(vertical = 10.dp) - .clickable { reload(File(entry.path)) }, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Filled.Folder, contentDescription = null, - tint = MaterialTheme.colorScheme.primary) - Text(entry.name, modifier = Modifier.padding(start = 12.dp).weight(1f)) - Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open") - } - } - } - Button( - onClick = { onPick(current.absolutePath) }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Use this folder") } - - if (showCreate) { - var name by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showCreate = false }, - title = { Text("New folder") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Inside:\n${current.absolutePath}", - style = MaterialTheme.typography.bodySmall) - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Folder name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { - createChildDir(current, name) - .onSuccess { reload(it) } - .onFailure { error = it.message } - if (error == null) showCreate = false - }, - enabled = isValidFolderName(name), - ) { Text("Create") } - }, - dismissButton = { TextButton(onClick = { showCreate = false }) { Text("Cancel") } }, - ) - } -} diff --git a/shared/com/shonar/ui/folder/FolderList.kt b/shared/com/shonar/ui/folder/FolderList.kt deleted file mode 100644 index 975786b..0000000 --- a/shared/com/shonar/ui/folder/FolderList.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.shonar.ui.folder - -import java.io.File - -/** - * Pure filesystem helpers for the in-app folder browser. No Android - * imports — unit-testable on the JVM. - */ - -data class DirEntry(val name: String, val path: String) - -/** Visible child folders of [dir], sorted case-insensitively. Empty when unreadable. */ -fun listChildDirs(dir: File): List = runCatching { - if (!dir.isDirectory || !dir.canRead()) return emptyList() - dir.listFiles() - ?.filter { it.isDirectory && !it.name.startsWith(".") } - ?.sortedBy { it.name.lowercase() } - ?.map { DirEntry(it.name, it.absolutePath) } - .orEmpty() -}.getOrDefault(emptyList()) - -/** True when [name] is safe to create as a single folder segment. */ -fun isValidFolderName(name: String): Boolean { - val t = name.trim() - return t.isNotEmpty() && '/' !in t && '\\' !in t && t != "." && t != ".." -} - -/** - * Create [name] inside [parent]. Returns the new dir, or null with a - * human-readable [reason] when it refuses (bad name, missing parent, - * already-exists-as-file, mkdir failure). - */ -fun createChildDir(parent: File, name: String): Result { - val trimmed = name.trim() - if (!isValidFolderName(trimmed)) { - return Result.failure(IllegalArgumentException("Not a valid folder name.")) - } - if (!parent.isDirectory) { - return Result.failure(IllegalArgumentException("Parent is not a folder.")) - } - val child = File(parent, trimmed) - if (child.exists()) { - return if (child.isDirectory) Result.success(child) - else Result.failure(IllegalArgumentException("A file with that name already exists.")) - } - return if (child.mkdir()) Result.success(child) - else Result.failure(IllegalArgumentException("Could not create the folder.")) -} diff --git a/shared/com/shonar/ui/home/HomeScreen.kt b/shared/com/shonar/ui/home/HomeScreen.kt deleted file mode 100644 index ba87b0e..0000000 --- a/shared/com/shonar/ui/home/HomeScreen.kt +++ /dev/null @@ -1,379 +0,0 @@ -package com.shonar.ui.home - -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.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.MoreVert -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -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.settings.BuiltInSettings -import kotlinx.coroutines.launch -import androidx.lifecycle.viewmodel.compose.viewModel - -/** - * Home: offline recording, local library, provider selection, and settings. - * a Settings entry, and the first-launch recording-consent notice which must - * be acknowledged before anything else. - */ -@Composable -fun HomeScreen( - onOpenSettings: () -> Unit, - onOpenStorage: () -> Unit, - onOpenDetail: (String) -> 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 notice by vm.notice.collectAsState() - val scope = rememberCoroutineScope() - var consentSeen by remember { mutableStateOf(null) } - var storageHeadline by remember { mutableStateOf(null) } - var storagePath by remember { mutableStateOf(null) } - var headlineTick by remember { mutableStateOf(0) } - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - // Returning from the folder picker resumes this screen without - // recomposition — re-read the headline so it shows the picked folder. - androidx.compose.runtime.DisposableEffect(lifecycleOwner) { - val obs = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) headlineTick++ - } - lifecycleOwner.lifecycle.addObserver(obs) - onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } - } - 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(headlineTick) { - app.settingsManager.ensureLoaded() - consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) - // Adopt files added outside the app (USB, downloads…) into the library. - runCatching { app.recordingRepository.refreshRoot() } - val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } - val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL) - // Live summary first (shows the picked folder); static label as fallback. - val summary = runCatching { - app.providerRegistry.provider(pid).storageLocationSummary() - }.getOrNull() - storageHeadline = summary?.headline ?: com.shonar.ui.provider.ProviderSelectionViewModel - .headlineFor(pid.ifBlank { "local-only" }, purl) - storagePath = summary?.path - } - - Scaffold( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - if (consentSeen == true) { - if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) { - startRecording() - } else { - vm.stop() - } - } - }, - icon = { Icon(Icons.Filled.Mic, contentDescription = null) }, - text = { - Text(if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) "Record" else "Stop") - }, - ) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("SHONAR", style = MaterialTheme.typography.headlineMedium) - Text( - "Self-hosted Oral Notes and Audio Recorder", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - storageHeadline?.let { headline -> - StorageChip(headline = headline, onClick = onOpenStorage) - } - Spacer(Modifier.height(24.dp)) - QuickCard("Choose storage", storagePath ?: storageHeadline ?: "Where recordings live", - Icons.Filled.Cloud, onOpenStorage) - QuickCard("Settings", "Appearance, custom settings", - Icons.Filled.Settings, onOpenSettings) - 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)) - notice?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.align(Alignment.Start)) - } - recordings.forEach { recording -> - RecordingRow( - recording = recording, - playing = playingId == recording.id, - onPlay = { vm.togglePlayback(recording) }, - onDelete = { vm.delete(recording) }, - onOpen = { onOpenDetail(recording.id) }, - onRename = { vm.rename(recording, it) }, - ) - } - } else { - Text( - "Your recordings will appear here and stay on this device until you choose a provider.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - if (consentSeen == false) { - ConsentDialog(onAcknowledge = { - scope.launch { - app.settingsManager.setValue(BuiltInSettings.CONSENT, "true") - } - consentSeen = true - }) - } -} - -@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, - onOpen: () -> Unit, - onRename: (String) -> Unit, -) { - var menu by remember { mutableStateOf(false) } - var renaming by remember { mutableStateOf(false) } - Card(onClick = onOpen, modifier = 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") } - androidx.compose.material3.IconButton(onClick = { menu = true }) { - androidx.compose.material3.Icon( - androidx.compose.material.icons.Icons.Filled.MoreVert, - contentDescription = "More actions", - ) - } - androidx.compose.material3.DropdownMenu( - expanded = menu, - onDismissRequest = { menu = false }, - ) { - androidx.compose.material3.DropdownMenuItem( - text = { Text("Rename file") }, - onClick = { menu = false; renaming = true }, - ) - androidx.compose.material3.DropdownMenuItem( - text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, - onClick = { menu = false; onDelete() }, - ) - } - } - } - if (renaming) { - var draft by remember(recording.id) { mutableStateOf(recording.title) } - AlertDialog( - onDismissRequest = { renaming = false }, - title = { Text("Rename file") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Text( - "Renames the audio file too — same folder, same format.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - TextButton( - onClick = { onRename(draft); renaming = false }, - enabled = draft.isNotBlank(), - ) { Text("Rename") } - }, - dismissButton = { TextButton(onClick = { renaming = false }) { Text("Cancel") } }, - ) - } -} - -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 -private fun ConsentDialog(onAcknowledge: () -> Unit) { - AlertDialog( - onDismissRequest = { }, - title = { Text("Record responsibly") }, - text = { - Text( - "Recording conversations may require consent where you live — " + - "many jurisdictions require everyone's permission, and " + - "recording conversations you are not part of is illegal " + - "almost everywhere. By continuing you confirm you will " + - "comply with your local recording-consent laws. " + - "SHONAR never records silently: an always-visible " + - "indicator and stop control are part of the design." - ) - }, - confirmButton = { TextButton(onClick = onAcknowledge) { Text("I understand") } }, - ) -} - -@Composable -private fun QuickCard( - title: String, - subtitle: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, - onClick: () -> Unit, -) { - Card(onClick = onClick, modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { - Row( - Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(32.dp)) - Spacer(Modifier.size(16.dp)) - Column { - Text(title, style = MaterialTheme.typography.titleMedium) - Text(subtitle, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/shared/com/shonar/ui/home/RecordingViewModel.kt b/shared/com/shonar/ui/home/RecordingViewModel.kt deleted file mode 100644 index 8379d2e..0000000 --- a/shared/com/shonar/ui/home/RecordingViewModel.kt +++ /dev/null @@ -1,88 +0,0 @@ -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> = app.recordingRepository.recordings - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val recorderState = RecordingService.snapshot - private val _playingId = kotlinx.coroutines.flow.MutableStateFlow(null) - val playingId: StateFlow = _playingId - private val _notice = kotlinx.coroutines.flow.MutableStateFlow(null) - /** Transient rename/delete failures surface here; cleared on next action. */ - val notice: StateFlow = _notice - 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_SAVE) - fun save() = RecordingService.command(app, RecordingService.ACTION_SAVE) - fun cancel() = RecordingService.command(app, RecordingService.ACTION_CANCEL) - fun discard() = RecordingService.command(app, RecordingService.ACTION_DELETE) - - 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) { - _notice.value = null - if (_playingId.value == recording.id) stopPlayback() - viewModelScope.launch { - runCatching { app.recordingRepository.delete(recording) } - .onFailure { _notice.value = it.message ?: "Delete failed." } - } - } - - fun rename(recording: RecordingEntity, newTitle: String) { - _notice.value = null - viewModelScope.launch { - runCatching { app.recordingRepository.rename(recording, newTitle) } - .onFailure { _notice.value = it.message ?: "Rename failed." } - } - } - - 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) - } - } - } -} diff --git a/shared/com/shonar/ui/provider/ProviderSelectionScreen.kt b/shared/com/shonar/ui/provider/ProviderSelectionScreen.kt deleted file mode 100644 index 8b6cd04..0000000 --- a/shared/com/shonar/ui/provider/ProviderSelectionScreen.kt +++ /dev/null @@ -1,450 +0,0 @@ -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.OutlinedButton -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.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 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), - browsePick: String? = null, - onPickConsumed: () -> Unit = {}, - onBrowse: () -> Unit = {}, -) { - val state by vm.state.collectAsState() - val context = LocalContext.current - var selectedId by remember { mutableStateOf(null) } - var urlInput by remember { mutableStateOf("") } - var pathInput by remember { mutableStateOf("") } - var confirmCreate by remember { mutableStateOf(false) } - var showAdvanced by remember { mutableStateOf(false) } - - // A path picked in the folder browser saves immediately — the browse - // choice IS the confirmation, no second tap needed. With no card - // tapped, the pick means on-this-phone (device storage is the only - // non-advanced target). - LaunchedEffect(browsePick) { - if (browsePick != null) { - pathInput = browsePick - val localId = com.shonar.provider.LocalOnlyProvider.ID - val target = vm.options.firstOrNull { it.id == selectedId } - ?: vm.options.firstOrNull { it.id == localId } - if (target != null && target.needsPath && target.available) { - selectedId = target.id - vm.connectFolder(browsePick, target.id) - } - onPickConsumed() - } - } - - 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( - "Recordings stay on this phone, in your own folder if you " + - "like. You can switch later without losing anything.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - } - items(vm.simpleOptions) { opt -> - ProviderCard( - option = opt, - selected = selectedId == opt.id, - onSelect = { - selectedId = opt.id - if (!opt.available) { - // surface the honest reason immediately - vm.choose(opt, "") - } else if (opt.allowsBlankPath) { - // Tapping is the choice: persist the default now. - // Picking a folder below upgrades it. - vm.choose(opt, "") - } - }, - ) - } - val sel = vm.options.firstOrNull { it.id == selectedId } - item { - TextButton( - onClick = { showAdvanced = !showAdvanced }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (showAdvanced) "Hide self-hosted servers" - else "Self-hosted servers (advanced)" - ) - } - } - if (showAdvanced) { - items(vm.advancedOptions) { opt -> - ProviderCard( - option = opt, - selected = selectedId == opt.id, - onSelect = { - selectedId = opt.id - if (!opt.available) { - vm.choose(opt, "") - } - }, - ) - } - } - 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 (or browse below)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - item { - OutlinedButton( - onClick = onBrowse, - modifier = Modifier.fillMaxWidth(), - ) { Text("Browse my folders…") } - } - item { - Button( - onClick = { vm.connectFolder(pathInput, sel.id) }, - enabled = state !is SelectionUiState.Probing && - (pathInput.isNotBlank() || sel.allowsBlankPath), - modifier = Modifier.fillMaxWidth(), - ) { Text(if (pathInput.isBlank()) "Use default folder" else "Use this folder") } - } - item { - OutlinedButton( - onClick = { confirmCreate = true }, - enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Create folder") } - } - if (confirmCreate) { - item { - AlertDialog( - onDismissRequest = { confirmCreate = false }, - title = { Text("Create folder?") }, - text = { - Text( - "This creates:\n${pathInput.trim()}\n\n" + - "Only the last part is created — its parent " + - "must already exist. Nothing else on your " + - "device is touched." - ) - }, - confirmButton = { - TextButton(onClick = { - confirmCreate = false - vm.createFolder(pathInput, sel.id) - }) { Text("Create") } - }, - dismissButton = { - TextButton(onClick = { confirmCreate = false }) { Text("Cancel") } - }, - ) - } - } - } - 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) - } - } - } -} diff --git a/shared/com/shonar/ui/provider/ProviderSelectionViewModel.kt b/shared/com/shonar/ui/provider/ProviderSelectionViewModel.kt deleted file mode 100644 index b1d8ee8..0000000 --- a/shared/com/shonar/ui/provider/ProviderSelectionViewModel.kt +++ /dev/null @@ -1,622 +0,0 @@ -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, // local folder choice: a directory path - val advanced: Boolean = false, // self-hosted servers, hidden by default -) { - /** On-this-phone accepts a blank path (app-private default folder). */ - val allowsBlankPath: Boolean - get() = id == com.shonar.provider.LocalOnlyProvider.ID && !needsUrl -} - -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 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.Idle) - val state: StateFlow = _state.asStateFlow() - - val options: List = listOf( - ProviderOption( - id = com.shonar.provider.LocalOnlyProvider.ID, - name = "On this phone", - blurb = "Recordings stay on this phone, in your own folder if you " + - "like — sync it yourself however you want. Playback and " + - "search work fully offline.", - needsUrl = false, - available = true, - needsPath = true, - ), - ProviderOption( - id = ProviderRegistry.NEXTCLOUD_ID, - name = "Nextcloud", - blurb = "Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.", - needsUrl = true, - available = true, // P4 - advanced = true, - ), - 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 - advanced = 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) - advanced = true, - ), - 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) - advanced = true, - ), - ) - - /** Cards shown without opening Advanced. Server choices stay one tap away. */ - val simpleOptions: List get() = options.filter { !it.advanced } - - /** Self-hosted server choices, behind the Advanced disclosure. */ - val advancedOptions: List get() = options.filter { it.advanced } - - 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 (its folder is optional — - // the path step below only overrides it). The sync folder - // has no default, so it waits for its path step. - if (option.needsPath && - option.id != com.shonar.provider.LocalOnlyProvider.ID - ) { - 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 - } - - /** - * Use a folder for a path-capable provider (on-this-phone or sync - * folder). Blank path means "just the default": valid for on-this-phone - * (app-private dir), an error for the sync folder, which has no default. - */ - fun connectFolder(rawPath: String, providerId: String = ProviderRegistry.SYNC_FOLDER_ID) { - viewModelScope.launch { - val path = rawPath.trim() - if (path.isEmpty()) { - if (providerId == com.shonar.provider.LocalOnlyProvider.ID) { - runCatching { save(providerId, "") } - .onFailure { - _state.value = SelectionUiState.Error( - it.message ?: "Could not save provider" - ) - } - } else { - _state.value = SelectionUiState.Error( - "Enter the folder path — e.g. the Syncthing folder for SHONAR." - ) - } - return@launch - } - _state.value = SelectionUiState.Probing(path) - runCatching { - registry.provider(providerId).connect( - com.shonar.provider.ProviderCredential.FolderPath( - accountLabel = path, - path = path, - ) - ) - }.onFailure { - _state.value = SelectionUiState.Error(it.message ?: "Could not use $path") - return@launch - } - save(providerId, "") - } - } - - /** - * Create the folder (only the final segment; the parent must already - * exist) and use it for [providerId]. The UI confirms the exact path - * before calling — creation is always explicit, never a typo side - * effect. Blank path falls back to the default, like [connectFolder]. - */ - fun createFolder( - rawPath: String, - providerId: String = ProviderRegistry.SYNC_FOLDER_ID, - ) { - viewModelScope.launch { - val path = rawPath.trim() - if (path.isEmpty()) { - connectFolder("", providerId) - return@launch - } - _state.value = SelectionUiState.Probing(path) - val provider = runCatching { registry.provider(providerId) }.getOrNull() - val failure = runCatching { - when (provider) { - is com.shonar.provider.LocalOnlyProvider -> provider.createRoot(path) - is com.shonar.provider.FolderSyncProvider -> provider.createRoot(path) - else -> throw com.shonar.provider.ProviderError.InvalidUrl( - "This provider has no folder to create" - ) - } - }.exceptionOrNull() - if (failure != null) { - _state.value = SelectionUiState.Error(failure.message ?: "Could not create $path") - return@launch - } - save(providerId, "") - } - } - - 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("\"", "\\\"") + "\"", - ) - } - // New recordings follow the picked folder from here on. - // Older clips stay where they were recorded. - runCatching { repository.refreshRoot() } - // 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 -} diff --git a/shared/com/shonar/ui/provider/StorageScreen.kt b/shared/com/shonar/ui/provider/StorageScreen.kt deleted file mode 100644 index 5a388c5..0000000 --- a/shared/com/shonar/ui/provider/StorageScreen.kt +++ /dev/null @@ -1,263 +0,0 @@ -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 -import com.shonar.provider.LocalOnlyProvider - -/** - * 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) - it.path?.let { path -> - Text( - path, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - 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 - val isLocal = ready.providerId == LocalOnlyProvider.ID - Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) { - Text("Location") - } - // On-device storage has no accounts, servers, or sync jobs — just the - // location. Everything below is server/provider lifecycle. - if (!isLocal) { - if (ready.auth != AuthState.CONNECTED) { - Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) { - Text("Reconnect") - } - } - 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") } - }, - ) -} diff --git a/shared/com/shonar/ui/provider/StorageViewModel.kt b/shared/com/shonar/ui/provider/StorageViewModel.kt deleted file mode 100644 index 4b748f8..0000000 --- a/shared/com/shonar/ui/provider/StorageViewModel.kt +++ /dev/null @@ -1,202 +0,0 @@ -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.Loading) - val state: StateFlow = _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) - } - } - } -} diff --git a/shared/com/shonar/ui/rename/RenameAfterSave.kt b/shared/com/shonar/ui/rename/RenameAfterSave.kt deleted file mode 100644 index eca273b..0000000 --- a/shared/com/shonar/ui/rename/RenameAfterSave.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.shonar.ui.rename - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.shonar.ShonarApplication -import com.shonar.recording.RecordingFiles -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -/** - * Post-save rename. Cancel preserves the file under its default name — - * nothing is ever deleted here. Validation: non-blank, legal chars, - * collision-proof via [RecordingFiles.uniqueFinalFile] semantics in the - * repository (adds " (2)" instead of overwriting). - */ -class RenameAfterSaveViewModel(private val app: ShonarApplication) : ViewModel() { - private val _error = MutableStateFlow(null) - val error: StateFlow = _error - private val _done = MutableStateFlow(false) - val done: StateFlow = _done - - fun confirm(recordingId: String, rawName: String, onDone: () -> Unit) { - val stem = RecordingFiles.sanitizeStem(rawName) - if (stem == null) { - _error.value = "Enter a valid name." - return - } - viewModelScope.launch { - try { - val row = app.database.recordingDao().getById(recordingId) - ?: throw IllegalArgumentException("Recording not found.") - if (row.title != stem) { - app.recordingRepository.rename(row, stem) - } - _done.value = true - onDone() - } catch (e: Exception) { - _error.value = e.message ?: "Rename failed." - } - } - } -} - -@Composable -fun RenameAfterSaveDialog( - defaultName: String, - error: String?, - onConfirm: (String) -> Unit, - onCancel: () -> Unit, -) { - var draft by remember(defaultName) { mutableStateOf(defaultName) } - val focus = remember { FocusRequester() } - val validation: String? = when { - draft.isBlank() -> "Name can't be empty." - RecordingFiles.sanitizeStem(draft) == null -> "That name has no usable characters." - else -> null - } - AlertDialog( - onDismissRequest = onCancel, // dismiss == Cancel == keep default name - title = { Text("Save recording") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - singleLine = true, - isError = validation != null, - supportingText = { - Text(validation ?: error ?: "Saved with this name. You can rename later.") - }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { - if (validation == null) onConfirm(draft) - }), - modifier = Modifier.fillMaxWidth().focusRequester(focus), - ) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(draft) }, enabled = validation == null) { - Text("Save") - } - }, - dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } }, - ) - LaunchedEffect(Unit) { - // Focus + keyboard: post-frame request works across OEM skins. - kotlinx.coroutines.delay(120) - runCatching { focus.requestFocus() } - } -} diff --git a/shared/com/shonar/ui/settings/SettingsScreen.kt b/shared/com/shonar/ui/settings/SettingsScreen.kt deleted file mode 100644 index 4c596bc..0000000 --- a/shared/com/shonar/ui/settings/SettingsScreen.kt +++ /dev/null @@ -1,539 +0,0 @@ -package com.shonar.ui.settings - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Slider -import androidx.compose.material3.Switch -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.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import com.shonar.ShonarApplication -import com.shonar.settings.SettingDefinition -import com.shonar.settings.SettingType -import com.shonar.ui.theme.parseColor -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive - -/** - * Generic settings screen: every control is rendered from the setting's - * [SettingType] — no per-setting UI code. Users can add/edit/delete custom - * settings, reset to defaults, search, and export/import JSON. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen(onBack: () -> Unit) { - val app = LocalContext.current.applicationContext as ShonarApplication - val vm = remember { SettingsViewModel(app) } - val state by vm.state.collectAsState() - - var showAddDialog by remember { mutableStateOf(false) } - var showImportDialog by remember { mutableStateOf(false) } - var exportText by remember { mutableStateOf(null) } - var pendingDelete by remember { mutableStateOf(null) } - - Column(Modifier.fillMaxWidth()) { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { vm.export { exportText = it } }) { - Icon(Icons.Filled.Download, contentDescription = "Export settings") - } - IconButton(onClick = { showImportDialog = true }) { - Icon(Icons.Filled.Upload, contentDescription = "Import settings") - } - IconButton(onClick = { showAddDialog = true }) { - Icon(Icons.Filled.Add, contentDescription = "Add custom setting") - } - }, - ) - - state.message?.let { msg -> - Text( - msg, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - OutlinedTextField( - value = state.search, - onValueChange = vm::setSearch, - placeholder = { Text("Search settings") }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - - LazyColumn(Modifier.fillMaxWidth()) { - state.categories.forEach { section -> - item { - Text( - section.name, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), - ) - } - section.items.forEach { row -> - item(key = row.def.id) { - SettingRowView( - row = row, - onValueChange = { jsonValue -> vm.setValue(row.def.id, jsonValue) }, - onReset = { vm.reset(row.def.id) }, - onDelete = if (row.def.custom) ({ pendingDelete = row.def }) else null, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - } - } - item { Spacer(Modifier.height(32.dp)) } - } - } - - if (showAddDialog) { - AddCustomSettingDialog( - onDismiss = { showAddDialog = false }, - onAdd = { def -> vm.addCustom(def); showAddDialog = false }, - ) - } - - if (showImportDialog) { - TextImportDialog( - title = "Import settings JSON", - onDismiss = { showImportDialog = false }, - onConfirm = { text -> vm.import(text); showImportDialog = false }, - ) - } - - exportText?.let { text -> - TextImportDialog( - title = "Export (copy this JSON; secrets are not included)", - initial = text, - editable = false, - confirmLabel = "Close", - onDismiss = { exportText = null }, - onConfirm = { exportText = null }, - ) - } - - pendingDelete?.let { def -> - AlertDialog( - onDismissRequest = { pendingDelete = null }, - title = { Text("Delete \u201C${def.name}\u201D?") }, - text = { Text("This removes the custom setting and its value.") }, - confirmButton = { - TextButton(onClick = { vm.deleteCustom(def.id); pendingDelete = null }) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } - }, - ) - } -} - -@Composable -private fun SettingRowView( - row: SettingsUiState.SettingRow, - onValueChange: (String) -> Unit, - onReset: () -> Unit, - onDelete: (() -> Unit)?, -) { - val def = row.def - Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(def.name, style = MaterialTheme.typography.bodyLarge) - if (def.description.isNotBlank()) { - Text( - def.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (!def.editable) { - Text( - "read-only", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (onDelete != null) { - IconButton(onClick = onDelete) { - Icon( - Icons.Filled.Delete, - contentDescription = "Delete setting", - tint = MaterialTheme.colorScheme.error, - ) - } - } - } - Spacer(Modifier.height(6.dp)) - Control(def = def, valueJson = row.valueJson, onValueChange = onValueChange, onReset = onReset) - } -} - -/** Renders the correct control for each SettingType. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun Control( - def: SettingDefinition, - valueJson: String, - onValueChange: (String) -> Unit, - onReset: () -> Unit, -) { - val json = remember { Json { ignoreUnknownKeys = true } } - val prim = runCatching { json.parseToJsonElement(valueJson).jsonPrimitive }.getOrNull() - - when (def.type) { - SettingType.BOOLEAN -> { - val v = prim?.booleanOrNull ?: false - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - checked = v, - onCheckedChange = { onValueChange(if (it) "true" else "false") }, - enabled = def.editable, - ) - } - } - - SettingType.STRING, SettingType.URL -> { - var text by remember(valueJson) { mutableStateOf(prim?.content ?: "") } - OutlinedTextField( - value = text, - onValueChange = { text = it }, - singleLine = true, - enabled = def.editable, - isError = def.type == SettingType.URL && text.isNotBlank() && - runCatching { - com.shonar.settings.SettingsManager.validateUrlOrThrow(text) - }.isFailure, - supportingText = { - Row { - TextButton(onClick = onReset, contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { - Text("Reset to default") - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) - if (text != (prim?.content ?: "")) { - // commit on change-complete via a small Apply button pattern: - TextButton(onClick = { onValueChange(encodeString(text)) }) { Text("Apply") } - } - } - - SettingType.SECRET -> { - var text by remember(valueJson) { mutableStateOf(prim?.content ?: "") } - OutlinedTextField( - value = text, - onValueChange = { text = it }, - singleLine = true, - enabled = def.editable, - visualTransformation = PasswordVisualTransformation(), - placeholder = { Text(if ((prim?.content ?: "").isBlank()) "not set" else "\u2022\u2022\u2022\u2022\u2022\u2022") }, - modifier = Modifier.fillMaxWidth(), - ) - Row { - TextButton(onClick = { onValueChange(encodeString(text)) }) { Text("Save") } - if ((prim?.content ?: "").isNotBlank()) { - TextButton(onClick = onReset) { Text("Clear") } - } - } - } - - SettingType.NUMBER -> { - val current = prim?.content?.toDoubleOrNull() ?: def.min ?: 0.0 - var text by remember(valueJson) { mutableStateOf(current.toString()) } - val lo = def.min ?: 0.0 - val hi = def.max ?: (lo + 100.0) - if (hi > lo) { - Slider( - value = current.toFloat().coerceIn(lo.toFloat(), hi.toFloat()), - onValueChange = { text = it.toString() }, - onValueChangeFinished = { onValueChange(text) }, - valueRange = lo.toFloat()..hi.toFloat(), - enabled = def.editable, - ) - } - OutlinedTextField( - value = text, - onValueChange = { text = it }, - singleLine = true, - enabled = def.editable, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - trailingIcon = { - TextButton(onClick = { onValueChange(text) }) { Text("Set") } - }, - modifier = Modifier.fillMaxWidth(), - ) - } - - SettingType.SELECT -> { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField( - value = prim?.content ?: "", - onValueChange = {}, - readOnly = true, - enabled = def.editable, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - def.choices.forEach { choice -> - DropdownMenuItem( - text = { Text(choice) }, - onClick = { - onValueChange(encodeString(choice)) - expanded = false - }, - ) - } - } - } - } - - SettingType.MULTI_SELECT -> { - val selected = runCatching { - json.parseToJsonElement(valueJson).jsonArray.map { it.jsonPrimitive.content } - }.getOrDefault(emptyList()) - def.choices.forEach { choice -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable(enabled = def.editable) { - val next = if (choice in selected) selected - choice else selected + choice - onValueChange(encodeStringList(next)) - }, - ) { - Checkbox(checked = choice in selected, onCheckedChange = null, enabled = def.editable) - Text(choice, style = MaterialTheme.typography.bodyMedium) - } - } - } - - SettingType.COLOR -> { - val presets = listOf("#4FD1C5", "#2A9D94", "#6C9EF8", "#F6C44C", "#E76F51", "#A78BFA") - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - presets.forEach { hex -> - Box( - modifier = Modifier - .size(32.dp) - .background(parseColor(hex), CircleShape) - .border( - width = if (prim?.content == hex) 3.dp else 1.dp, - color = MaterialTheme.colorScheme.outline, - shape = CircleShape, - ) - .clickable(enabled = def.editable) { onValueChange(encodeString(hex)) }, - ) - } - } - TextButton(onClick = onReset) { Text("Reset") } - } - } -} - -private fun encodeString(s: String): String = - JsonPrimitive(s).toString() - -private fun encodeStringList(items: List): String = - items.joinToString(prefix = "[", postfix = "]") { JsonPrimitive(it).toString() } - -/** Reusable JSON/text import dialog. */ -@Composable -fun TextImportDialog( - title: String, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, - initial: String = "", - editable: Boolean = true, - confirmLabel: String = "Import", -) { - var text by remember { mutableStateOf(initial) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title, style = MaterialTheme.typography.titleMedium) }, - text = { - OutlinedTextField( - value = text, - onValueChange = { if (editable) text = it }, - readOnly = !editable, - minLines = 6, - maxLines = 12, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton(onClick = { onConfirm(text) }, enabled = text.isNotBlank()) { - Text(confirmLabel) - } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -/** Dialog to create a custom setting definition. */ -@Composable -private fun AddCustomSettingDialog( - onDismiss: () -> Unit, - onAdd: (SettingDefinition) -> Unit, -) { - var id by remember { mutableStateOf("") } - var name by remember { mutableStateOf("") } - var description by remember { mutableStateOf("") } - var category by remember { mutableStateOf("Custom") } - var type by remember { mutableStateOf(SettingType.STRING) } - var defaultText by remember { mutableStateOf("") } - var minText by remember { mutableStateOf("") } - var maxText by remember { mutableStateOf("") } - var choicesText by remember { mutableStateOf("") } - var sensitive by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("New custom setting") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - OutlinedTextField(value = id, onValueChange = { id = it }, label = { Text("id (snake_case)") }, singleLine = true) - OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Display name") }, singleLine = true) - OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text("Description") }, singleLine = true) - OutlinedTextField(value = category, onValueChange = { category = it }, label = { Text("Category") }, singleLine = true) - TypeDropdown(type) { type = it } - OutlinedTextField(value = defaultText, onValueChange = { defaultText = it }, label = { Text("Default value") }, singleLine = true) - if (type == SettingType.NUMBER) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(value = minText, onValueChange = { minText = it }, label = { Text("min") }, singleLine = true, modifier = Modifier.weight(1f)) - OutlinedTextField(value = maxText, onValueChange = { maxText = it }, label = { Text("max") }, singleLine = true, modifier = Modifier.weight(1f)) - } - } - if (type == SettingType.SELECT || type == SettingType.MULTI_SELECT) { - OutlinedTextField(value = choicesText, onValueChange = { choicesText = it }, label = { Text("Choices (comma-separated)") }, singleLine = true) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = sensitive, onCheckedChange = { sensitive = it }) - Text("Sensitive (store encrypted)") - } - error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } - } - }, - confirmButton = { - TextButton(onClick = { - val defId = id.trim().lowercase().replace(' ', '_') - if (defId.isBlank() || name.isBlank()) { - error = "id and display name are required" - return@TextButton - } - val defaultJson = when (type) { - SettingType.BOOLEAN -> - if (defaultText.trim().lowercase() in setOf("true", "on", "yes", "1")) "true" else "false" - SettingType.NUMBER -> - defaultText.trim().toDoubleOrNull()?.toString() - ?: run { error = "Default must be a number"; return@TextButton } - SettingType.MULTI_SELECT -> encodeStringList( - defaultText.split(",").map { it.trim() }.filter { it.isNotBlank() } - ) - else -> encodeString(defaultText) - } - val def = SettingDefinition( - id = defId, - name = name.trim(), - description = description.trim(), - category = category.trim().ifBlank { "Custom" }, - type = type, - defaultJson = defaultJson, - min = minText.trim().toDoubleOrNull(), - max = maxText.trim().toDoubleOrNull(), - choices = choicesText.split(",").map { it.trim() }.filter { it.isNotBlank() }, - sensitive = sensitive || type == SettingType.SECRET, - ) - onAdd(def) // SettingsManager re-validates and reports errors - }) { Text("Create") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TypeDropdown(value: SettingType, onSelect: (SettingType) -> Unit) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField( - value = value.name.lowercase().replace('_', ' '), - onValueChange = {}, - readOnly = true, - label = { Text("Type") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - modifier = Modifier.menuAnchor().fillMaxWidth(), - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - SettingType.entries.forEach { t -> - DropdownMenuItem( - text = { Text(t.name.lowercase().replace('_', ' ')) }, - onClick = { onSelect(t); expanded = false }, - ) - } - } - } -} diff --git a/shared/com/shonar/ui/settings/SettingsViewModel.kt b/shared/com/shonar/ui/settings/SettingsViewModel.kt deleted file mode 100644 index c6f9f18..0000000 --- a/shared/com/shonar/ui/settings/SettingsViewModel.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.shonar.ui.settings - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.shonar.ShonarApplication -import com.shonar.settings.SettingDefinition -import com.shonar.settings.SettingType -import com.shonar.settings.SettingValidationException -import com.shonar.settings.SettingsManager -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -data class SettingsUiState( - val categories: List = emptyList(), - val search: String = "", - val busy: Boolean = false, - val message: String? = null, -) { - data class CategorySection( - val name: String, - val items: List, - ) - data class SettingRow( - val def: SettingDefinition, - /** Displayable current value (secrets masked by the UI itself). */ - val valueJson: String, - ) -} - -class SettingsViewModel(private val app: ShonarApplication) : ViewModel() { - - val settings: SettingsManager = app.settingsManager - private val searchFlow = MutableStateFlow("") - private val messageFlow = MutableStateFlow(null) - - // combine's transform is suspend, so value reads stay coroutine-native - // (no runBlocking anywhere near the main thread). - val state: StateFlow = combine( - settings.definitions, - settings.valuesChanged, - searchFlow, - messageFlow, - ) { defs, _, query, message -> - val gateValue = mutableMapOf() - suspend fun gate(id: String): Boolean = - gateValue.getOrPut(id) { settings.bool(id) } - - // visibility rules: hide settings gated on a false boolean - val visible = defs.filter { def -> - val g = def.visibleIfSettingId ?: return@filter true - gate(g) - } - val filtered = if (query.isBlank()) visible else visible.filter { - it.name.contains(query, ignoreCase = true) || - it.id.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) - } - val byCat = filtered.groupBy { it.category } - SettingsUiState( - categories = byCat.entries - .sortedBy { it.key } - .map { (cat, items) -> - SettingsUiState.CategorySection( - cat, - items.map { row -> - SettingsUiState.SettingRow( - def = row, - valueJson = settings.value(row.id)?.toString() ?: row.defaultJson, - ) - }, - ) - }, - search = query, - message = message, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) - - fun setSearch(q: String) { - searchFlow.value = q - } - - fun setValue(id: String, jsonValue: String) = viewModelScope.launch { - runCatching { settings.setValue(id, jsonValue) } - .onFailure { messageFlow.value = it.message ?: "Invalid value" } - } - - fun reset(id: String) = viewModelScope.launch { - runCatching { settings.reset(id) } - .onFailure { messageFlow.value = it.message } - } - - fun addCustom(def: SettingDefinition) = viewModelScope.launch { - runCatching { settings.addCustom(def) } - .onSuccess { messageFlow.value = "Added \u201C${def.name}\u201D" } - .onFailure { messageFlow.value = it.message } - } - - fun deleteCustom(id: String) = viewModelScope.launch { - runCatching { settings.deleteCustom(id) } - .onSuccess { messageFlow.value = "Setting deleted" } - .onFailure { messageFlow.value = it.message } - } - - fun export(onDone: (String) -> Unit) = viewModelScope.launch { - runCatching { settings.exportJson(includeSecrets = false) } - .onSuccess(onDone) - .onFailure { messageFlow.value = it.message } - } - - fun import(text: String) = viewModelScope.launch { - runCatching { settings.importJson(text) } - .onSuccess { res -> - messageFlow.value = if (res.rejected.isEmpty()) { - "Imported ${res.applied.size} settings" - } else { - "Import rejected: " + res.rejected.entries.joinToString { "${it.key}: ${it.value}" } - } - } - .onFailure { messageFlow.value = it.message } - } - -} diff --git a/shared/com/shonar/ui/theme/Theme.kt b/shared/com/shonar/ui/theme/Theme.kt deleted file mode 100644 index 4ceeefc..0000000 --- a/shared/com/shonar/ui/theme/Theme.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.shonar.ui.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -// SHONAR palette — deep-sea sonar: dark navy + teal ping. -private val Teal = Color(0xFF4FD1C5) -private val TealDark = Color(0xFF2A9D94) -private val DeepNavy = Color(0xFF0B1220) -private val NavySurface = Color(0xFF121C2E) -private val Ink = Color(0xFF10141A) - -private val DarkScheme = darkColorScheme( - primary = Teal, - onPrimary = Ink, - secondary = TealDark, - background = DeepNavy, - surface = NavySurface, - onBackground = Color(0xFFE6EDF3), - onSurface = Color(0xFFE6EDF3), -) - -private val LightScheme = lightColorScheme( - primary = TealDark, - onPrimary = Color.White, - secondary = Teal, -) - -@Composable -fun ShonarTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit, -) { - MaterialTheme( - colorScheme = if (darkTheme) DarkScheme else LightScheme, - content = content, - ) -} - -/** Parse "#RRGGBB"/"#AARRGGBB" (validated by SettingsManager); fallback teal. */ -fun parseColor(hex: String, fallback: Color = Teal): Color { - val v = hex.removePrefix("#") - val l = v.toLongOrNull(16) ?: return fallback - return when (v.length) { - 6 -> Color(0xFF000000 or l) - 8 -> Color(l) - else -> fallback - } -} diff --git a/shared/com/shonar/widget/ShonarBootReceiver.kt b/shared/com/shonar/widget/ShonarBootReceiver.kt deleted file mode 100644 index 37b1726..0000000 --- a/shared/com/shonar/widget/ShonarBootReceiver.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.shonar.widget - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.shonar.recording.RecordingStateStore -import com.shonar.recording.RecordingFiles -import com.shonar.ShonarApplication -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -/** - * After reboot the recording is gone (RAM + MediaRecorder died with the - * OS) but the temp file + journal may survive. Never resume silently: - * mark widgets IDLE, sweep stale temps, clear the journal. ACTION_MY_PACKAGE_REPLACED - * covers app updates the same way. - */ -class ShonarBootReceiver : BroadcastReceiver() { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action !in setOf( - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - Intent.ACTION_LOCKED_BOOT_COMPLETED, - ) - ) return - scope.launch { - val store = RecordingStateStore(context) - val persisted = runCatching { store.load() }.getOrNull() - runCatching { - val app = context.applicationContext as? ShonarApplication - val root = app?.filesDir ?: context.filesDir - RecordingFiles.sweepOrphanedTempFiles(root, validId = null) - // Keep a <24h orphan for manual recovery instead of deleting: - // only the journal is cleared so the widget returns to idle. - } - runCatching { store.clear() } - ShonarWidgetUpdater.refresh(context) - } - } -} diff --git a/shared/com/shonar/widget/ShonarWidget.kt b/shared/com/shonar/widget/ShonarWidget.kt deleted file mode 100644 index 52f8c64..0000000 --- a/shared/com/shonar/widget/ShonarWidget.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.shonar.widget - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Build -import android.view.View -import android.widget.RemoteViews -import com.shonar.MainActivity -import com.shonar.R -import com.shonar.recording.RecordingPermissionHelper -import com.shonar.recording.RecordingService - -/** - * 1x1 icon widget: Shonar-style mic (navy disc + teal mic + sonar arcs), - * NOT the launcher ping-dot. Icon stays a mic; only the badge dot changes: - * gone = idle, red = recording, amber = paused. All transport controls live - * in the notification (pause / stop / disregard / save-checkmark) per spec. - * - * Tap: idle -> START. Active/paused -> open app (never one-tap-discard). - */ -class ShonarRecordWidget : AppWidgetProvider() { - - override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) { - val s = RecordingService.snapshot.value - ids.forEach { mgr.updateAppWidget(it, views(ctx, s.phase)) } - } - - override fun onReceive(ctx: Context, intent: Intent) { - super.onReceive(ctx, intent) - if (intent.action == ACTION_WIDGET_RECORD) { - if (!RecordingPermissionHelper.hasRecordAudio(ctx)) { - val open = Intent(ctx, MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .setAction(RecordingService.ACTION_START) - ctx.startActivity(open) - } else { - RecordingService.command(ctx, RecordingService.ACTION_START) - } - ShonarWidgetUpdater.refresh(ctx) - } - } - - companion object { - const val ACTION_WIDGET_RECORD = "com.shonar.widget.RECORD_TAP" - - fun views( - ctx: Context, - phase: com.shonar.recording.RecordingSnapshot.Phase, - ): RemoteViews { - val v = RemoteViews(ctx.packageName, R.layout.shonar_widget) - v.setImageViewResource(R.id.widget_icon, R.drawable.ic_widget_mic) - when (phase) { - com.shonar.recording.RecordingSnapshot.Phase.RECORDING -> { - v.setImageViewResource(R.id.widget_badge, R.drawable.ic_widget_badge_rec) - v.setViewVisibility(R.id.widget_badge, View.VISIBLE) - v.setContentDescription( - R.id.widget_button, ctx.getString(R.string.widget_recording), - ) - } - com.shonar.recording.RecordingSnapshot.Phase.PAUSED -> { - v.setImageViewResource(R.id.widget_badge, R.drawable.ic_widget_badge_paused) - v.setViewVisibility(R.id.widget_badge, View.VISIBLE) - v.setContentDescription( - R.id.widget_button, ctx.getString(R.string.widget_paused), - ) - } - else -> { - v.setViewVisibility(R.id.widget_badge, View.GONE) - v.setContentDescription( - R.id.widget_button, ctx.getString(R.string.widget_record), - ) - } - } - - val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) { - PendingIntent.getBroadcast( - ctx, 50, - Intent(ctx, ShonarRecordWidget::class.java).setAction(ACTION_WIDGET_RECORD), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } else { - PendingIntent.getActivity( - ctx, 51, - Intent(ctx, MainActivity::class.java) - .setAction(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } - v.setOnClickPendingIntent(R.id.widget_button, tap) - return v - } - } -} - -/** Push state to every widget instance. Safe after death/reboot (IDLE). */ -object ShonarWidgetUpdater { - fun refresh(ctx: Context) { - val mgr = AppWidgetManager.getInstance(ctx) - val me = ComponentName(ctx, ShonarRecordWidget::class.java) - val ids = try { - mgr.getAppWidgetIds(me) - } catch (_: Exception) { - return - } - if (ids.isEmpty()) return - val s = RecordingService.snapshot.value - val views = ShonarRecordWidget.views(ctx, s.phase) - runCatching { mgr.updateAppWidget(me, views) } - if (Build.VERSION.SDK_INT >= 31) { - // requestPinAppWidget only on user action — no-op here. - } - } -}