diff --git a/app/build.gradle b/app/build.gradle index d3c9db3..0a9687f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,6 +102,7 @@ dependencies { implementation 'androidx.compose.material3:material3' implementation "androidx.compose.material:material-icons-extended:1.5.1" implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.documentfile:documentfile:1.0.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' @@ -128,4 +129,6 @@ dependencies { implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0' implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0' implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0' + + implementation 'androidx.activity:activity-ktx:1.8.0' } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt index 505f1a7..49b5481 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt @@ -1,8 +1,11 @@ package app.myzel394.alibi.db +import android.content.Context import android.media.MediaRecorder import android.os.Build import app.myzel394.alibi.R +import app.myzel394.alibi.helpers.AudioRecorderExporter +import app.myzel394.alibi.helpers.BatchesFolder import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.ReturnCode import kotlinx.serialization.Serializable @@ -74,10 +77,9 @@ data class RecordingInformation( val maxDuration: Long, val intervalDuration: Long, val fileExtension: String, - val forceExactMaxDuration: Boolean, ) { - val hasRecordingsAvailable - get() = File(folderPath).listFiles()?.isNotEmpty() ?: false + fun hasRecordingsAvailable(context: Context): Boolean = + BatchesFolder.importFromFolder(folderPath, context).hasRecordingsAvailable() } @Serializable @@ -86,7 +88,6 @@ data class AudioRecorderSettings( val maxDuration: Long = 30 * 60 * 1000L, // 60 seconds val intervalDuration: Long = 60 * 1000L, - val forceExactMaxDuration: Boolean = true, // 320 Kbps val bitRate: Int = 320000, val samplingRate: Int? = null, @@ -94,6 +95,7 @@ data class AudioRecorderSettings( val encoder: Int? = null, val showAllMicrophones: Boolean = false, val deleteRecordingsImmediately: Boolean = false, + val saveFolder: String? = null, ) { fun getOutputFormat(): Int { if (outputFormat != null) { @@ -161,6 +163,24 @@ data class AudioRecorderSettings( else MediaRecorder.AudioEncoder.AMR_NB + fun getSaveFolder(context: Context): File { + val defaultFolder = AudioRecorderExporter.getFolder(context) + + if (saveFolder == null) { + return defaultFolder + } + + runCatching { + return File(saveFolder!!).apply { + if (!exists()) { + mkdirs() + } + } + } + + return defaultFolder + } + fun setIntervalDuration(duration: Long): AudioRecorderSettings { if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) { throw Exception("Interval duration must be between 10 seconds and 1 hour") @@ -217,10 +237,6 @@ data class AudioRecorderSettings( return copy(maxDuration = duration) } - fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings { - return copy(forceExactMaxDuration = forceExactMaxDuration) - } - fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings { return copy(showAllMicrophones = showAllMicrophones) } @@ -229,6 +245,10 @@ data class AudioRecorderSettings( return copy(deleteRecordingsImmediately = deleteRecordingsImmediately) } + fun setSaveFolder(saveFolder: String?): AudioRecorderSettings { + return copy(saveFolder = saveFolder) + } + fun isEncoderCompatible(encoder: Int): Boolean { if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) { return true diff --git a/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt b/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt index 5c1d54c..7a30d5c 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt @@ -1,10 +1,14 @@ package app.myzel394.alibi.helpers import android.content.Context +import android.net.Uri +import android.system.Os import android.util.Log +import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.ReturnCode import java.io.File import java.time.format.DateTimeFormatter @@ -12,65 +16,31 @@ import java.time.format.DateTimeFormatter data class AudioRecorderExporter( val recording: RecordingInformation, ) { - val filePaths: List - get() = - File(recording.folderPath).listFiles()?.filter { - val name = it.nameWithoutExtension - - name.toIntOrNull() != null - }?.toList() ?: emptyList() - - val hasRecordingAvailable: Boolean - get() = filePaths.isNotEmpty() - - private fun stripConcatenatedFileToExactDuration( - outputFile: File + suspend fun concatenateFiles( + batchesFolder: BatchesFolder, + outputFilePath: String, + forceConcatenation: Boolean = false, ) { - // Move the concatenated file to a temporary file - val rawFile = - File("${recording.folderPath}/${outputFile.nameWithoutExtension}-raw.${recording.fileExtension}") - outputFile.renameTo(rawFile) + val filePaths = batchesFolder.getBatchesForFFmpeg() - val command = "-sseof ${recording.maxDuration / -1000} -i $rawFile -y $outputFile" - - val session = FFmpegKit.execute(command) - - if (!ReturnCode.isSuccess(session.returnCode)) { - Log.d( - "Audio Concatenation", - String.format( - "Command failed with state %s and rc %s.%s", - session.state, - session.returnCode, - session.failStackTrace, - ) - ) - - throw Exception("Failed to strip concatenated audio") - } - } - - suspend fun concatenateFiles(forceConcatenation: Boolean = false): File { - val paths = filePaths.joinToString("|") - val fileName = recording.recordingStart - .format(DateTimeFormatter.ISO_DATE_TIME) - .toString() - .replace(":", "-") - .replace(".", "_") - val outputFile = File("${recording.folderPath}/$fileName.${recording.fileExtension}") - - if (outputFile.exists() && !forceConcatenation) { - return outputFile + if (batchesFolder.checkIfOutputAlreadyExists( + recording.recordingStart, + recording.fileExtension + ) && !forceConcatenation + ) { + return } - val command = "-i 'concat:$paths' -y" + - " -acodec copy" + - " -metadata title='$fileName' " + - " -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" + - " -metadata batch_count='${filePaths.size}'" + - " -metadata batch_duration='${recording.intervalDuration}'" + - " -metadata max_duration='${recording.maxDuration}'" + - " $outputFile" + val filePathsConcatenated = filePaths.joinToString("|") + val command = + "-protocol_whitelist saf,concat,content,file,subfile" + + " -i 'concat:$filePathsConcatenated' -y" + + " -acodec copy" + + " -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" + + " -metadata batch_count='${filePaths.size}'" + + " -metadata batch_duration='${recording.intervalDuration}'" + + " -metadata max_duration='${recording.maxDuration}'" + + " $outputFilePath" val session = FFmpegKit.execute(command) @@ -87,32 +57,10 @@ data class AudioRecorderExporter( throw Exception("Failed to concatenate audios") } - - val minRequiredForPossibleInExactMaxDuration = - recording.maxDuration / recording.intervalDuration - if (recording.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) { - stripConcatenatedFileToExactDuration(outputFile) - } - - return outputFile - } - - suspend fun cleanupFiles() { - filePaths.forEach { - runCatching { - it.delete() - } - } } companion object { fun getFolder(context: Context) = File(context.filesDir, RECORDER_SUBFOLDER_NAME) - - fun clearAllRecordings(context: Context) { - getFolder(context).deleteRecursively() - } - - fun hasRecordingsAvailable(context: Context) = - getFolder(context).listFiles()?.isNotEmpty() ?: false } -} \ No newline at end of file +} + diff --git a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt new file mode 100644 index 0000000..025a48a --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -0,0 +1,215 @@ +package app.myzel394.alibi.helpers + +import android.content.Context +import androidx.documentfile.provider.DocumentFile +import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import com.arthenica.ffmpegkit.FFmpegKitConfig +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.FileDescriptor + +data class BatchesFolder( + val context: Context, + val type: BatchType, + val customFolder: DocumentFile? = null, + val subfolderName: String = ".recordings", +) { + private var customFileFileDescriptor: ParcelFileDescriptor? = null + + fun initFolders() { + when (type) { + BatchType.INTERNAL -> getFolder(context).mkdirs() + BatchType.CUSTOM -> { + if (customFolder!!.findFile(subfolderName) == null) { + customFolder.createDirectory(subfolderName) + } + } + } + } + + fun cleanup() { + customFileFileDescriptor?.close() + } + + private fun getInternalFolder(): File { + return getFolder(context) + } + + private fun getCustomDefinedFolder(): DocumentFile { + return customFolder!!.findFile(subfolderName)!! + } + + fun getBatchesForFFmpeg(): List { + return when (type) { + BatchType.INTERNAL -> + (getInternalFolder() + .listFiles() + ?.filter { + it.nameWithoutExtension.toIntOrNull() != null + } + ?.toList() + ?: emptyList()) + .map { it.absolutePath } + + BatchType.CUSTOM -> getCustomDefinedFolder() + .listFiles() + .filter { + it.name?.substringBeforeLast(".")?.toIntOrNull() != null + } + .map { + FFmpegKitConfig.getSafParameterForRead( + context, + it.uri, + )!! + } + } + } + + fun getName(date: LocalDateTime, extension: String): String { + val name = date + .format(DateTimeFormatter.ISO_DATE_TIME) + .toString() + .replace(":", "-") + .replace(".", "_") + + return "$name.$extension" + } + + fun asInternalGetOutputFile(date: LocalDateTime, extension: String): File { + return File(getInternalFolder(), getName(date, extension)) + } + + fun asCustomGetOutputFile( + date: LocalDateTime, + extension: String, + ): DocumentFile { + return getCustomDefinedFolder().createFile("audio/$extension", getName(date, extension))!! + } + + fun getOutputFileForFFmpeg( + date: LocalDateTime, + extension: String, + ): String { + return when (type) { + BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath + BatchType.CUSTOM -> FFmpegKitConfig.getSafParameterForWrite( + context, + customFolder!!.createFile( + "audio/${extension}", + getName(date, extension), + )!!.uri + )!! + } + } + + fun checkIfOutputAlreadyExists( + date: LocalDateTime, + extension: String + ): Boolean { + val name = date + .format(DateTimeFormatter.ISO_DATE_TIME) + .toString() + .replace(":", "-") + .replace(".", "_") + + return when (type) { + BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").exists() + BatchType.CUSTOM -> + getCustomDefinedFolder().findFile("${name}.${extension}")?.exists() ?: false + } + } + + fun exportFolderForSettings(): String { + return when (type) { + BatchType.INTERNAL -> "_'internal" + BatchType.CUSTOM -> customFolder!!.uri.toString() + } + } + + fun deleteRecordings() { + when (type) { + BatchType.INTERNAL -> getInternalFolder().deleteRecursively() + BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete() + ?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach { + it.delete() + } + } + } + + fun hasRecordingsAvailable(): Boolean { + return when (type) { + BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false + BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty() + ?: false + } + } + + fun deleteOldRecordings(earliestCounter: Long) { + when (type) { + BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach { + val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach + + if (fileCounter < earliestCounter) { + it.delete() + } + } + + BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().forEach { + val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach + + if (fileCounter < earliestCounter) { + it.delete() + } + } + } + } + + fun checkIfFolderIsAccessible(): Boolean { + return when (type) { + BatchType.INTERNAL -> true + BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead() + } + } + + fun asInternalGetOutputPath(counter: Long, fileExtension: String): String { + return getInternalFolder().absolutePath + "/$counter.$fileExtension" + } + + fun asCustomGetFileDescriptor( + counter: Long, + fileExtension: String, + ): FileDescriptor { + val file = + getCustomDefinedFolder().createFile("audio/$fileExtension", "$counter.$fileExtension")!! + + customFileFileDescriptor = context.contentResolver.openFileDescriptor(file.uri, "w")!! + + return customFileFileDescriptor!!.fileDescriptor + } + + enum class BatchType { + INTERNAL, + CUSTOM, + } + + companion object { + fun viaInternalFolder(context: Context): BatchesFolder { + return BatchesFolder(context, BatchType.INTERNAL) + } + + fun viaCustomFolder(context: Context, folder: DocumentFile): BatchesFolder { + return BatchesFolder(context, BatchType.CUSTOM, folder) + } + + fun getFolder(context: Context) = File(context.filesDir, RECORDER_SUBFOLDER_NAME) + + fun importFromFolder(folder: String, context: Context): BatchesFolder = when (folder) { + "_'internal" -> viaInternalFolder(context) + else -> viaCustomFolder(context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!!) + } + } +} + diff --git a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt index e871138..79d04fa 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -1,20 +1,20 @@ package app.myzel394.alibi.services -import android.annotation.SuppressLint import android.content.Context import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.MediaRecorder import android.media.MediaRecorder.OnErrorListener +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper -import androidx.core.content.ContextCompat.getSystemService +import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.enums.RecorderState +import app.myzel394.alibi.helpers.BatchesFolder import app.myzel394.alibi.ui.utils.MicrophoneInfo import java.lang.IllegalStateException -import java.util.concurrent.Executor class AudioRecorderService : IntervalRecorderService() { var amplitudesAmount = 1000 @@ -27,9 +27,6 @@ class AudioRecorderService : IntervalRecorderService() { var onMicrophoneDisconnected: () -> Unit = {} var onMicrophoneReconnected: () -> Unit = {} - val filePath: String - get() = "${outputFolder}/$counter.${settings!!.fileExtension}" - /// Tell Android to use the correct bluetooth microphone, if any selected private fun startAudioDevice() { if (selectedMicrophone == null) { @@ -68,11 +65,26 @@ class AudioRecorderService : IntervalRecorderService() { // - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17) // - DEFAULT: Uses the bottom microphone of the phone (17) setAudioSource(MediaRecorder.AudioSource.MIC) - setOutputFile(filePath) - setOutputFormat(settings!!.outputFormat) - setAudioEncoder(settings!!.encoder) - setAudioEncodingBitRate(settings!!.bitRate) - setAudioSamplingRate(settings!!.samplingRate) + + when (batchesFolder.type) { + BatchesFolder.BatchType.INTERNAL -> { + setOutputFile( + batchesFolder.asInternalGetOutputPath(counter, settings.fileExtension) + ) + } + + BatchesFolder.BatchType.CUSTOM -> { + setOutputFile( + batchesFolder.asCustomGetFileDescriptor(counter, settings.fileExtension) + ) + } + } + + setOutputFormat(settings.outputFormat) + + setAudioEncoder(settings.encoder) + setAudioEncodingBitRate(settings.bitRate) + setAudioSamplingRate(settings.samplingRate) setOnErrorListener(OnErrorListener { _, _, _ -> onError() }) @@ -86,6 +98,7 @@ class AudioRecorderService : IntervalRecorderService() { it.release() } clearAudioDevice() + batchesFolder.cleanup() } } diff --git a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt index 88d2bca..cc6ac55 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -1,15 +1,19 @@ package app.myzel394.alibi.services import android.media.MediaRecorder +import android.net.Uri +import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AudioRecorderSettings import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.helpers.AudioRecorderExporter +import app.myzel394.alibi.helpers.BatchesFolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.w3c.dom.DocumentFragment import java.io.File import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService @@ -19,24 +23,23 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { private var job = SupervisorJob() private var scope = CoroutineScope(Dispatchers.IO + job) - protected var counter = 0 + protected var counter = 0L private set - var settings: Settings? = null - protected set + lateinit var settings: Settings private lateinit var cycleTimer: ScheduledExecutorService - protected val outputFolder: File - get() = AudioRecorderExporter.getFolder(this) + var batchesFolder: BatchesFolder = BatchesFolder.viaInternalFolder(this) + + var onCustomOutputFolderNotAccessible: () -> Unit = {} fun getRecordingInformation(): RecordingInformation = RecordingInformation( - folderPath = outputFolder.absolutePath, + folderPath = batchesFolder.exportFolderForSettings(), recordingStart = recordingStart, - maxDuration = settings!!.maxDuration, - fileExtension = settings!!.fileExtension, - intervalDuration = settings!!.intervalDuration, - forceExactMaxDuration = settings!!.forceExactMaxDuration, + maxDuration = settings.maxDuration, + fileExtension = settings.fileExtension, + intervalDuration = settings.intervalDuration, ) // Make overrideable @@ -52,7 +55,7 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { startNewCycle() }, 0, - settings!!.intervalDuration, + settings.intervalDuration, TimeUnit.MILLISECONDS ) } @@ -61,17 +64,15 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { override fun start() { super.start() - outputFolder.mkdirs() - - scope.launch { - dataStore.data.collectLatest { preferenceSettings -> - if (settings == null) { - settings = Settings.from(preferenceSettings.audioRecorderSettings) - - createTimer() - } - } + batchesFolder.initFolders() + if (!batchesFolder.checkIfFolderIsAccessible()) { + batchesFolder = + BatchesFolder.viaInternalFolder(this@IntervalRecorderService) + batchesFolder.initFolders() + onCustomOutputFolderNotAccessible() } + + createTimer() } override fun pause() { @@ -90,27 +91,25 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { cycleTimer.shutdown() } + fun clearAllRecordings() { + batchesFolder.deleteRecordings() + } + private fun deleteOldRecordings() { val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration val earliestCounter = counter - timeMultiplier - outputFolder.listFiles()?.forEach { file -> - val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return - - if (fileCounter < earliestCounter) { - file.delete() - } - } + batchesFolder.deleteOldRecordings(earliestCounter) } data class Settings( val maxDuration: Long, val intervalDuration: Long, - val forceExactMaxDuration: Boolean, val bitRate: Int, val samplingRate: Int, val outputFormat: Int, val encoder: Int, + val folder: String? = null, ) { val fileExtension: String get() = when (outputFormat) { @@ -134,7 +133,6 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { outputFormat = audioRecorderSettings.getOutputFormat(), encoder = audioRecorderSettings.getEncoder(), maxDuration = audioRecorderSettings.maxDuration, - forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration, ) } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt index 72f2f42..f1bb889 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt @@ -20,13 +20,11 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.rememberStandardBottomSheetState 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.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -38,19 +36,13 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import app.myzel394.alibi.NotificationHelper import app.myzel394.alibi.R import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings -import app.myzel394.alibi.helpers.AudioRecorderExporter -import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.components.atoms.PermissionRequester +import app.myzel394.alibi.ui.effects.rememberForceUpdateOnLifeCycleChange import app.myzel394.alibi.ui.models.AudioRecorderModel -import app.myzel394.alibi.ui.utils.rememberFileSaverDialog -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.lastOrNull -import kotlinx.coroutines.launch import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -72,19 +64,8 @@ fun StartRecording( LaunchedEffect(startRecording) { if (startRecording) { startRecording = false - audioRecorder.notificationDetails = appSettings.notificationSettings.let { - if (it == null) - null - else - RecorderNotificationHelper.NotificationDetails.fromNotificationSettings( - context, - it - ) - } - AudioRecorderExporter.clearAllRecordings(context) - - audioRecorder.startRecording(context) + audioRecorder.startRecording(context, appSettings) } } @@ -149,9 +130,13 @@ fun StartRecording( .fillMaxWidth(), textAlign = TextAlign.Center, ) - if (appSettings.lastRecording?.hasRecordingsAvailable == true) { + + val forceUpdate = rememberForceUpdateOnLifeCycleChange() + if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) { Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .then(forceUpdate), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom, ) { diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt index 7aabe6e..ac2b163 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt @@ -107,8 +107,7 @@ fun RecordingStatus( DeleteButton( onDelete = { audioRecorder.stopRecording(context) - - AudioRecorderExporter.clearAllRecordings(context) + audioRecorder.batchesFolder!!.deleteRecordings(); } ) } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/FolderBreadcrumbs.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/FolderBreadcrumbs.kt new file mode 100644 index 0000000..6681db6 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/FolderBreadcrumbs.kt @@ -0,0 +1,48 @@ +package app.myzel394.alibi.ui.components.SettingsScreen.atoms + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun FolderBreadcrumbs( + modifier: Modifier = Modifier, + textStyle: TextStyle? = null, + folders: Iterable, +) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + folders.forEachIndexed { index, folder -> + if (index != 0) { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + ) + } + Text( + text = folder, + modifier = Modifier + .then(modifier), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle ?: MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/ForceExactMaxDurationTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/ForceExactMaxDurationTile.kt deleted file mode 100644 index 5baea8c..0000000 --- a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/ForceExactMaxDurationTile.kt +++ /dev/null @@ -1,54 +0,0 @@ -package app.myzel394.alibi.ui.components.SettingsScreen.atoms - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.GraphicEq -import androidx.compose.material3.Icon -import androidx.compose.material3.Switch -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import app.myzel394.alibi.R -import app.myzel394.alibi.dataStore -import app.myzel394.alibi.db.AppSettings -import app.myzel394.alibi.ui.components.atoms.SettingsTile -import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState -import kotlinx.coroutines.launch - - -@Composable -fun ForceExactMaxDurationTile( - settings: AppSettings, -) { - val scope = rememberCoroutineScope() - val dataStore = LocalContext.current.dataStore - - fun updateValue(forceExactMaxDuration: Boolean) { - scope.launch { - dataStore.updateData { - it.setAudioRecorderSettings( - it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration) - ) - } - } - } - - - SettingsTile( - title = stringResource(R.string.ui_settings_option_forceExactDuration_title), - description = stringResource(R.string.ui_settings_option_forceExactDuration_description), - leading = { - Icon( - Icons.Default.GraphicEq, - contentDescription = null, - ) - }, - trailing = { - Switch( - checked = settings.audioRecorderSettings.forceExactMaxDuration, - onCheckedChange = ::updateValue, - ) - }, - ) -} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/SaveFolderTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/SaveFolderTile.kt new file mode 100644 index 0000000..4f2fd7f --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/SaveFolderTile.kt @@ -0,0 +1,231 @@ +package app.myzel394.alibi.ui.components.SettingsScreen.atoms + +import android.content.Intent +import android.net.Uri +import android.text.TextUtils.split +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.ui.components.atoms.SettingsTile +import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog +import kotlinx.coroutines.launch +import java.net.URLDecoder + +@Composable +fun SaveFolderTile( + settings: AppSettings, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val dataStore = context.dataStore + + fun updateValue(path: String?) { + if (settings.audioRecorderSettings.saveFolder != null) { + runCatching { + context.contentResolver.releasePersistableUriPermission( + Uri.parse(settings.audioRecorderSettings.saveFolder), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + + scope.launch { + dataStore.updateData { + it.setAudioRecorderSettings( + it.audioRecorderSettings.setSaveFolder(path) + ) + } + } + } + + val selectFolder = rememberFolderSelectorDialog { folder -> + if (folder == null) { + return@rememberFolderSelectorDialog + } + + context.contentResolver.takePersistableUriPermission( + folder, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + updateValue(folder.toString()) + } + + var showWarning by remember { mutableStateOf(false) } + + if (showWarning) { + val title = stringResource(R.string.ui_settings_option_saveFolder_warning_title) + val text = stringResource(R.string.ui_settings_option_saveFolder_warning_text) + + AlertDialog( + icon = { + Icon( + Icons.Default.Warning, + contentDescription = null, + ) + }, + onDismissRequest = { + showWarning = false + }, + title = { + Text(text = title) + }, + text = { + Text(text = text) + }, + confirmButton = { + Button( + onClick = { + showWarning = false + selectFolder() + }, + ) { + Text( + text = stringResource(R.string.ui_settings_option_saveFolder_warning_action_confirm), + ) + } + }, + dismissButton = { + Button( + onClick = { + showWarning = false + }, + colors = ButtonDefaults.textButtonColors(), + ) { + Icon( + Icons.Default.Cancel, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.dialog_close_cancel_label)) + } + } + ) + } + + SettingsTile( + title = stringResource(R.string.ui_settings_option_saveFolder_title), + description = stringResource(R.string.ui_settings_option_saveFolder_explanation), + leading = { + Icon( + Icons.Default.AudioFile, + contentDescription = null, + ) + }, + trailing = { + Button( + onClick = { + showWarning = true + }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + shape = MaterialTheme.shapes.medium, + ) { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer( + modifier = Modifier.size(ButtonDefaults.IconSpacing) + ) + Text( + text = stringResource(R.string.ui_settings_option_saveFolder_action_select_label), + ) + } + }, + extra = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (settings.audioRecorderSettings.saveFolder != null) { + Text( + text = stringResource( + R.string.form_value_selected, + splitPath(settings.audioRecorderSettings.saveFolder).joinToString(" > ") + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Button( + colors = ButtonDefaults.filledTonalButtonColors(), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + onClick = { + updateValue(null) + } + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer( + modifier = Modifier.size(ButtonDefaults.IconSpacing) + ) + Text( + text = stringResource(R.string.ui_settings_option_saveFolder_action_default_label), + ) + } + } else { + Text( + text = stringResource( + R.string.form_value_selected, + stringResource(R.string.ui_settings_option_saveFolder_defaultValue) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + ) +} + +fun splitPath(path: String): List { + return try { + URLDecoder + .decode(path, "UTF-8") + .split(":", limit = 3)[2] + .split("/") + } catch (e: Exception) { + listOf(path) + } +} diff --git a/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt b/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt index be81a62..a46a01a 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt @@ -1,11 +1,20 @@ package app.myzel394.alibi.ui.effects import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.delay @Composable @@ -20,4 +29,39 @@ fun rememberForceUpdate( } return tickTack +} + +@Composable +fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +} + +@Composable +fun rememberForceUpdateOnLifeCycleChange( + events: Array = arrayOf( + Lifecycle.Event.ON_RESUME + ), +): Modifier { + var tickTack by rememberSaveable { mutableStateOf(1f) } + + OnLifecycleEvent { owner, event -> + if (events.contains(event)) { + tickTack = if (tickTack == 1f) 0.99f else 1f + } + } + + return Modifier.alpha(tickTack) } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt index 7c9724a..14540c1 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -4,15 +4,21 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.net.Uri import android.os.IBinder import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel +import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.enums.RecorderState +import app.myzel394.alibi.helpers.AudioRecorderExporter +import app.myzel394.alibi.helpers.BatchesFolder import app.myzel394.alibi.services.AudioRecorderService +import app.myzel394.alibi.services.IntervalRecorderService import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderService import kotlinx.serialization.json.Json @@ -45,6 +51,9 @@ class AudioRecorderModel : ViewModel() { var onRecordingSave: () -> Unit = {} var onError: () -> Unit = {} var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null + var batchesFolder: BatchesFolder? = null + + private lateinit var settings: AppSettings var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED private set @@ -58,7 +67,7 @@ class AudioRecorderModel : ViewModel() { override fun onServiceConnected(className: ComponentName, service: IBinder) { recorderService = ((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder -> - // Update UI when the service changes + // Init variables from us to the service recorder.onStateChange = { state -> recorderState = state } @@ -81,6 +90,11 @@ class AudioRecorderModel : ViewModel() { recorder.onMicrophoneReconnected = { microphoneStatus = MicrophoneConnectivityStatus.CONNECTED } + recorder.batchesFolder = batchesFolder ?: recorder.batchesFolder + recorder.settings = + IntervalRecorderService.Settings.from(settings.audioRecorderSettings) + + recorder.clearAllRecordings() }.also { // Init UI from the service it.startRecording() @@ -106,11 +120,33 @@ class AudioRecorderModel : ViewModel() { microphoneStatus = MicrophoneConnectivityStatus.CONNECTED } - fun startRecording(context: Context) { + fun startRecording(context: Context, settings: AppSettings) { runCatching { + recorderService?.clearAllRecordings() context.unbindService(connection) } + notificationDetails = settings.notificationSettings.let { + if (it == null) + null + else + RecorderNotificationHelper.NotificationDetails.fromNotificationSettings( + context, + it + ) + } + batchesFolder = if (settings.audioRecorderSettings.saveFolder == null) + BatchesFolder.viaInternalFolder(context) + else + BatchesFolder.viaCustomFolder( + context, + DocumentFile.fromTreeUri( + context, + Uri.parse(settings.audioRecorderSettings.saveFolder) + )!! + ) + this.settings = settings + val intent = Intent(context, AudioRecorderService::class.java).apply { action = "init" diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorderScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorderScreen.kt index ce3cff6..abc7d67 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorderScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorderScreen.kt @@ -1,5 +1,7 @@ package app.myzel394.alibi.ui.screens +import android.content.Intent +import android.net.Uri import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,13 +20,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -36,8 +45,8 @@ import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import app.myzel394.alibi.R import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings -import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.helpers.AudioRecorderExporter +import app.myzel394.alibi.helpers.BatchesFolder import app.myzel394.alibi.ui.effects.rememberSettings import app.myzel394.alibi.ui.models.AudioRecorderModel import kotlinx.coroutines.delay @@ -49,6 +58,7 @@ fun AudioRecorderScreen( navController: NavController, audioRecorder: AudioRecorderModel, ) { + val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current val dataStore = context.dataStore @@ -59,10 +69,10 @@ fun AudioRecorderScreen( settings.audioRecorderSettings.getMimeType() ) { if (settings.audioRecorderSettings.deleteRecordingsImmediately) { - AudioRecorderExporter.clearAllRecordings(context) + audioRecorder.batchesFolder!!.deleteRecordings() } - if (!AudioRecorderExporter.hasRecordingsAvailable(context)) { + if (!audioRecorder.batchesFolder!!.hasRecordingsAvailable()) { scope.launch { dataStore.updateData { it.setLastRecording(null) @@ -86,6 +96,29 @@ fun AudioRecorderScreen( } } + val successMessage = stringResource(R.string.ui_audioRecorder_action_save_success) + val openMessage = stringResource(R.string.ui_audioRecorder_action_save_openFolder) + + fun openFolder(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW, uri) + + context.startActivity(intent) + } + + fun showSnackbar(uri: Uri) { + scope.launch { + val result = snackbarHostState.showSnackbar( + message = successMessage, + actionLabel = openMessage, + duration = SnackbarDuration.Short, + ) + + if (result == SnackbarResult.ActionPerformed) { + openFolder(uri) + } + } + } + fun saveRecording() { scope.launch { isProcessingAudio = true @@ -94,13 +127,43 @@ fun AudioRecorderScreen( delay(100) try { - val file = AudioRecorderExporter( - audioRecorder.recorderService?.getRecordingInformation() - ?: settings.lastRecording - ?: throw Exception("No recording information available"), - ).concatenateFiles() + val recording = audioRecorder.recorderService?.getRecordingInformation() + ?: settings.lastRecording + ?: throw Exception("No recording information available") + val batchesFolder = BatchesFolder.importFromFolder(recording.folderPath, context) + val outputFile = batchesFolder.getOutputFileForFFmpeg( + recording.recordingStart, + recording.fileExtension + ) - saveFile(file, file.name) + AudioRecorderExporter(recording).concatenateFiles( + batchesFolder, + outputFile, + ) + + val name = batchesFolder.getName( + recording.recordingStart, + recording.fileExtension, + ) + + when (batchesFolder.type) { + BatchesFolder.BatchType.INTERNAL -> { + saveFile( + batchesFolder.asInternalGetOutputFile( + recording.recordingStart, + recording.fileExtension, + ), name + ) + } + + BatchesFolder.BatchType.CUSTOM -> { + showSnackbar(batchesFolder.customFolder!!.uri) + + if (settings.audioRecorderSettings.deleteRecordingsImmediately) { + batchesFolder.deleteRecordings() + } + } + } } catch (error: Exception) { Log.getStackTraceString(error) } finally { @@ -192,6 +255,21 @@ fun AudioRecorderScreen( } ) Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { + Snackbar( + snackbarData = it, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + dismissActionContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + ) + }, topBar = { TopAppBar( title = { @@ -229,4 +307,4 @@ fun AudioRecorderScreen( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt index 0a1b4f9..cdad664 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt @@ -37,20 +37,19 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import app.myzel394.alibi.R import app.myzel394.alibi.dataStore -import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.ui.SUPPORTS_DARK_MODE_NATIVELY import app.myzel394.alibi.ui.components.SettingsScreen.atoms.AboutTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.CustomNotificationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.DeleteRecordingsImmediatelyTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile -import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ImportExport import app.myzel394.alibi.ui.components.SettingsScreen.atoms.InAppLanguagePicker import app.myzel394.alibi.ui.components.SettingsScreen.atoms.IntervalDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.OutputFormatTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile +import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SaveFolderTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ShowAllMicrophonesTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ThemeSelector import app.myzel394.alibi.ui.components.atoms.GlobalSwitch @@ -145,7 +144,6 @@ fun SettingsScreen( ) MaxDurationTile(settings = settings) IntervalDurationTile(settings = settings) - ForceExactMaxDurationTile(settings = settings) InAppLanguagePicker() DeleteRecordingsImmediatelyTile(settings = settings) CustomNotificationTile(navController = navController, settings = settings) @@ -161,6 +159,7 @@ fun SettingsScreen( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 32.dp) ) + SaveFolderTile(settings = settings) ShowAllMicrophonesTile(settings = settings) BitrateTile(settings = settings) SamplingRateTile(settings = settings) diff --git a/app/src/main/java/app/myzel394/alibi/ui/utils/file.kt b/app/src/main/java/app/myzel394/alibi/ui/utils/file.kt index f90d01b..c6c2701 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/utils/file.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/utils/file.kt @@ -1,9 +1,12 @@ package app.myzel394.alibi.ui.utils +import android.app.Activity +import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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 @@ -56,3 +59,30 @@ fun rememberFileSelectorDialog( launcher.launch(arrayOf(mimeType)) } } + +@Composable +fun rememberFolderSelectorDialog( + callback: (Uri?) -> Unit +): (() -> Unit) { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + val uri = it.data?.data + + callback(uri) + } + } + + return { + launcher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ) + }) + } +} diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 73e0687..fabc9a6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -39,8 +39,6 @@ Set the maximum duration of the recording Batch duration Record a single batch for this duration. Alibi records multiple batches and deletes the oldest one. When exporting the audio, all batches will be merged together - Force exact duration - Force to strip the output file to be the exactly specified duration. If this is disabled, the output file may be a bit longer due to batches of audio samples being encoded together. Bitrate A higher bitrate means better quality but also larger file size Set the bitrate for the audio recording diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1328ef..4062115 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Please enter a valid number Please enter a number between %s and %s Please enter a number greater than %s + Selected: %s Recorder Shows the current recording status @@ -109,4 +110,14 @@ Become a GitHub Sponsor Delete Recordings Immediately If enabled, Alibi will immediately delete recordings after you have saved the file. + Batches folder + Where Alibi should store the temporary batches of your recordings. + Select + Encrypted Internal Storage + Are you sure you want to change the folder? + By default, Alibi will save the recording batches into its private, encrypted file storage. You can change this and specify an external, unencrypted folder. This will allow you to access the batches manually. ONLY DO THIS IF YOU KNOW WHAT YOU ARE DOING! + Yes, change folder + Use private, encrypted storage + Recording has been saved successfully! + Open \ No newline at end of file