diff --git a/CHANGELOG.md b/CHANGELOG.md index 97742af..cec9688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ## [Unreleased] ### Додано -- +- **Вибір власної папки для записів** (#12). "Папка записів" у Settings тепер клікабельна — відкриває стандартний Android-пікер (`ACTION_OPEN_DOCUMENT_TREE`), щоб зберігати нові записи у папці за вибором користувача (доступній файловим менеджерам, бекапу, синхронізації), а не лише в приватній `Android/data/…`. При зміні папки додаток пропонує перемістити вже наявні записи в неї (`FolderMigrationJob`) — по одному файлу, з ізоляцією помилок. `AacEncoder`/`WavEncoder` переведені на запис через `FileDescriptor` (SAF-сумісно); ~9 місць читання/видалення (playback, waveform, sharing, транскрипція, автоочищення, bulk-видалення, стартова звірка з диском) тепер розрізняють легасі-шлях і SAF `content://` URI через новий `RecordingPaths`. Є "скинути до типової папки" та безпечний fallback, якщо обрана папка стає недоступною. ### Змінено - **Апгрейд тулчейну: Gradle 8.11.1 → 9.6.1, AGP 8.9.1 → 9.3.1, Kotlin 2.1.20 → 2.3.21, KSP → 2.3.10, compileSdk 36 → 37** ([#25](https://github.com/LyoSU/cally/pull/25), [#22](https://github.com/LyoSU/cally/pull/22), [#20](https://github.com/LyoSU/cally/pull/20), [#17](https://github.com/LyoSU/cally/pull/17), [#6](https://github.com/LyoSU/cally/pull/6) — одним пакетом, бо поодинці не збираються). Разом із ним `core-ktx` 1.19.0, `lifecycle` 2.11.0, `media` 1.8.0, coroutines 1.11.0, serialization 1.11.0. Знадобилось: прибрати плагін `org.jetbrains.kotlin.android` з усіх модулів (AGP 9 має вбудовану Kotlin-підтримку і падає на apply), прибрати видалену проперті `android.defaults.buildfeatures.buildconfig`, прибрати компіляторні прапорці `-Xjvm-default=all` і `-Xcontext-receivers` (обидва застаріли в Kotlin 2.2+, жоден не був потрібен — у проєкті немає жодного `context(...)`). `targetSdk` свідомо лишається 36: підняття цільового API міняє рантайм-поведінку і має їхати окремо, з перевіркою на пристроях. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef11312..aff4b26 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,6 +139,7 @@ dependencies { implementation(libs.room.ktx) ksp(libs.room.compiler) implementation(libs.datastore.preferences) + implementation(libs.androidx.documentfile) // Coroutines + serialization implementation(libs.kotlinx.coroutines.android) diff --git a/app/src/main/kotlin/dev/lyo/callrec/App.kt b/app/src/main/kotlin/dev/lyo/callrec/App.kt index 0dafe70..e3dc5b7 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/App.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/App.kt @@ -10,7 +10,7 @@ import dev.lyo.callrec.cleanup.CleanupJob import dev.lyo.callrec.di.AppContainer import dev.lyo.callrec.notify.DaemonHealthNotification import dev.lyo.callrec.notify.NotificationChannels -import java.io.File +import dev.lyo.callrec.storage.RecordingPaths import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -59,7 +59,7 @@ class App : Application() { // on Dispatchers.IO — the launch returns immediately and never // blocks Application.onCreate or the main thread. container.appScope.launch { - runCatching { CleanupJob.runOnce(container.settings, container.db) } + runCatching { CleanupJob.runOnce(applicationContext, container.settings, container.db) } .onFailure { Log.w("Callrec", "[App] cleanup failed: ${it.message}", it) } } // Reconcile DB rows against the filesystem: if the user (or another @@ -78,8 +78,8 @@ class App : Application() { val dao = container.db.calls() val rows = dao.selectAllFinalised() val orphans = rows.filter { rec -> - val upGone = !File(rec.uplinkPath).exists() - val dnGone = rec.downlinkPath?.let { !File(it).exists() } ?: true + val upGone = !RecordingPaths.exists(applicationContext, rec.uplinkPath) + val dnGone = rec.downlinkPath?.let { !RecordingPaths.exists(applicationContext, it) } ?: true upGone && dnGone } if (orphans.isEmpty()) return diff --git a/app/src/main/kotlin/dev/lyo/callrec/cleanup/CleanupJob.kt b/app/src/main/kotlin/dev/lyo/callrec/cleanup/CleanupJob.kt index 15c2fd4..33ad86d 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/cleanup/CleanupJob.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/cleanup/CleanupJob.kt @@ -1,15 +1,16 @@ // SPDX-License-Identifier: GPL-3.0-or-later package dev.lyo.callrec.cleanup +import android.content.Context import dev.lyo.callrec.core.L import dev.lyo.callrec.settings.AppSettings import dev.lyo.callrec.storage.BulkOps import dev.lyo.callrec.storage.CallRecord +import dev.lyo.callrec.storage.RecordingPaths import dev.lyo.callrec.storage.RecordingsDb import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import java.io.File /** * Auto-cleanup orchestrator. Runs at app start (fire-and-forget on the @@ -42,6 +43,7 @@ object CleanupJob { * @param now current time in epoch millis; injected for testability. */ suspend fun runOnce( + ctx: Context, settings: AppSettings, db: RecordingsDb, now: Long = System.currentTimeMillis(), @@ -61,7 +63,7 @@ object CleanupJob { val victims = dao.selectOlderThan(cutoffMs) if (victims.isNotEmpty()) { L.i(TAG, "max-age=${maxAgeDays}d → pruning ${victims.size} record(s) older than $cutoffMs") - BulkOps.deleteFiles(victims) + BulkOps.deleteFiles(ctx, victims) prunedAge = dao.deleteOlderThan(cutoffMs) } else { L.d(TAG, "max-age=${maxAgeDays}d → nothing to prune") @@ -70,7 +72,7 @@ object CleanupJob { var prunedSize = 0 if (maxSizeGb != null) { - prunedSize = enforceSizeCap(db, maxSizeGb) + prunedSize = enforceSizeCap(ctx, db, maxSizeGb) } L.i(TAG, "done — age-pruned=$prunedAge size-pruned=$prunedSize") @@ -88,7 +90,7 @@ object CleanupJob { * non-favourite set down to zero. That matches the spec: "still keep * the most recent record. Never wipe to zero." */ - private suspend fun enforceSizeCap(db: RecordingsDb, capGb: Int): Int { + private suspend fun enforceSizeCap(ctx: Context, db: RecordingsDb, capGb: Int): Int { val capBytes = capGb.toLong() * BYTES_PER_GB val dao = db.calls() // Snapshot of non-favourites, oldest first. Favourites are excluded @@ -97,7 +99,7 @@ object CleanupJob { val candidates = dao.selectOldestNotFavorite() val all = dao.observeAll().first() - var totalBytes = all.sumOf { recordBytes(it) } + var totalBytes = all.sumOf { recordBytes(ctx, it) } if (totalBytes <= capBytes) { L.d(TAG, "max-size=${capGb}GB → already under cap (totalBytes=$totalBytes)") return 0 @@ -114,7 +116,7 @@ object CleanupJob { for (c in deletable) { if (totalBytes <= capBytes) break toDelete += c - totalBytes -= recordBytes(c) + totalBytes -= recordBytes(ctx, c) } if (toDelete.isEmpty()) { @@ -122,7 +124,7 @@ object CleanupJob { return 0 } - BulkOps.deleteFiles(toDelete) + BulkOps.deleteFiles(ctx, toDelete) dao.deleteAll(toDelete.map { it.callId }) return toDelete.size } @@ -130,11 +132,9 @@ object CleanupJob { /** Sum of uplink and (optional) downlink file sizes on disk. Missing * files contribute 0 bytes — matches what `BulkOps.deleteFiles` would * free up if asked. */ - private fun recordBytes(r: CallRecord): Long { - val up = runCatching { File(r.uplinkPath).length() }.getOrDefault(0L) - val dn = r.downlinkPath?.let { p -> - runCatching { File(p).length() }.getOrDefault(0L) - } ?: 0L + private fun recordBytes(ctx: Context, r: CallRecord): Long { + val up = RecordingPaths.length(ctx, r.uplinkPath) + val dn = r.downlinkPath?.let { RecordingPaths.length(ctx, it) } ?: 0L return up + dn } } diff --git a/app/src/main/kotlin/dev/lyo/callrec/codec/AacEncoder.kt b/app/src/main/kotlin/dev/lyo/callrec/codec/AacEncoder.kt index 2b4c632..10b1c79 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/codec/AacEncoder.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/codec/AacEncoder.kt @@ -5,6 +5,7 @@ import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat import android.media.MediaMuxer +import android.os.ParcelFileDescriptor import dev.lyo.callrec.core.L import dev.lyo.callrec.storage.RecordingFile import java.nio.ByteBuffer @@ -21,6 +22,7 @@ class AacEncoder(private val file: RecordingFile) : PcmEncoder { private lateinit var codec: MediaCodec private lateinit var muxer: MediaMuxer + private lateinit var pfd: ParcelFileDescriptor private val bufferInfo = MediaCodec.BufferInfo() @Volatile private var failed = false @@ -36,9 +38,6 @@ class AacEncoder(private val file: RecordingFile) : PcmEncoder { sampleRate = sampleRateHz channels = channelCount - // Force the file to exist; MediaMuxer needs the path to be writable. - file.openOrCreate() - val bitrate = if (channelCount == 2) 64_000 else 32_000 val format = MediaFormat.createAudioFormat(MIME_AAC, sampleRateHz, channelCount).apply { setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) @@ -50,7 +49,8 @@ class AacEncoder(private val file: RecordingFile) : PcmEncoder { configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) start() } - muxer = MediaMuxer(file.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + pfd = file.openWriteFd() + muxer = MediaMuxer(pfd.fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) L.d("AacEncoder", "open ${file.path} ${sampleRateHz}Hz ch=$channelCount @${bitrate}bps") } catch (t: Throwable) { failed = true @@ -108,6 +108,7 @@ class AacEncoder(private val file: RecordingFile) : PcmEncoder { runCatching { muxer.stop() } } runCatching { muxer.release() } + runCatching { pfd.close() } } } diff --git a/app/src/main/kotlin/dev/lyo/callrec/codec/WavEncoder.kt b/app/src/main/kotlin/dev/lyo/callrec/codec/WavEncoder.kt index 765f63d..a9d8d5a 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/codec/WavEncoder.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/codec/WavEncoder.kt @@ -1,15 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later package dev.lyo.callrec.codec +import android.os.ParcelFileDescriptor import dev.lyo.callrec.storage.RecordingFile -import java.io.RandomAccessFile +import java.io.FileOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import java.nio.channels.FileChannel /** - * RIFF/WAV writer. Keeps the file open as a [RandomAccessFile] so the final - * close can rewrite the placeholder RIFF/data chunk sizes — otherwise VLC - * (and any spec-strict decoder) refuses files that don't match the header. + * RIFF/WAV writer. Keeps the file open as a [FileChannel] so the final close + * can rewrite the placeholder RIFF/data chunk sizes — otherwise VLC (and any + * spec-strict decoder) refuses files that don't match the header. + * + * FileChannel (rather than RandomAccessFile) because the underlying sink may + * be a SAF `content://` document — [RecordingFile.openWriteFd] hands back a + * [ParcelFileDescriptor] either way, and RandomAccessFile has no public + * constructor from an arbitrary FileDescriptor. * * Why hand-rolled vs MediaMuxer: * - MediaMuxer's MUXER_OUTPUT_OGG only takes Opus/Vorbis, MUXER_OUTPUT_MPEG_4 @@ -20,7 +27,8 @@ import java.nio.ByteOrder */ class WavEncoder(private val file: RecordingFile) : PcmEncoder { - private lateinit var raf: RandomAccessFile + private lateinit var pfd: ParcelFileDescriptor + private lateinit var channel: FileChannel private var sampleRate = 0 private var channels = 0 private var dataBytes = 0L @@ -31,39 +39,41 @@ class WavEncoder(private val file: RecordingFile) : PcmEncoder { require(channelCount in 1..2) { "channels must be 1 or 2" } sampleRate = sampleRateHz channels = channelCount - raf = RandomAccessFile(file.openOrCreate(), "rw") - raf.setLength(0) + pfd = file.openWriteFd() + channel = FileOutputStream(pfd.fileDescriptor).channel + channel.truncate(0) // Placeholder header. Sizes get rewritten on close(). - raf.write(buildHeader(dataLen = 0)) + channel.write(ByteBuffer.wrap(buildHeader(dataLen = 0))) } override fun writePcm(buf: ByteArray, off: Int, len: Int) { if (len <= 0) return - raf.write(buf, off, len) + channel.write(ByteBuffer.wrap(buf, off, len)) dataBytes += len bytesSinceHeaderRefresh += len // header refreshed periodically so a hard kill leaves a playable file if (bytesSinceHeaderRefresh >= HEADER_REFRESH_INTERVAL_BYTES) { - val savedPos = raf.filePointer - raf.seek(0) + val savedPos = channel.position() + channel.position(0) val truncated = dataBytes.coerceAtMost(0xFFFFFFFFL).toInt() - raf.write(buildHeader(dataLen = truncated)) - raf.seek(savedPos) + channel.write(ByteBuffer.wrap(buildHeader(dataLen = truncated))) + channel.position(savedPos) bytesSinceHeaderRefresh = 0L } } override fun close() { try { - raf.seek(0) + channel.position(0) // dataBytes can exceed Int.MAX_VALUE for ~3+ hour calls at 16k/16-bit; // the WAV format has a 32-bit unsigned size field, so we cap at // UINT32_MAX (~4 GB). Real-world calls never come close. val truncated = dataBytes.coerceAtMost(0xFFFFFFFFL).toInt() - raf.write(buildHeader(dataLen = truncated)) + channel.write(ByteBuffer.wrap(buildHeader(dataLen = truncated))) } finally { - runCatching { raf.fd.sync() } - runCatching { raf.close() } + runCatching { channel.force(true) } + runCatching { channel.close() } + runCatching { pfd.close() } } } diff --git a/app/src/main/kotlin/dev/lyo/callrec/di/AppContainer.kt b/app/src/main/kotlin/dev/lyo/callrec/di/AppContainer.kt index 2d7c83f..0ba8112 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/di/AppContainer.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/di/AppContainer.kt @@ -29,6 +29,10 @@ private val Context.dataStore by preferencesDataStore(name = "callrec.settings") */ class AppContainer(private val ctx: Context) { + /** Application context — for callers (e.g. [dev.lyo.callrec.transcription.TranscribeJob]) + * that only hold a reference to the container, not an Android [Context]. */ + val appContext: Context get() = ctx.applicationContext + /** Long-lived scope for foreground services and one-shot persistence work. */ val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -36,10 +40,24 @@ class AppContainer(private val ctx: Context) { val db: RecordingsDb by lazy { RecordingsDb.create(ctx) } + /** + * Hot StateFlow mirror of the recording-folder setting, same rationale as + * [recordingFormat]: [RecordingStorage.create] runs synchronously off the + * pump-spawning thread, not a coroutine, so it can't suspend on a + * DataStore read. + */ + val recordingFolderUri: StateFlow by lazy { + settings.recordingFolderUri.stateIn( + scope = appScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + } + val storage: RecordingStorage by lazy { RecordingStorage( appCtx = ctx.applicationContext, - settings = settings, + folderUriProvider = { recordingFolderUri.value }, ) } diff --git a/app/src/main/kotlin/dev/lyo/callrec/recorder/RecorderController.kt b/app/src/main/kotlin/dev/lyo/callrec/recorder/RecorderController.kt index 1d76a0a..2e7ad8a 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/recorder/RecorderController.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/recorder/RecorderController.kt @@ -505,12 +505,12 @@ class RecorderController( return when { upSilent && !dnSilent -> { L.i("Recorder", "downgrade: uplink silent (max=$upMax) — keeping downlink only") - runCatching { java.io.File(out.uplink.path).delete() } + runCatching { storage.delete(out.uplink.path) } Outcome.Single(out.downlink, out.strategy) } dnSilent && !upSilent -> { L.i("Recorder", "downgrade: downlink silent (max=$dnMax) — keeping uplink only") - runCatching { java.io.File(out.downlink.path).delete() } + runCatching { storage.delete(out.downlink.path) } Outcome.Single(out.uplink, out.strategy) } else -> out diff --git a/app/src/main/kotlin/dev/lyo/callrec/settings/AppSettings.kt b/app/src/main/kotlin/dev/lyo/callrec/settings/AppSettings.kt index 0aa31f7..3fe7b38 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/settings/AppSettings.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/settings/AppSettings.kt @@ -96,6 +96,17 @@ class AppSettings(private val store: DataStore) { else it[KEY_CLEANUP_MAX_SIZE_GB] = v } + /** + * SAF tree URI (as a `content://` string) the user picked as the + * recording destination. `null` means the legacy app-private + * `getExternalFilesDir(null)/recordings` directory — see + * [dev.lyo.callrec.storage.RecordingStorage]. + */ + val recordingFolderUri: Flow = store.data.map { it[KEY_RECORDING_FOLDER_URI] } + suspend fun setRecordingFolderUri(v: String?) = store.edit { + if (v == null) it.remove(KEY_RECORDING_FOLDER_URI) else it[KEY_RECORDING_FOLDER_URI] = v + } + companion object { // OpenRouter — proxies the same OpenAI chat-completions schema across // hundreds of providers, accepts audio attachments natively for @@ -116,6 +127,7 @@ class AppSettings(private val store: DataStore) { val STT_MODEL = stringPreferencesKey("stt_model") val CLEANUP_MAX_AGE_DAYS = intPreferencesKey("auto_cleanup_max_age_days") val CLEANUP_MAX_SIZE_GB = intPreferencesKey("auto_cleanup_max_size_gb") + val RECORDING_FOLDER_URI = stringPreferencesKey("recording_folder_uri") } private val KEY_SAMPLE_RATE get() = Keys.SAMPLE_RATE @@ -129,4 +141,5 @@ class AppSettings(private val store: DataStore) { private val KEY_STT_MODEL get() = Keys.STT_MODEL private val KEY_CLEANUP_MAX_AGE_DAYS get() = Keys.CLEANUP_MAX_AGE_DAYS private val KEY_CLEANUP_MAX_SIZE_GB get() = Keys.CLEANUP_MAX_SIZE_GB + private val KEY_RECORDING_FOLDER_URI get() = Keys.RECORDING_FOLDER_URI } diff --git a/app/src/main/kotlin/dev/lyo/callrec/storage/BulkOps.kt b/app/src/main/kotlin/dev/lyo/callrec/storage/BulkOps.kt index 2daef3b..decb746 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/storage/BulkOps.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/storage/BulkOps.kt @@ -1,17 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later package dev.lyo.callrec.storage +import android.content.Context import dev.lyo.callrec.core.L -import java.io.File object BulkOps { - fun deleteFiles(records: List) { + fun deleteFiles(ctx: Context, records: List) { for (r in records) { // wrap each delete — file may already be gone or path may be invalid - runCatching { File(r.uplinkPath).delete() } + runCatching { RecordingPaths.delete(ctx, r.uplinkPath) } .onFailure { L.w("BulkOps", "delete uplink failed: ${it.message}") } r.downlinkPath?.let { p -> - runCatching { File(p).delete() } + runCatching { RecordingPaths.delete(ctx, p) } .onFailure { L.w("BulkOps", "delete downlink failed: ${it.message}") } } } diff --git a/app/src/main/kotlin/dev/lyo/callrec/storage/FolderMigrationJob.kt b/app/src/main/kotlin/dev/lyo/callrec/storage/FolderMigrationJob.kt new file mode 100644 index 0000000..c4e3994 --- /dev/null +++ b/app/src/main/kotlin/dev/lyo/callrec/storage/FolderMigrationJob.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.lyo.callrec.storage + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dev.lyo.callrec.core.L +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale + +/** + * One-shot mover: copies existing legacy (app-private) recordings into a + * newly-picked SAF folder, then repoints the DB rows at the new `content://` + * documents and deletes the originals. Modeled on [dev.lyo.callrec.cleanup.CleanupJob] — + * same "only finalised rows" guard, same per-file error isolation so one + * corrupt/unreadable file doesn't abort the batch. + */ +object FolderMigrationJob { + + data class Progress(val done: Int, val total: Int, val failed: Int) + + private val _progress = MutableStateFlow(null) + + /** Null when no migration is in flight. Observed by Settings UI for a determinate progress indicator. */ + val progress: StateFlow = _progress.asStateFlow() + + suspend fun run(ctx: Context, db: RecordingsDb, treeUri: Uri): Progress = withContext(Dispatchers.IO) { + val dao = db.calls() + val rows = dao.selectAllFinalised().filter { row -> + !RecordingPaths.isSaf(row.uplinkPath) || + row.downlinkPath?.let { !RecordingPaths.isSaf(it) } == true + } + val total = rows.size + var done = 0 + var failed = 0 + _progress.value = Progress(done, total, failed) + + for (row in rows) { + val newUp = migrateOne(ctx, treeUri, row.uplinkPath) + if (newUp == null) failed++ + val newDn = row.downlinkPath?.let { dn -> + migrateOne(ctx, treeUri, dn).also { if (it == null) failed++ } + } ?: row.downlinkPath + + val finalUp = newUp ?: row.uplinkPath + if (finalUp != row.uplinkPath || newDn != row.downlinkPath) { + runCatching { dao.updateOutcome(row.callId, row.mode, finalUp, newDn) } + .onFailure { L.w(TAG, "DB update failed for ${row.callId}", it) } + } + done++ + _progress.value = Progress(done, total, failed) + } + + Progress(done, total, failed).also { _progress.value = null } + } + + /** + * Copies [path] into [treeUri] if it's a legacy file. Returns the new + * `content://` URI string on success, the path unchanged if it was + * already SAF (nothing to do), or null on genuine failure (source + * missing, tree inaccessible, copy failed) — caller keeps the original + * path in that case, so a failed migration never loses a recording. + */ + private fun migrateOne(ctx: Context, treeUri: Uri, path: String): String? { + if (RecordingPaths.isSaf(path)) return path + val src = File(path) + if (!src.exists()) return null + return runCatching { + val tree = DocumentFile.fromTreeUri(ctx, treeUri) ?: return null + val doc = tree.createFile(mimeFor(src.extension), src.name) ?: return null + val copied = ctx.contentResolver.openOutputStream(doc.uri)?.use { out -> + src.inputStream().use { it.copyTo(out) } + true + } ?: false + if (!copied) { + runCatching { doc.delete() } + return null + } + src.delete() + doc.uri.toString() + }.onFailure { L.w(TAG, "migrate failed for $path", it) }.getOrNull() + } + + private fun mimeFor(ext: String): String = when (ext.lowercase(Locale.US)) { + "wav" -> "audio/wav" + "m4a" -> "audio/mp4" + else -> "application/octet-stream" + } + + private const val TAG = "FolderMigrationJob" +} diff --git a/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingFile.kt b/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingFile.kt index 3a1675f..25f4b32 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingFile.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingFile.kt @@ -1,30 +1,43 @@ // SPDX-License-Identifier: GPL-3.0-or-later package dev.lyo.callrec.storage +import android.content.Context +import android.os.ParcelFileDescriptor import java.io.File /** - * Abstract over the eventual SAF-backed sink. For MVP we live entirely in - * `getExternalFilesDir(null)` which is private to our app and survives - * uninstalls only if the user explicitly opts in. v1.0 will swap this out - * for [androidx.documentfile.provider.DocumentFile] without changing the - * encoder API. - * * @property name display name shown to the user (no extension) * @property tag source tag: "uplink" / "downlink" / "voicecall_mono" / "mic" - * @property path resolved absolute file path + * @property path resolved absolute file path, or (when the user has picked a + * SAF recording folder) a `content://` document URI — see [RecordingStorage.create]. */ data class RecordingFile( val name: String, val tag: String, val path: String, + private val appCtx: Context, ) { - fun openOrCreate(): File { - val f = File(path) - f.parentFile?.mkdirs() - if (!f.exists()) f.createNewFile() - return f - } + val isSaf: Boolean get() = RecordingPaths.isSaf(path) fun toFile(): File = File(path) + + /** + * Write-mode FD for the encoder layer (`AacEncoder`'s `MediaMuxer`, + * `WavEncoder`'s `FileChannel`) — one code path serves both legacy and + * SAF destinations, since both are just a [ParcelFileDescriptor] once + * opened. Caller owns the returned descriptor and must close it. + */ + fun openWriteFd(): ParcelFileDescriptor = + if (isSaf) { + requireNotNull(appCtx.contentResolver.openFileDescriptor(android.net.Uri.parse(path), "rw")) { + "openFileDescriptor returned null for $path" + } + } else { + val file = File(path) + file.parentFile?.mkdirs() + ParcelFileDescriptor.open( + file, + ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE, + ) + } } diff --git a/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingPaths.kt b/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingPaths.kt new file mode 100644 index 0000000..651ded4 --- /dev/null +++ b/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingPaths.kt @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package dev.lyo.callrec.storage + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile +import dev.lyo.callrec.core.L +import java.io.File +import java.io.InputStream + +/** + * Every place that reads/deletes a recording works off the raw path string + * persisted in [CallRecord.uplinkPath]/[CallRecord.downlinkPath] — not a + * [RecordingFile] instance. This centralises the "is it a legacy absolute + * path or a SAF `content://` document URI" branch so the ~8 call sites + * (playback, waveform, sharing, transcription, cleanup, bulk-delete, + * startup reconciliation) don't each reimplement it. + */ +object RecordingPaths { + + fun isSaf(path: String): Boolean = path.startsWith("content://") + + fun exists(ctx: Context, path: String): Boolean = + if (isSaf(path)) { + runCatching { DocumentFile.fromSingleUri(ctx, Uri.parse(path))?.exists() ?: false } + .getOrDefault(false) + } else { + File(path).exists() + } + + fun length(ctx: Context, path: String): Long = + if (isSaf(path)) { + runCatching { DocumentFile.fromSingleUri(ctx, Uri.parse(path))?.length() ?: 0L } + .getOrDefault(0L) + } else { + runCatching { File(path).length() }.getOrDefault(0L) + } + + fun delete(ctx: Context, path: String): Boolean = + if (isSaf(path)) { + runCatching { DocumentFile.fromSingleUri(ctx, Uri.parse(path))?.delete() ?: false } + .getOrDefault(false) + } else { + runCatching { File(path).delete() }.getOrDefault(false) + } + + /** For [android.media.MediaPlayer.setDataSource] (Context, Uri) — content:// as-is, file:// for legacy. */ + fun playableUri(path: String): Uri = + if (isSaf(path)) Uri.parse(path) else Uri.fromFile(File(path)) + + /** + * Uri suitable for an ACTION_SEND share. SAF `content://` documents are + * already shareable as-is — the persisted permission grant we hold lets + * us forward FLAG_GRANT_READ_URI_PERMISSION to the receiving app. Legacy + * absolute paths need FileProvider since a raw `file://` Uri isn't + * grantable to another app. + */ + fun shareUri(ctx: Context, path: String): Uri? = + if (isSaf(path)) { + Uri.parse(path) + } else { + runCatching { FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", File(path)) } + .getOrNull() + } + + fun openInputStream(ctx: Context, path: String): InputStream? = + if (isSaf(path)) { + runCatching { ctx.contentResolver.openInputStream(Uri.parse(path)) }.getOrNull() + } else { + runCatching { File(path).inputStream() }.getOrNull() + } + + /** Display name (with extension) — the SAF document's name, or the legacy file's name. */ + fun displayName(ctx: Context, path: String): String? = + if (isSaf(path)) { + runCatching { DocumentFile.fromSingleUri(ctx, Uri.parse(path))?.name }.getOrNull() + } else { + File(path).name + } + + /** + * No-op passthrough for legacy paths — returns the [File] directly, zero + * copy. For SAF paths, copies the document into `cacheDir/decode/` once + * and reuses that cached copy on subsequent calls (a recording's audio + * content never changes after it's finalised, so there's no staleness to + * guard against). Named with the resolved display-name extension so + * extension-dispatching readers (`PcmDecoder`, `AudioMixer`) keep working + * unmodified against the materialized copy. Returns null if the source + * can't be opened. + */ + fun materializeToCache(ctx: Context, path: String): File? { + if (!isSaf(path)) return File(path) + val uri = Uri.parse(path) + val name = displayName(ctx, path) ?: uri.lastPathSegment?.substringAfterLast('/') ?: return null + val dir = ctx.cacheDir.resolve("decode").apply { mkdirs() } + val out = File(dir, "${uri.toString().hashCode()}-$name") + if (out.exists() && out.length() > 0L) return out + return runCatching { + ctx.contentResolver.openInputStream(uri)?.use { input -> + out.outputStream().use { input.copyTo(it) } + } + out.takeIf { it.exists() && it.length() > 0L } + }.onFailure { L.w(TAG, "materializeToCache failed for $path", it) }.getOrNull() + } + + private const val TAG = "RecordingPaths" +} diff --git a/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingStorage.kt b/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingStorage.kt index cb44e6a..3b9d918 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingStorage.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/storage/RecordingStorage.kt @@ -2,7 +2,9 @@ package dev.lyo.callrec.storage import android.content.Context -import dev.lyo.callrec.settings.AppSettings +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dev.lyo.callrec.core.L import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -11,23 +13,23 @@ import java.util.Locale /** * Names and creates on-disk targets for raw PCM dumps. Returns a * [RecordingFile] which the encoder layer turns into a properly-formatted - * WAV (or, in v1.0, Ogg/Opus) file. + * WAV or AAC/m4a file. * * Naming convention: - * ____.wav + * ____. * * `callId` is a short ULID-ish stamp generated by the caller — it groups the * uplink/downlink halves of one call together in the library list. */ class RecordingStorage( private val appCtx: Context, - @Suppress("unused") private val settings: AppSettings, + private val folderUriProvider: () -> String? = { null }, ) { private val baseDir: File by lazy { // External files dir is per-app, mounted on shared storage. Indexed - // by MediaStore (so users can find the WAVs in any file picker) but - // wiped on uninstall. v1.0 will let users pin a SAF tree URI and - // mirror the writes there. + // by MediaStore (so users can find recordings in any file picker) + // but wiped on uninstall. Used whenever no SAF folder is configured, + // or as a fallback if the configured folder becomes inaccessible. appCtx.getExternalFilesDir(null)?.resolve("recordings")?.apply { mkdirs() } ?: appCtx.filesDir.resolve("recordings").apply { mkdirs() } } @@ -45,13 +47,42 @@ class RecordingStorage( fun create(callId: String, tag: String, ext: String): RecordingFile { val ts = TIMESTAMP.format(Date()) val name = "${ts}__${callId}__${tag}" - val file = File(baseDir, "$name.$ext") - return RecordingFile(name = name, tag = tag, path = file.absolutePath) + val fileName = "$name.$ext" + + val folderUri = folderUriProvider() + val docUri = folderUri?.let { uri -> createSafDocument(uri, fileName, ext) } + val path = docUri?.toString() ?: File(baseDir, fileName).absolutePath + return RecordingFile(name = name, tag = tag, path = path, appCtx = appCtx) } + /** Delegates to [RecordingPaths.delete] — kept here so callers that + * already hold a [RecordingStorage] (e.g. [dev.lyo.callrec.recorder.RecorderController]) + * don't need a separate [Context] reference. */ + fun delete(path: String): Boolean = RecordingPaths.delete(appCtx, path) + fun listAll(): List = baseDir.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList() + /** + * Creates a new document under the user-picked SAF tree. Returns null + * (falling back to the legacy directory in [create]) if the tree URI is + * no longer accessible — e.g. permission revoked, SD card removed — so a + * recording is never silently lost. + */ + private fun createSafDocument(treeUriString: String, fileName: String, ext: String): Uri? = + runCatching { + val tree = DocumentFile.fromTreeUri(appCtx, Uri.parse(treeUriString)) + tree?.takeIf { it.isDirectory && it.canWrite() } + ?.createFile(mimeFor(ext), fileName) + ?.uri + }.onFailure { L.w(TAG, "SAF folder inaccessible, falling back to legacy dir", it) }.getOrNull() + + private fun mimeFor(ext: String): String = when (ext.lowercase(Locale.US)) { + "wav" -> "audio/wav" + "m4a" -> "audio/mp4" + else -> "application/octet-stream" + } + companion object { // Canonical Crockford base32: 32 unique chars, digits before letters, // I/L/O/U omitted. The earlier "ABCDEFGHJKMNPQRSTVWXYZ23456789X" @@ -59,5 +90,6 @@ class RecordingStorage( // indexed with `0x1F` (0..31), so the mask could fall off the end. private const val BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" private val TIMESTAMP = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) + private const val TAG = "RecordingStorage" } } diff --git a/app/src/main/kotlin/dev/lyo/callrec/transcription/TranscribeJob.kt b/app/src/main/kotlin/dev/lyo/callrec/transcription/TranscribeJob.kt index 3d704db..943c49e 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/transcription/TranscribeJob.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/transcription/TranscribeJob.kt @@ -3,6 +3,7 @@ package dev.lyo.callrec.transcription import dev.lyo.callrec.core.L import dev.lyo.callrec.di.AppContainer +import dev.lyo.callrec.storage.RecordingPaths import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -12,7 +13,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.File /** UI-observable state of a single transcription run. */ sealed interface TranscribeState { @@ -46,7 +46,10 @@ class TranscribeJob( runCatching { val transcriber = TranscriberFactory.create(container) L.i("TranscribeJob", "starting transcribe callId=$callId") - val text = transcriber.transcribe(File(audioPath)) + val audioFile = requireNotNull(RecordingPaths.materializeToCache(container.appContext, audioPath)) { + "could not open $audioPath for transcription" + } + val text = transcriber.transcribe(audioFile) L.d("TranscribeJob", "got ${text.length} chars") db.calls().setTranscript(callId, text) _state.value = TranscribeState.Done(text) diff --git a/app/src/main/kotlin/dev/lyo/callrec/ui/playback/PlaybackScreen.kt b/app/src/main/kotlin/dev/lyo/callrec/ui/playback/PlaybackScreen.kt index f264fca..7bdcf8b 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/ui/playback/PlaybackScreen.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/ui/playback/PlaybackScreen.kt @@ -91,6 +91,7 @@ import dev.lyo.callrec.codec.Waveform import dev.lyo.callrec.core.L import dev.lyo.callrec.di.AppContainer import dev.lyo.callrec.storage.CallRecord +import dev.lyo.callrec.storage.RecordingPaths import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -99,7 +100,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File import java.util.Locale import kotlin.math.max import kotlin.math.roundToLong @@ -158,8 +158,8 @@ fun PlaybackScreen( // to whichever file actually exists so the user can still play the // surviving side instead of seeing ENOENT in the player. val (primaryPath, secondaryPath) = remember(rec?.uplinkPath, rec?.downlinkPath) { - val up = rec?.uplinkPath?.takeIf { File(it).exists() } - val dn = rec?.downlinkPath?.takeIf { File(it).exists() } + val up = rec?.uplinkPath?.takeIf { RecordingPaths.exists(ctx, it) } + val dn = rec?.downlinkPath?.takeIf { RecordingPaths.exists(ctx, it) } when { up != null && dn != null -> up to dn up != null -> up to null @@ -175,13 +175,17 @@ fun PlaybackScreen( val primaryBins by produceState(initialValue = null, key1 = primaryPath) { val path = primaryPath ?: return@produceState value = withContext(Dispatchers.Default) { - Waveform.buildBins(File(path))?.let { Waveform.normalize(it) } + RecordingPaths.materializeToCache(ctx, path) + ?.let { Waveform.buildBins(it) } + ?.let { Waveform.normalize(it) } } } val secondaryBins by produceState(initialValue = null, key1 = secondaryPath) { val path = secondaryPath ?: return@produceState value = withContext(Dispatchers.Default) { - Waveform.buildBins(File(path))?.let { Waveform.normalize(it) } + RecordingPaths.materializeToCache(ctx, path) + ?.let { Waveform.buildBins(it) } + ?.let { Waveform.normalize(it) } } } @@ -206,7 +210,7 @@ fun PlaybackScreen( val pri = primaryPath ?: return@LaunchedEffect runCatching { playerA.reset() - playerA.setDataSource(pri) + playerA.setDataSource(ctx, RecordingPaths.playableUri(pri)) playerA.prepare() durationA = playerA.duration.coerceAtLeast(0) preparedA = true @@ -215,7 +219,7 @@ fun PlaybackScreen( if (sec != null) { runCatching { playerB.reset() - playerB.setDataSource(sec) + playerB.setDataSource(ctx, RecordingPaths.playableUri(sec)) playerB.prepare() durationB = playerB.duration.coerceAtLeast(0) preparedB = true @@ -365,8 +369,8 @@ fun PlaybackScreen( runCatching { playerA.stop() } runCatching { playerB.stop() } scope.launch { - runCatching { File(r.uplinkPath).delete() } - r.downlinkPath?.let { runCatching { File(it).delete() } } + runCatching { RecordingPaths.delete(ctx, r.uplinkPath) } + r.downlinkPath?.let { runCatching { RecordingPaths.delete(ctx, it) } } container.db.calls().delete(r.callId) onBack() } @@ -516,14 +520,14 @@ fun PlaybackScreen( // dropped by the silence-downgrade. val pri = primaryPath val sttFile by produceState( - initialValue = pri?.let { java.io.File(it) }, + initialValue = pri?.takeUnless { RecordingPaths.isSaf(it) }?.let { java.io.File(it) }, key1 = r.callId, key2 = primaryPath, key3 = secondaryPath, ) { - val priFile = pri?.let { java.io.File(it) } ?: return@produceState + val priFile = pri?.let { RecordingPaths.materializeToCache(ctx, it) } ?: return@produceState val sec = secondaryPath ?: return@produceState - val secFile = java.io.File(sec) + val secFile = RecordingPaths.materializeToCache(ctx, sec) ?: return@produceState value = withContext(Dispatchers.Default) { val mixed = java.io.File(ctx.cacheDir, "stt/${r.callId}-stt.wav") if (mixed.exists() && @@ -905,18 +909,19 @@ private fun MetaCard(rec: CallRecord, isDual: Boolean) { @Composable private fun MetaRow(path: String) { - val f = remember(path) { File(path) } - val ext = remember(path) { path.substringAfterLast('.', "").lowercase(Locale.US) } + val ctx = LocalContext.current + val name = remember(path) { RecordingPaths.displayName(ctx, path) ?: path.substringAfterLast('/') } + val ext = remember(name) { name.substringAfterLast('.', "").lowercase(Locale.US) } val codec = when (ext) { "wav" -> "WAV PCM" "m4a", "mp4", "aac" -> "AAC" "ogg", "opus" -> "OPUS" else -> ext.uppercase(Locale.US).ifEmpty { "?" } } - val bytes = remember(path) { runCatching { f.length() }.getOrDefault(0L) } + val bytes = remember(path) { RecordingPaths.length(ctx, path) } Row(verticalAlignment = Alignment.CenterVertically) { Text( - f.nameWithoutExtension, + name.substringBeforeLast('.', name), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), diff --git a/app/src/main/kotlin/dev/lyo/callrec/ui/playback/Sharing.kt b/app/src/main/kotlin/dev/lyo/callrec/ui/playback/Sharing.kt index 4cdf9a2..9526371 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/ui/playback/Sharing.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/ui/playback/Sharing.kt @@ -7,6 +7,7 @@ import androidx.core.content.FileProvider import dev.lyo.callrec.codec.AudioMixer import dev.lyo.callrec.core.L import dev.lyo.callrec.storage.CallRecord +import dev.lyo.callrec.storage.RecordingPaths import java.io.File import java.util.Locale @@ -17,6 +18,11 @@ import java.util.Locale * - [shareSeparate]: dual-track → ACTION_SEND_MULTIPLE with both files. * - [shareStereoMix]: dual-track → mix to one stereo .wav, then SEND. * + * Recordings living in a user-picked SAF folder are shared via their own + * `content://` document URI directly ([RecordingPaths.shareUri]) — no + * FileProvider needed, the persisted grant we hold is itself forwardable. + * Legacy app-private recordings still go through FileProvider. + * * Stereo mixes are cached under `cacheDir/export/-stereo.wav` (also * exposed by the FileProvider's `cache-path/export` mapping). The cache is * invalidated by mtime: if either source file is newer than the cached mix, @@ -26,12 +32,9 @@ import java.util.Locale internal object Sharing { fun shareSingle(ctx: Context, rec: CallRecord) { - val authority = "${ctx.packageName}.fileprovider" - val file = File(rec.uplinkPath) - val uri = runCatching { FileProvider.getUriForFile(ctx, authority, file) }.getOrNull() - ?: return + val uri = RecordingPaths.shareUri(ctx, rec.uplinkPath) ?: return val intent = Intent(Intent.ACTION_SEND).apply { - type = mimeFor(file) + type = mimeFor(ctx, rec.uplinkPath) putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -39,18 +42,15 @@ internal object Sharing { } fun shareSeparate(ctx: Context, rec: CallRecord) { - val authority = "${ctx.packageName}.fileprovider" - val files = buildList { - add(File(rec.uplinkPath)) - rec.downlinkPath?.let { add(File(it)) } - } - val uris = files.mapNotNull { - runCatching { FileProvider.getUriForFile(ctx, authority, it) }.getOrNull() + val paths = buildList { + add(rec.uplinkPath) + rec.downlinkPath?.let { add(it) } } + val uris = paths.mapNotNull { RecordingPaths.shareUri(ctx, it) } if (uris.isEmpty()) return val intent = if (uris.size == 1) { Intent(Intent.ACTION_SEND).apply { - type = mimeFor(files.first()) + type = mimeFor(ctx, paths.first()) putExtra(Intent.EXTRA_STREAM, uris.first()) } } else { @@ -85,8 +85,12 @@ internal object Sharing { /** Returns cached mix if valid, otherwise rebuilds. Null on decode failure. */ private fun buildOrReuseStereoMix(ctx: Context, rec: CallRecord): File? { - val downlink = rec.downlinkPath?.let { File(it) } ?: return null - val uplink = File(rec.uplinkPath) + val downlinkPath = rec.downlinkPath ?: return null + // Materialize first — AudioMixer/PcmDecoder decode via plain File I/O; + // a SAF-stored recording is copied into cacheDir once (reused after + // that, since finalised recordings never change). + val uplink = RecordingPaths.materializeToCache(ctx, rec.uplinkPath) ?: return null + val downlink = RecordingPaths.materializeToCache(ctx, downlinkPath) ?: return null val out = File(ctx.cacheDir, "export/${rec.callId}-stereo.wav") if (out.exists() && out.lastModified() >= maxOf(uplink.lastModified(), downlink.lastModified())) { L.i(TAG, "stereo cache hit → ${out.path}") @@ -96,11 +100,14 @@ internal object Sharing { return AudioMixer.mixToStereoWav(uplink, downlink, out) } - private fun mimeFor(f: File): String = when (f.extension.lowercase(Locale.US)) { - "wav" -> "audio/wav" - "m4a", "mp4", "aac" -> "audio/mp4" - "ogg", "opus" -> "audio/ogg" - else -> "audio/*" + private fun mimeFor(ctx: Context, path: String): String { + val name = RecordingPaths.displayName(ctx, path) ?: path + return when (name.substringAfterLast('.', "").lowercase(Locale.US)) { + "wav" -> "audio/wav" + "m4a", "mp4", "aac" -> "audio/mp4" + "ogg", "opus" -> "audio/ogg" + else -> "audio/*" + } } private const val TAG = "Sharing" diff --git a/app/src/main/kotlin/dev/lyo/callrec/ui/primary/PrimaryScreen.kt b/app/src/main/kotlin/dev/lyo/callrec/ui/primary/PrimaryScreen.kt index be1f435..b47f836 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/ui/primary/PrimaryScreen.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/ui/primary/PrimaryScreen.kt @@ -96,7 +96,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider import androidx.core.net.toUri import dev.lyo.callrec.R import dev.lyo.callrec.contacts.ContactResolver @@ -106,6 +105,7 @@ import dev.lyo.callrec.recorder.DaemonHealth import dev.lyo.callrec.recorder.Strategy import dev.lyo.callrec.storage.BulkOps import dev.lyo.callrec.storage.CallRecord +import dev.lyo.callrec.storage.RecordingPaths import dev.lyo.callrec.telephony.CallMonitorService import dev.lyo.callrec.telephony.CallMonitorService.Companion.MODE_VOICE_MEMO import dev.lyo.callrec.ui.components.LiveLevelMeter @@ -117,7 +117,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -197,7 +196,7 @@ fun PrimaryScreen( SnackbarResult.ActionPerformed -> pendingDeletion.value = null SnackbarResult.Dismissed -> { withContext(Dispatchers.IO) { - BulkOps.deleteFiles(listOf(rec)) + BulkOps.deleteFiles(ctx, listOf(rec)) container.db.calls().delete(rec.callId) } pendingDeletion.value = null @@ -331,7 +330,7 @@ fun PrimaryScreen( selected.clear() manualSelectMode = false scope.launch(Dispatchers.IO) { - BulkOps.deleteFiles(toDelete) + BulkOps.deleteFiles(ctx, toDelete) container.db.calls().deleteAll(ids) } }) { Text(stringResource(R.string.playback_delete_confirm)) } @@ -1096,10 +1095,7 @@ private fun EmptyState( private fun shareMultiple(ctx: Context, records: List) { if (records.isEmpty()) return - val authority = "${ctx.packageName}.fileprovider" - val uris = records.mapNotNull { - runCatching { FileProvider.getUriForFile(ctx, authority, File(it.uplinkPath)) }.getOrNull() - } + val uris = records.mapNotNull { RecordingPaths.shareUri(ctx, it.uplinkPath) } if (uris.isEmpty()) return val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { type = "audio/*" diff --git a/app/src/main/kotlin/dev/lyo/callrec/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/lyo/callrec/ui/settings/SettingsScreen.kt index 144be4f..e442645 100644 --- a/app/src/main/kotlin/dev/lyo/callrec/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/lyo/callrec/ui/settings/SettingsScreen.kt @@ -1,6 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package dev.lyo.callrec.ui.settings +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +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 @@ -22,11 +27,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonGroup import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SnackbarHost @@ -34,6 +41,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.ToggleButton import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.runtime.Composable @@ -54,8 +62,11 @@ import dev.lyo.callrec.BuildConfig import dev.lyo.callrec.R import dev.lyo.callrec.di.AppContainer import dev.lyo.callrec.settings.RecordingFormat +import dev.lyo.callrec.storage.FolderMigrationJob +import dev.lyo.callrec.storage.RecordingPaths import dev.lyo.callrec.ui.components.strategyQuality import dev.lyo.callrec.ui.legal.LegalDisclaimerSheet +import java.util.Locale import kotlinx.coroutines.launch @Composable @@ -83,6 +94,47 @@ fun SettingsScreen( val cleanupAppliedMsg = stringResource(R.string.settings_cleanup_applied) var showLegalSheet by remember { mutableStateOf(false) } + val recordingFolderUri by container.settings.recordingFolderUri.collectAsState(initial = null) + var pendingFolderUri by remember { mutableStateOf(null) } + val migrationProgress by FolderMigrationJob.progress.collectAsState(initial = null) + val internalStorageLabel = stringResource(R.string.settings_storage_internal) + val migrationDoneMsg = stringResource(R.string.settings_folder_migration_done) + val migrationDoneWithFailuresMsg = stringResource(R.string.settings_folder_migration_done_with_failures) + + fun releaseFolderGrant(uriString: String) { + runCatching { + ctx.contentResolver.releasePersistableUriPermission( + Uri.parse(uriString), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + + val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + recordingFolderUri?.let { releaseFolderGrant(it) } + runCatching { + ctx.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + container.settings.setRecordingFolderUri(uri.toString()) + val hasLegacyRecordings = container.db.calls().selectAllFinalised().any { row -> + !RecordingPaths.isSaf(row.uplinkPath) || + row.downlinkPath?.let { !RecordingPaths.isSaf(it) } == true + } + if (hasLegacyRecordings) pendingFolderUri = uri + } + } + val onResetFolder: () -> Unit = { + scope.launch { + recordingFolderUri?.let { releaseFolderGrant(it) } + container.settings.setRecordingFolderUri(null) + } + } + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier .fillMaxSize() @@ -273,11 +325,22 @@ fun SettingsScreen( value = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", ) Divider() - InfoRow( + LinkRow( title = stringResource(R.string.settings_storage_path), - value = ctx.getExternalFilesDir(null)?.resolve("recordings")?.absolutePath - ?: ctx.filesDir.resolve("recordings").absolutePath, + subtitle = recordingFolderUri + ?.let { formatTreeUri(it, internalStorageLabel) } + ?: (ctx.getExternalFilesDir(null)?.resolve("recordings")?.absolutePath + ?: ctx.filesDir.resolve("recordings").absolutePath), + onClick = { folderPicker.launch(null) }, ) + if (recordingFolderUri != null) { + Divider() + LinkRow( + title = stringResource(R.string.settings_folder_reset_title), + subtitle = stringResource(R.string.settings_folder_reset_subtitle), + onClick = onResetFolder, + ) + } Divider() LinkRow( title = stringResource(R.string.settings_legal_title), @@ -304,9 +367,73 @@ fun SettingsScreen( onDismiss = { showLegalSheet = false }, ) } + + pendingFolderUri?.let { target -> + AlertDialog( + onDismissRequest = { pendingFolderUri = null }, + title = { Text(stringResource(R.string.settings_folder_migrate_title)) }, + text = { Text(stringResource(R.string.settings_folder_migrate_message)) }, + confirmButton = { + TextButton(onClick = { + pendingFolderUri = null + container.appScope.launch { + val result = FolderMigrationJob.run(ctx.applicationContext, container.db, target) + val msg = if (result.failed == 0) { + String.format(Locale.getDefault(), migrationDoneMsg, result.done) + } else { + String.format( + Locale.getDefault(), + migrationDoneWithFailuresMsg, + result.done, + result.failed, + ) + } + snackbar.showSnackbar(msg) + } + }) { Text(stringResource(R.string.settings_folder_migrate_confirm)) } + }, + dismissButton = { + TextButton(onClick = { pendingFolderUri = null }) { + Text(stringResource(R.string.settings_folder_migrate_skip)) + } + }, + ) + } + + migrationProgress?.let { p -> + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + title = { Text(stringResource(R.string.settings_folder_migrating_title)) }, + text = { + Column { + LinearProgressIndicator( + progress = { if (p.total > 0) p.done / p.total.toFloat() else 0f }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Text("${p.done}/${p.total}") + } + }, + ) + } } } +/** + * Best-effort human-friendly rendering of a SAF tree URI — Android exposes no + * real filesystem path for these, so we decode the tree document ID (e.g. + * `primary:Download/Cally` → "Внутрішня пам'ять/Download/Cally"), falling + * back to the raw URI's last segment if the format doesn't match. + */ +private fun formatTreeUri(uri: String, internalStorageLabel: String): String = runCatching { + val docId = DocumentsContract.getTreeDocumentId(Uri.parse(uri)) + val parts = docId.split(":", limit = 2) + val volume = if (parts.getOrNull(0) == "primary") internalStorageLabel else parts.getOrNull(0).orEmpty() + val subPath = parts.getOrNull(1)?.takeIf { it.isNotBlank() } + if (subPath != null) "$volume/$subPath" else volume +}.getOrDefault(Uri.parse(uri).lastPathSegment ?: uri) + @Composable private fun SectionHeader(text: String) { Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81fad14..e2d0a23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,17 @@ Про додаток Версія Папка записів + Торкнись, щоб обрати іншу папку + Внутрішня пам\'ять + Скинути до типової папки + Нові записи знову зберігатимуться у приватній папці додатку + Перемістити наявні записи? + У новій папці записи будуть доступні іншим застосункам (файловим менеджерам, бекапу). Перемістити вже наявні записи з приватної папки додатку туди? + Перемістити + Не зараз + Переміщення записів… + Переміщено записів: %1$d + Переміщено %1$d, не вдалося перемістити %2$d Юридичне попередження Огляд правил для запису розмов diff --git a/gradle.properties b/gradle.properties index 52f55cf..860ca84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,3 +14,6 @@ android.nonFinalResIds=true kotlin.code.style=official kotlin.incremental=true ksp.incremental=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a284e3..8f8499f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ graphics-shapes = "1.0.1" # Persistence room = "2.7.0" datastore = "1.2.1" +documentfile = "1.1.0" # Coroutines / serialization coroutines = "1.11.0" @@ -82,6 +83,7 @@ room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } # Coroutines kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }