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 42c5af0..4eefbda 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt @@ -16,25 +16,21 @@ import java.time.format.DateTimeFormatter data class AudioRecorderExporter( val recording: RecordingInformation, ) { - private fun getInternalFilePaths(context: Context): List = - getFolder(context) - .listFiles() - ?.filter { - val name = it.nameWithoutExtension - - name.toIntOrNull() != null - } - ?.toList() - ?: emptyList() - suspend fun concatenateFiles( context: Context, batchesFolder: BatchesFolder, + outputFilePath: String, forceConcatenation: Boolean = false, ) { val filePaths = batchesFolder.getBatchesForFFmpeg().joinToString("|") - val outputFile = - batchesFolder.getOutputFileForFFmpeg(recording.recordingStart, recording.fileExtension) + + if (batchesFolder.checkIfOutputAlreadyExists( + recording.recordingStart, + recording.fileExtension + ) && !forceConcatenation + ) { + return + } val command = "-protocol_whitelist saf,concat,content,file,subfile" + @@ -44,7 +40,7 @@ data class AudioRecorderExporter( " -metadata batch_count='${filePaths.length}'" + " -metadata batch_duration='${recording.intervalDuration}'" + " -metadata max_duration='${recording.maxDuration}'" + - " $outputFile" + " $outputFilePath" val session = FFmpegKit.execute(command) @@ -68,34 +64,6 @@ data class AudioRecorderExporter( 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 - - fun linkBatches(context: Context, batchesFolder: Uri, destinationFolder: File) { - val folder = - DocumentFile.fromTreeUri( - context, - batchesFolder, - )!! - - destinationFolder.mkdirs() - - folder.listFiles().forEach { - if (it.name?.substringBeforeLast(".")?.toIntOrNull() == null) { - return@forEach - } - - Os.symlink( - "${folder.uri}/${it.name}", - "${destinationFolder.absolutePath}/${it.name}", - ) - } - } } } diff --git a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt index 9b95451..8bc6f4d 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -68,10 +68,47 @@ data class BatchesFolder( } } + 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() @@ -79,14 +116,9 @@ data class BatchesFolder( .replace(".", "_") return when (type) { - BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").absolutePath - BatchType.CUSTOM -> FFmpegKitConfig.getSafParameterForWrite( - context, - getCustomDefinedFolder().createFile( - "audio/${extension}", - "${name}.${extension}" - )!!.uri - )!! + BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").exists() + BatchType.CUSTOM -> + getCustomDefinedFolder().findFile("${name}.${extension}")?.exists() ?: false } } @@ -104,6 +136,13 @@ data class BatchesFolder( } } + fun hasRecordingsAvailable(): Boolean { + return when (type) { + BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false + BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().isNotEmpty() + } + } + fun deleteOldRecordings(earliestCounter: Long) { when (type) { BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach { 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 b583665..3342774 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 @@ -1,7 +1,6 @@ package app.myzel394.alibi.ui.components.AudioRecorder.molecules import android.Manifest -import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -21,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 @@ -39,22 +36,12 @@ 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 androidx.documentfile.provider.DocumentFile -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.helpers.AudioRecorderExporter.Companion.clearAllRecordings -import app.myzel394.alibi.helpers.BatchesFolder -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.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 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/screens/AudioRecorderScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorderScreen.kt index ca39684..fd7ef17 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,7 +20,13 @@ 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 @@ -28,8 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile +import androidx.core.content.ContextCompat.startActivity import androidx.navigation.NavController import app.myzel394.alibi.ui.components.AudioRecorder.organisms.RecordingStatus import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording @@ -38,7 +45,6 @@ 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 @@ -52,6 +58,7 @@ fun AudioRecorderScreen( navController: NavController, audioRecorder: AudioRecorderModel, ) { + val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current val dataStore = context.dataStore @@ -62,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) @@ -89,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 @@ -97,16 +127,39 @@ fun AudioRecorderScreen( delay(100) try { - AudioRecorderExporter( - audioRecorder.recorderService?.getRecordingInformation() - ?: settings.lastRecording - ?: throw Exception("No recording information available"), - ).concatenateFiles( - context, - audioRecorder.recorderService!!.batchesFolder + val recording = audioRecorder.recorderService?.getRecordingInformation() + ?: settings.lastRecording + ?: throw Exception("No recording information available") + val outputFile = audioRecorder.batchesFolder!!.getOutputFileForFFmpeg( + recording.recordingStart, + recording.fileExtension ) - // saveFile(file, file.name) + AudioRecorderExporter(recording).concatenateFiles( + context, + audioRecorder.recorderService!!.batchesFolder, + outputFile, + ) + + val name = audioRecorder.batchesFolder!!.getName( + recording.recordingStart, + recording.fileExtension, + ) + + when (audioRecorder.batchesFolder!!.type) { + BatchesFolder.BatchType.INTERNAL -> { + saveFile( + audioRecorder.batchesFolder!!.asInternalGetOutputFile( + recording.recordingStart, + recording.fileExtension, + ), name + ) + } + + BatchesFolder.BatchType.CUSTOM -> { + showSnackbar(audioRecorder.batchesFolder!!.customFolder!!.uri) + } + } } catch (error: Exception) { Log.getStackTraceString(error) } finally { @@ -198,6 +251,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 = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52b5e83..4062115 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,4 +118,6 @@ 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