From 6adff096d2d9d8616a6041f009d379616006d570 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Sat, 18 Nov 2023 18:15:10 +0100 Subject: [PATCH] feat: Adding BatchesFolder --- .../alibi/helpers/AudioRecorderExporter.kt | 95 ++++------ .../myzel394/alibi/helpers/BatchesFolder.kt | 164 ++++++++++++++++++ .../alibi/services/AudioRecorderService.kt | 24 +-- .../alibi/services/IntervalRecorderService.kt | 66 ++----- .../AudioRecorder/molecules/StartRecording.kt | 17 +- .../alibi/ui/models/AudioRecorderModel.kt | 7 +- .../alibi/ui/screens/AudioRecorderScreen.kt | 16 +- 7 files changed, 237 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt 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 56495c7..42c5af0 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt @@ -4,8 +4,6 @@ import android.content.Context import android.net.Uri import android.system.Os import android.util.Log -import androidx.core.content.ContentProviderCompat.requireContext -import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME @@ -18,70 +16,35 @@ import java.time.format.DateTimeFormatter data class AudioRecorderExporter( val recording: RecordingInformation, ) { - private fun getFilePaths(context: Context): List = - getFolder(context).listFiles()?.filter { - val name = it.nameWithoutExtension + private fun getInternalFilePaths(context: Context): List = + getFolder(context) + .listFiles() + ?.filter { + val name = it.nameWithoutExtension - name.toIntOrNull() != null - }?.toList() ?: emptyList() - - private fun stripConcatenatedFileToExactDuration( - outputFile: File - ) { - // Move the concatenated file to a temporary file - val rawFile = - File("${recording.folderPath}/${outputFile.nameWithoutExtension}-raw.${recording.fileExtension}") - outputFile.renameTo(rawFile) - - 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") - } - } + name.toIntOrNull() != null + } + ?.toList() + ?: emptyList() suspend fun concatenateFiles( context: Context, - uri: Uri, - folder: DocumentFile, + batchesFolder: BatchesFolder, forceConcatenation: Boolean = false, ) { - val filePaths = getFilePaths(context) - val paths = filePaths.joinToString("|") { - it.path - } - val filePath = FFmpegKitConfig.getSafParameter(context, uri, "rw") - val fileName = recording.recordingStart - .format(DateTimeFormatter.ISO_DATE_TIME) - .toString() - .replace(":", "-") - .replace(".", "_") - val outputFile = FFmpegKitConfig.getSafParameterForWrite( - context, - folder.createFile("audio/aac", "${fileName}.aac")!!.uri, - ) + val filePaths = batchesFolder.getBatchesForFFmpeg().joinToString("|") + val outputFile = + batchesFolder.getOutputFileForFFmpeg(recording.recordingStart, recording.fileExtension) - val command = "-protocol_whitelist saf,concat,content,file,subfile" + - " -i 'concat:${filePath}' -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 command = + "-protocol_whitelist saf,concat,content,file,subfile" + + " -i 'concat:${filePaths}' -y" + + " -acodec copy" + + " -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" + + " -metadata batch_count='${filePaths.length}'" + + " -metadata batch_duration='${recording.intervalDuration}'" + + " -metadata max_duration='${recording.maxDuration}'" + + " $outputFile" val session = FFmpegKit.execute(command) @@ -101,7 +64,6 @@ data class AudioRecorderExporter( val minRequiredForPossibleInExactMaxDuration = recording.maxDuration / recording.intervalDuration - } companion object { @@ -115,10 +77,11 @@ data class AudioRecorderExporter( getFolder(context).listFiles()?.isNotEmpty() ?: false fun linkBatches(context: Context, batchesFolder: Uri, destinationFolder: File) { - val folder = DocumentFile.fromTreeUri( - context, - batchesFolder, - )!! + val folder = + DocumentFile.fromTreeUri( + context, + batchesFolder, + )!! destinationFolder.mkdirs() @@ -127,7 +90,6 @@ data class AudioRecorderExporter( return@forEach } - Os.symlink( "${folder.uri}/${it.name}", "${destinationFolder.absolutePath}/${it.name}", @@ -135,4 +97,5 @@ data class AudioRecorderExporter( } } } -} \ 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..f76d5d5 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -0,0 +1,164 @@ +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 -> 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, + customFolder!!.findFile(it.name!!)!!.uri + )!! + } + } + } + + fun getOutputFileForFFmpeg( + date: LocalDateTime, + extension: String, + ): String { + val name = date + .format(DateTimeFormatter.ISO_DATE_TIME) + .toString() + .replace(":", "-") + .replace(".", "_") + + return when (type) { + BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").absolutePath + BatchType.CUSTOM -> FFmpegKitConfig.getSafParameterForWrite( + context, + customFolder!!.createFile("audio/${extension}", "${name}.${extension}")!!.uri + )!! + } + } + + fun exportFolderForSettings(): String { + return when (type) { + BatchType.INTERNAL -> "_'internal" + BatchType.CUSTOM -> customFolder!!.uri.toString() + } + } + + fun deleteRecordings() { + when (type) { + BatchType.INTERNAL -> getInternalFolder().deleteRecursively() + BatchType.CUSTOM -> getCustomDefinedFolder().delete() + } + } + + 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 -> customFolder!!.canWrite() && customFolder.canRead() + } + } + + fun asInternalGetOutputPath(counter: Long, fileExtension: String): String { + return getInternalFolder().absolutePath + "/$counter.$fileExtension" + } + + fun asCustomGetFileDescriptor( + counter: Long, + fileExtension: String, + ): FileDescriptor { + val file = customFolder!!.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 54bb572..4c1cf1d 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -12,6 +12,7 @@ import android.os.Handler import android.os.Looper 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 @@ -65,19 +66,17 @@ class AudioRecorderService : IntervalRecorderService() { // - DEFAULT: Uses the bottom microphone of the phone (17) setAudioSource(MediaRecorder.AudioSource.MIC) - // Setting file path - if (customOutputFolder == null) { - val newFilePath = "${defaultOutputFolder}/$counter.${settings!!.fileExtension}" + when (batchesFolder.type) { + BatchesFolder.BatchType.INTERNAL -> { + setOutputFile( + batchesFolder.asInternalGetOutputPath(counter, settings!!.fileExtension) + ) + } - setOutputFile(newFilePath) - } else { - customOutputFolder!!.createFile( - "audio/${settings!!.fileExtension}", - "${counter}.${settings!!.fileExtension}" - )!!.let { - val fileDescriptor = - contentResolver.openFileDescriptor(it.uri, "w")!!.fileDescriptor - setOutputFile(fileDescriptor) + BatchesFolder.BatchType.CUSTOM -> { + setOutputFile( + batchesFolder.asCustomGetFileDescriptor(counter, settings!!.fileExtension) + ) } } @@ -99,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 e7ac4d0..32bbac0 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -7,6 +7,7 @@ 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 @@ -22,7 +23,7 @@ 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 @@ -33,12 +34,12 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { protected val defaultOutputFolder: File get() = AudioRecorderExporter.getFolder(this) - var customOutputFolder: DocumentFile? = null + var batchesFolder: BatchesFolder = BatchesFolder.viaInternalFolder(this) var onCustomOutputFolderNotAccessible: () -> Unit = {} fun getRecordingInformation(): RecordingInformation = RecordingInformation( - folderPath = customOutputFolder?.uri?.toString() ?: defaultOutputFolder.absolutePath, + folderPath = batchesFolder.exportFolderForSettings(), recordingStart = recordingStart, maxDuration = settings!!.maxDuration, fileExtension = settings!!.fileExtension, @@ -68,31 +69,14 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { override fun start() { super.start() - scope.launch { - dataStore.data.collectLatest { preferenceSettings -> - if (settings == null) { - settings = Settings.from(preferenceSettings.audioRecorderSettings) - - if (settings!!.folder != null) { - customOutputFolder = DocumentFile.fromTreeUri( - this@IntervalRecorderService, - Uri.parse(settings!!.folder) - ) - - if (!customOutputFolder!!.canRead() || !customOutputFolder!!.canWrite()) { - customOutputFolder = null - onCustomOutputFolderNotAccessible() - } - } - - createTimer() - } - - if (customOutputFolder == null) { - defaultOutputFolder.mkdirs() - } - } + if (!batchesFolder.checkIfFolderIsAccessible()) { + batchesFolder = + BatchesFolder.viaInternalFolder(this@IntervalRecorderService) + onCustomOutputFolderNotAccessible() } + batchesFolder.initFolders() + + createTimer() } override fun pause() { @@ -112,38 +96,14 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() { } fun clearAllRecordings() { - if (customOutputFolder != null) { - customOutputFolder!!.listFiles().forEach { - it.delete() - } - } else { - defaultOutputFolder.listFiles()?.forEach { - it.delete() - } - } + batchesFolder.deleteRecordings() } private fun deleteOldRecordings() { val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration val earliestCounter = counter - timeMultiplier - if (customOutputFolder != null) { - customOutputFolder!!.listFiles().forEach { - val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach - - if (fileCounter < earliestCounter) { - it.delete() - } - } - } else { - defaultOutputFolder.listFiles()?.forEach { - val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return - - if (fileCounter < earliestCounter) { - it.delete() - } - } - } + batchesFolder.deleteOldRecordings(earliestCounter) } data class Settings( 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 21d9b86..5672c91 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 @@ -46,6 +46,7 @@ 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 @@ -86,12 +87,16 @@ fun StartRecording( it ) } - recorder.customOutputFolder = appSettings.audioRecorderSettings.saveFolder.let { - if (it == null) - null - else - DocumentFile.fromTreeUri(context, Uri.parse(it)) - } + recorder.batchesFolder = if (appSettings.audioRecorderSettings.saveFolder == null) + BatchesFolder.viaInternalFolder(context) + else + BatchesFolder.viaCustomFolder( + context, + DocumentFile.fromTreeUri( + context, + Uri.parse(appSettings.audioRecorderSettings.saveFolder) + )!! + ) recorder.startRecording(context) } 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 671ca02..a1f1648 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 @@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModel 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.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderService @@ -47,7 +48,7 @@ class AudioRecorderModel : ViewModel() { var onRecordingSave: () -> Unit = {} var onError: () -> Unit = {} var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null - var customOutputFolder: DocumentFile? = null + var batchesFolder: BatchesFolder? = null var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED private set @@ -61,8 +62,6 @@ class AudioRecorderModel : ViewModel() { override fun onServiceConnected(className: ComponentName, service: IBinder) { recorderService = ((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder -> - recorder.clearAllRecordings() - // Update UI when the service changes recorder.onStateChange = { state -> recorderState = state @@ -86,7 +85,7 @@ class AudioRecorderModel : ViewModel() { recorder.onMicrophoneReconnected = { microphoneStatus = MicrophoneConnectivityStatus.CONNECTED } - recorder.customOutputFolder = customOutputFolder + recorder.batchesFolder = batchesFolder ?: recorder.batchesFolder }.also { // Init UI from the service it.startRecording() 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 8658f5d..ca39684 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 @@ -40,6 +40,7 @@ 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 @@ -96,23 +97,16 @@ fun AudioRecorderScreen( delay(100) try { - val file = AudioRecorderExporter( + AudioRecorderExporter( audioRecorder.recorderService?.getRecordingInformation() ?: settings.lastRecording ?: throw Exception("No recording information available"), ).concatenateFiles( context, - DocumentFile.fromTreeUri( - context, - settings.audioRecorderSettings.saveFolder!!.toUri(), - )!!.findFile("1.aac")!!.uri, - DocumentFile.fromTreeUri( - context, - settings.audioRecorderSettings.saveFolder!!.toUri(), - )!! + audioRecorder.recorderService!!.batchesFolder ) - //saveFile(file, file.name) + // saveFile(file, file.name) } catch (error: Exception) { Log.getStackTraceString(error) } finally { @@ -241,4 +235,4 @@ fun AudioRecorderScreen( ) } } -} \ No newline at end of file +}