diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b8f2dbc..fccf7db 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ - + \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt b/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt index b69cbed..679cee7 100644 --- a/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt +++ b/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt @@ -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) } + } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt index 039773e..3a5d7c5 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt @@ -2,7 +2,14 @@ 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.coroutines.delay 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 +34,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 + 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, diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt index 9a43ec0..ca91a75 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt @@ -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 { override val defaultValue: AppSettings = AppSettings.getDefaultInstance() @@ -30,4 +37,16 @@ class AppSettingsSerializer: Serializer { ).encodeToByteArray() ) } -} \ No newline at end of file +} + +class LocalDateTimeSerializer: KSerializer { + 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()) + } +} diff --git a/app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt b/app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt new file mode 100644 index 0000000..ed3cb12 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt @@ -0,0 +1,7 @@ +package app.myzel394.alibi.enums + +enum class RecorderState { + IDLE, + RECORDING, + PAUSED, +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt new file mode 100644 index 0000000..b54f5b7 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -0,0 +1,67 @@ +package app.myzel394.alibi.services + +import android.media.MediaRecorder +import android.os.Build + +class AudioRecorderService: IntervalRecorderService() { + var amplitudesAmount = 1000 + + 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 pause() { + super.pause() + + resetRecorder() + } + + override fun stop() { + super.stop() + + resetRecorder() + } + + override fun getAmplitudeAmount(): Int = amplitudesAmount + + override fun getAmplitude(): Int = recorder?.maxAmplitude ?: 0 +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt new file mode 100644 index 0000000..86a98ce --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt @@ -0,0 +1,51 @@ +package app.myzel394.alibi.services + +import android.os.Handler +import android.os.Looper +import app.myzel394.alibi.enums.RecorderState +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 + + var amplitudes = mutableListOf() + private set + + private val handler = Handler(Looper.getMainLooper()) + + var onAmplitudeChange: ((List) -> Unit)? = null + + private fun updateAmplitude() { + if (state !== RecorderState.RECORDING) { + return + } + + amplitudes.add(getAmplitude()) + onAmplitudeChange?.invoke(amplitudes) + + // Delete old amplitudes + if (amplitudes.size > getAmplitudeAmount()) { + amplitudes.drop(amplitudes.size - getAmplitudeAmount()) + } + + handler.postDelayed(::updateAmplitude, 100) + } + + private fun createAmplitudesTimer() { + handler.postDelayed(::updateAmplitude, 100) + } + + override fun start() { + createAmplitudesTimer() + } + + override fun resume() { + createAmplitudesTimer() + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt new file mode 100644 index 0000000..b2f6fa4 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -0,0 +1,152 @@ +package app.myzel394.alibi.services + +import android.content.Context +import android.media.MediaRecorder +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.AudioRecorderSettings +import app.myzel394.alibi.db.LastRecording +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.File +import java.time.LocalDateTime +import java.util.Timer +import java.util.TimerTask +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +abstract class IntervalRecorderService: ExtraRecorderInformationService() { + private var job = SupervisorJob() + private var scope = CoroutineScope(Dispatchers.IO + job) + + protected var counter = 0 + private set + protected lateinit var folder: File + var settings: Settings? = null + 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() { + counter += 1 + deleteOldRecordings() + } + + private fun createTimer() { + cycleTimer = Executors.newSingleThreadScheduledExecutor().also { + it.scheduleAtFixedRate( + { + startNewCycle() + }, + 0, + settings!!.intervalDuration, + TimeUnit.MILLISECONDS + ) + } + } + + private fun getRandomFileFolder(): String { + // uuid + val folder = UUID.randomUUID().toString() + + return "${externalCacheDir!!.absolutePath}/$folder" + } + + override fun start() { + super.start() + + folder = File(getRandomFileFolder()) + folder.mkdirs() + + scope.launch { + dataStore.data.collectLatest { preferenceSettings -> + if (settings == null) { + settings = Settings.from(preferenceSettings.audioRecorderSettings) + + createTimer() + } + } + } + } + + override fun pause() { + cycleTimer.shutdown() + } + + override fun resume() { + createTimer() + + // We first want to start our timers, so the `ExtraRecorderInformationService` can fetch + // amplitudes + super.resume() + } + + 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, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt index 9e6bd4d..9e91a5a 100644 --- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt @@ -1,334 +1,231 @@ package app.myzel394.alibi.services +import android.annotation.SuppressLint +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 androidx.core.app.NotificationManagerCompat 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 app.myzel394.alibi.ui.utils.PermissionHelper import java.time.LocalDateTime import java.time.ZoneId -import java.time.format.DateTimeFormatter.ISO_DATE_TIME +import java.util.Calendar import java.util.Date +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit -import java.util.UUID -const val AMPLITUDE_UPDATE_INTERVAL = 100L +abstract class RecorderService: Service() { + private val binder = RecorderBinder() -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 isPaused: Boolean = false - 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() - - var recordingStart: LocalDateTime? = null + var state = RecorderState.IDLE private set - val filePaths: List - get() = File(fileFolder!!).listFiles()?.filter { - val name = it.nameWithoutExtension + var onStateChange: ((RecorderState) -> Unit)? = null - if (name.toIntOrNull() == null) { - return@filter false - } + var recordingTime = 0L + private set + private lateinit var recordingTimeTimer: ScheduledExecutorService + var onRecordingTimeChange: ((Long) -> Unit)? = null - val extension = it.extension + protected abstract fun start() + protected abstract fun pause() + protected abstract fun resume() + protected abstract fun stop() - extension == settings!!.fileExtension - }?.toList() ?: emptyList() - - override fun onBind(p0: Intent?): IBinder = binder + 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() + "changeState" -> { + val newState = intent.getStringExtra("newState")?.let { + RecorderState.valueOf(it) + } ?: RecorderState.IDLE + changeState(newState) + } } return super.onStartCommand(intent, flags, startId) } + inner class RecorderBinder: Binder() { + fun getService(): RecorderService = this@RecorderService + } + + private fun createRecordingTimeTimer() { + recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also { + it.scheduleAtFixedRate( + { + recordingTime += 1000 + onRecordingTimeChange?.invoke(recordingTime) + }, + 0, + 1000, + TimeUnit.MILLISECONDS + ) + } + } + + @SuppressLint("MissingPermission") + fun changeState(newState: RecorderState) { + if (state == newState) { + return + } + + state = newState + when (newState) { + RecorderState.RECORDING -> { + if (isPaused) { + resume() + isPaused = false + } else { + start() + } + } + RecorderState.PAUSED -> { + pause() + isPaused = true + } + RecorderState.IDLE -> { + stop() + onDestroy() + } + } + + when (newState) { + RecorderState.RECORDING -> { + createRecordingTimeTimer() + } + RecorderState.PAUSED, RecorderState.IDLE -> { + recordingTimeTimer.shutdown() + } + } + + + if ( + arrayOf( + RecorderState.RECORDING, + RecorderState.PAUSED + ).contains(newState) && + PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS) + ){ + val notification = buildNotification() + NotificationManagerCompat.from(this).notify( + NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, + notification + ) + } + onStateChange?.invoke(newState) + } + + fun startRecording() { + recordingStart = LocalDateTime.now() + + val notification = buildStartNotification() + startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification) + + // Start + changeState(RecorderState.RECORDING) + } + + override fun onCreate() { + super.onCreate() + + startRecording() + } + 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() - } - } + changeState(RecorderState.IDLE) stopForeground(STOP_FOREGROUND_REMOVE) + NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID) stopSelf() } - fun reset() { - resetCoroutineScope() - settings = null - recordingStart = null - counter = 0 - amplitudes.clear() - isRecording.value = false + private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID) + .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title)) + .setContentText(getString(R.string.ui_audioRecorder_state_recording_description)) + .setSmallIcon(R.drawable.launcher_foreground) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() - if (fileFolder != null) { - File(fileFolder!!).listFiles()?.forEach { - it.delete() - } - - fileFolder = null - } + private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent { + return PendingIntent.getService( + this, + requestCode, + Intent(this, AudioRecorderService::class.java).apply { + action = "changeState" + putExtra("newState", newState.name) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } - 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") - .setContentText("Recording audio in background") + private fun buildNotification(): Notification = when(state) { + RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID) + .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title)) + .setContentText(getString(R.string.ui_audioRecorder_state_recording_description)) .setSmallIcon(R.drawable.launcher_foreground) .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setOngoing(true) + .setWhen( + Date.from( + Calendar + .getInstance() + .also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) } + .toInstant() + ).time, + ) + .setSilent(true) .setOnlyAlertOnce(true) .setUsesChronometer(true) .setChronometerCountDown(false) - .setWhen(Date.from(recordingStart!!.atZone(ZoneId.systemDefault()).toInstant()).time) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + ) + .addAction( + R.drawable.ic_cancel, + getString(R.string.ui_audioRecorder_action_delete_label), + getNotificationChangeStateIntent(RecorderState.IDLE, 1), + ) + .addAction( + R.drawable.ic_pause, + getString(R.string.ui_audioRecorder_action_pause_label), + getNotificationChangeStateIntent(RecorderState.PAUSED, 2), + ) + .build() + RecorderState.PAUSED -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID) + .setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title)) + .setContentText(getString(R.string.ui_audioRecorder_state_paused_description)) + .setSmallIcon(R.drawable.launcher_foreground) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setOngoing(false) + .setOnlyAlertOnce(true) + .setUsesChronometer(false) + .setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time) .setShowWhen(true) .setContentIntent( PendingIntent.getActivity( @@ -338,127 +235,12 @@ class RecorderService: Service() { PendingIntent.FLAG_IMMUTABLE, ) ) - .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, + .addAction( + R.drawable.ic_play, + getString(R.string.ui_audioRecorder_action_resume_label), + getNotificationChangeStateIntent(RecorderState.RECORDING, 3), ) - } + .build() + else -> throw IllegalStateException("Invalid state passed to `buildNotification()`") } -} - -@Composable -fun bindToRecorderService(): Pair { - val context = LocalContext.current - var service by remember { mutableStateOf(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 -} +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt index a88aa9b..8617b52 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -9,13 +9,20 @@ import androidx.compose.foundation.background import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.LastRecording import app.myzel394.alibi.ui.enums.Screen +import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.screens.AudioRecorder import app.myzel394.alibi.ui.screens.SettingsScreen import app.myzel394.alibi.ui.screens.WelcomeScreen @@ -23,7 +30,9 @@ import app.myzel394.alibi.ui.screens.WelcomeScreen const val SCALE_IN = 1.25f @Composable -fun Navigation() { +fun Navigation( + audioRecorder: AudioRecorderModel = viewModel() +) { val navController = rememberNavController() val context = LocalContext.current val settings = context @@ -32,6 +41,8 @@ fun Navigation() { .collectAsState(initial = null) .value ?: return + audioRecorder.BindToService(context) + NavHost( modifier = Modifier .background(MaterialTheme.colorScheme.background), @@ -53,7 +64,10 @@ fun Navigation() { scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150)) } ) { - AudioRecorder(navController = navController) + AudioRecorder( + navController = navController, + audioRecorder = audioRecorder, + ) } composable( Screen.Settings.route, @@ -64,7 +78,10 @@ fun Navigation() { scaleOut(targetScale = 1 / SCALE_IN) + fadeOut(tween(durationMillis = 150)) } ) { - SettingsScreen(navController = navController) + SettingsScreen( + navController = navController, + audioRecorder = audioRecorder, + ) } } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt index aeeaa86..7d35b35 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import app.myzel394.alibi.services.RecorderService import app.myzel394.alibi.ui.MAX_AMPLITUDE +import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.utils.clamp import kotlinx.coroutines.launch import kotlin.math.ceil @@ -34,10 +35,10 @@ private const val GROW_END = BOX_DIFF * 4 @Composable fun RealtimeAudioVisualizer( - service: RecorderService, + audioRecorder: AudioRecorderModel, ) { val scope = rememberCoroutineScope() - val amplitudes = service.amplitudes + val amplitudes = audioRecorder.amplitudes!! val primary = MaterialTheme.colorScheme.primary val primaryMuted = primary.copy(alpha = 0.3f) @@ -47,7 +48,7 @@ fun RealtimeAudioVisualizer( val animationProgress = remember { Animatable(0f) } LaunchedEffect(Unit) { - service.setOnAmplitudeUpdateListener { + audioRecorder.onAmplitudeChange = { scope.launch { animationProgress.snapTo(0f) animationProgress.animateTo( @@ -66,7 +67,7 @@ fun RealtimeAudioVisualizer( LaunchedEffect(screenWidth) { // Add 1 to allow the visualizer to overflow the screen - service.maxAmplitudes = ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1 + audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1) } Canvas( diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt new file mode 100644 index 0000000..1933c68 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt @@ -0,0 +1,110 @@ +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 + + scope.launch { + try { + } 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt index ab41188..300450b 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt @@ -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 @@ -20,10 +21,14 @@ 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.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFloatingActionButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,9 +40,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 @@ -48,7 +53,9 @@ 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.models.AudioRecorderModel import app.myzel394.alibi.ui.utils.KeepScreenOn import app.myzel394.alibi.ui.utils.formatDuration import kotlinx.coroutines.delay @@ -59,32 +66,26 @@ import java.time.ZoneId @Composable fun RecordingStatus( - service: RecorderService, - saveFile: (File) -> Unit, + audioRecorder: AudioRecorderModel, ) { 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) - 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 = audioRecorder.recordingTime!! <= 1000L var progressVisible by remember { mutableStateOf(!recordingJustStarted) } LaunchedEffect(Unit) { progressVisible = true } - KeepScreenOn() Column( @@ -94,7 +95,7 @@ fun RecordingStatus( verticalArrangement = Arrangement.SpaceBetween, ) { Box {} - RealtimeAudioVisualizer(service = service) + RealtimeAudioVisualizer(audioRecorder = audioRecorder) Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -102,8 +103,6 @@ fun RecordingStatus( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - val distance = Duration.between(service.recordingStart, now).toMillis() - Pulsating { Box( modifier = Modifier @@ -114,7 +113,7 @@ fun RecordingStatus( } Spacer(modifier = Modifier.width(16.dp)) Text( - text = formatDuration(distance), + text = formatDuration(audioRecorder.recordingTime!!), style = MaterialTheme.typography.headlineLarge, ) } @@ -126,7 +125,7 @@ fun RecordingStatus( ) ) { LinearProgressIndicator( - progress = progress, + progress = audioRecorder.progress, modifier = Modifier .width(300.dp) ) @@ -142,8 +141,7 @@ fun RecordingStatus( }, onConfirm = { showDeleteDialog = false - RecorderService.stopService(context) - service.reset() + audioRecorder.stopRecording(context, saveAsLastRecording = false) }, ) } @@ -167,22 +165,43 @@ fun RecordingStatus( Text(label) } } - val label = stringResource(R.string.ui_audioRecorder_action_save_label) - + + val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label) + val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label) + LargeFloatingActionButton( + modifier = Modifier + .semantics { + contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel + }, + onClick = { + if (audioRecorder.isPaused) { + audioRecorder.resumeRecording() + } else { + audioRecorder.pauseRecording() + } + }, + ) { + Icon( + if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause, + contentDescription = null, + ) + } + val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000)) + val label = stringResource(R.string.ui_audioRecorder_action_save_label) + Button( modifier = Modifier .padding(16.dp) .fillMaxWidth() .height(BIG_PRIMARY_BUTTON_SIZE) - .graphicsLayer(alpha = alpha) + .alpha(alpha) .semantics { contentDescription = label }, onClick = { - RecorderService.stopService(context) - - saveFile(service.concatenateFiles()) + audioRecorder.stopRecording(context) + audioRecorder.onRecordingSave() }, ) { Icon( @@ -191,7 +210,7 @@ fun RecordingStatus( modifier = Modifier.size(ButtonDefaults.IconSize), ) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(label) + Text(stringResource(R.string.ui_audioRecorder_action_save_label)) } } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt index c8ee01e..7954b2c 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt @@ -1,12 +1,6 @@ package app.myzel394.alibi.ui.components.AudioRecorder.molecules import android.Manifest -import android.content.ServiceConnection -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -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.Column import androidx.compose.foundation.layout.Spacer @@ -27,13 +21,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -46,18 +34,16 @@ import androidx.compose.ui.unit.dp import app.myzel394.alibi.R import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings -import app.myzel394.alibi.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.atoms.PermissionRequester +import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @Composable fun StartRecording( - connection: ServiceConnection, - service: RecorderService? = null, + audioRecorder: AudioRecorderModel, ) { val context = LocalContext.current val saveFile = rememberFileSaverDialog("audio/*") @@ -72,7 +58,7 @@ fun StartRecording( permission = Manifest.permission.RECORD_AUDIO, icon = Icons.Default.Mic, onPermissionAvailable = { - RecorderService.startService(context, connection) + audioRecorder.startRecording(context) }, ) { trigger -> val label = stringResource(R.string.ui_audioRecorder_action_start_label) @@ -122,37 +108,40 @@ fun StartRecording( .fillMaxWidth(), textAlign = TextAlign.Center, ) - if (service?.recordingStart != null) + if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) { Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom, ) { + val label = stringResource( + R.string.ui_audioRecorder_action_saveOldRecording_label, + DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart), + ) Button( modifier = Modifier .padding(16.dp) .fillMaxWidth() - .height(BIG_PRIMARY_BUTTON_SIZE), - onClick = { - saveFile(service.concatenateFiles()) - }, + .height(BIG_PRIMARY_BUTTON_SIZE) + .semantics { + contentDescription = label + }, colors = ButtonDefaults.textButtonColors(), + onClick = { + audioRecorder.stopRecording(context) + audioRecorder.onRecordingSave() + }, ) { Icon( Icons.Default.Save, contentDescription = null, - modifier = Modifier - .size(ButtonDefaults.IconSize), + 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!!), - ), - ) + Text(label) } } + } else Spacer(modifier = Modifier.weight(1f)) } diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt new file mode 100644 index 0000000..23dabc9 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -0,0 +1,122 @@ +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.Composable +import androidx.compose.runtime.LaunchedEffect +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.db.LastRecording +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(null) + private set + var amplitudes by mutableStateOf>(emptyList()) + private set + + var onAmplitudeChange: () -> Unit = {} + + val isInRecording: Boolean + get() = recorderState !== RecorderState.IDLE && recordingTime != null + + val isPaused: Boolean + get() = recorderState === RecorderState.PAUSED + + val progress: Float + get() = (recordingTime!! / recorderService!!.settings!!.maxDuration).toFloat() + + private var intent: Intent? = null + var recorderService: AudioRecorderService? = null + private set + + var lastRecording: LastRecording? by mutableStateOf(null) + private set + + var onRecordingSave: () -> Unit = {} + + 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 + onAmplitudeChange() + } + } + recorderState = recorderService!!.state + recordingTime = recorderService!!.recordingTime + amplitudes = recorderService!!.amplitudes + } + + override fun onServiceDisconnected(arg0: ComponentName) { + recorderService = null + reset() + } + } + + fun reset() { + recorderState = RecorderState.IDLE + recordingTime = null + amplitudes = emptyList() + } + + fun startRecording(context: Context) { + runCatching { + context.unbindService(connection) + } + + intent = Intent(context, AudioRecorderService::class.java) + ContextCompat.startForegroundService(context, intent!!) + context.bindService(intent!!, connection, Context.BIND_AUTO_CREATE) + } + + fun stopRecording(context: Context, saveAsLastRecording: Boolean = true) { + if (saveAsLastRecording) { + lastRecording = recorderService!!.createLastRecording() + } + + runCatching { + context.unbindService(connection) + context.stopService(intent) + } + + reset() + } + + fun pauseRecording() { + recorderService!!.changeState(RecorderState.PAUSED) + } + + fun resumeRecording() { + recorderService!!.changeState(RecorderState.RECORDING) + } + + fun setMaxAmplitudesAmount(amount: Int) { + recorderService?.amplitudesAmount = amount + } + + @Composable + fun BindToService(context: Context) { + LaunchedEffect(Unit) { + Intent(context, AudioRecorderService::class.java).also { intent -> + context.bindService(intent, connection, 0) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt index a9033ca..0e20792 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt @@ -1,37 +1,97 @@ package app.myzel394.alibi.ui.screens +import android.util.Log import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import app.myzel394.alibi.services.bindToRecorderService import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import app.myzel394.alibi.R +import app.myzel394.alibi.db.LastRecording +import app.myzel394.alibi.ui.models.AudioRecorderModel +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudioRecorder( navController: NavController, + audioRecorder: AudioRecorderModel, ) { val saveFile = rememberFileSaverDialog("audio/aac") - val (connection, service) = bindToRecorderService() - val isRecording = service?.isRecording?.value ?: false + val scope = rememberCoroutineScope() + var isProcessingAudio by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + audioRecorder.onRecordingSave = { + scope.launch { + isProcessingAudio = true + + try { + val file = audioRecorder.lastRecording!!.concatenateFiles() + + saveFile(file) + } catch (error: Exception) { + Log.getStackTraceString(error) + } finally { + isProcessingAudio = 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 = {} + ) Scaffold( topBar = { TopAppBar( @@ -58,10 +118,10 @@ fun AudioRecorder( .fillMaxSize() .padding(padding), ) { - if (isRecording) - RecordingStatus(service = service!!, saveFile = saveFile) + if (audioRecorder.isInRecording) + RecordingStatus(audioRecorder = audioRecorder) else - StartRecording(connection = connection, service = service) + StartRecording(audioRecorder = audioRecorder) } } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt index 14de6f3..2a92a54 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt @@ -32,7 +32,6 @@ import androidx.navigation.NavController import app.myzel394.alibi.R import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings -import app.myzel394.alibi.services.bindToRecorderService import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile @@ -43,15 +42,15 @@ import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile import app.myzel394.alibi.ui.components.atoms.GlobalSwitch import app.myzel394.alibi.ui.components.atoms.MessageBox import app.myzel394.alibi.ui.components.atoms.MessageType +import app.myzel394.alibi.ui.models.AudioRecorderModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( - navController: NavController + navController: NavController, + audioRecorder: AudioRecorderModel, ) { - val (_, service) = bindToRecorderService() - val isRecording = service?.isRecording?.value ?: false val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState() ) @@ -91,7 +90,7 @@ fun SettingsScreen( .value // Show alert - if (isRecording) + if (audioRecorder.isInRecording) Box( modifier = Modifier .padding(16.dp) diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..a0a94e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..4958a11 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..2217e32 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 824f89e..451b430 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,7 +23,14 @@ Delete Delete Recording? Are you sure you want to delete this recording? + Pause Recording + Resume Recording Save Recording + Alibi will continue recording in the background and store the last %s minutes at your request + Processing + Processing Audio, do not close Alibi! You will be automatically prompted to save the file once it\'s ready + Recording Audio + Alibi keeps recording in the background Welcome to Alibi! 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. @@ -50,5 +57,6 @@ Define how many samples per second are taken from the audio signal Set the sampling rate Encoder - Alibi will continue recording in the background and store the last %s minutes at your request + Recording paused + Audio Recording has been paused \ No newline at end of file