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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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