Merge pull request #50 from Myzel394/add-custom-location

Add custom location
This commit is contained in:
Myzel394 2023-11-21 12:02:52 +01:00 committed by GitHub
commit 85c9009259
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 826 additions and 224 deletions

View File

@ -102,6 +102,7 @@ dependencies {
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.material:material-icons-extended:1.5.1"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.documentfile:documentfile:1.0.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
@ -128,4 +129,6 @@ dependencies {
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
implementation 'androidx.activity:activity-ktx:1.8.0'
}

View File

@ -1,8 +1,11 @@
package app.myzel394.alibi.db
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import app.myzel394.alibi.R
import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.helpers.BatchesFolder
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.serialization.Serializable
@ -74,10 +77,9 @@ data class RecordingInformation(
val maxDuration: Long,
val intervalDuration: Long,
val fileExtension: String,
val forceExactMaxDuration: Boolean,
) {
val hasRecordingsAvailable
get() = File(folderPath).listFiles()?.isNotEmpty() ?: false
fun hasRecordingsAvailable(context: Context): Boolean =
BatchesFolder.importFromFolder(folderPath, context).hasRecordingsAvailable()
}
@Serializable
@ -86,7 +88,6 @@ data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L,
// 60 seconds
val intervalDuration: Long = 60 * 1000L,
val forceExactMaxDuration: Boolean = true,
// 320 Kbps
val bitRate: Int = 320000,
val samplingRate: Int? = null,
@ -94,6 +95,7 @@ data class AudioRecorderSettings(
val encoder: Int? = null,
val showAllMicrophones: Boolean = false,
val deleteRecordingsImmediately: Boolean = false,
val saveFolder: String? = null,
) {
fun getOutputFormat(): Int {
if (outputFormat != null) {
@ -161,6 +163,24 @@ data class AudioRecorderSettings(
else
MediaRecorder.AudioEncoder.AMR_NB
fun getSaveFolder(context: Context): File {
val defaultFolder = AudioRecorderExporter.getFolder(context)
if (saveFolder == null) {
return defaultFolder
}
runCatching {
return File(saveFolder!!).apply {
if (!exists()) {
mkdirs()
}
}
}
return defaultFolder
}
fun setIntervalDuration(duration: Long): AudioRecorderSettings {
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
throw Exception("Interval duration must be between 10 seconds and 1 hour")
@ -217,10 +237,6 @@ data class AudioRecorderSettings(
return copy(maxDuration = duration)
}
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
return copy(forceExactMaxDuration = forceExactMaxDuration)
}
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
return copy(showAllMicrophones = showAllMicrophones)
}
@ -229,6 +245,10 @@ data class AudioRecorderSettings(
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
}
fun setSaveFolder(saveFolder: String?): AudioRecorderSettings {
return copy(saveFolder = saveFolder)
}
fun isEncoderCompatible(encoder: Int): Boolean {
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
return true

View File

@ -1,10 +1,14 @@
package app.myzel394.alibi.helpers
import android.content.Context
import android.net.Uri
import android.system.Os
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import java.io.File
import java.time.format.DateTimeFormatter
@ -12,65 +16,31 @@ 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
suspend fun concatenateFiles(
batchesFolder: BatchesFolder,
outputFilePath: String,
forceConcatenation: Boolean = false,
) {
// Move the concatenated file to a temporary file
val rawFile =
File("${recording.folderPath}/${outputFile.nameWithoutExtension}-raw.${recording.fileExtension}")
outputFile.renameTo(rawFile)
val filePaths = batchesFolder.getBatchesForFFmpeg()
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
if (batchesFolder.checkIfOutputAlreadyExists(
recording.recordingStart,
recording.fileExtension
) && !forceConcatenation
) {
return
}
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 filePathsConcatenated = filePaths.joinToString("|")
val command =
"-protocol_whitelist saf,concat,content,file,subfile" +
" -i 'concat:$filePathsConcatenated' -y" +
" -acodec copy" +
" -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" +
" -metadata batch_count='${filePaths.size}'" +
" -metadata batch_duration='${recording.intervalDuration}'" +
" -metadata max_duration='${recording.maxDuration}'" +
" $outputFilePath"
val session = FFmpegKit.execute(command)
@ -87,32 +57,10 @@ data class AudioRecorderExporter(
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

@ -0,0 +1,215 @@
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 -> {
if (customFolder!!.findFile(subfolderName) == null) {
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,
it.uri,
)!!
}
}
}
fun getName(date: LocalDateTime, extension: String): String {
val name = date
.format(DateTimeFormatter.ISO_DATE_TIME)
.toString()
.replace(":", "-")
.replace(".", "_")
return "$name.$extension"
}
fun asInternalGetOutputFile(date: LocalDateTime, extension: String): File {
return File(getInternalFolder(), getName(date, extension))
}
fun asCustomGetOutputFile(
date: LocalDateTime,
extension: String,
): DocumentFile {
return getCustomDefinedFolder().createFile("audio/$extension", getName(date, extension))!!
}
fun getOutputFileForFFmpeg(
date: LocalDateTime,
extension: String,
): String {
return when (type) {
BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath
BatchType.CUSTOM -> FFmpegKitConfig.getSafParameterForWrite(
context,
customFolder!!.createFile(
"audio/${extension}",
getName(date, extension),
)!!.uri
)!!
}
}
fun checkIfOutputAlreadyExists(
date: LocalDateTime,
extension: String
): Boolean {
val name = date
.format(DateTimeFormatter.ISO_DATE_TIME)
.toString()
.replace(":", "-")
.replace(".", "_")
return when (type) {
BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").exists()
BatchType.CUSTOM ->
getCustomDefinedFolder().findFile("${name}.${extension}")?.exists() ?: false
}
}
fun exportFolderForSettings(): String {
return when (type) {
BatchType.INTERNAL -> "_'internal"
BatchType.CUSTOM -> customFolder!!.uri.toString()
}
}
fun deleteRecordings() {
when (type) {
BatchType.INTERNAL -> getInternalFolder().deleteRecursively()
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete()
?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach {
it.delete()
}
}
}
fun hasRecordingsAvailable(): Boolean {
return when (type) {
BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty()
?: false
}
}
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 -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead()
}
}
fun asInternalGetOutputPath(counter: Long, fileExtension: String): String {
return getInternalFolder().absolutePath + "/$counter.$fileExtension"
}
fun asCustomGetFileDescriptor(
counter: Long,
fileExtension: String,
): FileDescriptor {
val file =
getCustomDefinedFolder().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

@ -1,20 +1,20 @@
package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaRecorder
import android.media.MediaRecorder.OnErrorListener
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat.getSystemService
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
import java.util.concurrent.Executor
class AudioRecorderService : IntervalRecorderService() {
var amplitudesAmount = 1000
@ -27,9 +27,6 @@ class AudioRecorderService : IntervalRecorderService() {
var onMicrophoneDisconnected: () -> Unit = {}
var onMicrophoneReconnected: () -> Unit = {}
val filePath: String
get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
/// Tell Android to use the correct bluetooth microphone, if any selected
private fun startAudioDevice() {
if (selectedMicrophone == null) {
@ -68,11 +65,26 @@ class AudioRecorderService : IntervalRecorderService() {
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
// - DEFAULT: Uses the bottom microphone of the phone (17)
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(filePath)
setOutputFormat(settings!!.outputFormat)
setAudioEncoder(settings!!.encoder)
setAudioEncodingBitRate(settings!!.bitRate)
setAudioSamplingRate(settings!!.samplingRate)
when (batchesFolder.type) {
BatchesFolder.BatchType.INTERNAL -> {
setOutputFile(
batchesFolder.asInternalGetOutputPath(counter, settings.fileExtension)
)
}
BatchesFolder.BatchType.CUSTOM -> {
setOutputFile(
batchesFolder.asCustomGetFileDescriptor(counter, settings.fileExtension)
)
}
}
setOutputFormat(settings.outputFormat)
setAudioEncoder(settings.encoder)
setAudioEncodingBitRate(settings.bitRate)
setAudioSamplingRate(settings.samplingRate)
setOnErrorListener(OnErrorListener { _, _, _ ->
onError()
})
@ -86,6 +98,7 @@ class AudioRecorderService : IntervalRecorderService() {
it.release()
}
clearAudioDevice()
batchesFolder.cleanup()
}
}

View File

@ -1,15 +1,19 @@
package app.myzel394.alibi.services
import android.media.MediaRecorder
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
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
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.w3c.dom.DocumentFragment
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
@ -19,24 +23,23 @@ 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
protected set
lateinit var settings: Settings
private lateinit var cycleTimer: ScheduledExecutorService
protected val outputFolder: File
get() = AudioRecorderExporter.getFolder(this)
var batchesFolder: BatchesFolder = BatchesFolder.viaInternalFolder(this)
var onCustomOutputFolderNotAccessible: () -> Unit = {}
fun getRecordingInformation(): RecordingInformation = RecordingInformation(
folderPath = outputFolder.absolutePath,
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings!!.maxDuration,
fileExtension = settings!!.fileExtension,
intervalDuration = settings!!.intervalDuration,
forceExactMaxDuration = settings!!.forceExactMaxDuration,
maxDuration = settings.maxDuration,
fileExtension = settings.fileExtension,
intervalDuration = settings.intervalDuration,
)
// Make overrideable
@ -52,7 +55,7 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
startNewCycle()
},
0,
settings!!.intervalDuration,
settings.intervalDuration,
TimeUnit.MILLISECONDS
)
}
@ -61,17 +64,15 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
override fun start() {
super.start()
outputFolder.mkdirs()
scope.launch {
dataStore.data.collectLatest { preferenceSettings ->
if (settings == null) {
settings = Settings.from(preferenceSettings.audioRecorderSettings)
createTimer()
}
}
batchesFolder.initFolders()
if (!batchesFolder.checkIfFolderIsAccessible()) {
batchesFolder =
BatchesFolder.viaInternalFolder(this@IntervalRecorderService)
batchesFolder.initFolders()
onCustomOutputFolderNotAccessible()
}
createTimer()
}
override fun pause() {
@ -90,27 +91,25 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
cycleTimer.shutdown()
}
fun clearAllRecordings() {
batchesFolder.deleteRecordings()
}
private fun deleteOldRecordings() {
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
val earliestCounter = counter - timeMultiplier
outputFolder.listFiles()?.forEach { file ->
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
if (fileCounter < earliestCounter) {
file.delete()
}
}
batchesFolder.deleteOldRecordings(earliestCounter)
}
data class Settings(
val maxDuration: Long,
val intervalDuration: Long,
val forceExactMaxDuration: Boolean,
val bitRate: Int,
val samplingRate: Int,
val outputFormat: Int,
val encoder: Int,
val folder: String? = null,
) {
val fileExtension: String
get() = when (outputFormat) {
@ -134,7 +133,6 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
outputFormat = audioRecorderSettings.getOutputFormat(),
encoder = audioRecorderSettings.getEncoder(),
maxDuration = audioRecorderSettings.maxDuration,
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
)
}
}

View File

@ -20,13 +20,11 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -38,19 +36,13 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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
import app.myzel394.alibi.ui.effects.rememberForceUpdateOnLifeCycleChange
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@ -72,19 +64,8 @@ fun StartRecording(
LaunchedEffect(startRecording) {
if (startRecording) {
startRecording = false
audioRecorder.notificationDetails = appSettings.notificationSettings.let {
if (it == null)
null
else
RecorderNotificationHelper.NotificationDetails.fromNotificationSettings(
context,
it
)
}
AudioRecorderExporter.clearAllRecordings(context)
audioRecorder.startRecording(context)
audioRecorder.startRecording(context, appSettings)
}
}
@ -149,9 +130,13 @@ fun StartRecording(
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
if (appSettings.lastRecording?.hasRecordingsAvailable == true) {
val forceUpdate = rememberForceUpdateOnLifeCycleChange()
if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) {
Column(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.then(forceUpdate),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {

View File

@ -107,8 +107,7 @@ fun RecordingStatus(
DeleteButton(
onDelete = {
audioRecorder.stopRecording(context)
AudioRecorderExporter.clearAllRecordings(context)
audioRecorder.batchesFolder!!.deleteRecordings();
}
)
}

View File

@ -0,0 +1,48 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun FolderBreadcrumbs(
modifier: Modifier = Modifier,
textStyle: TextStyle? = null,
folders: Iterable<String>,
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
folders.forEachIndexed { index, folder ->
if (index != 0) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
)
}
Text(
text = folder,
modifier = Modifier
.then(modifier),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = textStyle ?: MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
}
}
}

View File

@ -1,54 +0,0 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GraphicEq
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 com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import kotlinx.coroutines.launch
@Composable
fun ForceExactMaxDurationTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
fun updateValue(forceExactMaxDuration: Boolean) {
scope.launch {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
)
}
}
}
SettingsTile(
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
leading = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.audioRecorderSettings.forceExactMaxDuration,
onCheckedChange = ::updateValue,
)
},
)
}

View File

@ -0,0 +1,231 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import android.content.Intent
import android.net.Uri
import android.text.TextUtils.split
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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 app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog
import kotlinx.coroutines.launch
import java.net.URLDecoder
@Composable
fun SaveFolderTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val dataStore = context.dataStore
fun updateValue(path: String?) {
if (settings.audioRecorderSettings.saveFolder != null) {
runCatching {
context.contentResolver.releasePersistableUriPermission(
Uri.parse(settings.audioRecorderSettings.saveFolder),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
}
scope.launch {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setSaveFolder(path)
)
}
}
}
val selectFolder = rememberFolderSelectorDialog { folder ->
if (folder == null) {
return@rememberFolderSelectorDialog
}
context.contentResolver.takePersistableUriPermission(
folder,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
updateValue(folder.toString())
}
var showWarning by remember { mutableStateOf(false) }
if (showWarning) {
val title = stringResource(R.string.ui_settings_option_saveFolder_warning_title)
val text = stringResource(R.string.ui_settings_option_saveFolder_warning_text)
AlertDialog(
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
)
},
onDismissRequest = {
showWarning = false
},
title = {
Text(text = title)
},
text = {
Text(text = text)
},
confirmButton = {
Button(
onClick = {
showWarning = false
selectFolder()
},
) {
Text(
text = stringResource(R.string.ui_settings_option_saveFolder_warning_action_confirm),
)
}
},
dismissButton = {
Button(
onClick = {
showWarning = false
},
colors = ButtonDefaults.textButtonColors(),
) {
Icon(
Icons.Default.Cancel,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.dialog_close_cancel_label))
}
}
)
}
SettingsTile(
title = stringResource(R.string.ui_settings_option_saveFolder_title),
description = stringResource(R.string.ui_settings_option_saveFolder_explanation),
leading = {
Icon(
Icons.Default.AudioFile,
contentDescription = null,
)
},
trailing = {
Button(
onClick = {
showWarning = true
},
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = MaterialTheme.shapes.medium,
) {
Icon(
Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(
modifier = Modifier.size(ButtonDefaults.IconSpacing)
)
Text(
text = stringResource(R.string.ui_settings_option_saveFolder_action_select_label),
)
}
},
extra = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (settings.audioRecorderSettings.saveFolder != null) {
Text(
text = stringResource(
R.string.form_value_selected,
splitPath(settings.audioRecorderSettings.saveFolder).joinToString(" > ")
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Button(
colors = ButtonDefaults.filledTonalButtonColors(),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
onClick = {
updateValue(null)
}
) {
Icon(
Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(
modifier = Modifier.size(ButtonDefaults.IconSpacing)
)
Text(
text = stringResource(R.string.ui_settings_option_saveFolder_action_default_label),
)
}
} else {
Text(
text = stringResource(
R.string.form_value_selected,
stringResource(R.string.ui_settings_option_saveFolder_defaultValue)
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
)
}
fun splitPath(path: String): List<String> {
return try {
URLDecoder
.decode(path, "UTF-8")
.split(":", limit = 3)[2]
.split("/")
} catch (e: Exception) {
listOf(path)
}
}

View File

@ -1,11 +1,20 @@
package app.myzel394.alibi.ui.effects
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.delay
@Composable
@ -20,4 +29,39 @@ fun rememberForceUpdate(
}
return tickTack
}
@Composable
fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) {
val eventHandler = rememberUpdatedState(onEvent)
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner.value) {
val lifecycle = lifecycleOwner.value.lifecycle
val observer = LifecycleEventObserver { owner, event ->
eventHandler.value(owner, event)
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
}
}
}
@Composable
fun rememberForceUpdateOnLifeCycleChange(
events: Array<Lifecycle.Event> = arrayOf(
Lifecycle.Event.ON_RESUME
),
): Modifier {
var tickTack by rememberSaveable { mutableStateOf(1f) }
OnLifecycleEvent { owner, event ->
if (events.contains(event)) {
tickTack = if (tickTack == 1f) 0.99f else 1f
}
}
return Modifier.alpha(tickTack)
}

View File

@ -4,15 +4,21 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.IBinder
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import app.myzel394.alibi.db.AppSettings
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.IntervalRecorderService
import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.services.RecorderService
import kotlinx.serialization.json.Json
@ -45,6 +51,9 @@ class AudioRecorderModel : ViewModel() {
var onRecordingSave: () -> Unit = {}
var onError: () -> Unit = {}
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
var batchesFolder: BatchesFolder? = null
private lateinit var settings: AppSettings
var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED
private set
@ -58,7 +67,7 @@ class AudioRecorderModel : ViewModel() {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
recorderService =
((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder ->
// Update UI when the service changes
// Init variables from us to the service
recorder.onStateChange = { state ->
recorderState = state
}
@ -81,6 +90,11 @@ class AudioRecorderModel : ViewModel() {
recorder.onMicrophoneReconnected = {
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
}
recorder.batchesFolder = batchesFolder ?: recorder.batchesFolder
recorder.settings =
IntervalRecorderService.Settings.from(settings.audioRecorderSettings)
recorder.clearAllRecordings()
}.also {
// Init UI from the service
it.startRecording()
@ -106,11 +120,33 @@ class AudioRecorderModel : ViewModel() {
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
}
fun startRecording(context: Context) {
fun startRecording(context: Context, settings: AppSettings) {
runCatching {
recorderService?.clearAllRecordings()
context.unbindService(connection)
}
notificationDetails = settings.notificationSettings.let {
if (it == null)
null
else
RecorderNotificationHelper.NotificationDetails.fromNotificationSettings(
context,
it
)
}
batchesFolder = if (settings.audioRecorderSettings.saveFolder == null)
BatchesFolder.viaInternalFolder(context)
else
BatchesFolder.viaCustomFolder(
context,
DocumentFile.fromTreeUri(
context,
Uri.parse(settings.audioRecorderSettings.saveFolder)
)!!
)
this.settings = settings
val intent = Intent(context, AudioRecorderService::class.java).apply {
action = "init"

View File

@ -1,5 +1,7 @@
package app.myzel394.alibi.ui.screens
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -18,13 +20,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -36,8 +45,8 @@ 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.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
@ -49,6 +58,7 @@ fun AudioRecorderScreen(
navController: NavController,
audioRecorder: AudioRecorderModel,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val dataStore = context.dataStore
@ -59,10 +69,10 @@ fun AudioRecorderScreen(
settings.audioRecorderSettings.getMimeType()
) {
if (settings.audioRecorderSettings.deleteRecordingsImmediately) {
AudioRecorderExporter.clearAllRecordings(context)
audioRecorder.batchesFolder!!.deleteRecordings()
}
if (!AudioRecorderExporter.hasRecordingsAvailable(context)) {
if (!audioRecorder.batchesFolder!!.hasRecordingsAvailable()) {
scope.launch {
dataStore.updateData {
it.setLastRecording(null)
@ -86,6 +96,29 @@ fun AudioRecorderScreen(
}
}
val successMessage = stringResource(R.string.ui_audioRecorder_action_save_success)
val openMessage = stringResource(R.string.ui_audioRecorder_action_save_openFolder)
fun openFolder(uri: Uri) {
val intent = Intent(Intent.ACTION_VIEW, uri)
context.startActivity(intent)
}
fun showSnackbar(uri: Uri) {
scope.launch {
val result = snackbarHostState.showSnackbar(
message = successMessage,
actionLabel = openMessage,
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
openFolder(uri)
}
}
}
fun saveRecording() {
scope.launch {
isProcessingAudio = true
@ -94,13 +127,43 @@ fun AudioRecorderScreen(
delay(100)
try {
val file = AudioRecorderExporter(
audioRecorder.recorderService?.getRecordingInformation()
?: settings.lastRecording
?: throw Exception("No recording information available"),
).concatenateFiles()
val recording = audioRecorder.recorderService?.getRecordingInformation()
?: settings.lastRecording
?: throw Exception("No recording information available")
val batchesFolder = BatchesFolder.importFromFolder(recording.folderPath, context)
val outputFile = batchesFolder.getOutputFileForFFmpeg(
recording.recordingStart,
recording.fileExtension
)
saveFile(file, file.name)
AudioRecorderExporter(recording).concatenateFiles(
batchesFolder,
outputFile,
)
val name = batchesFolder.getName(
recording.recordingStart,
recording.fileExtension,
)
when (batchesFolder.type) {
BatchesFolder.BatchType.INTERNAL -> {
saveFile(
batchesFolder.asInternalGetOutputFile(
recording.recordingStart,
recording.fileExtension,
), name
)
}
BatchesFolder.BatchType.CUSTOM -> {
showSnackbar(batchesFolder.customFolder!!.uri)
if (settings.audioRecorderSettings.deleteRecordingsImmediately) {
batchesFolder.deleteRecordings()
}
}
}
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {
@ -192,6 +255,21 @@ fun AudioRecorderScreen(
}
)
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
snackbar = {
Snackbar(
snackbarData = it,
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
dismissActionContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
)
},
topBar = {
TopAppBar(
title = {
@ -229,4 +307,4 @@ fun AudioRecorderScreen(
)
}
}
}
}

View File

@ -37,20 +37,19 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
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.BitrateTile
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.ForceExactMaxDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ImportExport
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.InAppLanguagePicker
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.IntervalDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.OutputFormatTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SaveFolderTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ShowAllMicrophonesTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ThemeSelector
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
@ -145,7 +144,6 @@ fun SettingsScreen(
)
MaxDurationTile(settings = settings)
IntervalDurationTile(settings = settings)
ForceExactMaxDurationTile(settings = settings)
InAppLanguagePicker()
DeleteRecordingsImmediatelyTile(settings = settings)
CustomNotificationTile(navController = navController, settings = settings)
@ -161,6 +159,7 @@ fun SettingsScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp)
)
SaveFolderTile(settings = settings)
ShowAllMicrophonesTile(settings = settings)
BitrateTile(settings = settings)
SamplingRateTile(settings = settings)

View File

@ -1,9 +1,12 @@
package app.myzel394.alibi.ui.utils
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -56,3 +59,30 @@ fun rememberFileSelectorDialog(
launcher.launch(arrayOf(mimeType))
}
}
@Composable
fun rememberFolderSelectorDialog(
callback: (Uri?) -> Unit
): (() -> Unit) {
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data
callback(uri)
}
}
return {
launcher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
})
}
}

View File

@ -39,8 +39,6 @@
<string name="ui_settings_option_maxDuration_description">Set the maximum duration of the recording</string>
<string name="ui_settings_option_intervalDuration_title">Batch duration</string>
<string name="ui_settings_option_intervalDuration_description">Record a single batch for this duration. Alibi records multiple batches and deletes the oldest one. When exporting the audio, all batches will be merged together</string>
<string name="ui_settings_option_forceExactDuration_title">Force exact duration</string>
<string name="ui_settings_option_forceExactDuration_description">Force to strip the output file to be the exactly specified duration. If this is disabled, the output file may be a bit longer due to batches of audio samples being encoded together.</string>
<string name="ui_settings_option_bitrate_title">Bitrate</string>
<string name="ui_settings_option_bitrate_description">A higher bitrate means better quality but also larger file size</string>
<string name="ui_settings_option_bitrate_explanation">Set the bitrate for the audio recording</string>

View File

@ -9,6 +9,7 @@
<string name="form_error_type_notNumber">Please enter a valid number</string>
<string name="form_error_value_notInRange">Please enter a number between <xliff:g name="min">%s</xliff:g> and <xliff:g name="max">%s</xliff:g></string>
<string name="form_error_value_mustBeGreaterThan">Please enter a number greater than <xliff:g name="min">%s</xliff:g></string>
<string name="form_value_selected">Selected: %s</string>
<string name="notificationChannels_recorder_name">Recorder</string>
<string name="notificationChannels_recorder_description">Shows the current recording status</string>
@ -109,4 +110,14 @@
<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>
<string name="ui_settings_option_saveFolder_title">Batches folder</string>
<string name="ui_settings_option_saveFolder_explanation">Where Alibi should store the temporary batches of your recordings.</string>
<string name="ui_settings_option_saveFolder_action_select_label">Select</string>
<string name="ui_settings_option_saveFolder_defaultValue">Encrypted Internal Storage</string>
<string name="ui_settings_option_saveFolder_warning_title">Are you sure you want to change the folder?</string>
<string name="ui_settings_option_saveFolder_warning_text">By default, Alibi will save the recording batches into its private, encrypted file storage. You can change this and specify an external, unencrypted folder. This will allow you to access the batches manually. ONLY DO THIS IF YOU KNOW WHAT YOU ARE DOING!</string>
<string name="ui_settings_option_saveFolder_warning_action_confirm">Yes, change folder</string>
<string name="ui_settings_option_saveFolder_action_default_label">Use private, encrypted storage</string>
<string name="ui_audioRecorder_action_save_success">Recording has been saved successfully!</string>
<string name="ui_audioRecorder_action_save_openFolder">Open</string>
</resources>