Remove all Android/phone code: the app is standalone desktop
shared/ carried the vendored phone app's tree behind a 30-entry
exclude list. Deleted the 46 phone-only files (recording service,
widgets, Room DB, Nextcloud/local-only/folder-sync providers, TOFU
trust, Android settings stores, phone UI screens) and kept only the
seven the desktop compiles: CustomShonarProvider, ProviderTypes,
ShonarProvider, ShonarAuthStore, ShonarHandshake, ProviderRegistry,
AiContent. ProviderRegistry reduces to its id constant; handshake
drops the TOFU param; provider defaults self-identify as desktop.
Gradle exclude list gone — srcDir('../shared') is pure desktop now.
This commit is contained in:
parent
751428cf3e
commit
71b55715b4
52 changed files with 24 additions and 9049 deletions
|
|
@ -19,43 +19,10 @@ kotlin {
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
kotlin {
|
kotlin {
|
||||||
// Portable shared sources vendored into this project (formerly
|
// Portable provider/recording sources vendored into this
|
||||||
// compiled in place from the sibling Android repo). Android-only
|
// project (the Android-only files were removed; this app is
|
||||||
// files excluded below.
|
// standalone desktop).
|
||||||
srcDir("../shared")
|
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")
|
srcDir("src/main/kotlin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import okhttp3.OkHttpClient
|
||||||
/**
|
/**
|
||||||
* Desktop app state (Stage 3). Plain class + StateFlows — no ViewModel
|
* Desktop app state (Stage 3). Plain class + StateFlows — no ViewModel
|
||||||
* framework. Talks to the SHONAR backend through the *shared*
|
* 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()) {
|
class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,9 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SHONAR Desktop theme — the Android companion's "deep-sea sonar" palette,
|
* SHONAR Desktop theme — the "deep-sea sonar" palette: dark navy +
|
||||||
* copied exactly from android ui/theme/Theme.kt dark scheme: dark navy +
|
* teal ping. Seven role overrides; everything else falls through to
|
||||||
* teal ping. Same seven role overrides the phone app sets; everything else
|
* Material3 defaults.
|
||||||
* falls through to Material3 defaults, same as Android.
|
|
||||||
*
|
*
|
||||||
* Status semantics in the library list map to scheme roles:
|
* Status semantics in the library list map to scheme roles:
|
||||||
* done -> primary (teal), failed -> error (red), queued/running ->
|
* 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
|
* ic_launcher_foreground.xml: a center dot with quarter arcs ringing out
|
||||||
* (teal upper-right, teal-dark lower-left), 108-unit viewport.
|
* (teal upper-right, teal-dark lower-left), 108-unit viewport.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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<String?>(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<String?>("picked_path", null)
|
|
||||||
.collectAsState()
|
|
||||||
ProviderSelectionScreen(
|
|
||||||
onDone = { nav.popBackStack() },
|
|
||||||
onBack = { nav.popBackStack() },
|
|
||||||
browsePick = pick,
|
|
||||||
onPickConsumed = {
|
|
||||||
entry.savedStateHandle.remove<String>("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<String?>(null)
|
|
||||||
}
|
|
||||||
val vm: com.shonar.ui.rename.RenameAfterSaveViewModel =
|
|
||||||
androidx.lifecycle.viewmodel.compose.viewModel(
|
|
||||||
factory = object : androidx.lifecycle.ViewModelProvider.Factory {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : androidx.lifecycle.ViewModel> create(
|
|
||||||
modelClass: Class<T>,
|
|
||||||
): 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Boolean, Boolean>? = null
|
|
||||||
|
|
||||||
private suspend fun scheduleSync() {
|
|
||||||
val flags = wifiOnly() to chargingOnly()
|
|
||||||
com.shonar.recording.SyncScheduler.ensurePeriodic(this, flags.first, flags.second)
|
|
||||||
if (lastSyncFlags == null || lastSyncFlags != flags) {
|
|
||||||
com.shonar.recording.SyncScheduler.requestNow(this, flags.first, flags.second)
|
|
||||||
}
|
|
||||||
lastSyncFlags = flags
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun wifiOnly(): Boolean = runCatching {
|
|
||||||
settingsManager.bool(com.shonar.settings.BuiltInSettings.WIFI_ONLY_UPLOAD)
|
|
||||||
}.getOrDefault(true)
|
|
||||||
|
|
||||||
private suspend fun chargingOnly(): Boolean = runCatching {
|
|
||||||
settingsManager.bool(com.shonar.settings.BuiltInSettings.CHARGING_ONLY_UPLOAD)
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
private suspend fun syncBodyLoggingFlag() {
|
|
||||||
bodyLogging.set(
|
|
||||||
runCatching { settingsManager.bool("log_http_bodies") }.getOrDefault(false)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,10 +40,10 @@ import okhttp3.Response
|
||||||
* refresh, and concurrent 401s serialize on [refreshMutex] — two parallel
|
* refresh, and concurrent 401s serialize on [refreshMutex] — two parallel
|
||||||
* refreshes would look like token reuse and burn the whole family.
|
* refreshes would look like token reuse and burn the whole family.
|
||||||
*
|
*
|
||||||
* Sidecars: file cache under [sidecarRoot] keyed by remote recording id
|
* Sidecars: file cache under [sidecarRoot] keyed by remote recording id.
|
||||||
* (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs)
|
* AI content (transcript/summary/jobs) goes through the real M7
|
||||||
* goes through the real M7 endpoints instead — see [fetchTranscript] and
|
* endpoints instead — see [fetchTranscript] and friends, consumed by
|
||||||
* friends, consumed by the M8 details screen.
|
* the details screen.
|
||||||
*
|
*
|
||||||
* Cancellation safety: a cancelled upload leaves an open server session
|
* Cancellation safety: a cancelled upload leaves an open server session
|
||||||
* with some chunks stored — invisible until finalize, resumable via the
|
* with some chunks stored — invisible until finalize, resumable via the
|
||||||
|
|
@ -56,8 +56,8 @@ class CustomShonarProvider(
|
||||||
private val client: OkHttpClient = defaultClient(),
|
private val client: OkHttpClient = defaultClient(),
|
||||||
private val handshake: ShonarHandshake = ShonarHandshake(),
|
private val handshake: ShonarHandshake = ShonarHandshake(),
|
||||||
/** Identifies this client to the server (login device_name/platform). */
|
/** Identifies this client to the server (login device_name/platform). */
|
||||||
private val deviceName: String = "SHONAR Android",
|
private val deviceName: String = "SHONAR Desktop",
|
||||||
private val platform: String = "android",
|
private val platform: String = "desktop",
|
||||||
) : ShonarProvider {
|
) : ShonarProvider {
|
||||||
|
|
||||||
override val descriptor = ProviderDescriptor(
|
override val descriptor = ProviderDescriptor(
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P4: sync-folder provider — "bring your own sync". The app reads and
|
|
||||||
* writes plain files under a user-chosen directory; an external tool
|
|
||||||
* (Syncthing, the Nextcloud desktop client, rsync, …) moves those bytes
|
|
||||||
* between devices. The app never talks to a network for this provider —
|
|
||||||
* provable by construction (no HTTP imports in this file).
|
|
||||||
*
|
|
||||||
* The on-disk layout is identical to [LocalOnlyProvider] (`audio/{id}`,
|
|
||||||
* `sidecars/…`), so switching between local-only and a sync folder is a
|
|
||||||
* copy, not a migration, and anything already syncing the folder picks
|
|
||||||
* the recordings up with no special handling.
|
|
||||||
*
|
|
||||||
* Two deliberate differences from local-only:
|
|
||||||
* - [connect] validates the directory (must exist, be a directory, be
|
|
||||||
* readable AND writable) and refuses anything else. A typo must be an
|
|
||||||
* error, never a silently created folder somewhere surprising. Paths
|
|
||||||
* escaping via `..` are rejected for the same reason.
|
|
||||||
* - [deleteAccountAndData] NEVER deletes the folder's contents. That
|
|
||||||
* directory belongs to the user and their sync tool, not to the app —
|
|
||||||
* forgetting the path is the whole operation.
|
|
||||||
*/
|
|
||||||
class FolderSyncProvider(
|
|
||||||
private val pathStore: com.shonar.settings.SettingsStore =
|
|
||||||
com.shonar.settings.InMemorySettingsStore(),
|
|
||||||
) : ShonarProvider {
|
|
||||||
|
|
||||||
override val descriptor = ProviderDescriptor(
|
|
||||||
id = ID,
|
|
||||||
displayName = "Sync folder",
|
|
||||||
capabilities = setOf(), // the sync tool owns the protocol, not us
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
|
|
||||||
override val authState: StateFlow<AuthState> = _authState
|
|
||||||
|
|
||||||
private suspend fun storedRoot(): File? {
|
|
||||||
val raw = pathStore.getString(KEY_ROOT) ?: return null
|
|
||||||
return File(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkDir(dir: File): File {
|
|
||||||
if (".." in dir.path.split(File.separatorChar)) {
|
|
||||||
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
|
|
||||||
}
|
|
||||||
if (!dir.exists()) throw ProviderError.InvalidUrl(
|
|
||||||
"Folder does not exist: ${dir.path}. Create it (or let Syncthing create it) first."
|
|
||||||
)
|
|
||||||
if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}")
|
|
||||||
if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl(
|
|
||||||
"Folder is not readable and writable: ${dir.path}"
|
|
||||||
)
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun probe(baseUrl: ServerUrl): ProbeResult =
|
|
||||||
ProbeResult.Incompatible // no server involved — nothing to probe
|
|
||||||
|
|
||||||
override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
|
|
||||||
val folder = credential as? ProviderCredential.FolderPath
|
|
||||||
?: throw ProviderError.InvalidUrl(
|
|
||||||
"Sync folder needs a folder path to sync through"
|
|
||||||
)
|
|
||||||
val dir = checkDir(File(folder.path))
|
|
||||||
pathStore.putString(KEY_ROOT, dir.canonicalPath)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the folder, then connect. Safety rules (a typo must never
|
|
||||||
* spray directories across storage):
|
|
||||||
* - no `..` segments, never a blank path;
|
|
||||||
* - the direct parent must already exist, be a directory, and be
|
|
||||||
* writable — only the final segment is ever created;
|
|
||||||
* - when the folder already exists this is just [connect].
|
|
||||||
*/
|
|
||||||
suspend fun createRoot(rawPath: String) = withContext(Dispatchers.IO) {
|
|
||||||
val trimmed = rawPath.trim()
|
|
||||||
if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty")
|
|
||||||
val dir = File(trimmed)
|
|
||||||
if (".." in dir.path.split(File.separatorChar)) {
|
|
||||||
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
|
|
||||||
}
|
|
||||||
if (!dir.exists()) {
|
|
||||||
val parent = dir.absoluteFile.parentFile
|
|
||||||
?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed")
|
|
||||||
if (!parent.isDirectory) throw ProviderError.InvalidUrl(
|
|
||||||
"Parent is not a folder: ${parent.path}"
|
|
||||||
)
|
|
||||||
if (!parent.canWrite()) throw ProviderError.InvalidUrl(
|
|
||||||
"Parent folder is not writable: ${parent.path}"
|
|
||||||
)
|
|
||||||
if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl(
|
|
||||||
"Could not create folder: ${dir.path}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val checked = checkDir(dir)
|
|
||||||
pathStore.putString(KEY_ROOT, checked.canonicalPath)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
|
|
||||||
val root = storedRoot()
|
|
||||||
if (root == null) {
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
return@withContext _authState.value
|
|
||||||
}
|
|
||||||
runCatching { checkDir(root) }
|
|
||||||
.onSuccess { _authState.value = AuthState.CONNECTED }
|
|
||||||
.onFailure { _authState.value = AuthState.DISCONNECTED }
|
|
||||||
_authState.value
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun disconnect(revokeOnServer: Boolean) {
|
|
||||||
// Nothing remote to revoke; the path is kept so reconnect is one tap.
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) {
|
|
||||||
// Forget the folder. The files stay — they belong to the user and
|
|
||||||
// their sync tool, and deleting someone's Syncthing folder because
|
|
||||||
// they tapped "disconnect" would be unforgivable.
|
|
||||||
pathStore.remove(KEY_ROOT)
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- storage: delegate with rewritten identity --------------------------
|
|
||||||
|
|
||||||
private suspend fun root(): File {
|
|
||||||
if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
|
|
||||||
return storedRoot()?.let { checkDir(it) } ?: throw ProviderError.NotConnected()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val ref = LocalOnlyProvider(root()).upload(draft, onProgress)
|
|
||||||
ref.copy(providerId = ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LocalOnlyProvider(root()).download(ref.copy(providerId = LocalOnlyProvider.ID), dest, onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun delete(ref: RemoteRef) = withContext(Dispatchers.IO) {
|
|
||||||
LocalOnlyProvider(root()).delete(ref.copy(providerId = LocalOnlyProvider.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun list(cursor: String?): Page<RemoteRecording> = withContext(Dispatchers.IO) {
|
|
||||||
val page = LocalOnlyProvider(root()).list(cursor)
|
|
||||||
Page(
|
|
||||||
page.items.map { it.copy(ref = it.ref.copy(providerId = ID)) },
|
|
||||||
page.nextCursor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LocalOnlyProvider(root())
|
|
||||||
.putSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind, bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LocalOnlyProvider(root())
|
|
||||||
.getSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun storageLocationSummary(): StorageLocation =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val r = root()
|
|
||||||
val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
|
|
||||||
StorageLocation(
|
|
||||||
headline = "Sync folder",
|
|
||||||
detail = "${audio.size} recordings — synced by your sync tool, not by this app.",
|
|
||||||
syncedCount = 0,
|
|
||||||
localOnlyCount = audio.size,
|
|
||||||
bytesUsed = audio.sumOf { it.length() },
|
|
||||||
path = r.path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ID = "sync-folder"
|
|
||||||
const val KEY_ROOT = "provider.sync-folder.root"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local-only provider: everything stays in app-private storage. First-class
|
|
||||||
* so the app has no "no server" special case. No networking code paths at
|
|
||||||
* all — provable by construction (this file imports no HTTP library).
|
|
||||||
*
|
|
||||||
* The storage root is the app-private [defaultRoot], unless the user picked
|
|
||||||
* their own folder ([connect]/[createRoot] with a [ProviderCredential.FolderPath]):
|
|
||||||
* then files live there and [deleteAccountAndData] only forgets the path —
|
|
||||||
* a user folder is never deleted by this app.
|
|
||||||
*/
|
|
||||||
class LocalOnlyProvider(
|
|
||||||
private val defaultRoot: File,
|
|
||||||
private val pathStore: com.shonar.settings.SettingsStore =
|
|
||||||
com.shonar.settings.InMemorySettingsStore(),
|
|
||||||
private val rootKey: String = KEY_ROOT,
|
|
||||||
) : ShonarProvider {
|
|
||||||
|
|
||||||
override val descriptor = ProviderDescriptor(
|
|
||||||
id = ID,
|
|
||||||
displayName = "Local-only storage",
|
|
||||||
capabilities = setOf(), // no chunked protocol needed; no server AI
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _authState = MutableStateFlow(AuthState.CONNECTED)
|
|
||||||
override val authState: StateFlow<AuthState> = _authState
|
|
||||||
|
|
||||||
private suspend fun storedRoot(): File? {
|
|
||||||
val raw = pathStore.getString(rootKey) ?: return null
|
|
||||||
return File(raw).takeIf { it.exists() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** App-private dir by default; the user's own folder once picked. */
|
|
||||||
private suspend fun effectiveRoot(): File = storedRoot() ?: defaultRoot
|
|
||||||
|
|
||||||
/** Where new recordings belong right now. Read by the recording repository. */
|
|
||||||
suspend fun currentRoot(): File = effectiveRoot()
|
|
||||||
|
|
||||||
private fun checkDir(dir: File): File {
|
|
||||||
if (".." in dir.path.split(File.separatorChar)) {
|
|
||||||
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
|
|
||||||
}
|
|
||||||
if (!dir.exists()) throw ProviderError.InvalidUrl(
|
|
||||||
"Folder does not exist: ${dir.path}."
|
|
||||||
)
|
|
||||||
if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}")
|
|
||||||
if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl(
|
|
||||||
"Folder is not readable and writable: ${dir.path}"
|
|
||||||
)
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun probe(baseUrl: ServerUrl): ProbeResult =
|
|
||||||
ProbeResult.Incompatible // local-only never talks to servers
|
|
||||||
|
|
||||||
override suspend fun connect(credential: ProviderCredential) {
|
|
||||||
when (credential) {
|
|
||||||
ProviderCredential.None -> _authState.value = AuthState.CONNECTED
|
|
||||||
is ProviderCredential.FolderPath -> {
|
|
||||||
val dir = checkDir(File(credential.path))
|
|
||||||
pathStore.putString(rootKey, dir.canonicalPath)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
}
|
|
||||||
else -> throw ProviderError.InvalidUrl(
|
|
||||||
"Local-only storage takes no credentials"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use the app-private folder again (forget a previously picked folder;
|
|
||||||
* its files stay where they are).
|
|
||||||
*/
|
|
||||||
suspend fun useDefaultRoot() {
|
|
||||||
pathStore.remove(rootKey)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the folder, then use it. Same safety rules as the sync
|
|
||||||
* folder: only the final segment is ever created, the parent must
|
|
||||||
* already exist, no `..`. When it exists this is just [connect].
|
|
||||||
*/
|
|
||||||
suspend fun createRoot(rawPath: String) {
|
|
||||||
val trimmed = rawPath.trim()
|
|
||||||
if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty")
|
|
||||||
val dir = File(trimmed)
|
|
||||||
if (".." in dir.path.split(File.separatorChar)) {
|
|
||||||
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
|
|
||||||
}
|
|
||||||
if (!dir.exists()) {
|
|
||||||
val parent = dir.absoluteFile.parentFile
|
|
||||||
?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed")
|
|
||||||
if (!parent.isDirectory) throw ProviderError.InvalidUrl(
|
|
||||||
"Parent is not a folder: ${parent.path}"
|
|
||||||
)
|
|
||||||
if (!parent.canWrite()) throw ProviderError.InvalidUrl(
|
|
||||||
"Parent folder is not writable: ${parent.path}"
|
|
||||||
)
|
|
||||||
if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl(
|
|
||||||
"Could not create folder: ${dir.path}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val checked = checkDir(dir)
|
|
||||||
pathStore.putString(rootKey, checked.canonicalPath)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun reconnect(): AuthState {
|
|
||||||
// A picked folder may have been deleted out from under us; fall
|
|
||||||
// back to the app-private dir rather than stranding recordings.
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
return _authState.value
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun disconnect(revokeOnServer: Boolean) {
|
|
||||||
// Nothing to revoke; DISCONNECTED means "user left local mode" — the
|
|
||||||
// sync layer treats it as paused, files remain on disk.
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteAccountAndData() {
|
|
||||||
if (storedRoot() != null) {
|
|
||||||
// A user-picked folder belongs to the user — forget it, never
|
|
||||||
// delete it.
|
|
||||||
pathStore.remove(rootKey)
|
|
||||||
} else if (defaultRoot.exists()) {
|
|
||||||
defaultRoot.deleteRecursively()
|
|
||||||
}
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- storage -----------------------------------------------------------
|
|
||||||
|
|
||||||
private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key)
|
|
||||||
private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) =
|
|
||||||
File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}")
|
|
||||||
|
|
||||||
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
|
|
||||||
val key = "audio/${draft.id}"
|
|
||||||
val dest = File(effectiveRoot(), key)
|
|
||||||
dest.parentFile?.mkdirs()
|
|
||||||
// "Upload" locally = copy; report progress in slices so UI behaves uniformly
|
|
||||||
draft.sourceFile.inputStream().use { input ->
|
|
||||||
dest.outputStream().use { output ->
|
|
||||||
val buf = ByteArray(64 * 1024)
|
|
||||||
val total = draft.sizeBytes.coerceAtLeast(1)
|
|
||||||
var written = 0L
|
|
||||||
while (true) {
|
|
||||||
val n = input.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
output.write(buf, 0, n)
|
|
||||||
written += n
|
|
||||||
onProgress((written.toFloat() / total).coerceIn(0f, 1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return RemoteRef(ID, key, etag = "sha256:" + dest.sha256Hex(), sizeBytes = dest.length())
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) {
|
|
||||||
val src = audioFile(ref)
|
|
||||||
if (!src.exists()) throw ProviderError.NotFound(ref.key)
|
|
||||||
src.copyTo(dest, overwrite = true)
|
|
||||||
onProgress(1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun delete(ref: RemoteRef) {
|
|
||||||
audioFile(ref).delete()
|
|
||||||
File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun list(cursor: String?): Page<RemoteRecording> {
|
|
||||||
val audioRoot = File(effectiveRoot(), "audio")
|
|
||||||
val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList()
|
|
||||||
// single page; no paging for local storage
|
|
||||||
val items = files.map { f ->
|
|
||||||
RemoteRecording(
|
|
||||||
ref = RemoteRef(ID, "audio/${f.name}", etag = "sha256:" + f.sha256Hex(), sizeBytes = f.length()),
|
|
||||||
title = f.nameWithoutExtension,
|
|
||||||
createdAtEpochMs = f.lastModified(),
|
|
||||||
durationMs = 0, // duration is tracked in Room, not on disk
|
|
||||||
mime = "application/octet-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return Page(items, nextCursor = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) {
|
|
||||||
val f = sidecarFile(ref, kind)
|
|
||||||
f.parentFile?.mkdirs()
|
|
||||||
f.writeBytes(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? {
|
|
||||||
val f = sidecarFile(ref, kind)
|
|
||||||
return if (f.exists()) f.readBytes() else null
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun storageLocationSummary(): StorageLocation {
|
|
||||||
val raw = pathStore.getString(rootKey)
|
|
||||||
val custom = storedRoot()
|
|
||||||
if (raw != null && custom == null) {
|
|
||||||
return StorageLocation(
|
|
||||||
headline = "Folder unavailable",
|
|
||||||
detail = "Your picked folder is gone or unreadable. " +
|
|
||||||
"Reconnect storage or pick again — new recordings use the app folder meanwhile.",
|
|
||||||
syncedCount = 0,
|
|
||||||
localOnlyCount = 0,
|
|
||||||
bytesUsed = 0,
|
|
||||||
path = raw,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val r = custom ?: defaultRoot
|
|
||||||
val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
|
|
||||||
return StorageLocation(
|
|
||||||
headline = if (custom != null) "Your folder" else "On this device only",
|
|
||||||
detail = if (custom != null) "Recordings stay in your folder and never leave your phone."
|
|
||||||
else "Recordings and transcripts never leave your phone.",
|
|
||||||
syncedCount = 0,
|
|
||||||
localOnlyCount = audio.size,
|
|
||||||
bytesUsed = audio.sumOf { it.length() },
|
|
||||||
path = r.absolutePath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ID = "local-only"
|
|
||||||
const val KEY_ROOT = "provider.local-only.root"
|
|
||||||
|
|
||||||
private fun File.sha256Hex(): String {
|
|
||||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
|
||||||
inputStream().use { inn ->
|
|
||||||
val buf = ByteArray(64 * 1024)
|
|
||||||
while (true) {
|
|
||||||
val n = inn.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
md.update(buf, 0, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return md.digest().joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nextcloud server identification + login flow v2 (docs/server-providers.md
|
|
||||||
* §3, §6). No credentials are ever sent here: [startLogin] is anonymous,
|
|
||||||
* [poll] carries only the one-time poll token.
|
|
||||||
*
|
|
||||||
* Login flow v2 (official, supported):
|
|
||||||
* 1. POST {base}/index.php/login/v2 -> {poll:{token,endpoint}, login:url}
|
|
||||||
* 2. user approves {login} in a browser (server-owned consent screen)
|
|
||||||
* 3. POST {poll.endpoint} {"token":...} -> 404 while pending,
|
|
||||||
* 200 {server, loginName, appPassword} once approved
|
|
||||||
*/
|
|
||||||
class NextcloudAuth(
|
|
||||||
private val client: OkHttpClient = defaultClient(),
|
|
||||||
private val tofu: TofuManager? = null,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** Is there a Nextcloud at [url]? Read-only, no credentials. */
|
|
||||||
suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder().url(url.origin + "/status.php").get().build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (resp.code != 200) return@withContext ProbeResult.Incompatible
|
|
||||||
val body = JSONObject(resp.body?.string().orEmpty())
|
|
||||||
// status.php: {productname, versionstring, ...}. Some forks
|
|
||||||
// report "Nextcloud" with different casing — accept any.
|
|
||||||
val product = body.optString("productname", "")
|
|
||||||
if (!product.equals("nextcloud", ignoreCase = true)) {
|
|
||||||
return@withContext ProbeResult.Incompatible
|
|
||||||
}
|
|
||||||
ProbeResult.Compatible(
|
|
||||||
descriptor = ProviderDescriptor(
|
|
||||||
id = ProviderRegistry.NEXTCLOUD_ID,
|
|
||||||
displayName = "Nextcloud",
|
|
||||||
isDefault = true,
|
|
||||||
capabilities = setOf(
|
|
||||||
ProviderDescriptor.Capability.CHUNKED_UPLOAD,
|
|
||||||
ProviderDescriptor.Capability.QUOTA_INFO,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
serverName = product,
|
|
||||||
version = body.optString("versionstring", "unknown"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: javax.net.ssl.SSLHandshakeException) {
|
|
||||||
ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown")
|
|
||||||
} catch (e: javax.net.ssl.SSLPeerUnverifiedException) {
|
|
||||||
ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ProbeResult.NetworkError(e.javaClass.simpleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Begins login flow v2. Returns the browser URL + poll handle. */
|
|
||||||
suspend fun startLogin(url: ServerUrl): LoginFlowSession = withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder().url(url.origin + "/index.php/login/v2")
|
|
||||||
.post(ByteArray(0).toRequestBody(null)).build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (resp.code != 200) {
|
|
||||||
throw ProviderError.Transient(
|
|
||||||
"Server refused the login request (HTTP ${resp.code}). " +
|
|
||||||
"Is this a Nextcloud?"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val body = JSONObject(resp.body?.string().orEmpty())
|
|
||||||
val poll = body.optJSONObject("poll")
|
|
||||||
val login = body.optString("login", "")
|
|
||||||
val token = poll?.optString("token", "").orEmpty()
|
|
||||||
val endpoint = poll?.optString("endpoint", "").orEmpty()
|
|
||||||
if (login.isBlank() || token.isBlank() || endpoint.isBlank()) {
|
|
||||||
throw ProviderError.Transient("Server gave an incomplete login response")
|
|
||||||
}
|
|
||||||
LoginFlowSession(
|
|
||||||
baseUrl = url.origin,
|
|
||||||
loginUrl = login,
|
|
||||||
pollToken = token,
|
|
||||||
pollEndpoint = endpoint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: ProviderError) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ProviderError.Transient("Could not reach the server (${e.javaClass.simpleName})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One poll attempt. Call repeatedly until [PollResult.Approved]. */
|
|
||||||
suspend fun poll(flow: LoginFlowSession): PollResult = withContext(Dispatchers.IO) {
|
|
||||||
val json = """{"token":"${flow.pollToken}"}"""
|
|
||||||
val req = Request.Builder().url(flow.pollEndpoint)
|
|
||||||
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200 -> {
|
|
||||||
val body = JSONObject(resp.body?.string().orEmpty())
|
|
||||||
val server = body.optString("server", flow.baseUrl)
|
|
||||||
val name = body.optString("loginName", "")
|
|
||||||
val pass = body.optString("appPassword", "")
|
|
||||||
if (name.isBlank() || pass.isBlank()) {
|
|
||||||
return@withContext PollResult.Failed("Server approved but sent no credentials")
|
|
||||||
}
|
|
||||||
PollResult.Approved(
|
|
||||||
ProviderCredential.AppPassword(
|
|
||||||
accountLabel = "$name@${baseHost(server)}",
|
|
||||||
loginUrl = server,
|
|
||||||
user = name,
|
|
||||||
password = pass,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
404 -> PollResult.Pending
|
|
||||||
else -> PollResult.Failed("Login poll failed (HTTP ${resp.code})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
PollResult.Failed("Login poll failed (${e.javaClass.simpleName})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(20, TimeUnit.SECONDS)
|
|
||||||
.followRedirects(false)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun baseHost(server: String): String =
|
|
||||||
runCatching { java.net.URI(server).host }.getOrNull() ?: server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser URL + one-time poll handle from [NextcloudAuth.startLogin]. */
|
|
||||||
data class LoginFlowSession(
|
|
||||||
val baseUrl: String,
|
|
||||||
val loginUrl: String,
|
|
||||||
val pollToken: String,
|
|
||||||
val pollEndpoint: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One poll attempt's outcome. The token itself never appears in messages. */
|
|
||||||
sealed class PollResult {
|
|
||||||
data object Pending : PollResult()
|
|
||||||
data class Approved(val credential: ProviderCredential.AppPassword) : PollResult()
|
|
||||||
data class Failed(val reason: String) : PollResult()
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import com.shonar.settings.SettingsStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Secure persistence for the Nextcloud session: server origin, DAV user id,
|
|
||||||
* display name, and the app password from login flow v2. The user's *normal*
|
|
||||||
* account password is never requested, typed, or stored — only the
|
|
||||||
* server-issued app password lives here (docs/server-providers.md §3).
|
|
||||||
*/
|
|
||||||
class NextcloudAuthStore(private val secure: SettingsStore) {
|
|
||||||
|
|
||||||
suspend fun save(session: NcSession) {
|
|
||||||
secure.putString(KEY_BASE_URL, session.baseUrl)
|
|
||||||
secure.putString(KEY_USER_ID, session.userId)
|
|
||||||
secure.putString(KEY_USERNAME, session.username)
|
|
||||||
secure.putString(KEY_APP_PASSWORD, session.appPassword)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun load(): NcSession? {
|
|
||||||
val base = secure.getString(KEY_BASE_URL) ?: return null
|
|
||||||
val pass = secure.getString(KEY_APP_PASSWORD) ?: return null
|
|
||||||
return NcSession(
|
|
||||||
baseUrl = base,
|
|
||||||
userId = secure.getString(KEY_USER_ID).orEmpty(),
|
|
||||||
username = secure.getString(KEY_USERNAME).orEmpty(),
|
|
||||||
appPassword = pass,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun clear() {
|
|
||||||
secure.remove(KEY_BASE_URL)
|
|
||||||
secure.remove(KEY_USER_ID)
|
|
||||||
secure.remove(KEY_USERNAME)
|
|
||||||
secure.remove(KEY_APP_PASSWORD)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFIX = "provider.nextcloud."
|
|
||||||
const val KEY_BASE_URL = PREFIX + "base_url"
|
|
||||||
const val KEY_USER_ID = PREFIX + "user_id"
|
|
||||||
const val KEY_USERNAME = PREFIX + "username"
|
|
||||||
const val KEY_APP_PASSWORD = PREFIX + "app_password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Authenticated Nextcloud session. Never logged (see toString). */
|
|
||||||
data class NcSession(
|
|
||||||
val baseUrl: String,
|
|
||||||
val userId: String, // DAV path user (OCS `id`, not the display name)
|
|
||||||
val username: String, // human hint only
|
|
||||||
val appPassword: String,
|
|
||||||
) {
|
|
||||||
override fun toString(): String = "NcSession(server=$baseUrl, user=$username, [redacted])"
|
|
||||||
}
|
|
||||||
|
|
@ -1,644 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ensureActive
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P4: Nextcloud provider — the product default. Talks only official,
|
|
||||||
* supported endpoints (docs/server-providers.md §6):
|
|
||||||
*
|
|
||||||
* - GET {base}/status.php (probe)
|
|
||||||
* - login flow v2 (see [NextcloudAuth])
|
|
||||||
* - GET {base}/ocs/v2.php/cloud/user (identity + quota)
|
|
||||||
* - DELETE {base}/ocs/v2.php/core/apppassword (revoke own app password)
|
|
||||||
* - WebDAV {base}/remote.php/dav/files/{user}/… (PROPFIND/GET/PUT/MKCOL/MOVE/DELETE)
|
|
||||||
* - Chunked upload v2 {base}/remote.php/dav/uploads/{user}/{transfer}/
|
|
||||||
* (MKCOL, PUT chunks 00001..N, MOVE {transfer}/.file -> destination)
|
|
||||||
*
|
|
||||||
* Layout: `SHONAR/audio/{uuid}.m4a`, `SHONAR/sidecars/{uuid}/{kind}.json`.
|
|
||||||
* Originals are never overwritten by processing artifacts — uploads with
|
|
||||||
* the same draft id MOVE onto the same key (idempotent replace).
|
|
||||||
*
|
|
||||||
* Chunk naming follows the developer manual: chunks are numbered 1..10000
|
|
||||||
* and assembled in name order, so names are zero-padded to 5 digits
|
|
||||||
* ("00001".."10000") to keep lexical order == numeric order. Chunk size
|
|
||||||
* defaults to 16 MiB — the server requires 5 MiB..5 GiB per chunk (last
|
|
||||||
* chunk exempt), so never lower the default for production use; the
|
|
||||||
* constructor parameter exists for tests only.
|
|
||||||
*
|
|
||||||
* Transfer ids are deterministic per recording (`shonar-{draft.id}`), so a
|
|
||||||
* killed upload resumes by PROPFIND-ing the transfer folder and skipping
|
|
||||||
* present chunks — including across process restarts.
|
|
||||||
*/
|
|
||||||
class NextcloudProvider(
|
|
||||||
private val auth: NextcloudAuthStore,
|
|
||||||
private val client: OkHttpClient = NextcloudAuth.defaultClient(),
|
|
||||||
private val loginFlow: NextcloudAuth = NextcloudAuth(client),
|
|
||||||
private val chunkSizeBytes: Long = 16 * 1024 * 1024,
|
|
||||||
) : ShonarProvider {
|
|
||||||
|
|
||||||
override val descriptor = ProviderDescriptor(
|
|
||||||
id = ProviderRegistry.NEXTCLOUD_ID,
|
|
||||||
displayName = "Nextcloud",
|
|
||||||
isDefault = true,
|
|
||||||
capabilities = setOf(
|
|
||||||
ProviderDescriptor.Capability.CHUNKED_UPLOAD,
|
|
||||||
ProviderDescriptor.Capability.QUOTA_INFO,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _authState = MutableStateFlow(AuthState.DISCONNECTED)
|
|
||||||
override val authState: StateFlow<AuthState> = _authState
|
|
||||||
|
|
||||||
// ---- lifecycle ---------------------------------------------------------
|
|
||||||
|
|
||||||
override suspend fun probe(baseUrl: ServerUrl): ProbeResult = loginFlow.probe(baseUrl)
|
|
||||||
|
|
||||||
override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) {
|
|
||||||
val app = credential as? ProviderCredential.AppPassword
|
|
||||||
?: throw ProviderError.InvalidUrl(
|
|
||||||
"Nextcloud connects with an app password from the browser login flow"
|
|
||||||
)
|
|
||||||
val origin = ServerUrl.parse(app.loginUrl).getOrNull()?.origin
|
|
||||||
?: throw ProviderError.InvalidUrl("Not a valid server URL")
|
|
||||||
// Validate before persisting: a wrong password must not overwrite a
|
|
||||||
// working session.
|
|
||||||
val userId = try {
|
|
||||||
ocsUserId(origin, app.user, app.password)
|
|
||||||
} catch (e: ProviderError.AuthExpired) {
|
|
||||||
throw ProviderError.Transient(
|
|
||||||
"Nextcloud rejected the login — approve it in the browser again"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
auth.save(
|
|
||||||
NcSession(
|
|
||||||
baseUrl = origin,
|
|
||||||
userId = userId,
|
|
||||||
username = app.user,
|
|
||||||
appPassword = app.password,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
|
|
||||||
val saved = auth.load()
|
|
||||||
if (saved == null || saved.appPassword.isBlank() || saved.userId.isBlank()) {
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
return@withContext _authState.value
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ocsUserId(saved.baseUrl, saved.userId, saved.appPassword)
|
|
||||||
_authState.value = AuthState.CONNECTED
|
|
||||||
} catch (e: ProviderError.AuthExpired) {
|
|
||||||
_authState.value = AuthState.EXPIRED
|
|
||||||
} catch (e: ProviderError) {
|
|
||||||
_authState.value = AuthState.OFFLINE
|
|
||||||
}
|
|
||||||
_authState.value
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) {
|
|
||||||
if (revokeOnServer) {
|
|
||||||
// Best effort: local state is cleared even if revoke fails.
|
|
||||||
runCatching {
|
|
||||||
val saved = auth.load()
|
|
||||||
if (saved != null && saved.appPassword.isNotBlank()) {
|
|
||||||
dav(saved, "DELETE", ocsPath("/core/apppassword"), null).close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auth.clear()
|
|
||||||
_authState.value = AuthState.DISCONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revokes the app password and forgets the session. There is no API for
|
|
||||||
* deleting the whole Nextcloud account — that stays a manual step on the
|
|
||||||
* server, and the message says so.
|
|
||||||
*/
|
|
||||||
override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) {
|
|
||||||
disconnect(revokeOnServer = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- storage -----------------------------------------------------------
|
|
||||||
|
|
||||||
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
val key = "SHONAR/audio/${draft.id}${extFor(draft.mime)}"
|
|
||||||
val total = draft.sizeBytes.coerceAtLeast(1)
|
|
||||||
ensureDir(session, "SHONAR")
|
|
||||||
ensureDir(session, "SHONAR/audio")
|
|
||||||
|
|
||||||
val transfer = "shonar-${draft.id}"
|
|
||||||
mkcol(session, transfer)
|
|
||||||
val received = transferChunks(session, transfer)
|
|
||||||
val chunkCount = ((draft.sizeBytes + chunkSizeBytes - 1) / chunkSizeBytes)
|
|
||||||
.toInt().coerceAtLeast(1)
|
|
||||||
var sent = 0L
|
|
||||||
for (i in received) {
|
|
||||||
sent += if (i < chunkCount) chunkSizeBytes else 0L
|
|
||||||
}
|
|
||||||
sent = sent.coerceAtMost(draft.sizeBytes)
|
|
||||||
if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
|
|
||||||
|
|
||||||
var idx = 1
|
|
||||||
while (idx <= chunkCount) {
|
|
||||||
ensureActive()
|
|
||||||
if (idx !in received) {
|
|
||||||
val slice = readSlice(draft.sourceFile, (idx - 1) * chunkSizeBytes, chunkSizeBytes)
|
|
||||||
putChunk(session, transfer, idx, slice, draft.sizeBytes, key)
|
|
||||||
sent += slice.size
|
|
||||||
onProgress((sent.toFloat() / total).coerceIn(0f, 1f))
|
|
||||||
}
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
assemble(session, transfer, key, draft)
|
|
||||||
// Best-effort cleanup of the transfer folder; the server also
|
|
||||||
// expires stale upload dirs on its own.
|
|
||||||
runCatching {
|
|
||||||
dav(session, "DELETE", uploadsPath(session, transfer) + "/", null).close()
|
|
||||||
}
|
|
||||||
onProgress(1f)
|
|
||||||
RemoteRef(ProviderRegistry.NEXTCLOUD_ID, key, etag = null, sizeBytes = draft.sizeBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
dav(session, "GET", filesPath(session, ref.key), null).use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200 -> {
|
|
||||||
val body = resp.body ?: throw ProviderError.Transient("Empty download response")
|
|
||||||
val total = body.contentLength().takeIf { it > 0 } ?: -1
|
|
||||||
dest.parentFile?.mkdirs()
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
dest.outputStream().use { output ->
|
|
||||||
val buf = ByteArray(64 * 1024)
|
|
||||||
var written = 0L
|
|
||||||
var last = -1f
|
|
||||||
while (true) {
|
|
||||||
val n = input.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
output.write(buf, 0, n)
|
|
||||||
written += n
|
|
||||||
if (total > 0) {
|
|
||||||
val p = (written.toFloat() / total).coerceIn(0f, 1f)
|
|
||||||
if (p > last) {
|
|
||||||
onProgress(p)
|
|
||||||
last = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onProgress(1f)
|
|
||||||
}
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
404 -> throw ProviderError.NotFound(ref.key)
|
|
||||||
else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun delete(ref: RemoteRef) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
dav(session, "DELETE", filesPath(session, ref.key), null).use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200, 201, 204 -> Unit
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
404 -> throw ProviderError.NotFound(ref.key)
|
|
||||||
else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sidecars go with the recording; ignore failures (may not exist).
|
|
||||||
runCatching {
|
|
||||||
dav(session, "DELETE", filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/"), null)
|
|
||||||
.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun list(cursor: String?): Page<RemoteRecording> = withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
// Single page: a PROPFIND Depth:1 returns the whole folder. Paging
|
|
||||||
// stays null until a library outgrows one response.
|
|
||||||
if (cursor != null) return@withContext Page(emptyList(), null)
|
|
||||||
val items = propfind(session, filesPath(session, "SHONAR/audio/"), depth = "1")
|
|
||||||
.filter { it.isFile && it.relativePath != "SHONAR/audio/" && !it.relativePath.removePrefix("SHONAR/audio/").contains('/') }
|
|
||||||
.map { e ->
|
|
||||||
val name = e.relativePath.removePrefix("SHONAR/audio/")
|
|
||||||
RemoteRecording(
|
|
||||||
ref = RemoteRef(ProviderRegistry.NEXTCLOUD_ID, e.relativePath, e.etag, e.size),
|
|
||||||
title = name.substringBeforeLast('.'),
|
|
||||||
createdAtEpochMs = e.lastModified,
|
|
||||||
durationMs = 0, // duration is tracked locally, not over DAV
|
|
||||||
mime = e.contentType ?: "application/octet-stream",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Page(items, nextCursor = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- sidecars: real remote files under SHONAR/sidecars/{uuid}/ --------
|
|
||||||
|
|
||||||
override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
val dir = "SHONAR/sidecars/${uuidForKey(ref.key)}"
|
|
||||||
ensureDir(session, "SHONAR/sidecars")
|
|
||||||
ensureDir(session, dir)
|
|
||||||
dav(
|
|
||||||
session, "PUT", filesPath(session, "$dir/${kind.fileName}"),
|
|
||||||
bytes.toRequestBody("application/json; charset=utf-8".toMediaType()),
|
|
||||||
sensitiveBody = true, // transcripts are never logged, in any mode
|
|
||||||
).use { resp ->
|
|
||||||
if (resp.code !in 200..201 && resp.code != 204) {
|
|
||||||
throw mapError(resp.code, "Sidecar upload failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
dav(
|
|
||||||
session, "GET",
|
|
||||||
filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/${kind.fileName}"), null,
|
|
||||||
sensitiveBody = true, // transcripts are never logged, in any mode
|
|
||||||
).use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200 -> resp.body?.bytes()
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
404 -> null
|
|
||||||
else -> throw mapError(resp.code, "Sidecar download failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- status ------------------------------------------------------------
|
|
||||||
|
|
||||||
override suspend fun storageLocationSummary(): StorageLocation =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val session = connectedSession()
|
|
||||||
val quota = ocsQuota(session)
|
|
||||||
var count = 0
|
|
||||||
var bytes = 0L
|
|
||||||
// list() is single-page for now; keep the loop for when it pages.
|
|
||||||
var cursor: String? = null
|
|
||||||
do {
|
|
||||||
val page = list(cursor)
|
|
||||||
count += page.items.size
|
|
||||||
bytes += page.items.sumOf { it.ref.sizeBytes }
|
|
||||||
cursor = page.nextCursor
|
|
||||||
} while (cursor != null)
|
|
||||||
val host = runCatching { java.net.URI(session.baseUrl).host }.getOrNull()
|
|
||||||
?: session.baseUrl
|
|
||||||
StorageLocation(
|
|
||||||
headline = "Nextcloud at $host",
|
|
||||||
detail = "${session.username} · $count recordings synced" +
|
|
||||||
(quota?.let { " · ${formatBytes(it.free)} free of ${formatBytes(it.total)}" } ?: ""),
|
|
||||||
syncedCount = count,
|
|
||||||
localOnlyCount = 0,
|
|
||||||
bytesUsed = bytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- HTTP + DAV plumbing -----------------------------------------------
|
|
||||||
|
|
||||||
private suspend fun connectedSession(): NcSession {
|
|
||||||
if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected()
|
|
||||||
return auth.load()?.takeIf { it.appPassword.isNotBlank() && it.userId.isNotBlank() }
|
|
||||||
?: throw ProviderError.NotConnected()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun basic(session: NcSession): String {
|
|
||||||
val raw = "${session.userId}:${session.appPassword}".toByteArray(Charsets.UTF_8)
|
|
||||||
return "Basic " + java.util.Base64.getEncoder().encodeToString(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw DAV/OCS call. Caller closes the response. [path] starts with '/'. */
|
|
||||||
private fun dav(
|
|
||||||
session: NcSession,
|
|
||||||
method: String,
|
|
||||||
path: String,
|
|
||||||
body: okhttp3.RequestBody?,
|
|
||||||
sensitiveBody: Boolean = false,
|
|
||||||
): Response {
|
|
||||||
val builder = Request.Builder().url(session.baseUrl + path)
|
|
||||||
.header("Authorization", basic(session))
|
|
||||||
if (sensitiveBody) builder.header(RedactingLogger.SENSITIVE_BODY, "1")
|
|
||||||
if (method == "GET") builder.get()
|
|
||||||
else builder.method(method, body)
|
|
||||||
if (path.startsWith("/ocs/")) {
|
|
||||||
builder.header("OCS-APIRequest", "true")
|
|
||||||
builder.header("Accept", "application/json")
|
|
||||||
}
|
|
||||||
return client.newCall(builder.build()).execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filesPath(session: NcSession, relative: String): String {
|
|
||||||
val segs = relative.split('/').filter { it.isNotEmpty() }.joinToString("/") { enc(it) }
|
|
||||||
return "/remote.php/dav/files/${enc(session.userId)}/$segs"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun uploadsPath(session: NcSession, transfer: String): String =
|
|
||||||
"/remote.php/dav/uploads/${enc(session.userId)}/${enc(transfer)}"
|
|
||||||
|
|
||||||
private fun ocsPath(suffix: String): String = "/ocs/v2.php$suffix"
|
|
||||||
|
|
||||||
/** OCS identity check; returns the DAV user id or throws. */
|
|
||||||
private suspend fun ocsUserId(origin: String, user: String, appPassword: String): String =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val raw = "$user:$appPassword".toByteArray(Charsets.UTF_8)
|
|
||||||
val req = Request.Builder().url(origin + ocsPath("/cloud/user")).get()
|
|
||||||
.header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString(raw))
|
|
||||||
.header("OCS-APIRequest", "true")
|
|
||||||
.header("Accept", "application/json").build()
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200 -> {
|
|
||||||
val data = JSONObject(resp.body?.string().orEmpty())
|
|
||||||
.optJSONObject("ocs")?.optJSONObject("data")
|
|
||||||
val id = data?.optString("id", "").orEmpty()
|
|
||||||
if (id.isBlank()) throw ProviderError.Transient("Server identity reply was empty")
|
|
||||||
id
|
|
||||||
}
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
else -> throw ProviderError.Transient("Server identity check failed (HTTP ${resp.code})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Quota(val free: Long, val total: Long)
|
|
||||||
|
|
||||||
private suspend fun ocsQuota(session: NcSession): Quota? = withContext(Dispatchers.IO) {
|
|
||||||
// Quota is informational; never fail the summary over it.
|
|
||||||
runCatching {
|
|
||||||
dav(session, "GET", ocsPath("/cloud/user"), null).use { resp ->
|
|
||||||
if (resp.code != 200) return@runCatching null
|
|
||||||
val q = JSONObject(resp.body?.string().orEmpty())
|
|
||||||
.optJSONObject("ocs")?.optJSONObject("data")?.optJSONObject("quota")
|
|
||||||
?: return@runCatching null
|
|
||||||
// Quota values may be numbers or numeric strings; total -3
|
|
||||||
// (or "unknown") means unlimited.
|
|
||||||
fun num(v: Any?): Long = when (v) {
|
|
||||||
is Number -> v.toLong()
|
|
||||||
is String -> v.toLongOrNull() ?: -3L
|
|
||||||
else -> -3L
|
|
||||||
}
|
|
||||||
val free = num(q.opt("free"))
|
|
||||||
val total = num(q.opt("total"))
|
|
||||||
if (total < 0) null else Quota(free.coerceAtLeast(0), total)
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MKCOL tolerant of "already exists". */
|
|
||||||
private suspend fun ensureDir(session: NcSession, relative: String) {
|
|
||||||
// Create level by level so a missing parent reads as progress, not 409.
|
|
||||||
val parts = relative.split('/').filter { it.isNotEmpty() }
|
|
||||||
var prefix = ""
|
|
||||||
for (part in parts) {
|
|
||||||
prefix = if (prefix.isEmpty()) part else "$prefix/$part"
|
|
||||||
dav(session, "MKCOL", filesPath(session, prefix), null).use { resp ->
|
|
||||||
if (resp.code == 401) throw ProviderError.AuthExpired()
|
|
||||||
if (resp.code != 201 && resp.code != 405) {
|
|
||||||
throw mapError(resp.code, "Could not create folder $prefix")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mkcol(session: NcSession, transfer: String) {
|
|
||||||
dav(session, "MKCOL", uploadsPath(session, transfer) + "/", null).use { resp ->
|
|
||||||
// 405: transfer folder from a previous attempt — resume into it.
|
|
||||||
if (resp.code == 401) throw ProviderError.AuthExpired()
|
|
||||||
if (resp.code != 201 && resp.code != 405) {
|
|
||||||
throw mapError(resp.code, "Could not start the upload")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Chunk names present in the transfer folder (resume). */
|
|
||||||
private suspend fun transferChunks(session: NcSession, transfer: String): Set<Int> {
|
|
||||||
val prefix = "/remote.php/dav/uploads/${session.userId}/"
|
|
||||||
val entries = propfind(session, uploadsPath(session, transfer) + "/", depth = "1", prefix = prefix)
|
|
||||||
return entries.mapNotNullTo(mutableSetOf()) { e ->
|
|
||||||
// Chunk names are "00001".. — compare by numeric value.
|
|
||||||
e.name.trimStart('0').ifEmpty { "0" }.toIntOrNull()
|
|
||||||
?.takeIf { it in 1..10000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun putChunk(
|
|
||||||
session: NcSession,
|
|
||||||
transfer: String,
|
|
||||||
index: Int, // 1-based
|
|
||||||
bytes: ByteArray,
|
|
||||||
totalBytes: Long,
|
|
||||||
destKey: String,
|
|
||||||
) {
|
|
||||||
val name = "%05d".format(index)
|
|
||||||
val dest = session.baseUrl + filesPath(session, destKey)
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url(session.baseUrl + uploadsPath(session, transfer) + "/" + name)
|
|
||||||
.put(bytes.toRequestBody("application/octet-stream".toMediaType()))
|
|
||||||
.header("Authorization", basic(session))
|
|
||||||
.header("OC-Total-Length", totalBytes.toString())
|
|
||||||
.header("Destination", dest).build()
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200, 201, 204 -> Unit
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
507 -> throw ProviderError.QuotaExceeded()
|
|
||||||
else -> throw mapError(resp.code, "Chunk $index rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun assemble(
|
|
||||||
session: NcSession,
|
|
||||||
transfer: String,
|
|
||||||
destKey: String,
|
|
||||||
draft: RecordingDraft,
|
|
||||||
) {
|
|
||||||
val dest = session.baseUrl + filesPath(session, destKey)
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url(session.baseUrl + uploadsPath(session, transfer) + "/.file")
|
|
||||||
.method("MOVE", null)
|
|
||||||
.header("Authorization", basic(session))
|
|
||||||
.header("Destination", dest)
|
|
||||||
.header("Overwrite", "T")
|
|
||||||
.header("OC-Total-Length", draft.sizeBytes.toString())
|
|
||||||
// Server mtime = recording time, so listings sort by when it
|
|
||||||
// was recorded, not when it finished uploading.
|
|
||||||
.header("X-OC-Mtime", (draft.createdAtEpochMs / 1000).toString()).build()
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200, 201, 204 -> Unit
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
404 -> throw ProviderError.Transient("Upload assembly failed — retry the upload")
|
|
||||||
507 -> throw ProviderError.QuotaExceeded()
|
|
||||||
else -> throw mapError(resp.code, "Upload assembly failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal data class DavEntry(
|
|
||||||
val relativePath: String, // relative to files/{user}/, decoded
|
|
||||||
val name: String,
|
|
||||||
val isFile: Boolean,
|
|
||||||
val size: Long,
|
|
||||||
val etag: String?,
|
|
||||||
val contentType: String?,
|
|
||||||
val lastModified: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun propfind(
|
|
||||||
session: NcSession,
|
|
||||||
path: String,
|
|
||||||
depth: String,
|
|
||||||
prefix: String = "/remote.php/dav/files/${session.userId}/",
|
|
||||||
): List<DavEntry> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val body = """<?xml version="1.0"?>
|
|
||||||
<d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getetag/><d:resourcetype/><d:getcontenttype/><d:getlastmodified/><d:displayname/></d:prop></d:propfind>"""
|
|
||||||
val req = Request.Builder().url(session.baseUrl + path)
|
|
||||||
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
|
||||||
.header("Authorization", basic(session))
|
|
||||||
.header("Depth", depth).build()
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
when (resp.code) {
|
|
||||||
200, 207 -> parseMultistatus(resp.body?.string().orEmpty(), prefix = prefix)
|
|
||||||
401 -> throw ProviderError.AuthExpired()
|
|
||||||
404 -> emptyList()
|
|
||||||
else -> throw mapError(resp.code, "Listing failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapError(code: Int, fallback: String): ProviderError = when (code) {
|
|
||||||
401 -> ProviderError.AuthExpired()
|
|
||||||
507 -> ProviderError.QuotaExceeded()
|
|
||||||
else -> ProviderError.Transient("$fallback (HTTP $code)")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** One path segment, percent-encoded (spaces as %20, not '+'). */
|
|
||||||
internal fun enc(segment: String): String =
|
|
||||||
java.net.URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
|
|
||||||
|
|
||||||
internal fun uuidForKey(key: String): String =
|
|
||||||
key.substringAfterLast('/').substringBeforeLast('.')
|
|
||||||
|
|
||||||
internal fun extFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) {
|
|
||||||
"audio/mp4", "audio/m4a" -> ".m4a"
|
|
||||||
"audio/aac" -> ".aac"
|
|
||||||
"audio/wav", "audio/x-wav" -> ".wav"
|
|
||||||
"audio/ogg", "audio/opus" -> ".ogg"
|
|
||||||
"audio/webm" -> ".webm"
|
|
||||||
"audio/mpeg" -> ".mp3"
|
|
||||||
else -> ".m4a" // the app records m4a; unknown mimes keep a playable suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun formatBytes(n: Long): String {
|
|
||||||
if (n < 1024) return "$n B"
|
|
||||||
val units = arrayOf("KB", "MB", "GB", "TB")
|
|
||||||
var v = n.toDouble() / 1024
|
|
||||||
var u = 0
|
|
||||||
while (v >= 1024 && u < units.size - 1) {
|
|
||||||
v /= 1024
|
|
||||||
u++
|
|
||||||
}
|
|
||||||
return "%s %s".format(if (v >= 100) "%.0f" else "%.1f".format(v), units[u])
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseMultistatus(xml: String, prefix: String): List<DavEntry> {
|
|
||||||
if (xml.isBlank()) return emptyList()
|
|
||||||
val out = mutableListOf<DavEntry>()
|
|
||||||
try {
|
|
||||||
val factory = javax.xml.parsers.DocumentBuilderFactory.newInstance()
|
|
||||||
factory.isNamespaceAware = true
|
|
||||||
// Harden against XXE: multistatus docs never need doctypes.
|
|
||||||
runCatching {
|
|
||||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
|
|
||||||
}
|
|
||||||
val doc = factory.newDocumentBuilder()
|
|
||||||
.parse(java.io.ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8)))
|
|
||||||
val responses = doc.getElementsByTagNameNS("DAV:", "response")
|
|
||||||
for (i in 0 until responses.length) {
|
|
||||||
val el = responses.item(i) as? org.w3c.dom.Element ?: continue
|
|
||||||
fun text(tag: String): String? {
|
|
||||||
val nodes = el.getElementsByTagNameNS("DAV:", tag)
|
|
||||||
if (nodes.length == 0) return null
|
|
||||||
return nodes.item(0).textContent?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
val href = text("href") ?: continue
|
|
||||||
val decoded = runCatching {
|
|
||||||
java.net.URLDecoder.decode(href, "UTF-8")
|
|
||||||
}.getOrNull() ?: href
|
|
||||||
// Strip scheme+host when the server returns absolute hrefs.
|
|
||||||
val pathOnly = runCatching { java.net.URI(decoded).path }.getOrNull() ?: decoded
|
|
||||||
val relative = pathOnly.removePrefix(prefix).trim('/')
|
|
||||||
val isCollection = runCatching {
|
|
||||||
val rt = el.getElementsByTagNameNS("DAV:", "resourcetype")
|
|
||||||
rt.length > 0 && (rt.item(0) as org.w3c.dom.Element)
|
|
||||||
.getElementsByTagNameNS("DAV:", "collection").length > 0
|
|
||||||
}.getOrDefault(false)
|
|
||||||
val lastMod = text("getlastmodified")?.let {
|
|
||||||
runCatching {
|
|
||||||
java.time.ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME)
|
|
||||||
.toInstant().toEpochMilli()
|
|
||||||
}.getOrNull()
|
|
||||||
} ?: 0L
|
|
||||||
out += DavEntry(
|
|
||||||
relativePath = relative,
|
|
||||||
name = relative.substringAfterLast('/'),
|
|
||||||
isFile = !isCollection,
|
|
||||||
size = text("getcontentlength")?.toLongOrNull() ?: 0L,
|
|
||||||
etag = text("getetag")?.trim('"'),
|
|
||||||
contentType = text("getcontenttype"),
|
|
||||||
lastModified = lastMod,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ProviderError.Transient("Could not read the server listing")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readSlice(file: File, offset: Long, max: Long): ByteArray {
|
|
||||||
file.inputStream().use { input ->
|
|
||||||
var skipped = 0L
|
|
||||||
while (skipped < offset) {
|
|
||||||
val n = input.skip(offset - skipped)
|
|
||||||
if (n <= 0) break
|
|
||||||
skipped += n
|
|
||||||
}
|
|
||||||
val cap = max.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
|
||||||
val buf = ByteArray(cap)
|
|
||||||
var read = 0
|
|
||||||
while (read < cap) {
|
|
||||||
val n = input.read(buf, read, cap - read)
|
|
||||||
if (n < 0) break
|
|
||||||
read += n
|
|
||||||
}
|
|
||||||
return if (read == cap) buf else buf.copyOf(read)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +1,11 @@
|
||||||
package com.shonar.provider
|
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
|
* Provider ids. The desktop app talks to exactly one service — the local
|
||||||
* user selected during setup; switching is just changing this id — the app
|
* SHONAR engine — so this is just the id constant its RemoteRefs carry.
|
||||||
* never branches on concrete provider classes.
|
* (The phone's multi-provider registry — Nextcloud, local folder, sync
|
||||||
|
* folder — was removed; the app is standalone desktop.)
|
||||||
*/
|
*/
|
||||||
class ProviderRegistry(
|
object ProviderRegistry {
|
||||||
private val factories: Map<String, () -> ShonarProvider>,
|
|
||||||
/** Builds every provider HTTP client (TOFU trust + redacting logger). */
|
|
||||||
val tls: TlsPolicy = defaultTls(),
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val _activeId = MutableStateFlow(LocalOnlyProvider.ID)
|
|
||||||
val activeId: StateFlow<String> = _activeId
|
|
||||||
|
|
||||||
/** Nextcloud is the product default once its factory registers (P4). */
|
|
||||||
val defaultProviderId: String
|
|
||||||
get() = factories.keys.sorted().let { ids ->
|
|
||||||
ids.firstOrNull { it == NEXTCLOUD_ID } ?: ids.firstOrNull() ?: LocalOnlyProvider.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
fun provider(id: String): ShonarProvider =
|
|
||||||
factories[id]?.invoke() ?: throw ProviderError.InvalidUrl("Unknown provider: $id")
|
|
||||||
|
|
||||||
val active: ShonarProvider get() = provider(_activeId.value)
|
|
||||||
|
|
||||||
/** Selecting a provider does not touch existing local data. */
|
|
||||||
fun select(id: String): ShonarProvider {
|
|
||||||
if (id !in factories) throw ProviderError.InvalidUrl("Unknown provider: $id")
|
|
||||||
_activeId.value = id
|
|
||||||
return provider(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun available(): List<ProviderDescriptor> =
|
|
||||||
factories.keys.map { provider(it).descriptor }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NEXTCLOUD_ID = "nextcloud"
|
|
||||||
const val CUSTOM_SHONAR_ID = "custom-shonar"
|
const val 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import javax.net.ssl.SSLHandshakeException
|
||||||
*/
|
*/
|
||||||
class ShonarHandshake(
|
class ShonarHandshake(
|
||||||
private val client: OkHttpClient = defaultClient(),
|
private val client: OkHttpClient = defaultClient(),
|
||||||
private val tofu: TofuManager? = null,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
|
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
|
* SPKI SHA-256 captured during the failed handshake. The desktop talks
|
||||||
* handshake, or "unknown" when nothing was captured (plain HTTP,
|
* to a local engine over plain HTTP, so this is a placeholder kept for
|
||||||
* pre-handshake failure, or no TOFU manager wired).
|
* the ProbeResult contract.
|
||||||
*/
|
*/
|
||||||
private fun fingerprintFor(host: String): String =
|
private fun fingerprintFor(host: String): String = "unknown"
|
||||||
tofu?.failureFor(host)?.spkiHex ?: "unknown"
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync lifecycle for one local recording against the active provider
|
|
||||||
* (docs/server-providers.md §2). P3 defines the vocabulary and the legal
|
|
||||||
* transitions; the WorkManager driver that moves recordings through it is
|
|
||||||
* M5 — until then uploads go through [ShonarProvider.upload] directly and
|
|
||||||
* land in UPLOADED.
|
|
||||||
*
|
|
||||||
* Pausing/canceling is a state, not a job kill: QUEUED and ERROR are stable
|
|
||||||
* resting states a later run resumes from.
|
|
||||||
*/
|
|
||||||
enum class SyncState {
|
|
||||||
LOCAL_ONLY, // provider is local-only, or user never enabled sync
|
|
||||||
QUEUED, // waiting for constraints (network, Wi-Fi-only, charging-only)
|
|
||||||
UPLOADING, // bytes in flight (resumable via the provider's session)
|
|
||||||
UPLOADED, // audio on the server; sidecars may still be pending
|
|
||||||
SYNCED, // audio + all sidecars confirmed server-side
|
|
||||||
ERROR, // failed; [SyncStatus.reasonCode] says why, [SyncStatus.retryAtEpochMs] when
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SyncStatus(
|
|
||||||
val state: SyncState,
|
|
||||||
val retryAtEpochMs: Long? = null,
|
|
||||||
val reasonCode: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legal transitions. Anything not listed here is a programming error, not
|
|
||||||
* a state the UI should ever render.
|
|
||||||
*/
|
|
||||||
fun SyncStatus.canTransitionTo(next: SyncState): Boolean = when (state) {
|
|
||||||
SyncState.LOCAL_ONLY -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY
|
|
||||||
SyncState.QUEUED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY ||
|
|
||||||
next == SyncState.ERROR
|
|
||||||
SyncState.UPLOADING -> next == SyncState.UPLOADED || next == SyncState.QUEUED ||
|
|
||||||
next == SyncState.ERROR || next == SyncState.LOCAL_ONLY
|
|
||||||
SyncState.UPLOADED -> next == SyncState.SYNCED || next == SyncState.UPLOADING ||
|
|
||||||
next == SyncState.ERROR || next == SyncState.LOCAL_ONLY
|
|
||||||
SyncState.SYNCED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY
|
|
||||||
SyncState.ERROR -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P5: builds every provider HTTP client with the TOFU trust manager and the
|
|
||||||
* redacting logger (docs/server-providers.md §4). One place, so no call
|
|
||||||
* site can accidentally build a client that skips either.
|
|
||||||
*
|
|
||||||
* The log [sink] is an explicit constructor argument (no platform default)
|
|
||||||
* so this file compiles on Android and JVM desktop alike; each platform
|
|
||||||
* passes its own sink (Logcat vs stdout).
|
|
||||||
*/
|
|
||||||
class TlsPolicy(
|
|
||||||
val tofu: TofuManager,
|
|
||||||
private val bodiesEnabled: () -> Boolean = { false },
|
|
||||||
private val sink: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
fun newClient(
|
|
||||||
connectTimeoutS: Long,
|
|
||||||
readTimeoutS: Long,
|
|
||||||
writeTimeoutS: Long = readTimeoutS,
|
|
||||||
followRedirects: Boolean = true,
|
|
||||||
): OkHttpClient {
|
|
||||||
val tm = tofu.trustManager
|
|
||||||
val sslContext = javax.net.ssl.SSLContext.getInstance("TLS")
|
|
||||||
sslContext.init(null, arrayOf(tm), null)
|
|
||||||
return OkHttpClient.Builder()
|
|
||||||
.sslSocketFactory(sslContext.socketFactory, tm)
|
|
||||||
.connectTimeout(connectTimeoutS, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(readTimeoutS, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(writeTimeoutS, TimeUnit.SECONDS)
|
|
||||||
.followRedirects(followRedirects)
|
|
||||||
.addInterceptor(RedactingLogger(bodiesEnabled, sink))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** P3-era API shape (15/60/60s). */
|
|
||||||
fun apiClient(): OkHttpClient = newClient(15, 60, 60)
|
|
||||||
|
|
||||||
/** Short probing shape (10/15s, no redirects). */
|
|
||||||
fun probeClient(): OkHttpClient = newClient(10, 15, 15, followRedirects = false)
|
|
||||||
|
|
||||||
/** Nextcloud shape (10/20/60s, no redirects). */
|
|
||||||
fun nextcloudClient(): OkHttpClient = newClient(10, 20, 60, followRedirects = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logging is OFF by default; when the `log_http_bodies` debug setting is
|
|
||||||
* on, requests log in redacted form. Invariants (leak-tested):
|
|
||||||
* - Authorization / Cookie / Set-Cookie headers are never logged.
|
|
||||||
* - bodies log only for JSON/XML/text under [MAX_BODY] bytes; audio and
|
|
||||||
* other binary bodies never log.
|
|
||||||
* - token-shaped JSON values (`…token…`, `password`, `appPassword`) are
|
|
||||||
* masked, and Basic credentials are masked, even inside bodies.
|
|
||||||
*/
|
|
||||||
class RedactingLogger(
|
|
||||||
private val bodiesEnabled: () -> Boolean,
|
|
||||||
private val sink: (String) -> Unit,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val sensitive = chain.request().header(SENSITIVE_BODY) != null
|
|
||||||
val req = chain.request().newBuilder().removeHeader(SENSITIVE_BODY).build()
|
|
||||||
if (!bodiesEnabled()) return chain.proceed(req)
|
|
||||||
val t0 = System.currentTimeMillis()
|
|
||||||
val reqBody = req.body
|
|
||||||
val reqLen = reqBody?.contentLength()?.takeIf { it >= 0 }
|
|
||||||
sink("→ ${req.method} ${req.url.host}${req.url.encodedPath} body=${reqLen?.let { "$it B" } ?: "?"}")
|
|
||||||
for (i in 0 until req.headers.size) {
|
|
||||||
val name = req.headers.name(i)
|
|
||||||
sink(" $name: ${if (isSensitiveHeader(name)) "[redacted]" else req.headers.value(i)}")
|
|
||||||
}
|
|
||||||
if (sensitive) {
|
|
||||||
sink(" request-body: [sensitive, not logged]")
|
|
||||||
} else {
|
|
||||||
logRequestBody(reqBody?.contentType()?.toString(), reqBody)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val resp = chain.proceed(req)
|
|
||||||
val ms = System.currentTimeMillis() - t0
|
|
||||||
sink("← ${resp.code} ${req.url.encodedPath} (${ms}ms)")
|
|
||||||
if (sensitive) {
|
|
||||||
sink(" response-body: [sensitive, not logged]")
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
val peek = resp.peekBody(MAX_BODY + 1)
|
|
||||||
logBody(
|
|
||||||
" response",
|
|
||||||
peek.contentType()?.toString(),
|
|
||||||
peek.bytes().toList(),
|
|
||||||
)
|
|
||||||
return resp
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sink("✕ ${req.url.encodedPath} failed (${e.javaClass.simpleName})")
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request bodies need a size gate BEFORE reading: uploads are file
|
|
||||||
* bodies that must never be buffered just to log a prefix of them.
|
|
||||||
*/
|
|
||||||
private fun logRequestBody(contentType: String?, body: okhttp3.RequestBody?) {
|
|
||||||
if (body == null) {
|
|
||||||
sink(" request-body: none")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val len = try {
|
|
||||||
body.contentLength()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
-1L
|
|
||||||
}
|
|
||||||
if (len < 0 || len > MAX_BODY) {
|
|
||||||
val what = if (len < 0) "streaming" else "$len bytes"
|
|
||||||
sink(" request-body: [$what, not logged]")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val bytes = try {
|
|
||||||
val buf = okio.Buffer()
|
|
||||||
body.writeTo(buf)
|
|
||||||
buf.readByteArray().toList()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
logBody(" request", contentType, bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logBody(prefix: String, contentType: String?, bytes: List<Byte>?) {
|
|
||||||
if (bytes == null) {
|
|
||||||
sink("$prefix-body: none")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isLoggableType(contentType)) {
|
|
||||||
sink("$prefix-body: [${bytes.size} bytes, not logged]")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bytes.size > MAX_BODY) {
|
|
||||||
sink("$prefix-body: [${bytes.size} bytes, over the $MAX_BODY B cap, not logged]")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sink("$prefix-body: ${redact(bytes.toByteArray().toString(Charsets.UTF_8))}")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val MAX_BODY: Long = 8192
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opt out of body logging per request (both directions). Providers
|
|
||||||
* set this on calls whose bodies are transcripts or other sensitive
|
|
||||||
* payloads; the logger strips it before sending so it never reaches
|
|
||||||
* the wire.
|
|
||||||
*/
|
|
||||||
const val SENSITIVE_BODY = "X-Shonar-Sensitive-Body"
|
|
||||||
|
|
||||||
internal fun isSensitiveHeader(name: String): Boolean = when (name.lowercase()) {
|
|
||||||
"authorization", "cookie", "set-cookie", "x-chunk-sha256" -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isLoggableType(contentType: String?): Boolean {
|
|
||||||
if (contentType == null) return false
|
|
||||||
val t = contentType.lowercase().substringBefore(';').trim()
|
|
||||||
return t.startsWith("application/json") || t.endsWith("+json") ||
|
|
||||||
t.startsWith("text/") || t.endsWith("+xml") ||
|
|
||||||
t == "application/xml" || t == "application/x-www-form-urlencoded"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SECRET_JSON = Regex(
|
|
||||||
""""[^"]*(token|password|secret)[^"]*"\s*:\s*"[^"]*"""",
|
|
||||||
RegexOption.IGNORE_CASE,
|
|
||||||
)
|
|
||||||
private val BASIC = Regex("""Basic\s+[A-Za-z0-9+/=]{8,}""")
|
|
||||||
private val BEARER = Regex("""Bearer\s+[A-Za-z0-9\-_.~+/=]{8,}""")
|
|
||||||
|
|
||||||
/** Mask token-shaped values; safe to run on any text. */
|
|
||||||
internal fun redact(text: String): String {
|
|
||||||
var out = SECRET_JSON.replace(text) { m ->
|
|
||||||
val key = m.value.substringBefore(':')
|
|
||||||
"""$key:"***""""
|
|
||||||
}
|
|
||||||
out = BASIC.replace(out, "Basic [redacted]")
|
|
||||||
out = BEARER.replace(out, "Bearer [redacted]")
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
package com.shonar.provider
|
|
||||||
|
|
||||||
import com.shonar.settings.SettingsStore
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import javax.net.ssl.SSLEngine
|
|
||||||
import javax.net.ssl.SSLSession
|
|
||||||
import javax.net.ssl.X509ExtendedTrustManager
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P5: trust-on-first-use (TOFU) for self-signed LAN servers
|
|
||||||
* (docs/server-providers.md §4).
|
|
||||||
*
|
|
||||||
* Policy, enforced by construction:
|
|
||||||
* - system CAs are always tried first; TOFU pins are a fallback, never a
|
|
||||||
* replacement. A host that later gets a real certificate just works.
|
|
||||||
* - pins are per-host: an approved cert for `nas.local` is trusted ONLY
|
|
||||||
* when `nas.local` presents it. Byte-equality on the leaf DER, so a
|
|
||||||
* rotation needs a fresh approval (correct TOFU semantics).
|
|
||||||
* - nothing is ever trusted silently: the first failure only RECORDS the
|
|
||||||
* chain, and trust requires an explicit [TofuManager.approve] call from
|
|
||||||
* a UI that showed the fingerprint.
|
|
||||||
* - the normal TLS hostname verifier stays strict; TOFU covers unknown
|
|
||||||
* CAs, not name mismatches.
|
|
||||||
*/
|
|
||||||
class TofuStore(private val secure: SettingsStore) {
|
|
||||||
|
|
||||||
suspend fun addPin(host: String, derBase64: String) {
|
|
||||||
val pins = pins(host).toMutableSet()
|
|
||||||
pins += derBase64
|
|
||||||
secure.putString(pinKey(host), org.json.JSONArray(pins.toList()).toString())
|
|
||||||
val hosts = hosts().toMutableSet()
|
|
||||||
if (hosts.add(host.lowercase())) {
|
|
||||||
secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun pins(host: String): Set<String> {
|
|
||||||
val raw = secure.getString(pinKey(host.lowercase())) ?: return emptySet()
|
|
||||||
return runCatching {
|
|
||||||
val arr = org.json.JSONArray(raw)
|
|
||||||
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
|
||||||
}.getOrDefault(emptySet())
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun hosts(): Set<String> {
|
|
||||||
val raw = secure.getString(KEY_HOSTS) ?: return emptySet()
|
|
||||||
return runCatching {
|
|
||||||
val arr = org.json.JSONArray(raw)
|
|
||||||
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
|
||||||
}.getOrDefault(emptySet())
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeHost(host: String) {
|
|
||||||
secure.remove(pinKey(host.lowercase()))
|
|
||||||
val hosts = hosts().toMutableSet()
|
|
||||||
if (hosts.remove(host.lowercase())) {
|
|
||||||
secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFIX = "tofu."
|
|
||||||
const val KEY_HOSTS = PREFIX + "hosts"
|
|
||||||
fun pinKey(host: String): String = PREFIX + "pins." + host.lowercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A recorded untrusted chain, shown to the user for approval. */
|
|
||||||
data class TofuFailure(
|
|
||||||
val host: String,
|
|
||||||
/** SPKI SHA-256, browser-style `AB:CD:…` uppercase hex. */
|
|
||||||
val spkiHex: String,
|
|
||||||
val subject: String,
|
|
||||||
val issuer: String,
|
|
||||||
val validFrom: String,
|
|
||||||
val validUntil: String,
|
|
||||||
val leafDer: ByteArray,
|
|
||||||
val recordedAtMs: Long = System.currentTimeMillis(),
|
|
||||||
) {
|
|
||||||
override fun toString(): String =
|
|
||||||
"TofuFailure(host=$host, spki=$spkiHex, subject=$subject)"
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other !is TofuFailure) return false
|
|
||||||
return host == other.host && spkiHex == other.spkiHex &&
|
|
||||||
leafDer.contentEquals(other.leafDer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int = 31 * host.hashCode() + spkiHex.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown (as a [CertificateException], so it propagates through the TLS
|
|
||||||
* stack untouched) when a chain is neither system-trusted nor pinned.
|
|
||||||
* Carries no secrets — DNs and fingerprints are public cert contents.
|
|
||||||
*/
|
|
||||||
class TofuUntrustedException(
|
|
||||||
val failure: TofuFailure,
|
|
||||||
message: String = "Untrusted certificate for ${failure.host} (SPKI ${failure.spkiHex})",
|
|
||||||
) : CertificateException(message)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System-first trust manager with per-host TOFU fallback. The pin cache is
|
|
||||||
* in-memory (handshakes run on TLS threads that cannot suspend); it is
|
|
||||||
* filled by [TofuManager.refresh] at startup and kept in sync by
|
|
||||||
* approve/forget.
|
|
||||||
*/
|
|
||||||
class TofuTrustManager(
|
|
||||||
private val system: X509TrustManager,
|
|
||||||
private val manager: TofuManager,
|
|
||||||
) : X509ExtendedTrustManager() {
|
|
||||||
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
system.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-auth paths: this app is always the TLS client, so these only
|
|
||||||
// need to exist (newer JDKs/Android declare them abstract). Delegate to
|
|
||||||
// the 2-arg system check.
|
|
||||||
override fun checkClientTrusted(
|
|
||||||
chain: Array<X509Certificate>, authType: String, socket: Socket,
|
|
||||||
) {
|
|
||||||
system.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkClientTrusted(
|
|
||||||
chain: Array<X509Certificate>, authType: String, engine: SSLEngine,
|
|
||||||
) {
|
|
||||||
system.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
checkServerTrusted(chain, authType, host = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(
|
|
||||||
chain: Array<X509Certificate>, authType: String, socket: Socket,
|
|
||||||
) {
|
|
||||||
val host = runCatching {
|
|
||||||
(socket as? javax.net.ssl.SSLSocket)?.handshakeSession?.peerHost
|
|
||||||
}.getOrNull()
|
|
||||||
checkServerTrusted(chain, authType, host = host)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(
|
|
||||||
chain: Array<X509Certificate>, authType: String, engine: SSLEngine,
|
|
||||||
) {
|
|
||||||
val host = runCatching {
|
|
||||||
(engine.session as? javax.net.ssl.ExtendedSSLSession)?.peerHost
|
|
||||||
}.getOrNull()
|
|
||||||
checkServerTrusted(chain, authType, host = host)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkServerTrusted(
|
|
||||||
chain: Array<X509Certificate>, authType: String, host: String?,
|
|
||||||
) {
|
|
||||||
// pins are checked first so an approved self-signed cert keeps
|
|
||||||
// working even where a system path would also exist; system trust
|
|
||||||
// is the fallback that makes later real certs frictionless.
|
|
||||||
if (chain.isNotEmpty() && host != null && manager.isPinned(host, chain[0])) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
system.checkServerTrusted(chain, authType)
|
|
||||||
} catch (e: CertificateException) {
|
|
||||||
val failure = TofuFailure(
|
|
||||||
host = (host ?: "").lowercase(),
|
|
||||||
spkiHex = spkiHex(chain[0]),
|
|
||||||
subject = chain[0].subjectX500Principal.name,
|
|
||||||
issuer = chain[0].issuerX500Principal.name,
|
|
||||||
validFrom = chain[0].notBefore.toString(),
|
|
||||||
validUntil = chain[0].notAfter.toString(),
|
|
||||||
leafDer = chain[0].encoded,
|
|
||||||
)
|
|
||||||
manager.record(failure)
|
|
||||||
throw TofuUntrustedException(failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = system.acceptedIssuers
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** SPKI SHA-256 as browser-style colon-separated uppercase hex. */
|
|
||||||
internal fun spkiHex(cert: X509Certificate): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(cert.publicKey.encoded)
|
|
||||||
return digest.joinToString(":") { "%02X".format(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the pin store, the in-memory cache the trust manager reads, and the
|
|
||||||
* recorded failures the approval UI consumes.
|
|
||||||
*/
|
|
||||||
class TofuManager(
|
|
||||||
private val store: TofuStore,
|
|
||||||
system: X509TrustManager = defaultSystemTrustManager(),
|
|
||||||
) {
|
|
||||||
private val cache = ConcurrentHashMap<String, Set<String>>()
|
|
||||||
private val failures = ConcurrentHashMap<String, TofuFailure>()
|
|
||||||
|
|
||||||
val trustManager: TofuTrustManager by lazy { TofuTrustManager(system, this) }
|
|
||||||
|
|
||||||
/** Fill the cache from the store. Call at startup (and only there). */
|
|
||||||
suspend fun refresh() {
|
|
||||||
val fresh = mutableMapOf<String, Set<String>>()
|
|
||||||
for (host in store.hosts()) {
|
|
||||||
fresh[host.lowercase()] = store.pins(host)
|
|
||||||
}
|
|
||||||
cache.clear()
|
|
||||||
cache.putAll(fresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isPinned(host: String, leaf: X509Certificate): Boolean {
|
|
||||||
val pins = cache[host.lowercase()] ?: return false
|
|
||||||
val der = runCatching { leaf.encoded }.getOrNull() ?: return false
|
|
||||||
return pins.any { pin ->
|
|
||||||
runCatching {
|
|
||||||
MessageDigest.isEqual(
|
|
||||||
der,
|
|
||||||
java.util.Base64.getDecoder().decode(pin),
|
|
||||||
)
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun record(failure: TofuFailure) {
|
|
||||||
failures[failure.host] = failure
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Most recent failure for [host], else a fresh hostless one, else null. */
|
|
||||||
fun failureFor(host: String): TofuFailure? {
|
|
||||||
val key = host.lowercase()
|
|
||||||
failures[key]?.let { return it }
|
|
||||||
val fallback = failures[""] ?: return null
|
|
||||||
// A hostless record only belongs to this probe if it just happened
|
|
||||||
// (single onboarding flow, no concurrency to speak of).
|
|
||||||
if (System.currentTimeMillis() - fallback.recordedAtMs > FRESH_MS) return null
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trust [host]'s recorded leaf from now on. Returns false when there is
|
|
||||||
* nothing recorded (never invent trust).
|
|
||||||
*/
|
|
||||||
suspend fun approve(host: String): Boolean {
|
|
||||||
val key = host.lowercase()
|
|
||||||
val failure = failureFor(key) ?: return false
|
|
||||||
val der = java.util.Base64.getEncoder().encodeToString(failure.leafDer)
|
|
||||||
store.addPin(key, der)
|
|
||||||
cache[key] = store.pins(key)
|
|
||||||
failures.remove(key)
|
|
||||||
failures.remove("")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun decline(host: String) {
|
|
||||||
failures.remove(host.lowercase())
|
|
||||||
failures.remove("")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun forget(host: String) {
|
|
||||||
store.removeHost(host)
|
|
||||||
cache.remove(host.lowercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun pinnedHosts(): Set<String> = store.hosts()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val FRESH_MS = 30_000L
|
|
||||||
|
|
||||||
fun defaultSystemTrustManager(): X509TrustManager {
|
|
||||||
val factory = javax.net.ssl.TrustManagerFactory.getInstance(
|
|
||||||
javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()
|
|
||||||
)
|
|
||||||
factory.init(null as java.security.KeyStore?)
|
|
||||||
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String>, val cancelled: Boolean)
|
|
||||||
|
|
||||||
suspend fun migrateAllTo(
|
|
||||||
providerId: String,
|
|
||||||
onProgress: (Progress) -> Unit = {},
|
|
||||||
): Result {
|
|
||||||
require(providerId != LocalOnlyProvider.ID) { "cannot migrate to local-only" }
|
|
||||||
val target: ShonarProvider = registry.provider(providerId)
|
|
||||||
val rows = slots.rows()
|
|
||||||
var uploaded = 0
|
|
||||||
val failed = mutableListOf<String>()
|
|
||||||
var cancelled = false
|
|
||||||
rows.forEachIndexed { index, row ->
|
|
||||||
// Cancellation lands here between files: counted, honest, resumable.
|
|
||||||
try {
|
|
||||||
currentCoroutineContext().ensureActive()
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
cancelled = true
|
|
||||||
return Result(uploaded, failed, cancelled)
|
|
||||||
}
|
|
||||||
onProgress(Progress(index, rows.size, row.title))
|
|
||||||
val draft = slots.draftFor(row)
|
|
||||||
if (draft == null) {
|
|
||||||
slots.markFailed(row, "local file is gone")
|
|
||||||
failed += row.title
|
|
||||||
return@forEachIndexed
|
|
||||||
}
|
|
||||||
stepToUploading(row)
|
|
||||||
try {
|
|
||||||
val ref = target.upload(draft) {}
|
|
||||||
slots.markUploaded(slots.rows().firstOrNull { it.id == row.id } ?: row, providerId, ref)
|
|
||||||
uploaded++
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
// Interrupted mid-file: leave the row UPLOADING so a later
|
|
||||||
// run retries it explicitly rather than assuming either
|
|
||||||
// outcome.
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
|
|
||||||
// Backoff included: the M5 drain picks ERROR rows up when due.
|
|
||||||
slots.markFailed(fresh, e.message ?: e.javaClass.simpleName)
|
|
||||||
failed += row.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onProgress(Progress(rows.size, rows.size, ""))
|
|
||||||
return Result(uploaded, failed, cancelled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legal first steps toward UPLOADING from any resting state, per the
|
|
||||||
* transition table (shared with the M5 drain).
|
|
||||||
*/
|
|
||||||
private suspend fun stepToUploading(row: RecordingEntity) {
|
|
||||||
var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
|
|
||||||
for (step in stepsToUploading(fresh.syncState)) {
|
|
||||||
slots.markState(fresh, step)
|
|
||||||
fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<State?>(null)
|
|
||||||
val state: StateFlow<State?> = _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
|
|
||||||
}
|
|
||||||
|
|
@ -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<List<RecordingEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
|
|
||||||
suspend fun getAll(): List<RecordingEntity>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM recordings WHERE id = :id")
|
|
||||||
suspend fun getById(id: String): RecordingEntity?
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
suspend fun insert(recording: RecordingEntity)
|
|
||||||
|
|
||||||
@Update
|
|
||||||
suspend fun update(recording: RecordingEntity)
|
|
||||||
|
|
||||||
@Delete
|
|
||||||
suspend fun delete(recording: RecordingEntity)
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String> {
|
|
||||||
val out = mutableListOf<String>()
|
|
||||||
if (!hasRecordAudio(c)) out += Manifest.permission.RECORD_AUDIO
|
|
||||||
if (Build.VERSION.SDK_INT >= 33 && !hasPostNotifications(c)) {
|
|
||||||
out += Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<List<RecordingEntity>> = 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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<RecordingSnapshot> = _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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String>, val postponed: Boolean)
|
|
||||||
|
|
||||||
suspend fun drainOnce(
|
|
||||||
provider: ShonarProvider,
|
|
||||||
wifiOnly: Boolean,
|
|
||||||
chargingOnly: Boolean,
|
|
||||||
gates: Gates,
|
|
||||||
onProgress: (Progress) -> Unit = {},
|
|
||||||
): Result {
|
|
||||||
if (provider.descriptor.id == LocalOnlyProvider.ID) {
|
|
||||||
return Result(0, emptyList(), postponed = false)
|
|
||||||
}
|
|
||||||
val now = clock()
|
|
||||||
val due = slots.rows().filter { row ->
|
|
||||||
row.syncState == SyncState.QUEUED ||
|
|
||||||
(row.syncState == SyncState.ERROR &&
|
|
||||||
(row.syncRetryAtMs == null || row.syncRetryAtMs <= now))
|
|
||||||
}
|
|
||||||
if (due.isEmpty()) return Result(0, emptyList(), postponed = false)
|
|
||||||
if (!gates.satisfiedBy(wifiOnly, chargingOnly)) {
|
|
||||||
return Result(0, emptyList(), postponed = true)
|
|
||||||
}
|
|
||||||
var uploaded = 0
|
|
||||||
val failed = mutableListOf<String>()
|
|
||||||
due.forEachIndexed { index, row ->
|
|
||||||
try {
|
|
||||||
currentCoroutineContext().ensureActive()
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
return Result(uploaded, failed, postponed = false)
|
|
||||||
}
|
|
||||||
onProgress(Progress(index, due.size, row.title))
|
|
||||||
val draft = slots.draftFor(row)
|
|
||||||
if (draft == null) {
|
|
||||||
slots.markFailed(row, "local file is gone", now)
|
|
||||||
failed += row.title
|
|
||||||
return@forEachIndexed
|
|
||||||
}
|
|
||||||
var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
|
|
||||||
for (step in stepsToUploading(fresh.syncState)) {
|
|
||||||
slots.markState(fresh, step)
|
|
||||||
fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val ref = provider.upload(draft) {}
|
|
||||||
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
|
||||||
slots.markUploaded(done, provider.descriptor.id, ref)
|
|
||||||
uploaded++
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
// Interrupted mid-file: revert so a later pass retries
|
|
||||||
// explicitly instead of assuming either outcome.
|
|
||||||
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
|
||||||
slots.markState(done, SyncState.QUEUED)
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
|
|
||||||
slots.markFailed(done, e.message ?: e.javaClass.simpleName, now)
|
|
||||||
failed += row.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onProgress(Progress(due.size, due.size, ""))
|
|
||||||
return Result(uploaded, failed, postponed = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pure gate evaluation, unit-tested. */
|
|
||||||
internal fun SyncDrain.Gates.satisfiedBy(wifiOnly: Boolean, chargingOnly: Boolean): Boolean =
|
|
||||||
online && (!wifiOnly || unmetered) && (!chargingOnly || charging)
|
|
||||||
|
|
@ -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<RecordingEntity> = dao.getAll()
|
|
||||||
|
|
||||||
/** Draft for upload, or null when the local file is gone. */
|
|
||||||
fun draftFor(entity: RecordingEntity): RecordingDraft? {
|
|
||||||
val file = File(entity.filePath)
|
|
||||||
if (!file.isFile) return null
|
|
||||||
return RecordingDraft(
|
|
||||||
id = entity.id,
|
|
||||||
title = entity.title,
|
|
||||||
createdAtEpochMs = entity.createdAtEpochMs,
|
|
||||||
durationMs = entity.durationMs,
|
|
||||||
mime = entity.mimeType,
|
|
||||||
sourceFile = file,
|
|
||||||
sizeBytes = file.length(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) {
|
|
||||||
dao.update(entity.copy(syncState = state, syncReason = reason))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) {
|
|
||||||
dao.update(
|
|
||||||
entity.copy(
|
|
||||||
originProviderId = providerId,
|
|
||||||
remoteKey = ref.key,
|
|
||||||
remoteEtag = ref.etag,
|
|
||||||
remoteSizeBytes = ref.sizeBytes,
|
|
||||||
syncState = SyncState.UPLOADED,
|
|
||||||
syncReason = null,
|
|
||||||
syncAttempts = 0,
|
|
||||||
syncRetryAtMs = null,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a failed attempt with exponential backoff. [nowMs] is injected
|
|
||||||
* (default wall-clock) so tests run on a fixed clock.
|
|
||||||
*/
|
|
||||||
suspend fun markFailed(
|
|
||||||
entity: RecordingEntity,
|
|
||||||
reason: String,
|
|
||||||
nowMs: Long = System.currentTimeMillis(),
|
|
||||||
) {
|
|
||||||
val attempts = (entity.syncAttempts + 1).coerceAtLeast(1)
|
|
||||||
dao.update(
|
|
||||||
entity.copy(
|
|
||||||
syncState = SyncState.ERROR,
|
|
||||||
syncReason = reason,
|
|
||||||
syncAttempts = attempts,
|
|
||||||
syncRetryAtMs = nowMs + backoffDelayMs(attempts),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forget every remote link ("start fresh"). Files stay; rows read as
|
|
||||||
* never-synced under [newOriginId], ready for a later upload run.
|
|
||||||
* Nothing remote is touched — forgetting is metadata-only.
|
|
||||||
*/
|
|
||||||
suspend fun forgetAllRemotes(newOriginId: String) { for (row in dao.getAll()) {
|
|
||||||
dao.update(
|
|
||||||
row.copy(
|
|
||||||
originProviderId = newOriginId,
|
|
||||||
remoteKey = null,
|
|
||||||
remoteEtag = null,
|
|
||||||
remoteSizeBytes = null,
|
|
||||||
syncState = SyncState.QUEUED,
|
|
||||||
syncReason = null,
|
|
||||||
syncAttempts = 0,
|
|
||||||
syncRetryAtMs = null,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legal steps from any resting state toward UPLOADING, per the transition
|
|
||||||
* table. Shared by the P7 foreground migrator and the M5 background drain
|
|
||||||
* so both move rows through identical states.
|
|
||||||
*/
|
|
||||||
internal fun stepsToUploading(from: SyncState): List<SyncState> = when (from) {
|
|
||||||
SyncState.LOCAL_ONLY, SyncState.ERROR -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
|
|
||||||
// A leftover UPLOADING row (killed run) has no self-loop: detour via QUEUED.
|
|
||||||
SyncState.UPLOADING -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
|
|
||||||
else -> listOf(SyncState.UPLOADING)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consecutive-failure backoff: 1m, 2m, 4m … capped at 1h. [attempt] is the
|
|
||||||
* 1-based count *including* the failure just recorded.
|
|
||||||
*/
|
|
||||||
internal fun backoffDelayMs(attempt: Int): Long {
|
|
||||||
val shift = (attempt.coerceAtLeast(1) - 1).coerceAtMost(6)
|
|
||||||
return (60_000L shl shift).coerceAtMost(3_600_000L)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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<SyncWorker>()
|
|
||||||
.setConstraints(workConstraints(wifiOnly, chargingOnly))
|
|
||||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
|
||||||
.addTag(ONCE_NAME)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance(context)
|
|
||||||
.enqueueUniqueWork(ONCE_NAME, ExistingWorkPolicy.REPLACE, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Steady-state background drain. Idempotent; safe to call on every launch. */
|
|
||||||
fun ensurePeriodic(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
|
|
||||||
val req = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
|
||||||
.setConstraints(workConstraints(wifiOnly, chargingOnly))
|
|
||||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
|
||||||
.addTag(PERIODIC_NAME)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
|
||||||
PERIODIC_NAME, ExistingPeriodicWorkPolicy.UPDATE, req
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pause: cancel scheduled work and revert in-flight rows to QUEUED. */
|
|
||||||
suspend fun pauseAll(context: Context, dao: RecordingDao) {
|
|
||||||
val wm = WorkManager.getInstance(context)
|
|
||||||
wm.cancelUniqueWork(ONCE_NAME)
|
|
||||||
wm.cancelUniqueWork(PERIODIC_NAME)
|
|
||||||
for (row in dao.getAll()) {
|
|
||||||
if (row.syncState == com.shonar.provider.SyncState.UPLOADING) {
|
|
||||||
dao.update(row.copy(syncState = com.shonar.provider.SyncState.QUEUED))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<SettingDefinition> = 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 <n>", "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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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<String> = 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)
|
|
||||||
|
|
@ -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<String>, val rejected: Map<String, String>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<List<SettingDefinition>>(emptyList())
|
|
||||||
val definitions: StateFlow<List<SettingDefinition>> = definitionsFlow.asStateFlow()
|
|
||||||
|
|
||||||
/** Bumped on any value change so ViewModels can re-read. */
|
|
||||||
private val valuesVersion = MutableStateFlow(0L)
|
|
||||||
val valuesChanged: StateFlow<Long> = 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<List<SettingDefinition>>(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<String> =
|
|
||||||
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<SettingDefinition>) {
|
|
||||||
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<SettingDefinition>()
|
|
||||||
val defsById = definitionsFlow.value.associateBy { it.id }.toMutableMap()
|
|
||||||
val rejected = mutableMapOf<String, String>()
|
|
||||||
root["customDefinitions"]?.let { el ->
|
|
||||||
val defs = runCatching {
|
|
||||||
json.decodeFromJsonElement<List<SettingDefinition>>(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<Pair<SettingDefinition, String>>()
|
|
||||||
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.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String>
|
|
||||||
|
|
||||||
/** Emits whenever any value changes (for reactive UI). */
|
|
||||||
val changes: StateFlow<Long>
|
|
||||||
}
|
|
||||||
|
|
||||||
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
||||||
name = "shonar_settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
class DataStoreSettingsStore(private val context: Context) : SettingsStore {
|
|
||||||
private val changeFlow = MutableStateFlow(0L)
|
|
||||||
override val changes: StateFlow<Long> = 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<String> =
|
|
||||||
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<Long> = 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<String> = prefs.all.keys
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-memory store: used by unit tests. */
|
|
||||||
class InMemorySettingsStore : SettingsStore {
|
|
||||||
private val map = mutableMapOf<String, String>()
|
|
||||||
private val changeFlow = MutableStateFlow(0L)
|
|
||||||
override val changes: StateFlow<Long> = 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<String> = map.keys.toSet()
|
|
||||||
}
|
|
||||||
|
|
@ -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<String, Any?>) -> 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<String, Any?>) -> 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<String> =
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -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<JobInfo>) : 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>(UiState.Loading)
|
|
||||||
val state: StateFlow<UiState> = _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 <T : ViewModel> create(modelClass: Class<T>): 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<String, Any?>) {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String?>(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") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<DirEntry> = 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<File> {
|
|
||||||
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."))
|
|
||||||
}
|
|
||||||
|
|
@ -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<Boolean?>(null) }
|
|
||||||
var storageHeadline by remember { mutableStateOf<String?>(null) }
|
|
||||||
var storagePath by remember { mutableStateOf<String?>(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<List<RecordingEntity>> = app.recordingRepository.recordings
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
||||||
val recorderState = RecordingService.snapshot
|
|
||||||
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
|
||||||
val playingId: StateFlow<String?> = _playingId
|
|
||||||
private val _notice = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
|
||||||
/** Transient rename/delete failures surface here; cleared on next action. */
|
|
||||||
val notice: StateFlow<String?> = _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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String?>(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>(SelectionUiState.Idle)
|
|
||||||
val state: StateFlow<SelectionUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
val options: List<ProviderOption> = 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<ProviderOption> get() = options.filter { !it.advanced }
|
|
||||||
|
|
||||||
/** Self-hosted server choices, behind the Advanced disclosure. */
|
|
||||||
val advancedOptions: List<ProviderOption> 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
|
|
||||||
}
|
|
||||||
|
|
@ -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") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>(UiState.Loading)
|
|
||||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.value = UiState.Loading
|
|
||||||
_state.value = load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun load(): UiState {
|
|
||||||
settings.ensureLoaded()
|
|
||||||
val id = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
|
|
||||||
val provider = try {
|
|
||||||
registry.provider(id)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return UiState.Error("Unknown provider \"$id\" — pick one again.")
|
|
||||||
}
|
|
||||||
val auth = provider.authState.value
|
|
||||||
var summary: StorageLocation? = null
|
|
||||||
var summaryError: String? = null
|
|
||||||
try {
|
|
||||||
summary = provider.storageLocationSummary()
|
|
||||||
} catch (e: com.shonar.provider.ProviderError.NotConnected) {
|
|
||||||
summaryError = "Not connected — reconnect to see live numbers."
|
|
||||||
} catch (e: Exception) {
|
|
||||||
summaryError = e.message ?: "Could not load storage info."
|
|
||||||
}
|
|
||||||
return UiState.ReadyState(
|
|
||||||
Ready(
|
|
||||||
providerId = id,
|
|
||||||
displayName = provider.descriptor.displayName,
|
|
||||||
auth = auth,
|
|
||||||
summary = summary,
|
|
||||||
summaryError = summaryError,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reconnect() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val cur = currentReady() ?: return@launch
|
|
||||||
setBusy(cur, "Reconnecting…")
|
|
||||||
val provider = registry.provider(cur.providerId)
|
|
||||||
runCatching { provider.reconnect() }
|
|
||||||
_state.value = loadWith(cur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect(revokeOnServer: Boolean) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val cur = currentReady() ?: return@launch
|
|
||||||
setBusy(cur, "Disconnecting…")
|
|
||||||
runCatching { registry.provider(cur.providerId).disconnect(revokeOnServer) }
|
|
||||||
_state.value = loadWith(cur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M5 pause: cancel scheduled sync work and revert in-flight rows to
|
|
||||||
* QUEUED. Pause is a resting state, not a job kill — the next drain
|
|
||||||
* (or a settings change) resumes cleanly.
|
|
||||||
*/
|
|
||||||
fun pauseSync() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val cur = currentReady() ?: return@launch
|
|
||||||
setBusy(cur, "Pausing…")
|
|
||||||
runCatching {
|
|
||||||
com.shonar.recording.SyncScheduler.pauseAll(app.applicationContext, dao)
|
|
||||||
}
|
|
||||||
_state.value = loadWith(cur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun askDelete() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val cur = currentReady() ?: return@launch
|
|
||||||
val needsPassword = registry.provider(cur.providerId) is
|
|
||||||
com.shonar.provider.CustomShonarProvider
|
|
||||||
_state.value = UiState.ReadyState(
|
|
||||||
cur.copy(confirmDelete = true, needPassword = needsPassword, deleteError = null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelDelete() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val cur = currentReady() ?: return@launch
|
|
||||||
_state.value = UiState.ReadyState(cur.copy(confirmDelete = false, deleteError = null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete account & data on the active provider, then fall back to
|
|
||||||
* local-only with cleared remote links. Provider semantics differ and
|
|
||||||
* the confirm dialog says which apply; the local library files always
|
|
||||||
* stay — only the remote links are forgotten.
|
|
||||||
*/
|
|
||||||
fun confirmDelete(password: String = "") {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val cur = currentReady() ?: return@launch
|
|
||||||
setBusy(cur, "Deleting…")
|
|
||||||
val provider = registry.provider(cur.providerId)
|
|
||||||
val failure = runCatching {
|
|
||||||
val custom = provider as? com.shonar.provider.CustomShonarProvider
|
|
||||||
if (custom != null) {
|
|
||||||
if (password.isEmpty()) throw IllegalArgumentException("Password required")
|
|
||||||
custom.deleteAccount(password)
|
|
||||||
} else {
|
|
||||||
provider.deleteAccountAndData()
|
|
||||||
}
|
|
||||||
}.exceptionOrNull()
|
|
||||||
if (failure != null) {
|
|
||||||
_state.value = UiState.ReadyState(
|
|
||||||
cur.copy(
|
|
||||||
busy = null,
|
|
||||||
deleteError = failure.message ?: "Delete failed.",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
settings.ensureLoaded()
|
|
||||||
settings.setValue(BuiltInSettings.PROVIDER_ID, "\"local-only\"")
|
|
||||||
settings.reset(BuiltInSettings.PROVIDER_URL)
|
|
||||||
repository.forgetAllRemotes("local-only")
|
|
||||||
_state.value = load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun currentReady(): Ready? =
|
|
||||||
(_state.value as? UiState.ReadyState)?.ready
|
|
||||||
|
|
||||||
private suspend fun setBusy(cur: Ready, label: String) {
|
|
||||||
_state.value = UiState.ReadyState(cur.copy(busy = label))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadWith(cur: Ready): UiState {
|
|
||||||
val fresh = load()
|
|
||||||
// Preserve an open confirm dialog across refreshes.
|
|
||||||
if (fresh is UiState.ReadyState && cur.confirmDelete) {
|
|
||||||
return UiState.ReadyState(fresh.ready.copy(confirmDelete = true))
|
|
||||||
}
|
|
||||||
return fresh
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication
|
|
||||||
StorageViewModel(app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String?>(null)
|
|
||||||
val error: StateFlow<String?> = _error
|
|
||||||
private val _done = MutableStateFlow(false)
|
|
||||||
val done: StateFlow<Boolean> = _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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String?>(null) }
|
|
||||||
var pendingDelete by remember { mutableStateOf<SettingDefinition?>(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>): 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<String?>(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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<CategorySection> = emptyList(),
|
|
||||||
val search: String = "",
|
|
||||||
val busy: Boolean = false,
|
|
||||||
val message: String? = null,
|
|
||||||
) {
|
|
||||||
data class CategorySection(
|
|
||||||
val name: String,
|
|
||||||
val items: List<SettingRow>,
|
|
||||||
)
|
|
||||||
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<String?>(null)
|
|
||||||
|
|
||||||
// combine's transform is suspend, so value reads stay coroutine-native
|
|
||||||
// (no runBlocking anywhere near the main thread).
|
|
||||||
val state: StateFlow<SettingsUiState> = combine(
|
|
||||||
settings.definitions,
|
|
||||||
settings.valuesChanged,
|
|
||||||
searchFlow,
|
|
||||||
messageFlow,
|
|
||||||
) { defs, _, query, message ->
|
|
||||||
val gateValue = mutableMapOf<String, Boolean>()
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue