mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-19 07:15:25 +02:00
refactor: Improving architecture (current stand)
This commit is contained in:
parent
d5100501f7
commit
bc42f35eba
@ -7,10 +7,13 @@ import android.os.Build
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
|
const val RECORDER_CHANNEL_ID = "recorder"
|
||||||
|
const val RECORDER_CHANNEL_NOTIFICATION_ID = 1
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
fun createChannels(context: Context) {
|
fun createChannels(context: Context) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
"recorder",
|
RECORDER_CHANNEL_ID,
|
||||||
context.resources.getString(R.string.notificationChannels_recorder_name),
|
context.resources.getString(R.string.notificationChannels_recorder_name),
|
||||||
android.app.NotificationManager.IMPORTANCE_LOW,
|
android.app.NotificationManager.IMPORTANCE_LOW,
|
||||||
)
|
)
|
||||||
@ -19,4 +22,5 @@ object NotificationHelper {
|
|||||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -2,7 +2,13 @@ package app.myzel394.alibi.db
|
|||||||
|
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
@ -27,6 +33,103 @@ data class AppSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LastRecording(
|
||||||
|
val folderPath: String,
|
||||||
|
@Serializable(with = LocalDateTimeSerializer::class)
|
||||||
|
val recordingStart: LocalDateTime,
|
||||||
|
val maxDuration: Long,
|
||||||
|
val intervalDuration: Long,
|
||||||
|
val fileExtension: String,
|
||||||
|
val forceExactMaxDuration: Boolean,
|
||||||
|
) {
|
||||||
|
val fileFolder: File
|
||||||
|
get() = File(folderPath)
|
||||||
|
|
||||||
|
val filePaths: List<File>
|
||||||
|
get() =
|
||||||
|
File(folderPath).listFiles()?.filter {
|
||||||
|
val name = it.nameWithoutExtension
|
||||||
|
|
||||||
|
name.toIntOrNull() != null
|
||||||
|
}?.toList() ?: emptyList()
|
||||||
|
|
||||||
|
val hasRecordingAvailable: Boolean
|
||||||
|
get() = filePaths.isNotEmpty()
|
||||||
|
|
||||||
|
private fun stripConcatenatedFileToExactDuration(
|
||||||
|
outputFile: File
|
||||||
|
) {
|
||||||
|
// Move the concatenated file to a temporary file
|
||||||
|
val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
|
||||||
|
outputFile.renameTo(rawFile)
|
||||||
|
|
||||||
|
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
|
||||||
|
|
||||||
|
val session = FFmpegKit.execute(command)
|
||||||
|
|
||||||
|
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
Log.d(
|
||||||
|
"Audio Concatenation",
|
||||||
|
String.format(
|
||||||
|
"Command failed with state %s and rc %s.%s",
|
||||||
|
session.getState(),
|
||||||
|
session.getReturnCode(),
|
||||||
|
session.getFailStackTrace()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
throw Exception("Failed to strip concatenated audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||||
|
val paths = filePaths.joinToString("|")
|
||||||
|
val fileName = recordingStart
|
||||||
|
.format(ISO_DATE_TIME)
|
||||||
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "_")
|
||||||
|
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
|
||||||
|
|
||||||
|
if (outputFile.exists() && !forceConcatenation) {
|
||||||
|
return outputFile
|
||||||
|
}
|
||||||
|
|
||||||
|
val command = "-i 'concat:$paths' -y" +
|
||||||
|
" -acodec copy" +
|
||||||
|
" -metadata title='$fileName' " +
|
||||||
|
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
|
||||||
|
" -metadata batch_count='${filePaths.size}'" +
|
||||||
|
" -metadata batch_duration='${intervalDuration}'" +
|
||||||
|
" -metadata max_duration='${maxDuration}'" +
|
||||||
|
" $outputFile"
|
||||||
|
|
||||||
|
val session = FFmpegKit.execute(command)
|
||||||
|
|
||||||
|
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||||
|
Log.d(
|
||||||
|
"Audio Concatenation",
|
||||||
|
String.format(
|
||||||
|
"Command failed with state %s and rc %s.%s",
|
||||||
|
session.getState(),
|
||||||
|
session.getReturnCode(),
|
||||||
|
session.getFailStackTrace()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
throw Exception("Failed to concatenate audios")
|
||||||
|
}
|
||||||
|
|
||||||
|
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
|
||||||
|
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
||||||
|
stripConcatenatedFileToExactDuration(outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AudioRecorderSettings(
|
data class AudioRecorderSettings(
|
||||||
val maxDuration: Long = 30 * 60 * 1000L,
|
val maxDuration: Long = 30 * 60 * 1000L,
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
package app.myzel394.alibi.db
|
package app.myzel394.alibi.db
|
||||||
|
|
||||||
import androidx.datastore.core.Serializer
|
import androidx.datastore.core.Serializer
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class AppSettingsSerializer: Serializer<AppSettings> {
|
class AppSettingsSerializer: Serializer<AppSettings> {
|
||||||
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
||||||
@ -31,3 +38,15 @@ class AppSettingsSerializer: Serializer<AppSettings> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||||
|
return LocalDateTime.parse(decoder.decodeString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: LocalDateTime) {
|
||||||
|
encoder.encodeString(value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
package app.myzel394.alibi.enums
|
||||||
|
|
||||||
|
enum class RecorderState {
|
||||||
|
IDLE,
|
||||||
|
RECORDING,
|
||||||
|
PAUSED,
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
class AudioRecorderService: IntervalRecorderService() {
|
||||||
|
var recorder: MediaRecorder? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
val filePath: String
|
||||||
|
get() = "$folder/$counter.${settings.fileExtension}"
|
||||||
|
|
||||||
|
private fun createRecorder(): MediaRecorder {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(this)
|
||||||
|
} else {
|
||||||
|
MediaRecorder()
|
||||||
|
}.apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFile(filePath)
|
||||||
|
setOutputFormat(settings.outputFormat)
|
||||||
|
setAudioEncoder(settings.encoder)
|
||||||
|
setAudioEncodingBitRate(settings.bitRate)
|
||||||
|
setAudioSamplingRate(settings.samplingRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetRecorder() {
|
||||||
|
runCatching {
|
||||||
|
recorder?.let {
|
||||||
|
it.stop()
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startNewCycle() {
|
||||||
|
super.startNewCycle()
|
||||||
|
|
||||||
|
val newRecorder = createRecorder().also {
|
||||||
|
it.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRecorder()
|
||||||
|
|
||||||
|
newRecorder.start()
|
||||||
|
recorder = newRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAmplitudeAmount(): Int {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAmplitude(): Int = recorder?.maxAmplitude ?: 0
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
abstract class ExtraRecorderInformationService: RecorderService() {
|
||||||
|
abstract fun getAmplitudeAmount(): Int
|
||||||
|
abstract fun getAmplitude(): Int
|
||||||
|
|
||||||
|
private var recordingTime = 0L
|
||||||
|
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||||
|
|
||||||
|
var amplitudes = mutableListOf<Int>()
|
||||||
|
private set
|
||||||
|
private lateinit var amplitudesTimer: Timer
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||||
|
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun createRecordingTimeTimer() {
|
||||||
|
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
|
it.scheduleAtFixedRate(
|
||||||
|
{
|
||||||
|
recordingTime += 1000
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAmplitude() {
|
||||||
|
amplitudes.add(getAmplitude())
|
||||||
|
|
||||||
|
// Delete old amplitudes
|
||||||
|
if (amplitudes.size > getAmplitudeAmount()) {
|
||||||
|
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.postDelayed(::updateAmplitude, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAmplitudesTimer() {
|
||||||
|
amplitudesTimer = Timer().also {
|
||||||
|
it.scheduleAtFixedRate(
|
||||||
|
object: TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
updateAmplitude()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
createRecordingTimeTimer()
|
||||||
|
createAmplitudesTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
recordingTimeTimer.shutdown()
|
||||||
|
amplitudesTimer.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resume() {
|
||||||
|
createRecordingTimeTimer()
|
||||||
|
createAmplitudesTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
recordingTimeTimer.shutdown()
|
||||||
|
amplitudesTimer.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||||
|
import app.myzel394.alibi.db.LastRecording
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||||
|
protected var counter = 0
|
||||||
|
private set
|
||||||
|
protected lateinit var folder: File
|
||||||
|
lateinit var settings: Settings
|
||||||
|
protected set
|
||||||
|
|
||||||
|
private lateinit var cycleTimer: ScheduledExecutorService
|
||||||
|
|
||||||
|
fun createLastRecording(): LastRecording = LastRecording(
|
||||||
|
folderPath = folder.absolutePath,
|
||||||
|
recordingStart = recordingStart,
|
||||||
|
maxDuration = settings.maxDuration,
|
||||||
|
fileExtension = settings.fileExtension,
|
||||||
|
intervalDuration = settings.intervalDuration,
|
||||||
|
forceExactMaxDuration = settings.forceExactMaxDuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Make overrideable
|
||||||
|
open fun startNewCycle() {
|
||||||
|
deleteOldRecordings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTimer() {
|
||||||
|
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
|
it.scheduleAtFixedRate(
|
||||||
|
{
|
||||||
|
startNewCycle()
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
settings.intervalDuration,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
folder.mkdirs()
|
||||||
|
|
||||||
|
createTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
cycleTimer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resume() {
|
||||||
|
createTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
cycleTimer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteOldRecordings() {
|
||||||
|
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||||
|
val earliestCounter = counter - timeMultiplier
|
||||||
|
|
||||||
|
folder.listFiles()?.forEach { file ->
|
||||||
|
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
||||||
|
|
||||||
|
if (fileCounter < earliestCounter) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fileExtension: String
|
||||||
|
get() = when(outputFormat) {
|
||||||
|
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||||
|
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||||
|
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||||
|
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||||
|
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||||
|
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||||
|
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||||
|
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||||
|
else -> "raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||||
|
return Settings(
|
||||||
|
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||||
|
bitRate = audioRecorderSettings.bitRate,
|
||||||
|
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||||
|
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||||
|
encoder = audioRecorderSettings.getEncoder(),
|
||||||
|
maxDuration = audioRecorderSettings.maxDuration,
|
||||||
|
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,324 +1,94 @@
|
|||||||
package app.myzel394.alibi.services
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.media.MediaRecorder
|
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import app.myzel394.alibi.MainActivity
|
import app.myzel394.alibi.MainActivity
|
||||||
|
import app.myzel394.alibi.NotificationHelper
|
||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
import java.util.UUID
|
abstract class RecorderService: Service() {
|
||||||
|
private val binder = RecorderBinder()
|
||||||
|
|
||||||
const val AMPLITUDE_UPDATE_INTERVAL = 100L
|
private var isPaused: Boolean = false
|
||||||
|
|
||||||
class RecorderService: Service() {
|
lateinit var recordingStart: LocalDateTime
|
||||||
private val binder = LocalBinder()
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
private var job = SupervisorJob()
|
|
||||||
private var scope = CoroutineScope(Dispatchers.IO + job)
|
|
||||||
|
|
||||||
private var mediaRecorder: MediaRecorder? = null
|
|
||||||
private var onError: MediaRecorder.OnErrorListener? = null
|
|
||||||
private var onAmplitudeUpdate: () -> Unit = {}
|
|
||||||
|
|
||||||
private var counter = 0
|
|
||||||
var maxAmplitudes = 1000
|
|
||||||
|
|
||||||
var settings: Settings? = null
|
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var fileFolder: String? = null
|
var state = RecorderState.RECORDING
|
||||||
private set
|
|
||||||
val isRecording = mutableStateOf(false)
|
|
||||||
|
|
||||||
val amplitudes = mutableStateListOf<Int>()
|
|
||||||
|
|
||||||
var recordingStart: LocalDateTime? = null
|
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val filePaths: List<File>
|
var onStateChange: ((RecorderState) -> Unit)? = null
|
||||||
get() = File(fileFolder!!).listFiles()?.filter {
|
|
||||||
val name = it.nameWithoutExtension
|
|
||||||
|
|
||||||
if (name.toIntOrNull() == null) {
|
protected abstract fun start()
|
||||||
return@filter false
|
protected abstract fun pause()
|
||||||
|
protected abstract fun resume()
|
||||||
|
protected abstract fun stop()
|
||||||
|
|
||||||
|
override fun onBind(p0: Intent?): IBinder? = binder
|
||||||
|
|
||||||
|
inner class RecorderBinder: Binder() {
|
||||||
|
fun getService(): RecorderService = this@RecorderService
|
||||||
}
|
}
|
||||||
|
|
||||||
val extension = it.extension
|
fun changeState(newState: RecorderState) {
|
||||||
|
if (state == newState) {
|
||||||
extension == settings!!.fileExtension
|
return
|
||||||
}?.toList() ?: emptyList()
|
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder = binder
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
when (intent?.action) {
|
|
||||||
Actions.START.toString() -> start()
|
|
||||||
Actions.STOP.toString() -> stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
when (newState) {
|
||||||
|
RecorderState.RECORDING -> {
|
||||||
|
if (isPaused) {
|
||||||
|
resume()
|
||||||
|
isPaused = false
|
||||||
|
} else {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecorderState.PAUSED -> {
|
||||||
|
pause()
|
||||||
|
isPaused = true
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("$newState is not a valid state. Destroy or recreate the service instead.")
|
||||||
|
}
|
||||||
|
|
||||||
|
state = newState
|
||||||
|
onStateChange?.invoke(newState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
val notification = buildNotification()
|
||||||
|
|
||||||
|
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
recordingStart = LocalDateTime.now()
|
||||||
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setOnAmplitudeUpdateListener(onAmplitudeUpdate: () -> Unit) {
|
|
||||||
this.onAmplitudeUpdate = onAmplitudeUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun start() {
|
|
||||||
reset()
|
|
||||||
fileFolder = getRandomFileFolder(this)
|
|
||||||
|
|
||||||
// Create folder
|
|
||||||
File(this.fileFolder!!).mkdirs()
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
dataStore.data.collectLatest { preferenceSettings ->
|
|
||||||
if (settings == null) {
|
|
||||||
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
|
||||||
recordingStart = LocalDateTime.now()
|
|
||||||
isRecording.value = true
|
|
||||||
|
|
||||||
showNotification()
|
|
||||||
startNewRecording()
|
|
||||||
updateAmplitude()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resetCoroutineScope() {
|
|
||||||
// Reset `scope`
|
|
||||||
scope.cancel()
|
|
||||||
job = SupervisorJob()
|
|
||||||
scope = CoroutineScope(Dispatchers.IO + job)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stop() {
|
|
||||||
isRecording.value = false
|
|
||||||
mediaRecorder?.apply {
|
|
||||||
runCatching {
|
|
||||||
stop()
|
stop()
|
||||||
release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
private fun buildNotification(): Notification {
|
||||||
resetCoroutineScope()
|
return NotificationCompat.Builder(this, "recorder")
|
||||||
settings = null
|
|
||||||
recordingStart = null
|
|
||||||
counter = 0
|
|
||||||
amplitudes.clear()
|
|
||||||
isRecording.value = false
|
|
||||||
|
|
||||||
if (fileFolder != null) {
|
|
||||||
File(fileFolder!!).listFiles()?.forEach {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
fileFolder = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stripConcatenatedFileToExactDuration(
|
|
||||||
outputFile: File
|
|
||||||
) {
|
|
||||||
// Move the concatenated file to a temporary file
|
|
||||||
val rawFile = File("$fileFolder/${outputFile.nameWithoutExtension}-raw.${settings!!.fileExtension}")
|
|
||||||
outputFile.renameTo(rawFile)
|
|
||||||
|
|
||||||
val command = "-sseof ${settings!!.maxDuration / -1000} -i $rawFile -y $outputFile"
|
|
||||||
|
|
||||||
val session = FFmpegKit.execute(command)
|
|
||||||
|
|
||||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
|
||||||
Log.d(
|
|
||||||
"Audio Concatenation",
|
|
||||||
String.format(
|
|
||||||
"Command failed with state %s and rc %s.%s",
|
|
||||||
session.getState(),
|
|
||||||
session.getReturnCode(),
|
|
||||||
session.getFailStackTrace()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
throw Exception("Failed to strip concatenated audio")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
|
||||||
val paths = filePaths.joinToString("|")
|
|
||||||
val fileName = recordingStart!!
|
|
||||||
.format(ISO_DATE_TIME)
|
|
||||||
.toString()
|
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "_")
|
|
||||||
val outputFile = File("$fileFolder/$fileName.${settings!!.fileExtension}")
|
|
||||||
|
|
||||||
if (outputFile.exists() && !forceConcatenation) {
|
|
||||||
return outputFile
|
|
||||||
}
|
|
||||||
|
|
||||||
val command = "-i 'concat:$paths' -y" +
|
|
||||||
" -acodec copy" +
|
|
||||||
" -metadata title='$fileName' " +
|
|
||||||
" -metadata date='${recordingStart!!.format(ISO_DATE_TIME)}'" +
|
|
||||||
" -metadata batch_count='${filePaths.size}'" +
|
|
||||||
" -metadata batch_duration='${settings!!.intervalDuration}'" +
|
|
||||||
" -metadata max_duration='${settings!!.maxDuration}'" +
|
|
||||||
" $outputFile"
|
|
||||||
|
|
||||||
val session = FFmpegKit.execute(command)
|
|
||||||
|
|
||||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
|
||||||
Log.d(
|
|
||||||
"Audio Concatenation",
|
|
||||||
String.format(
|
|
||||||
"Command failed with state %s and rc %s.%s",
|
|
||||||
session.getState(),
|
|
||||||
session.getReturnCode(),
|
|
||||||
session.getFailStackTrace()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
throw Exception("Failed to concatenate audios")
|
|
||||||
}
|
|
||||||
|
|
||||||
val minRequiredForPossibleInExactMaxDuration = settings!!.maxDuration / settings!!.intervalDuration
|
|
||||||
if (settings!!.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
|
||||||
stripConcatenatedFileToExactDuration(outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputFile
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAmplitude() {
|
|
||||||
if (!isRecording.value || mediaRecorder == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val amplitude = mediaRecorder!!.maxAmplitude
|
|
||||||
amplitudes.add(amplitude)
|
|
||||||
|
|
||||||
// Delete old amplitudes
|
|
||||||
if (amplitudes.size > maxAmplitudes) {
|
|
||||||
amplitudes.removeRange(0, amplitudes.size - maxAmplitudes)
|
|
||||||
}
|
|
||||||
|
|
||||||
onAmplitudeUpdate()
|
|
||||||
handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startNewRecording() {
|
|
||||||
if (!isRecording.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteOldRecordings()
|
|
||||||
|
|
||||||
val newRecorder = createRecorder()
|
|
||||||
|
|
||||||
newRecorder.prepare()
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
mediaRecorder?.let {
|
|
||||||
it.stop()
|
|
||||||
it.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newRecorder.start()
|
|
||||||
mediaRecorder = newRecorder
|
|
||||||
|
|
||||||
counter++
|
|
||||||
|
|
||||||
handler.postDelayed(this::startNewRecording, settings!!.intervalDuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteOldRecordings() {
|
|
||||||
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
|
|
||||||
val earliestCounter = counter - timeMultiplier
|
|
||||||
|
|
||||||
File(fileFolder!!).listFiles()?.forEach { file ->
|
|
||||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
|
||||||
|
|
||||||
if (fileCounter < earliestCounter) {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createRecorder(): MediaRecorder {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
MediaRecorder(this)
|
|
||||||
} else {
|
|
||||||
MediaRecorder()
|
|
||||||
}.apply {
|
|
||||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
||||||
setOutputFile(getFilePath())
|
|
||||||
setOutputFormat(settings!!.outputFormat)
|
|
||||||
setAudioEncoder(settings!!.encoder)
|
|
||||||
setAudioEncodingBitRate(settings!!.bitRate)
|
|
||||||
setAudioSamplingRate(settings!!.samplingRate)
|
|
||||||
|
|
||||||
setOnErrorListener { mr, what, extra ->
|
|
||||||
onError?.onError(mr, what, extra)
|
|
||||||
|
|
||||||
this@RecorderService.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNotification() {
|
|
||||||
if (!isRecording.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, "recorder")
|
|
||||||
.setContentTitle("Recording Audio")
|
.setContentTitle("Recording Audio")
|
||||||
.setContentText("Recording audio in background")
|
.setContentText("Recording audio in background")
|
||||||
.setSmallIcon(R.drawable.launcher_foreground)
|
.setSmallIcon(R.drawable.launcher_foreground)
|
||||||
@ -328,7 +98,7 @@ class RecorderService: Service() {
|
|||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setUsesChronometer(true)
|
.setUsesChronometer(true)
|
||||||
.setChronometerCountDown(false)
|
.setChronometerCountDown(false)
|
||||||
.setWhen(Date.from(recordingStart!!.atZone(ZoneId.systemDefault()).toInstant()).time)
|
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||||
.setShowWhen(true)
|
.setShowWhen(true)
|
||||||
.setContentIntent(
|
.setContentIntent(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
@ -339,126 +109,5 @@ class RecorderService: Service() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// show notification
|
|
||||||
startForeground(getNotificationId(), notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
// To avoid int overflow, we'll use the number of seconds since 2023-01-01 01:01:01
|
|
||||||
private fun getNotificationId(): Int {
|
|
||||||
val offset = ZoneId.of("UTC").rules.getOffset(recordingStart!!)
|
|
||||||
|
|
||||||
return (
|
|
||||||
recordingStart!!.toEpochSecond(offset) -
|
|
||||||
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
|
|
||||||
).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFilePath(): String = "$fileFolder/$counter.${settings!!.fileExtension}"
|
|
||||||
|
|
||||||
inner class LocalBinder: Binder() {
|
|
||||||
fun getService(): RecorderService = this@RecorderService
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class Actions {
|
|
||||||
START,
|
|
||||||
STOP,
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun getRandomFileFolder(context: Context): String {
|
|
||||||
// uuid
|
|
||||||
val folder = UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
return "${context.externalCacheDir!!.absolutePath}/$folder"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startService(context: Context, connection: ServiceConnection?) {
|
|
||||||
Intent(context, RecorderService::class.java).also { intent ->
|
|
||||||
intent.action = Actions.START.toString()
|
|
||||||
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
|
|
||||||
if (connection != null) {
|
|
||||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun stopService(context: Context) {
|
|
||||||
Intent(context, RecorderService::class.java).also { intent ->
|
|
||||||
intent.action = Actions.STOP.toString()
|
|
||||||
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 fileExtension: String
|
|
||||||
get() = when(outputFormat) {
|
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
|
||||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
|
||||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
|
||||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
|
||||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
|
||||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
|
||||||
else -> "raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
|
||||||
return Settings(
|
|
||||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
|
||||||
bitRate = audioRecorderSettings.bitRate,
|
|
||||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
|
||||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
|
||||||
encoder = audioRecorderSettings.getEncoder(),
|
|
||||||
maxDuration = audioRecorderSettings.maxDuration,
|
|
||||||
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun bindToRecorderService(): Pair<ServiceConnection, RecorderService?> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var service by remember { mutableStateOf<RecorderService?>(null) }
|
|
||||||
|
|
||||||
val connection = remember {
|
|
||||||
object : ServiceConnection {
|
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
|
||||||
service = (binder as RecorderService.LocalBinder).getService()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
Intent(context, RecorderService::class.java).also { intent ->
|
|
||||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
service?.let {
|
|
||||||
context.unbindService(connection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection to service
|
|
||||||
}
|
|
||||||
|
@ -0,0 +1,114 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Memory
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
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.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.services.RecorderService
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SaveRecordingButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
service: RecorderService,
|
||||||
|
onSaveFile: (File) -> Unit,
|
||||||
|
label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isProcessingAudio by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (isProcessingAudio)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Memory,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
LinearProgressIndicator()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {}
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.then(modifier),
|
||||||
|
onClick = {
|
||||||
|
isProcessingAudio = true
|
||||||
|
RecorderService.stopService(context)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val file = service.concatenateFiles()
|
||||||
|
|
||||||
|
onSaveFile(file)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.getStackTraceString(error)
|
||||||
|
} finally {
|
||||||
|
isProcessingAudio = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.expandHorizontally
|
import androidx.compose.animation.expandHorizontally
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -12,15 +13,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@ -35,9 +33,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
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.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
@ -45,9 +43,9 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.services.RecorderService
|
import app.myzel394.alibi.services.RecorderService
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
||||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
||||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||||
import app.myzel394.alibi.ui.utils.formatDuration
|
import app.myzel394.alibi.ui.utils.formatDuration
|
||||||
@ -60,31 +58,28 @@ import java.time.ZoneId
|
|||||||
@Composable
|
@Composable
|
||||||
fun RecordingStatus(
|
fun RecordingStatus(
|
||||||
service: RecorderService,
|
service: RecorderService,
|
||||||
saveFile: (File) -> Unit,
|
onSaveFile: (File) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
||||||
|
|
||||||
val start = service.recordingStart!!
|
val progress = service.recordingTime.value!! / (service.settings!!.maxDuration / 1000f)
|
||||||
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
|
|
||||||
val progress = duration / (service.settings!!.maxDuration / 1000f)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
now = LocalDateTime.now()
|
now = LocalDateTime.now()
|
||||||
delay(1000)
|
delay(900)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show animation when the recording has just started
|
// Only show animation when the recording has just started
|
||||||
val recordingJustStarted = duration < 1
|
val recordingJustStarted = service.recordingTime.value!! < 1
|
||||||
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
progressVisible = true
|
progressVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
KeepScreenOn()
|
KeepScreenOn()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@ -102,7 +97,7 @@ fun RecordingStatus(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
val distance = Duration.between(service.recordingStart, now).toMillis()
|
val distance = Duration.between(service.recordingStart!!, now).toMillis()
|
||||||
|
|
||||||
Pulsating {
|
Pulsating {
|
||||||
Box(
|
Box(
|
||||||
@ -167,31 +162,12 @@ fun RecordingStatus(
|
|||||||
Text(label)
|
Text(label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
|
||||||
|
|
||||||
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
|
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
|
||||||
Button(
|
SaveRecordingButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.alpha(alpha),
|
||||||
.fillMaxWidth()
|
service = service,
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
onSaveFile = onSaveFile,
|
||||||
.graphicsLayer(alpha = alpha)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
RecorderService.stopService(context)
|
|
||||||
|
|
||||||
saveFile(service.concatenateFiles())
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,6 +8,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.expandHorizontally
|
import androidx.compose.animation.expandHorizontally
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -49,6 +50,7 @@ import app.myzel394.alibi.db.AppSettings
|
|||||||
import app.myzel394.alibi.services.RecorderService
|
import app.myzel394.alibi.services.RecorderService
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
||||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@ -58,6 +60,7 @@ import java.time.format.FormatStyle
|
|||||||
fun StartRecording(
|
fun StartRecording(
|
||||||
connection: ServiceConnection,
|
connection: ServiceConnection,
|
||||||
service: RecorderService? = null,
|
service: RecorderService? = null,
|
||||||
|
onStart: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val saveFile = rememberFileSaverDialog("audio/*")
|
val saveFile = rememberFileSaverDialog("audio/*")
|
||||||
@ -78,6 +81,16 @@ fun StartRecording(
|
|||||||
},
|
},
|
||||||
onPermissionAvailable = {
|
onPermissionAvailable = {
|
||||||
RecorderService.startService(context, connection)
|
RecorderService.startService(context, connection)
|
||||||
|
|
||||||
|
if (service == null) {
|
||||||
|
onStart()
|
||||||
|
} else {
|
||||||
|
// To avoid any leaks from the previous recording, we need to wait until it
|
||||||
|
// fully started
|
||||||
|
service.setOnStartedListener {
|
||||||
|
onStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { trigger ->
|
) { trigger ->
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
|
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
|
||||||
@ -127,37 +140,22 @@ fun StartRecording(
|
|||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
if (service?.recordingStart != null)
|
if (service?.hasRecordingAvailable == true)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.Bottom,
|
||||||
) {
|
) {
|
||||||
Button(
|
SaveRecordingButton(
|
||||||
modifier = Modifier
|
service = service,
|
||||||
.padding(16.dp)
|
onSaveFile = saveFile,
|
||||||
.fillMaxWidth()
|
label = stringResource(
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
|
||||||
onClick = {
|
|
||||||
saveFile(service.concatenateFiles())
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.ui_audioRecorder_action_saveOldRecording_label,
|
R.string.ui_audioRecorder_action_saveOldRecording_label,
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart!!),
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart),
|
||||||
),
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
package app.myzel394.alibi.ui.models
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
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.lifecycle.ViewModel
|
||||||
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
|
import app.myzel394.alibi.services.AudioRecorderService
|
||||||
|
import app.myzel394.alibi.services.RecorderService
|
||||||
|
|
||||||
|
class AudioRecorderModel: ViewModel() {
|
||||||
|
var recorderState by mutableStateOf(RecorderState.IDLE)
|
||||||
|
private set
|
||||||
|
var recordingTime by mutableStateOf<Long?>(null)
|
||||||
|
private set
|
||||||
|
var amplitudes by mutableStateOf<List<Int>?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var intent: Intent? = null
|
||||||
|
private var recorderService: RecorderService? = null
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||||
|
recorderService = ((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also {recorder ->
|
||||||
|
recorder.onStateChange = { state ->
|
||||||
|
recorderState = state
|
||||||
|
}
|
||||||
|
recorder.onRecordingTimeChange = { time ->
|
||||||
|
recordingTime = time
|
||||||
|
}
|
||||||
|
recorder.onAmplitudeChange = { amps ->
|
||||||
|
amplitudes = amps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||||
|
recorderService = null
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
recorderState = RecorderState.IDLE
|
||||||
|
recordingTime = null
|
||||||
|
amplitudes = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRecording(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
context.unbindService(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, AudioRecorderService::class.java)
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopRecording(context: Context) {
|
||||||
|
context.stopService(intent)
|
||||||
|
context.unbindService(connection)
|
||||||
|
}
|
||||||
|
}
|
@ -30,7 +30,10 @@ fun AudioRecorder(
|
|||||||
) {
|
) {
|
||||||
val saveFile = rememberFileSaverDialog("audio/aac")
|
val saveFile = rememberFileSaverDialog("audio/aac")
|
||||||
val (connection, service) = bindToRecorderService()
|
val (connection, service) = bindToRecorderService()
|
||||||
val isRecording = service?.isRecording?.value ?: false
|
|
||||||
|
var showRecorderStatus by remember {
|
||||||
|
mutableStateOf(service?.isRecording ?: false)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -58,10 +61,22 @@ fun AudioRecorder(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
if (isRecording)
|
if (showRecorderStatus && service?.recordingTime?.value != null)
|
||||||
RecordingStatus(service = service!!, saveFile = saveFile)
|
RecordingStatus(
|
||||||
|
service = service,
|
||||||
|
onSaveFile = {
|
||||||
|
saveFile(it)
|
||||||
|
showRecorderStatus = false
|
||||||
|
}
|
||||||
|
)
|
||||||
else
|
else
|
||||||
StartRecording(connection = connection, service = service)
|
StartRecording(
|
||||||
|
connection = connection,
|
||||||
|
service = service,
|
||||||
|
onStart = {
|
||||||
|
showRecorderStatus = true
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -51,7 +51,7 @@ fun SettingsScreen(
|
|||||||
navController: NavController
|
navController: NavController
|
||||||
) {
|
) {
|
||||||
val (_, service) = bindToRecorderService()
|
val (_, service) = bindToRecorderService()
|
||||||
val isRecording = service?.isRecording?.value ?: false
|
val isRecording = service?.isRecording ?: false
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||||
rememberTopAppBarState()
|
rememberTopAppBarState()
|
||||||
)
|
)
|
||||||
|
@ -23,6 +23,9 @@
|
|||||||
<string name="ui_audioRecorder_action_delete_confirm_title">Delete Recording?</string>
|
<string name="ui_audioRecorder_action_delete_confirm_title">Delete Recording?</string>
|
||||||
<string name="ui_audioRecorder_action_delete_confirm_message">Are you sure you want to delete this recording?</string>
|
<string name="ui_audioRecorder_action_delete_confirm_message">Are you sure you want to delete this recording?</string>
|
||||||
<string name="ui_audioRecorder_action_save_label">Save Recording</string>
|
<string name="ui_audioRecorder_action_save_label">Save Recording</string>
|
||||||
|
<string name="ui_audioRecorder_action_start_description">Alibi will continue recording in the background and store the last <xliff:g name="durationInMinutes">%s</xliff:g> minutes at your request</string>
|
||||||
|
<string name="ui_audioRecorder_action_save_processing_dialog_title">Processing</string>
|
||||||
|
<string name="ui_audioRecorder_action_save_processing_dialog_description">Processing Audio, do not close Alibi! You will be automatically prompted to save the file once it\'s ready</string>
|
||||||
|
|
||||||
<string name="ui_welcome_explanation_title">Welcome to Alibi!</string>
|
<string name="ui_welcome_explanation_title">Welcome to Alibi!</string>
|
||||||
<string name="ui_welcome_explanation_message">Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it.</string>
|
<string name="ui_welcome_explanation_message">Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it.</string>
|
||||||
@ -49,5 +52,4 @@
|
|||||||
<string name="ui_settings_option_samplingRate_description">Define how many samples per second are taken from the audio signal</string>
|
<string name="ui_settings_option_samplingRate_description">Define how many samples per second are taken from the audio signal</string>
|
||||||
<string name="ui_settings_option_samplingRate_explanation">Set the sampling rate</string>
|
<string name="ui_settings_option_samplingRate_explanation">Set the sampling rate</string>
|
||||||
<string name="ui_settings_option_encoder_title">Encoder</string>
|
<string name="ui_settings_option_encoder_title">Encoder</string>
|
||||||
<string name="ui_audioRecorder_action_start_description">Alibi will continue recording in the background and store the last <xliff:g name="durationInMinutes">%s</xliff:g> minutes at your request</string>
|
|
||||||
</resources>
|
</resources>
|
Loading…
x
Reference in New Issue
Block a user