mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
feat: Use internal directory for saving files
This commit is contained in:
parent
517516518f
commit
9dc1c05d69
@ -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<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
|
||||
}
|
||||
val hasRecordingsAvailable
|
||||
get() = File(folderPath).listFiles()?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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"
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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<LastRecording?>(null)
|
||||
var lastRecording: RecordingInformation? by mutableStateOf<RecordingInformation?>(null)
|
||||
private set
|
||||
|
||||
var onRecordingSave: () -> Unit = {}
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user