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 236d2e6..465ad19 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt @@ -2,7 +2,6 @@ package app.myzel394.alibi.db import android.media.MediaRecorder import android.os.Build -import android.util.Log import app.myzel394.alibi.R import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.ReturnCode @@ -63,7 +62,7 @@ data class AppSettings( } @Serializable -data class LastRecording( +data class RecordingInformation( val folderPath: String, @Serializable(with = LocalDateTimeSerializer::class) val recordingStart: LocalDateTime, @@ -72,91 +71,8 @@ data class LastRecording( val fileExtension: String, val forceExactMaxDuration: Boolean, ) { - val fileFolder: File - get() = File(folderPath) - - val filePaths: List - get() = - File(folderPath).listFiles()?.filter { - val name = it.nameWithoutExtension - - name.toIntOrNull() != null - }?.toList() ?: emptyList() - - val hasRecordingAvailable: Boolean - get() = filePaths.isNotEmpty() - - private fun stripConcatenatedFileToExactDuration( - outputFile: File - ) { - // Move the concatenated file to a temporary file - val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}") - outputFile.renameTo(rawFile) - - val command = "-sseof ${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.getState(), - session.getReturnCode(), - session.getFailStackTrace() - ) - ) - - throw Exception("Failed to strip concatenated audio") - } - } - - suspend fun concatenateFiles(forceConcatenation: Boolean = false): File { - val paths = filePaths.joinToString("|") - val fileName = recordingStart - .format(ISO_DATE_TIME) - .toString() - .replace(":", "-") - .replace(".", "_") - val outputFile = File("$fileFolder/$fileName.${fileExtension}") - - if (outputFile.exists() && !forceConcatenation) { - return outputFile - } - - val command = "-i 'concat:$paths' -y" + - " -acodec copy" + - " -metadata title='$fileName' " + - " -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" + - " -metadata batch_count='${filePaths.size}'" + - " -metadata batch_duration='${intervalDuration}'" + - " -metadata max_duration='${maxDuration}'" + - " $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.getState(), - session.getReturnCode(), - session.getFailStackTrace() - ) - ) - - throw Exception("Failed to concatenate audios") - } - - val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration - if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) { - stripConcatenatedFileToExactDuration(outputFile) - } - - return outputFile - } + val hasRecordingsAvailable + get() = File(folderPath).listFiles()?.isNotEmpty() ?: false } @Serializable diff --git a/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt b/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt new file mode 100644 index 0000000..6be1f67 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioRecorderExporter.kt @@ -0,0 +1,119 @@ +package app.myzel394.alibi.helpers + +import android.content.Context +import android.util.Log +import app.myzel394.alibi.db.RecordingInformation +import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.ReturnCode +import java.io.File +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 + ) { + // 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") + } + } + + 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 + } + + 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 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 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/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt index 0d8617a..62b089a 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -5,7 +5,7 @@ import android.media.MediaRecorder.OnErrorListener import android.os.Build import java.lang.IllegalStateException -class AudioRecorderService: IntervalRecorderService() { +class AudioRecorderService : IntervalRecorderService() { var amplitudesAmount = 1000 var recorder: MediaRecorder? = null @@ -13,7 +13,7 @@ class AudioRecorderService: IntervalRecorderService() { var onError: () -> Unit = {} val filePath: String - get() = "$folder/$counter.${settings!!.fileExtension}" + get() = "${outputFolder}/$counter.${settings!!.fileExtension}" private fun createRecorder(): MediaRecorder { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 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 b2f6fa4..262aceb 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -1,39 +1,37 @@ package app.myzel394.alibi.services -import android.content.Context import android.media.MediaRecorder import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AudioRecorderSettings -import app.myzel394.alibi.db.LastRecording +import app.myzel394.alibi.db.RecordingInformation +import app.myzel394.alibi.helpers.AudioRecorderExporter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.io.File -import java.time.LocalDateTime -import java.util.Timer -import java.util.TimerTask -import java.util.UUID -import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit -abstract class IntervalRecorderService: ExtraRecorderInformationService() { +abstract class IntervalRecorderService : ExtraRecorderInformationService() { private var job = SupervisorJob() private var scope = CoroutineScope(Dispatchers.IO + job) protected var counter = 0 private set - protected lateinit var folder: File + var settings: Settings? = null protected set private lateinit var cycleTimer: ScheduledExecutorService - fun createLastRecording(): LastRecording = LastRecording( - folderPath = folder.absolutePath, + protected val outputFolder: File + get() = AudioRecorderExporter.getFolder(this) + + fun createLastRecording(): RecordingInformation = RecordingInformation( + folderPath = outputFolder.absolutePath, recordingStart = recordingStart, maxDuration = settings!!.maxDuration, fileExtension = settings!!.fileExtension, @@ -60,18 +58,10 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { } } - private fun getRandomFileFolder(): String { - // uuid - val folder = UUID.randomUUID().toString() - - return "${externalCacheDir!!.absolutePath}/$folder" - } - override fun start() { super.start() - folder = File(getRandomFileFolder()) - folder.mkdirs() + outputFolder.mkdirs() scope.launch { dataStore.data.collectLatest { preferenceSettings -> @@ -104,7 +94,7 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration val earliestCounter = counter - timeMultiplier - folder.listFiles()?.forEach { file -> + outputFolder.listFiles()?.forEach { file -> val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return if (fileCounter < earliestCounter) { @@ -123,7 +113,7 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { val encoder: Int, ) { val fileExtension: String - get() = when(outputFormat) { + get() = when (outputFormat) { MediaRecorder.OutputFormat.AAC_ADTS -> "aac" MediaRecorder.OutputFormat.THREE_GPP -> "3gp" MediaRecorder.OutputFormat.MPEG_4 -> "mp4" diff --git a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt index bf3dce1..28a4404 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt @@ -2,10 +2,12 @@ package app.myzel394.alibi.ui import android.os.Build import androidx.compose.ui.unit.dp +import java.io.File val BIG_PRIMARY_BUTTON_SIZE = 64.dp val MAX_AMPLITUDE = 20000 val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q +val RECORDER_SUBFOLDER_NAME = ".recordings" // You are not allowed to change the constants below. // If you do so, you will be blocked on GitHub. diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt index efef5d4..07c5710 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -1,6 +1,5 @@ package app.myzel394.alibi.ui -import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -13,10 +12,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel @@ -24,7 +19,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import app.myzel394.alibi.dataStore -import app.myzel394.alibi.db.LastRecording import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.screens.AboutScreen 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 0c3c046..2c7580b 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 @@ -42,6 +42,7 @@ 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 @@ -79,6 +80,9 @@ fun StartRecording( it ) } + + AudioRecorderExporter.clearAllRecordings(context) + audioRecorder.startRecording(context) } } @@ -144,7 +148,7 @@ fun StartRecording( .fillMaxWidth(), textAlign = TextAlign.Center, ) - if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) { + if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingsAvailable) { Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, 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 7e278ee..b754a81 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,22 +4,17 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.media.MediaRecorder import android.os.IBinder -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel -import app.myzel394.alibi.dataStore -import app.myzel394.alibi.db.LastRecording +import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.services.AudioRecorderService import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderService -import kotlinx.coroutines.flow.last import kotlinx.serialization.json.Json class AudioRecorderModel : ViewModel() { @@ -44,7 +39,7 @@ class AudioRecorderModel : ViewModel() { var recorderService: AudioRecorderService? = null private set - var lastRecording: LastRecording? by mutableStateOf(null) + var lastRecording: RecordingInformation? by mutableStateOf(null) private set var onRecordingSave: () -> Unit = {} diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt index e5124b2..ec10049 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt @@ -28,7 +28,6 @@ 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.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording @@ -37,8 +36,7 @@ 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.LastRecording -import app.myzel394.alibi.services.RecorderNotificationHelper +import app.myzel394.alibi.helpers.AudioRecorderExporter import app.myzel394.alibi.ui.effects.rememberSettings import app.myzel394.alibi.ui.models.AudioRecorderModel import kotlinx.coroutines.delay @@ -67,7 +65,8 @@ fun AudioRecorder( delay(100) try { - val file = audioRecorder.lastRecording!!.concatenateFiles() + val file = + AudioRecorderExporter(audioRecorder.lastRecording!!).concatenateFiles() saveFile(file, file.name) } catch (error: Exception) {