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.material3:material3'
implementation "androidx.compose.material:material-icons-extended:1.5.1" implementation "androidx.compose.material:material-icons-extended:1.5.1"
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.documentfile:documentfile:1.0.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 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:duration:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0' implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:input: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 package app.myzel394.alibi.db
import android.content.Context
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import app.myzel394.alibi.R 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.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -74,10 +77,9 @@ data class RecordingInformation(
val maxDuration: Long, val maxDuration: Long,
val intervalDuration: Long, val intervalDuration: Long,
val fileExtension: String, val fileExtension: String,
val forceExactMaxDuration: Boolean,
) { ) {
val hasRecordingsAvailable fun hasRecordingsAvailable(context: Context): Boolean =
get() = File(folderPath).listFiles()?.isNotEmpty() ?: false BatchesFolder.importFromFolder(folderPath, context).hasRecordingsAvailable()
} }
@Serializable @Serializable
@ -86,7 +88,6 @@ data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L, val maxDuration: Long = 30 * 60 * 1000L,
// 60 seconds // 60 seconds
val intervalDuration: Long = 60 * 1000L, val intervalDuration: Long = 60 * 1000L,
val forceExactMaxDuration: Boolean = true,
// 320 Kbps // 320 Kbps
val bitRate: Int = 320000, val bitRate: Int = 320000,
val samplingRate: Int? = null, val samplingRate: Int? = null,
@ -94,6 +95,7 @@ data class AudioRecorderSettings(
val encoder: Int? = null, val encoder: Int? = null,
val showAllMicrophones: Boolean = false, val showAllMicrophones: Boolean = false,
val deleteRecordingsImmediately: Boolean = false, val deleteRecordingsImmediately: Boolean = false,
val saveFolder: String? = null,
) { ) {
fun getOutputFormat(): Int { fun getOutputFormat(): Int {
if (outputFormat != null) { if (outputFormat != null) {
@ -161,6 +163,24 @@ data class AudioRecorderSettings(
else else
MediaRecorder.AudioEncoder.AMR_NB 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 { fun setIntervalDuration(duration: Long): AudioRecorderSettings {
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) { if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
throw Exception("Interval duration must be between 10 seconds and 1 hour") throw Exception("Interval duration must be between 10 seconds and 1 hour")
@ -217,10 +237,6 @@ data class AudioRecorderSettings(
return copy(maxDuration = duration) return copy(maxDuration = duration)
} }
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
return copy(forceExactMaxDuration = forceExactMaxDuration)
}
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings { fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
return copy(showAllMicrophones = showAllMicrophones) return copy(showAllMicrophones = showAllMicrophones)
} }
@ -229,6 +245,10 @@ data class AudioRecorderSettings(
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately) return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
} }
fun setSaveFolder(saveFolder: String?): AudioRecorderSettings {
return copy(saveFolder = saveFolder)
}
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

@ -1,10 +1,14 @@
package app.myzel394.alibi.helpers package app.myzel394.alibi.helpers
import android.content.Context import android.content.Context
import android.net.Uri
import android.system.Os
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME
import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode import com.arthenica.ffmpegkit.ReturnCode
import java.io.File import java.io.File
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -12,65 +16,31 @@ import java.time.format.DateTimeFormatter
data class AudioRecorderExporter( data class AudioRecorderExporter(
val recording: RecordingInformation, val recording: RecordingInformation,
) { ) {
val filePaths: List<File> suspend fun concatenateFiles(
get() = batchesFolder: BatchesFolder,
File(recording.folderPath).listFiles()?.filter { outputFilePath: String,
val name = it.nameWithoutExtension forceConcatenation: Boolean = false,
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 filePaths = batchesFolder.getBatchesForFFmpeg()
val rawFile =
File("${recording.folderPath}/${outputFile.nameWithoutExtension}-raw.${recording.fileExtension}")
outputFile.renameTo(rawFile)
val command = "-sseof ${recording.maxDuration / -1000} -i $rawFile -y $outputFile" if (batchesFolder.checkIfOutputAlreadyExists(
recording.recordingStart,
val session = FFmpegKit.execute(command) recording.fileExtension
) && !forceConcatenation
if (!ReturnCode.isSuccess(session.returnCode)) { ) {
Log.d( return
"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 filePathsConcatenated = filePaths.joinToString("|")
val paths = filePaths.joinToString("|") val command =
val fileName = recording.recordingStart "-protocol_whitelist saf,concat,content,file,subfile" +
.format(DateTimeFormatter.ISO_DATE_TIME) " -i 'concat:$filePathsConcatenated' -y" +
.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" + " -acodec copy" +
" -metadata title='$fileName' " +
" -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" + " -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" +
" -metadata batch_count='${filePaths.size}'" + " -metadata batch_count='${filePaths.size}'" +
" -metadata batch_duration='${recording.intervalDuration}'" + " -metadata batch_duration='${recording.intervalDuration}'" +
" -metadata max_duration='${recording.maxDuration}'" + " -metadata max_duration='${recording.maxDuration}'" +
" $outputFile" " $outputFilePath"
val session = FFmpegKit.execute(command) val session = FFmpegKit.execute(command)
@ -87,32 +57,10 @@ data class AudioRecorderExporter(
throw Exception("Failed to concatenate audios") 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 { companion object {
fun getFolder(context: Context) = File(context.filesDir, RECORDER_SUBFOLDER_NAME) 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 package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.media.AudioDeviceCallback import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.MediaRecorder.OnErrorListener import android.media.MediaRecorder.OnErrorListener
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper 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.enums.RecorderState
import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.ui.utils.MicrophoneInfo import app.myzel394.alibi.ui.utils.MicrophoneInfo
import java.lang.IllegalStateException import java.lang.IllegalStateException
import java.util.concurrent.Executor
class AudioRecorderService : IntervalRecorderService() { class AudioRecorderService : IntervalRecorderService() {
var amplitudesAmount = 1000 var amplitudesAmount = 1000
@ -27,9 +27,6 @@ class AudioRecorderService : IntervalRecorderService() {
var onMicrophoneDisconnected: () -> Unit = {} var onMicrophoneDisconnected: () -> Unit = {}
var onMicrophoneReconnected: () -> Unit = {} var onMicrophoneReconnected: () -> Unit = {}
val filePath: String
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() {
if (selectedMicrophone == null) { if (selectedMicrophone == null) {
@ -68,11 +65,26 @@ class AudioRecorderService : IntervalRecorderService() {
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17) // - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
// - DEFAULT: Uses the bottom microphone of the phone (17) // - DEFAULT: Uses the bottom microphone of the phone (17)
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(filePath)
setOutputFormat(settings!!.outputFormat) when (batchesFolder.type) {
setAudioEncoder(settings!!.encoder) BatchesFolder.BatchType.INTERNAL -> {
setAudioEncodingBitRate(settings!!.bitRate) setOutputFile(
setAudioSamplingRate(settings!!.samplingRate) 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 { _, _, _ -> setOnErrorListener(OnErrorListener { _, _, _ ->
onError() onError()
}) })
@ -86,6 +98,7 @@ class AudioRecorderService : IntervalRecorderService() {
it.release() it.release()
} }
clearAudioDevice() clearAudioDevice()
batchesFolder.cleanup()
} }
} }

View File

@ -1,15 +1,19 @@
package app.myzel394.alibi.services package app.myzel394.alibi.services
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
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.RecordingInformation import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.helpers.AudioRecorderExporter import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.helpers.BatchesFolder
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 org.w3c.dom.DocumentFragment
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledExecutorService
@ -19,24 +23,23 @@ 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 = 0L
private set private set
var settings: Settings? = null lateinit var settings: Settings
protected set
private lateinit var cycleTimer: ScheduledExecutorService private lateinit var cycleTimer: ScheduledExecutorService
protected val outputFolder: File var batchesFolder: BatchesFolder = BatchesFolder.viaInternalFolder(this)
get() = AudioRecorderExporter.getFolder(this)
var onCustomOutputFolderNotAccessible: () -> Unit = {}
fun getRecordingInformation(): RecordingInformation = RecordingInformation( fun getRecordingInformation(): RecordingInformation = RecordingInformation(
folderPath = outputFolder.absolutePath, folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart, recordingStart = recordingStart,
maxDuration = settings!!.maxDuration, maxDuration = settings.maxDuration,
fileExtension = settings!!.fileExtension, fileExtension = settings.fileExtension,
intervalDuration = settings!!.intervalDuration, intervalDuration = settings.intervalDuration,
forceExactMaxDuration = settings!!.forceExactMaxDuration,
) )
// Make overrideable // Make overrideable
@ -52,7 +55,7 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
startNewCycle() startNewCycle()
}, },
0, 0,
settings!!.intervalDuration, settings.intervalDuration,
TimeUnit.MILLISECONDS TimeUnit.MILLISECONDS
) )
} }
@ -61,18 +64,16 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
override fun start() { override fun start() {
super.start() super.start()
outputFolder.mkdirs() batchesFolder.initFolders()
if (!batchesFolder.checkIfFolderIsAccessible()) {
scope.launch { batchesFolder =
dataStore.data.collectLatest { preferenceSettings -> BatchesFolder.viaInternalFolder(this@IntervalRecorderService)
if (settings == null) { batchesFolder.initFolders()
settings = Settings.from(preferenceSettings.audioRecorderSettings) onCustomOutputFolderNotAccessible()
}
createTimer() createTimer()
} }
}
}
}
override fun pause() { override fun pause() {
cycleTimer.shutdown() cycleTimer.shutdown()
@ -90,27 +91,25 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
cycleTimer.shutdown() cycleTimer.shutdown()
} }
fun clearAllRecordings() {
batchesFolder.deleteRecordings()
}
private fun deleteOldRecordings() { private fun deleteOldRecordings() {
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
val earliestCounter = counter - timeMultiplier val earliestCounter = counter - timeMultiplier
outputFolder.listFiles()?.forEach { file -> batchesFolder.deleteOldRecordings(earliestCounter)
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
if (fileCounter < earliestCounter) {
file.delete()
}
}
} }
data class Settings( data class Settings(
val maxDuration: Long, val maxDuration: Long,
val intervalDuration: Long, val intervalDuration: Long,
val forceExactMaxDuration: Boolean,
val bitRate: Int, val bitRate: Int,
val samplingRate: Int, val samplingRate: Int,
val outputFormat: Int, val outputFormat: Int,
val encoder: Int, val encoder: Int,
val folder: String? = null,
) { ) {
val fileExtension: String val fileExtension: String
get() = when (outputFormat) { get() = when (outputFormat) {
@ -134,7 +133,6 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
outputFormat = audioRecorderSettings.getOutputFormat(), outputFormat = audioRecorderSettings.getOutputFormat(),
encoder = audioRecorderSettings.getEncoder(), encoder = audioRecorderSettings.getEncoder(),
maxDuration = audioRecorderSettings.maxDuration, 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.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
import app.myzel394.alibi.ui.effects.rememberForceUpdateOnLifeCycleChange
import app.myzel394.alibi.ui.models.AudioRecorderModel 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.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
@ -72,19 +64,8 @@ fun StartRecording(
LaunchedEffect(startRecording) { LaunchedEffect(startRecording) {
if (startRecording) { if (startRecording) {
startRecording = false startRecording = false
audioRecorder.notificationDetails = appSettings.notificationSettings.let {
if (it == null)
null
else
RecorderNotificationHelper.NotificationDetails.fromNotificationSettings(
context,
it
)
}
AudioRecorderExporter.clearAllRecordings(context) audioRecorder.startRecording(context, appSettings)
audioRecorder.startRecording(context)
} }
} }
@ -149,9 +130,13 @@ fun StartRecording(
.fillMaxWidth(), .fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (appSettings.lastRecording?.hasRecordingsAvailable == true) {
val forceUpdate = rememberForceUpdateOnLifeCycleChange()
if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.then(forceUpdate),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.Bottom,
) { ) {

View File

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

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 package app.myzel394.alibi.ui.effects
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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 import kotlinx.coroutines.delay
@Composable @Composable
@ -21,3 +30,38 @@ fun rememberForceUpdate(
return tickTack 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.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.Uri
import android.os.IBinder import android.os.IBinder
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.enums.RecorderState 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.AudioRecorderService
import app.myzel394.alibi.services.IntervalRecorderService
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.serialization.json.Json import kotlinx.serialization.json.Json
@ -45,6 +51,9 @@ class AudioRecorderModel : ViewModel() {
var onRecordingSave: () -> Unit = {} var onRecordingSave: () -> Unit = {}
var onError: () -> Unit = {} var onError: () -> Unit = {}
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
var batchesFolder: BatchesFolder? = null
private lateinit var settings: AppSettings
var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED
private set private set
@ -58,7 +67,7 @@ class AudioRecorderModel : ViewModel() {
override fun onServiceConnected(className: ComponentName, service: IBinder) { override fun onServiceConnected(className: ComponentName, service: IBinder) {
recorderService = recorderService =
((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder -> ((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 -> recorder.onStateChange = { state ->
recorderState = state recorderState = state
} }
@ -81,6 +90,11 @@ class AudioRecorderModel : ViewModel() {
recorder.onMicrophoneReconnected = { recorder.onMicrophoneReconnected = {
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
} }
recorder.batchesFolder = batchesFolder ?: recorder.batchesFolder
recorder.settings =
IntervalRecorderService.Settings.from(settings.audioRecorderSettings)
recorder.clearAllRecordings()
}.also { }.also {
// Init UI from the service // Init UI from the service
it.startRecording() it.startRecording()
@ -106,11 +120,33 @@ class AudioRecorderModel : ViewModel() {
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
} }
fun startRecording(context: Context) { fun startRecording(context: Context, settings: AppSettings) {
runCatching { runCatching {
recorderService?.clearAllRecordings()
context.unbindService(connection) 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 { val intent = Intent(context, AudioRecorderService::class.java).apply {
action = "init" action = "init"

View File

@ -1,5 +1,7 @@
package app.myzel394.alibi.ui.screens package app.myzel394.alibi.ui.screens
import android.content.Intent
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -18,13 +20,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -36,8 +45,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.RecordingInformation
import app.myzel394.alibi.helpers.AudioRecorderExporter import app.myzel394.alibi.helpers.AudioRecorderExporter
import app.myzel394.alibi.helpers.BatchesFolder
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
@ -49,6 +58,7 @@ fun AudioRecorderScreen(
navController: NavController, navController: NavController,
audioRecorder: AudioRecorderModel, audioRecorder: AudioRecorderModel,
) { ) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val context = LocalContext.current
val dataStore = context.dataStore val dataStore = context.dataStore
@ -59,10 +69,10 @@ fun AudioRecorderScreen(
settings.audioRecorderSettings.getMimeType() settings.audioRecorderSettings.getMimeType()
) { ) {
if (settings.audioRecorderSettings.deleteRecordingsImmediately) { if (settings.audioRecorderSettings.deleteRecordingsImmediately) {
AudioRecorderExporter.clearAllRecordings(context) audioRecorder.batchesFolder!!.deleteRecordings()
} }
if (!AudioRecorderExporter.hasRecordingsAvailable(context)) { if (!audioRecorder.batchesFolder!!.hasRecordingsAvailable()) {
scope.launch { scope.launch {
dataStore.updateData { dataStore.updateData {
it.setLastRecording(null) 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() { fun saveRecording() {
scope.launch { scope.launch {
isProcessingAudio = true isProcessingAudio = true
@ -94,13 +127,43 @@ fun AudioRecorderScreen(
delay(100) delay(100)
try { try {
val file = AudioRecorderExporter( val recording = audioRecorder.recorderService?.getRecordingInformation()
audioRecorder.recorderService?.getRecordingInformation()
?: settings.lastRecording ?: settings.lastRecording
?: throw Exception("No recording information available"), ?: throw Exception("No recording information available")
).concatenateFiles() 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) { } catch (error: Exception) {
Log.getStackTraceString(error) Log.getStackTraceString(error)
} finally { } finally {
@ -192,6 +255,21 @@ fun AudioRecorderScreen(
} }
) )
Scaffold( 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 = { topBar = {
TopAppBar( TopAppBar(
title = { title = {

View File

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

View File

@ -1,9 +1,12 @@
package app.myzel394.alibi.ui.utils package app.myzel394.alibi.ui.utils
import android.app.Activity
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -56,3 +59,30 @@ fun rememberFileSelectorDialog(
launcher.launch(arrayOf(mimeType)) 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_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_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_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_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_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> <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_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_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_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_name">Recorder</string>
<string name="notificationChannels_recorder_description">Shows the current recording status</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_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_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_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> </resources>