Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 міняє рантайм-поведінку і має їхати окремо, з перевіркою на пристроях.
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/kotlin/dev/lyo/callrec/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 12 additions & 12 deletions app/src/main/kotlin/dev/lyo/callrec/cleanup/CleanupJob.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand All @@ -114,27 +116,25 @@ object CleanupJob {
for (c in deletable) {
if (totalBytes <= capBytes) break
toDelete += c
totalBytes -= recordBytes(c)
totalBytes -= recordBytes(ctx, c)
}

if (toDelete.isEmpty()) {
L.w(TAG, "max-size=${capGb}GB → nothing to delete (favourites push over cap?)")
return 0
}

BulkOps.deleteFiles(toDelete)
BulkOps.deleteFiles(ctx, toDelete)
dao.deleteAll(toDelete.map { it.callId })
return toDelete.size
}

/** 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
}
}
9 changes: 5 additions & 4 deletions app/src/main/kotlin/dev/lyo/callrec/codec/AacEncoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -108,6 +108,7 @@ class AacEncoder(private val file: RecordingFile) : PcmEncoder {
runCatching { muxer.stop() }
}
runCatching { muxer.release() }
runCatching { pfd.close() }
}
}

Expand Down
44 changes: 27 additions & 17 deletions app/src/main/kotlin/dev/lyo/callrec/codec/WavEncoder.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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() }
}
}

Expand Down
20 changes: 19 additions & 1 deletion app/src/main/kotlin/dev/lyo/callrec/di/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,35 @@ 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)

val settings: AppSettings by lazy { AppSettings(ctx.dataStore) }

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<String?> 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 },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/kotlin/dev/lyo/callrec/settings/AppSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ class AppSettings(private val store: DataStore<Preferences>) {
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<String?> = 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
Expand All @@ -116,6 +127,7 @@ class AppSettings(private val store: DataStore<Preferences>) {
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
Expand All @@ -129,4 +141,5 @@ class AppSettings(private val store: DataStore<Preferences>) {
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
}
8 changes: 4 additions & 4 deletions app/src/main/kotlin/dev/lyo/callrec/storage/BulkOps.kt
Original file line number Diff line number Diff line change
@@ -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<CallRecord>) {
fun deleteFiles(ctx: Context, records: List<CallRecord>) {
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}") }
}
}
Expand Down
Loading