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:
avi 2026-09-15 11:42:20 -05:00
commit 71b55715b4
52 changed files with 24 additions and 9049 deletions

View file

@ -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")
} }
} }

View file

@ -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()) {

View file

@ -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.
*/ */

View file

@ -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
}
}

View file

@ -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)
)
}
}

View file

@ -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(

View file

@ -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"
}
}

View file

@ -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) }
}
}
}

View file

@ -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()
}

View file

@ -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])"
}

View file

@ -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)
}
}
}
}

View file

@ -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,
)
}
}
} }

View file

@ -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()

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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 }
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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() }
}
}

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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))
}
}
}
}

View file

@ -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,
),
)
}

View file

@ -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)

View file

@ -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.")
}
}
}

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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()
}
}

View file

@ -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") } },
)
}
}

View file

@ -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."))
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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
}

View file

@ -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") }
},
)
}

View file

@ -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)
}
}
}
}

View file

@ -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() }
}
}

View file

@ -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 },
)
}
}
}
}

View file

@ -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 }
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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.
}
}
}