refactor: Improving architecture (current stand)

This commit is contained in:
Myzel394 2023-08-08 06:54:47 +02:00
parent d5100501f7
commit bc42f35eba
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
15 changed files with 691 additions and 478 deletions

View File

@ -7,10 +7,13 @@ import android.os.Build
import androidx.annotation.RequiresApi
object NotificationHelper {
const val RECORDER_CHANNEL_ID = "recorder"
const val RECORDER_CHANNEL_NOTIFICATION_ID = 1
@RequiresApi(Build.VERSION_CODES.O)
fun createChannels(context: Context) {
val channel = NotificationChannel(
"recorder",
RECORDER_CHANNEL_ID,
context.resources.getString(R.string.notificationChannels_recorder_name),
android.app.NotificationManager.IMPORTANCE_LOW,
)
@ -19,4 +22,5 @@ object NotificationHelper {
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}

View File

@ -2,7 +2,13 @@ package app.myzel394.alibi.db
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.serialization.Serializable
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@Serializable
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
data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L,

View File

@ -1,10 +1,17 @@
package app.myzel394.alibi.db
import androidx.datastore.core.Serializer
import kotlinx.serialization.KSerializer
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 java.io.InputStream
import java.io.OutputStream
import java.time.LocalDateTime
class AppSettingsSerializer: Serializer<AppSettings> {
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
@ -30,4 +37,16 @@ class AppSettingsSerializer: Serializer<AppSettings> {
).encodeToByteArray()
)
}
}
}
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())
}
}

View File

@ -0,0 +1,7 @@
package app.myzel394.alibi.enums
enum class RecorderState {
IDLE,
RECORDING,
PAUSED,
}

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -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,
)
}
}
}
}

View File

@ -1,324 +1,94 @@
package app.myzel394.alibi.services
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.media.MediaRecorder
import android.os.Binder
import android.os.Build
import android.os.Handler
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.content.ContextCompat
import app.myzel394.alibi.MainActivity
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
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 app.myzel394.alibi.enums.RecorderState
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
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() {
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
lateinit var recordingStart: LocalDateTime
private set
var fileFolder: String? = null
private set
val isRecording = mutableStateOf(false)
val amplitudes = mutableStateListOf<Int>()
var recordingStart: LocalDateTime? = null
var state = RecorderState.RECORDING
private set
val filePaths: List<File>
get() = File(fileFolder!!).listFiles()?.filter {
val name = it.nameWithoutExtension
var onStateChange: ((RecorderState) -> Unit)? = null
if (name.toIntOrNull() == null) {
return@filter false
}
protected abstract fun start()
protected abstract fun pause()
protected abstract fun resume()
protected abstract fun stop()
val extension = it.extension
override fun onBind(p0: Intent?): IBinder? = binder
extension == settings!!.fileExtension
}?.toList() ?: emptyList()
inner class RecorderBinder: Binder() {
fun getService(): RecorderService = this@RecorderService
}
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()
fun changeState(newState: RecorderState) {
if (state == newState) {
return
}
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() {
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()
release()
}
}
stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
fun reset() {
resetCoroutineScope()
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")
private fun buildNotification(): Notification {
return NotificationCompat.Builder(this, "recorder")
.setContentTitle("Recording Audio")
.setContentText("Recording audio in background")
.setSmallIcon(R.drawable.launcher_foreground)
@ -328,7 +98,7 @@ class RecorderService: Service() {
.setOnlyAlertOnce(true)
.setUsesChronometer(true)
.setChronometerCountDown(false)
.setWhen(Date.from(recordingStart!!.atZone(ZoneId.systemDefault()).toInstant()).time)
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
.setShowWhen(true)
.setContentIntent(
PendingIntent.getActivity(
@ -339,126 +109,5 @@ class RecorderService: Service() {
)
)
.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
}
}

View File

@ -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)
}
}

View File

@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
@ -35,9 +33,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@ -45,9 +43,9 @@ 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 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.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.Pulsating
import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.formatDuration
@ -60,31 +58,28 @@ import java.time.ZoneId
@Composable
fun RecordingStatus(
service: RecorderService,
saveFile: (File) -> Unit,
onSaveFile: (File) -> Unit,
) {
val context = LocalContext.current
var now by remember { mutableStateOf(LocalDateTime.now()) }
val start = service.recordingStart!!
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
val progress = duration / (service.settings!!.maxDuration / 1000f)
val progress = service.recordingTime.value!! / (service.settings!!.maxDuration / 1000f)
LaunchedEffect(Unit) {
while (true) {
now = LocalDateTime.now()
delay(1000)
delay(900)
}
}
// 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) }
LaunchedEffect(Unit) {
progressVisible = true
}
KeepScreenOn()
Column(
@ -102,7 +97,7 @@ fun RecordingStatus(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
val distance = Duration.between(service.recordingStart, now).toMillis()
val distance = Duration.between(service.recordingStart!!, now).toMillis()
Pulsating {
Box(
@ -167,31 +162,12 @@ fun RecordingStatus(
Text(label)
}
}
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
Button(
SaveRecordingButton(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.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)
}
.alpha(alpha),
service = service,
onSaveFile = onSaveFile,
)
}
}

View File

@ -8,6 +8,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.ui.BIG_PRIMARY_BUTTON_SIZE
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.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter
@ -58,6 +60,7 @@ import java.time.format.FormatStyle
fun StartRecording(
connection: ServiceConnection,
service: RecorderService? = null,
onStart: () -> Unit,
) {
val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/*")
@ -78,6 +81,16 @@ fun StartRecording(
},
onPermissionAvailable = {
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 ->
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
@ -127,36 +140,21 @@ fun StartRecording(
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
if (service?.recordingStart != null)
if (service?.hasRecordingAvailable == true)
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.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,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart!!),
),
)
}
SaveRecordingButton(
service = service,
onSaveFile = saveFile,
label = stringResource(
R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart),
),
)
}
else
Spacer(modifier = Modifier.weight(1f))

View File

@ -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)
}
}

View File

@ -30,7 +30,10 @@ fun AudioRecorder(
) {
val saveFile = rememberFileSaverDialog("audio/aac")
val (connection, service) = bindToRecorderService()
val isRecording = service?.isRecording?.value ?: false
var showRecorderStatus by remember {
mutableStateOf(service?.isRecording ?: false)
}
Scaffold(
topBar = {
@ -58,10 +61,22 @@ fun AudioRecorder(
.fillMaxSize()
.padding(padding),
) {
if (isRecording)
RecordingStatus(service = service!!, saveFile = saveFile)
if (showRecorderStatus && service?.recordingTime?.value != null)
RecordingStatus(
service = service,
onSaveFile = {
saveFile(it)
showRecorderStatus = false
}
)
else
StartRecording(connection = connection, service = service)
StartRecording(
connection = connection,
service = service,
onStart = {
showRecorderStatus = true
}
)
}
}
}

View File

@ -51,7 +51,7 @@ fun SettingsScreen(
navController: NavController
) {
val (_, service) = bindToRecorderService()
val isRecording = service?.isRecording?.value ?: false
val isRecording = service?.isRecording ?: false
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)

View File

@ -23,6 +23,9 @@
<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_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_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_explanation">Set the sampling rate</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>