mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
Merge pull request #50 from Myzel394/add-custom-location
Add custom location
This commit is contained in:
commit
85c9009259
@ -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'
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
215
app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt
Normal file
215
app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt
Normal 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))!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
) {
|
||||
|
@ -107,8 +107,7 @@ fun RecordingStatus(
|
||||
DeleteButton(
|
||||
onDelete = {
|
||||
audioRecorder.stopRecording(context)
|
||||
|
||||
AudioRecorderExporter.clearAllRecordings(context)
|
||||
audioRecorder.batchesFolder!!.deleteRecordings();
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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"
|
||||
|
||||
|
@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user