feat: Use internal directory for saving files

This commit is contained in:
Myzel394 2023-10-26 18:27:10 +02:00
parent 517516518f
commit 9dc1c05d69
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
9 changed files with 148 additions and 129 deletions

View File

@ -2,7 +2,6 @@ package app.myzel394.alibi.db
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.util.Log
import app.myzel394.alibi.R import app.myzel394.alibi.R
import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
@ -63,7 +62,7 @@ data class AppSettings(
} }
@Serializable @Serializable
data class LastRecording( data class RecordingInformation(
val folderPath: String, val folderPath: String,
@Serializable(with = LocalDateTimeSerializer::class) @Serializable(with = LocalDateTimeSerializer::class)
val recordingStart: LocalDateTime, val recordingStart: LocalDateTime,
@ -72,91 +71,8 @@ data class LastRecording(
val fileExtension: String, val fileExtension: String,
val forceExactMaxDuration: Boolean, val forceExactMaxDuration: Boolean,
) { ) {
val fileFolder: File val hasRecordingsAvailable
get() = File(folderPath) get() = File(folderPath).listFiles()?.isNotEmpty() ?: false
val filePaths: List<File>
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
}
} }
@Serializable @Serializable

View File

@ -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<File>
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
}
}
}

View File

@ -5,7 +5,7 @@ import android.media.MediaRecorder.OnErrorListener
import android.os.Build import android.os.Build
import java.lang.IllegalStateException import java.lang.IllegalStateException
class AudioRecorderService: IntervalRecorderService() { class AudioRecorderService : IntervalRecorderService() {
var amplitudesAmount = 1000 var amplitudesAmount = 1000
var recorder: MediaRecorder? = null var recorder: MediaRecorder? = null
@ -13,7 +13,7 @@ class AudioRecorderService: IntervalRecorderService() {
var onError: () -> Unit = {} var onError: () -> Unit = {}
val filePath: String val filePath: String
get() = "$folder/$counter.${settings!!.fileExtension}" get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
private fun createRecorder(): MediaRecorder { private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {

View File

@ -1,39 +1,37 @@
package app.myzel394.alibi.services package app.myzel394.alibi.services
import android.content.Context
import android.media.MediaRecorder import android.media.MediaRecorder
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AudioRecorderSettings 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File 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.Executors
import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
abstract class IntervalRecorderService: ExtraRecorderInformationService() { abstract class IntervalRecorderService : ExtraRecorderInformationService() {
private var job = SupervisorJob() private var job = SupervisorJob()
private var scope = CoroutineScope(Dispatchers.IO + job) private var scope = CoroutineScope(Dispatchers.IO + job)
protected var counter = 0 protected var counter = 0
private set private set
protected lateinit var folder: File
var settings: Settings? = null var settings: Settings? = null
protected set protected set
private lateinit var cycleTimer: ScheduledExecutorService private lateinit var cycleTimer: ScheduledExecutorService
fun createLastRecording(): LastRecording = LastRecording( protected val outputFolder: File
folderPath = folder.absolutePath, get() = AudioRecorderExporter.getFolder(this)
fun createLastRecording(): RecordingInformation = RecordingInformation(
folderPath = outputFolder.absolutePath,
recordingStart = recordingStart, recordingStart = recordingStart,
maxDuration = settings!!.maxDuration, maxDuration = settings!!.maxDuration,
fileExtension = settings!!.fileExtension, 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() { override fun start() {
super.start() super.start()
folder = File(getRandomFileFolder()) outputFolder.mkdirs()
folder.mkdirs()
scope.launch { scope.launch {
dataStore.data.collectLatest { preferenceSettings -> dataStore.data.collectLatest { preferenceSettings ->
@ -104,7 +94,7 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
val earliestCounter = counter - timeMultiplier val earliestCounter = counter - timeMultiplier
folder.listFiles()?.forEach { file -> outputFolder.listFiles()?.forEach { file ->
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
if (fileCounter < earliestCounter) { if (fileCounter < earliestCounter) {
@ -123,7 +113,7 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
val encoder: Int, val encoder: Int,
) { ) {
val fileExtension: String val fileExtension: String
get() = when(outputFormat) { get() = when (outputFormat) {
MediaRecorder.OutputFormat.AAC_ADTS -> "aac" MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
MediaRecorder.OutputFormat.THREE_GPP -> "3gp" MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
MediaRecorder.OutputFormat.MPEG_4 -> "mp4" MediaRecorder.OutputFormat.MPEG_4 -> "mp4"

View File

@ -2,10 +2,12 @@ package app.myzel394.alibi.ui
import android.os.Build import android.os.Build
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.io.File
val BIG_PRIMARY_BUTTON_SIZE = 64.dp val BIG_PRIMARY_BUTTON_SIZE = 64.dp
val MAX_AMPLITUDE = 20000 val MAX_AMPLITUDE = 20000
val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q 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. // You are not allowed to change the constants below.
// If you do so, you will be blocked on GitHub. // If you do so, you will be blocked on GitHub.

View File

@ -1,6 +1,5 @@
package app.myzel394.alibi.ui package app.myzel394.alibi.ui
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -13,10 +12,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -24,7 +19,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.LastRecording
import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.screens.AboutScreen import app.myzel394.alibi.ui.screens.AboutScreen

View File

@ -42,6 +42,7 @@ import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.atoms.PermissionRequester import app.myzel394.alibi.ui.components.atoms.PermissionRequester
@ -79,6 +80,9 @@ fun StartRecording(
it it
) )
} }
AudioRecorderExporter.clearAllRecordings(context)
audioRecorder.startRecording(context) audioRecorder.startRecording(context)
} }
} }
@ -144,7 +148,7 @@ fun StartRecording(
.fillMaxWidth(), .fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) { if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingsAvailable) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,

View File

@ -4,22 +4,17 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.media.MediaRecorder
import android.os.IBinder import android.os.IBinder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.db.LastRecording
import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.services.AudioRecorderService import app.myzel394.alibi.services.AudioRecorderService
import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.services.RecorderService import app.myzel394.alibi.services.RecorderService
import kotlinx.coroutines.flow.last
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class AudioRecorderModel : ViewModel() { class AudioRecorderModel : ViewModel() {
@ -44,7 +39,7 @@ class AudioRecorderModel : ViewModel() {
var recorderService: AudioRecorderService? = null var recorderService: AudioRecorderService? = null
private set private set
var lastRecording: LastRecording? by mutableStateOf<LastRecording?>(null) var lastRecording: RecordingInformation? by mutableStateOf<RecordingInformation?>(null)
private set private set
var onRecordingSave: () -> Unit = {} var onRecordingSave: () -> Unit = {}

View File

@ -28,7 +28,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording 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.R
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.LastRecording import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.ui.effects.rememberSettings import app.myzel394.alibi.ui.effects.rememberSettings
import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.models.AudioRecorderModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -67,7 +65,8 @@ fun AudioRecorder(
delay(100) delay(100)
try { try {
val file = audioRecorder.lastRecording!!.concatenateFiles() val file =
AudioRecorderExporter(audioRecorder.lastRecording!!).concatenateFiles()
saveFile(file, file.name) saveFile(file, file.name)
} catch (error: Exception) { } catch (error: Exception) {