Merge pull request #42 from Myzel394/delete-recordings-immediately-and-fix-dir

Delete recordings immediately and fix dir
This commit is contained in:
Myzel394 2023-10-26 20:11:41 +02:00 committed by GitHub
commit dd58ce23a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 293 additions and 162 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
@ -19,6 +18,7 @@ data class AppSettings(
val hasSeenOnboarding: Boolean = false, val hasSeenOnboarding: Boolean = false,
val showAdvancedSettings: Boolean = false, val showAdvancedSettings: Boolean = false,
val theme: Theme = Theme.SYSTEM, val theme: Theme = Theme.SYSTEM,
val lastRecording: RecordingInformation? = null,
) { ) {
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings { fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
return copy(showAdvancedSettings = showAdvancedSettings) return copy(showAdvancedSettings = showAdvancedSettings)
@ -40,6 +40,10 @@ data class AppSettings(
return copy(theme = theme) return copy(theme = theme)
} }
fun setLastRecording(lastRecording: RecordingInformation?): AppSettings {
return copy(lastRecording = lastRecording)
}
enum class Theme { enum class Theme {
SYSTEM, SYSTEM,
LIGHT, LIGHT,
@ -63,7 +67,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 +76,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
@ -172,6 +93,7 @@ data class AudioRecorderSettings(
val outputFormat: Int? = null, val outputFormat: Int? = null,
val encoder: Int? = null, val encoder: Int? = null,
val showAllMicrophones: Boolean = false, val showAllMicrophones: Boolean = false,
val deleteRecordingsImmediately: Boolean = false,
) { ) {
fun getOutputFormat(): Int { fun getOutputFormat(): Int {
if (outputFormat != null) { if (outputFormat != null) {
@ -303,6 +225,10 @@ data class AudioRecorderSettings(
return copy(showAllMicrophones = showAllMicrophones) return copy(showAllMicrophones = showAllMicrophones)
} }
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AudioRecorderSettings {
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
}
fun isEncoderCompatible(encoder: Int): Boolean { fun isEncoderCompatible(encoder: Int): Boolean {
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) { if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
return true return true

View File

@ -0,0 +1,118 @@
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

@ -28,7 +28,7 @@ class AudioRecorderService : IntervalRecorderService() {
var onMicrophoneReconnected: () -> Unit = {} var onMicrophoneReconnected: () -> Unit = {}
val filePath: String val filePath: String
get() = "$folder/$counter.${settings!!.fileExtension}" get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
/// Tell Android to use the correct bluetooth microphone, if any selected /// Tell Android to use the correct bluetooth microphone, if any selected
private fun startAudioDevice() { private fun startAudioDevice() {

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 getRecordingInformation(): 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,11 +19,10 @@ 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
import app.myzel394.alibi.ui.screens.AudioRecorder import app.myzel394.alibi.ui.screens.AudioRecorderScreen
import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
import app.myzel394.alibi.ui.screens.SettingsScreen import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen import app.myzel394.alibi.ui.screens.WelcomeScreen
@ -76,7 +70,7 @@ fun Navigation(
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150)) scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
} }
) { ) {
AudioRecorder( AudioRecorderScreen(
navController = navController, navController = navController,
audioRecorder = audioRecorder, audioRecorder = audioRecorder,
) )

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
@ -59,7 +60,8 @@ fun StartRecording(
// Loading this from parent, because if we load it ourselves // Loading this from parent, because if we load it ourselves
// and permissions have already been granted, initial // and permissions have already been granted, initial
// settings will be used, instead of the actual settings. // settings will be used, instead of the actual settings.
appSettings: AppSettings appSettings: AppSettings,
onSaveLastRecording: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -79,6 +81,9 @@ fun StartRecording(
it it
) )
} }
AudioRecorderExporter.clearAllRecordings(context)
audioRecorder.startRecording(context) audioRecorder.startRecording(context)
} }
} }
@ -144,7 +149,7 @@ fun StartRecording(
.fillMaxWidth(), .fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) { if (appSettings.lastRecording?.hasRecordingsAvailable == true) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -153,7 +158,7 @@ fun StartRecording(
val label = stringResource( val label = stringResource(
R.string.ui_audioRecorder_action_saveOldRecording_label, R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
.format(audioRecorder.lastRecording!!.recordingStart), .format(appSettings.lastRecording.recordingStart),
) )
Button( Button(
modifier = Modifier modifier = Modifier
@ -164,10 +169,7 @@ fun StartRecording(
contentDescription = label contentDescription = label
}, },
colors = ButtonDefaults.textButtonColors(), colors = ButtonDefaults.textButtonColors(),
onClick = { onClick = onSaveLastRecording,
audioRecorder.stopRecording(context)
audioRecorder.onRecordingSave()
},
) { ) {
Icon( Icon(
Icons.Default.Save, Icons.Default.Save,

View File

@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.ui.components.AudioRecorder.atoms.DeleteButton import app.myzel394.alibi.ui.components.AudioRecorder.atoms.DeleteButton
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
@ -105,7 +106,9 @@ fun RecordingStatus(
) { ) {
DeleteButton( DeleteButton(
onDelete = { onDelete = {
audioRecorder.stopRecording(context, saveAsLastRecording = false) audioRecorder.stopRecording(context)
AudioRecorderExporter.clearAllRecordings(context)
} }
) )
} }

View File

@ -0,0 +1,52 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import kotlinx.coroutines.launch
@Composable
fun DeleteRecordingsImmediatelyTile() {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
SettingsTile(
title = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_title),
description = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_description),
leading = {
Icon(
Icons.Default.DeleteSweep,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.audioRecorderSettings.deleteRecordingsImmediately,
onCheckedChange = {
scope.launch {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setDeleteRecordingsImmediately(it.audioRecorderSettings.deleteRecordingsImmediately.not())
)
}
}
}
)
}
)
}

View File

@ -10,13 +10,11 @@ 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
import app.myzel394.alibi.ui.utils.MicrophoneInfo import app.myzel394.alibi.ui.utils.MicrophoneInfo
@ -44,9 +42,6 @@ class AudioRecorderModel : ViewModel() {
var recorderService: AudioRecorderService? = null var recorderService: AudioRecorderService? = null
private set private set
var lastRecording: LastRecording? by mutableStateOf<LastRecording?>(null)
private set
var onRecordingSave: () -> Unit = {} var onRecordingSave: () -> Unit = {}
var onError: () -> Unit = {} var onError: () -> Unit = {}
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
@ -75,7 +70,6 @@ class AudioRecorderModel : ViewModel() {
onAmplitudeChange() onAmplitudeChange()
} }
recorder.onError = { recorder.onError = {
recorderService!!.createLastRecording()
onError() onError()
} }
recorder.onSelectedMicrophoneChange = { microphone -> recorder.onSelectedMicrophoneChange = { microphone ->
@ -134,11 +128,7 @@ class AudioRecorderModel : ViewModel() {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE) context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
} }
fun stopRecording(context: Context, saveAsLastRecording: Boolean = true) { fun stopRecording(context: Context) {
if (saveAsLastRecording) {
lastRecording = recorderService!!.createLastRecording()
}
runCatching { runCatching {
context.unbindService(connection) context.unbindService(connection)
} }

View File

@ -36,8 +36,8 @@ 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.db.RecordingInformation
import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.helpers.AudioRecorderExporter
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
@ -45,40 +45,80 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AudioRecorder( fun AudioRecorderScreen(
navController: NavController, navController: NavController,
audioRecorder: AudioRecorderModel, audioRecorder: AudioRecorderModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val dataStore = context.dataStore
val settings = rememberSettings() val settings = rememberSettings()
val saveFile = rememberFileSaverDialog(settings.audioRecorderSettings.getMimeType())
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val saveFile = rememberFileSaverDialog(
settings.audioRecorderSettings.getMimeType()
) {
if (settings.audioRecorderSettings.deleteRecordingsImmediately) {
AudioRecorderExporter.clearAllRecordings(context)
}
if (!AudioRecorderExporter.hasRecordingsAvailable(context)) {
scope.launch {
dataStore.updateData {
it.setLastRecording(null)
}
}
}
}
var isProcessingAudio by remember { mutableStateOf(false) } var isProcessingAudio by remember { mutableStateOf(false) }
var showRecorderError by remember { mutableStateOf(false) } var showRecorderError by remember { mutableStateOf(false) }
DisposableEffect(Unit) { fun saveAsLastRecording() {
audioRecorder.onRecordingSave = { if (!settings.audioRecorderSettings.deleteRecordingsImmediately) {
scope.launch { scope.launch {
isProcessingAudio = true dataStore.updateData {
it.setLastRecording(
// Give the user some time to see the processing dialog audioRecorder.recorderService!!.getRecordingInformation()
delay(100) )
try {
val file = audioRecorder.lastRecording!!.concatenateFiles()
saveFile(file, file.name)
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {
isProcessingAudio = false
} }
} }
} }
}
fun saveRecording() {
scope.launch {
isProcessingAudio = true
// Give the user some time to see the processing dialog
delay(100)
try {
val file = AudioRecorderExporter(
audioRecorder.recorderService?.getRecordingInformation()
?: settings.lastRecording
?: throw Exception("No recording information available"),
).concatenateFiles()
saveFile(file, file.name)
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {
isProcessingAudio = false
}
}
}
DisposableEffect(key1 = audioRecorder, key2 = settings) {
audioRecorder.onRecordingSave = onRecordingSave@{
saveAsLastRecording()
saveRecording()
}
audioRecorder.onError = { audioRecorder.onError = {
// No need to save last recording as it's done automatically on error saveAsLastRecording()
audioRecorder.stopRecording(context, saveAsLastRecording = false)
audioRecorder.stopRecording(context)
showRecorderError = true showRecorderError = true
} }
@ -141,7 +181,9 @@ fun AudioRecorder(
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
audioRecorder.onRecordingSave() showRecorderError = false
saveRecording()
}, },
colors = ButtonDefaults.textButtonColors(), colors = ButtonDefaults.textButtonColors(),
) { ) {
@ -181,7 +223,10 @@ fun AudioRecorder(
if (audioRecorder.isInRecording) if (audioRecorder.isInRecording)
RecordingStatus(audioRecorder = audioRecorder) RecordingStatus(audioRecorder = audioRecorder)
else else
StartRecording(audioRecorder = audioRecorder, appSettings = appSettings) StartRecording(
audioRecorder = audioRecorder, appSettings = appSettings,
onSaveLastRecording = ::saveRecording,
)
} }
} }
} }

View File

@ -42,6 +42,7 @@ import app.myzel394.alibi.ui.SUPPORTS_DARK_MODE_NATIVELY
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.AboutTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.AboutTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.CustomNotificationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.CustomNotificationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.DeleteRecordingsImmediatelyTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ImportExport import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ImportExport
@ -148,6 +149,7 @@ fun SettingsScreen(
IntervalDurationTile() IntervalDurationTile()
ForceExactMaxDurationTile() ForceExactMaxDurationTile()
InAppLanguagePicker() InAppLanguagePicker()
DeleteRecordingsImmediatelyTile()
CustomNotificationTile(navController = navController) CustomNotificationTile(navController = navController)
AboutTile(navController = navController) AboutTile(navController = navController)
AnimatedVisibility(visible = settings.showAdvancedSettings) { AnimatedVisibility(visible = settings.showAdvancedSettings) {

View File

@ -12,7 +12,10 @@ import androidx.compose.ui.platform.LocalContext
import java.io.File import java.io.File
@Composable @Composable
fun rememberFileSaverDialog(mimeType: String): ((File, String) -> Unit) { fun rememberFileSaverDialog(
mimeType: String,
callback: (Uri?) -> Unit = {},
): ((File, String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
var file = remember { mutableStateOf<File?>(null) } var file = remember { mutableStateOf<File?>(null) }
@ -28,6 +31,8 @@ fun rememberFileSaverDialog(mimeType: String): ((File, String) -> Unit) {
} }
file.value = null file.value = null
callback(it)
} }
return { it, name -> return { it, name ->

View File

@ -107,4 +107,6 @@
<string name="ui_about_gpg_key_hint">You can copy my GPG key here. This key only exists once and I can use it to prove to you that I\'m really who I am. Please save it now so that you can verify my signature later.</string> <string name="ui_about_gpg_key_hint">You can copy my GPG key here. This key only exists once and I can use it to prove to you that I\'m really who I am. Please save it now so that you can verify my signature later.</string>
<string name="ui_about_gpg_key_copy">Copy GPG Key</string> <string name="ui_about_gpg_key_copy">Copy GPG Key</string>
<string name="ui_about_contribute_donation_githubSponsors">Become a GitHub Sponsor</string> <string name="ui_about_contribute_donation_githubSponsors">Become a GitHub Sponsor</string>
<string name="ui_settings_option_deleteRecordingsImmediately_title">Delete Recordings Immediately</string>
<string name="ui_settings_option_deleteRecordingsImmediately_description">If enabled, Alibi will immediately delete recordings after you have saved the file.</string>
</resources> </resources>