feat: Adding BatchesFolder

This commit is contained in:
Myzel394 2023-11-18 18:15:10 +01:00
parent 7722127796
commit 6adff096d2
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
7 changed files with 237 additions and 152 deletions

View File

@ -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<File> =
getFolder(context).listFiles()?.filter {
val name = it.nameWithoutExtension
private fun getInternalFilePaths(context: Context): List<File> =
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(
}
}
}
}
}

View File

@ -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<String> {
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))!!)
}
}
}

View File

@ -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()
}
}

View File

@ -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(

View File

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

View File

@ -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()

View File

@ -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(
)
}
}
}
}