From bc42f35eba0b6e9f4528b66fe4713e708395df95 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 06:54:47 +0200 Subject: [PATCH 1/7] refactor: Improving architecture (current stand) --- .../app/myzel394/alibi/NotificationHelper.kt | 6 +- .../java/app/myzel394/alibi/db/AppSettings.kt | 103 ++++ .../alibi/db/AppSettingsSerializer.kt | 21 +- .../app/myzel394/alibi/enums/RecorderState.kt | 7 + .../alibi/services/AudioRecorderService.kt | 55 +++ .../ExtraRecorderInformationService.kt | 85 ++++ .../alibi/services/IntervalRecorderService.kt | 117 +++++ .../alibi/services/RecorderService.kt | 453 ++---------------- .../atoms/SaveRecordingButton.kt | 114 +++++ .../molecules/RecordingStatus.kt | 50 +- .../AudioRecorder/molecules/StartRecording.kt | 48 +- .../alibi/ui/models/AudioRecorderModel.kt | 69 +++ .../alibi/ui/screens/AudioRecorder.kt | 23 +- .../alibi/ui/screens/SettingsScreen.kt | 2 +- app/src/main/res/values/strings.xml | 4 +- 15 files changed, 685 insertions(+), 472 deletions(-) create mode 100644 app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt create mode 100644 app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt create mode 100644 app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt create mode 100644 app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt create mode 100644 app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt create mode 100644 app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt diff --git a/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt b/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt index b69cbed1c..679cee744 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 039773ed2..7cfd621f0 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,13 @@ package app.myzel394.alibi.db import android.media.MediaRecorder import android.os.Build +import android.util.Log +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.ReturnCode import kotlinx.serialization.Serializable +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter.ISO_DATE_TIME @Serializable data class AppSettings( @@ -27,6 +33,103 @@ data class AppSettings( } } +@Serializable +data class LastRecording( + val folderPath: String, + @Serializable(with = LocalDateTimeSerializer::class) + val recordingStart: LocalDateTime, + val maxDuration: Long, + val intervalDuration: Long, + val fileExtension: String, + val forceExactMaxDuration: Boolean, +) { + val fileFolder: File + get() = File(folderPath) + + val filePaths: List + 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 9a43ec098..ca91a755d 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 000000000..ed3cb1286 --- /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 000000000..273431d87 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -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 +} \ 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 000000000..784ea5f0f --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt @@ -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() + private set + private lateinit var amplitudesTimer: Timer + + private val handler = Handler(Looper.getMainLooper()) + + var onRecordingTimeChange: ((Long) -> Unit)? = null + var onAmplitudeChange: ((List) -> 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() + } + +} \ 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 000000000..cdb006387 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -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, + ) + } + } + } +} \ 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 c637bee5e..f881948a2 100644 --- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt @@ -1,324 +1,94 @@ package app.myzel394.alibi.services +import android.app.Notification import android.app.PendingIntent import android.app.Service -import android.content.ComponentName -import android.content.Context import android.content.Intent -import android.content.ServiceConnection -import android.media.MediaRecorder import android.os.Binder -import android.os.Build -import android.os.Handler import android.os.IBinder -import android.os.Looper -import android.util.Log -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat import app.myzel394.alibi.MainActivity +import app.myzel394.alibi.NotificationHelper import app.myzel394.alibi.R -import app.myzel394.alibi.dataStore -import app.myzel394.alibi.db.AudioRecorderSettings -import com.arthenica.ffmpegkit.FFmpegKit -import com.arthenica.ffmpegkit.ReturnCode -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import java.io.File +import app.myzel394.alibi.enums.RecorderState import java.time.LocalDateTime import java.time.ZoneId -import java.time.format.DateTimeFormatter.ISO_DATE_TIME import java.util.Date +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit -import java.util.UUID +abstract class RecorderService: Service() { + private val binder = RecorderBinder() -const val AMPLITUDE_UPDATE_INTERVAL = 100L + private var isPaused: Boolean = false -class RecorderService: Service() { - private val binder = LocalBinder() - private val handler = Handler(Looper.getMainLooper()) - private var job = SupervisorJob() - private var scope = CoroutineScope(Dispatchers.IO + job) - - private var mediaRecorder: MediaRecorder? = null - private var onError: MediaRecorder.OnErrorListener? = null - private var onAmplitudeUpdate: () -> Unit = {} - - private var counter = 0 - var maxAmplitudes = 1000 - - var settings: Settings? = null + lateinit var recordingStart: LocalDateTime private set - var fileFolder: String? = null + var state = RecorderState.RECORDING private set - val isRecording = mutableStateOf(false) - - val amplitudes = mutableStateListOf() - - var recordingStart: LocalDateTime? = null - private set - - val filePaths: List - get() = File(fileFolder!!).listFiles()?.filter { - val name = it.nameWithoutExtension - - if (name.toIntOrNull() == null) { - return@filter false - } - val extension = it.extension + var onStateChange: ((RecorderState) -> Unit)? = null - extension == settings!!.fileExtension - }?.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) - } - - override fun onDestroy() { - super.onDestroy() + protected abstract fun start() + protected abstract fun pause() + protected abstract fun resume() + protected abstract fun stop() - scope.cancel() - } + override fun onBind(p0: Intent?): IBinder? = binder - fun setOnAmplitudeUpdateListener(onAmplitudeUpdate: () -> Unit) { - this.onAmplitudeUpdate = onAmplitudeUpdate + inner class RecorderBinder: Binder() { + fun getService(): RecorderService = this@RecorderService } - 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 + fun changeState(newState: RecorderState) { + if (state == newState) { + return + } - showNotification() - startNewRecording() - updateAmplitude() + when (newState) { + RecorderState.RECORDING -> { + if (isPaused) { + resume() + isPaused = false + } else { + start() } } - } - } - - 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() + RecorderState.PAUSED -> { + pause() + isPaused = true } + else -> throw IllegalStateException("$newState is not a valid state. Destroy or recreate the service instead.") } - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - fun reset() { - resetCoroutineScope() - settings = null - recordingStart = null - counter = 0 - amplitudes.clear() - isRecording.value = false - - if (fileFolder != null) { - File(fileFolder!!).listFiles()?.forEach { - it.delete() - } - - fileFolder = null - } - } - - private fun stripConcatenatedFileToExactDuration( - outputFile: File - ) { - // Move the concatenated file to a temporary file - val rawFile = File("$fileFolder/${outputFile.nameWithoutExtension}-raw.${settings!!.fileExtension}") - outputFile.renameTo(rawFile) - - val command = "-sseof ${settings!!.maxDuration / -1000} -i $rawFile -y $outputFile" - - val session = FFmpegKit.execute(command) - - if (!ReturnCode.isSuccess(session.returnCode)) { - Log.d( - "Audio Concatenation", - String.format( - "Command failed with state %s and rc %s.%s", - session.getState(), - session.getReturnCode(), - session.getFailStackTrace() - ) - ) - - throw Exception("Failed to strip concatenated audio") - } - } - - fun concatenateFiles(forceConcatenation: Boolean = false): File { - val paths = filePaths.joinToString("|") - val fileName = recordingStart!! - .format(ISO_DATE_TIME) - .toString() - .replace(":", "-") - .replace(".", "_") - val outputFile = File("$fileFolder/$fileName.${settings!!.fileExtension}") - - if (outputFile.exists() && !forceConcatenation) { - return outputFile - } - - val command = "-i 'concat:$paths' -y" + - " -acodec copy" + - " -metadata title='$fileName' " + - " -metadata date='${recordingStart!!.format(ISO_DATE_TIME)}'" + - " -metadata batch_count='${filePaths.size}'" + - " -metadata batch_duration='${settings!!.intervalDuration}'" + - " -metadata max_duration='${settings!!.maxDuration}'" + - " $outputFile" - - val session = FFmpegKit.execute(command) - - if (!ReturnCode.isSuccess(session.returnCode)) { - Log.d( - "Audio Concatenation", - String.format( - "Command failed with state %s and rc %s.%s", - session.getState(), - session.getReturnCode(), - session.getFailStackTrace() - ) - ) - - throw Exception("Failed to concatenate audios") - } - - val minRequiredForPossibleInExactMaxDuration = settings!!.maxDuration / settings!!.intervalDuration - if (settings!!.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) { - stripConcatenatedFileToExactDuration(outputFile) - } - - return outputFile - } - - private fun updateAmplitude() { - if (!isRecording.value || mediaRecorder == null) { - return - } - - val amplitude = mediaRecorder!!.maxAmplitude - amplitudes.add(amplitude) - - // Delete old amplitudes - if (amplitudes.size > maxAmplitudes) { - amplitudes.removeRange(0, amplitudes.size - maxAmplitudes) - } - - onAmplitudeUpdate() - handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL) + state = newState + onStateChange?.invoke(newState) } - private fun startNewRecording() { - if (!isRecording.value) { - return - } - - deleteOldRecordings() + override fun onCreate() { + super.onCreate() - val newRecorder = createRecorder() + val notification = buildNotification() - newRecorder.prepare() + startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification) - runCatching { - mediaRecorder?.let { - it.stop() - it.release() - } - } - - newRecorder.start() - mediaRecorder = newRecorder - - counter++ - - handler.postDelayed(this::startNewRecording, settings!!.intervalDuration) + recordingStart = LocalDateTime.now() + start() } - 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) + override fun onDestroy() { + super.onDestroy() - setOnErrorListener { mr, what, extra -> - onError?.onError(mr, what, extra) + stop() - this@RecorderService.stop() - } - } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() } - private fun showNotification() { - if (!isRecording.value) { - return - } - - val notification = NotificationCompat.Builder(this, "recorder") + private fun buildNotification(): Notification { + return NotificationCompat.Builder(this, "recorder") .setContentTitle("Recording Audio") .setContentText("Recording audio in background") .setSmallIcon(R.drawable.launcher_foreground) @@ -328,7 +98,7 @@ class RecorderService: Service() { .setOnlyAlertOnce(true) .setUsesChronometer(true) .setChronometerCountDown(false) - .setWhen(Date.from(recordingStart!!.atZone(ZoneId.systemDefault()).toInstant()).time) + .setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time) .setShowWhen(true) .setContentIntent( PendingIntent.getActivity( @@ -339,126 +109,5 @@ class RecorderService: Service() { ) ) .build() - - // show notification - startForeground(getNotificationId(), notification) } - - // To avoid int overflow, we'll use the number of seconds since 2023-01-01 01:01:01 - private fun getNotificationId(): Int { - val offset = ZoneId.of("UTC").rules.getOffset(recordingStart!!) - - return ( - recordingStart!!.toEpochSecond(offset) - - LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset) - ).toInt() - } - - private fun getFilePath(): String = "$fileFolder/$counter.${settings!!.fileExtension}" - - inner class LocalBinder: Binder() { - fun getService(): RecorderService = this@RecorderService - } - - enum class Actions { - START, - STOP, - } - - companion object { - fun getRandomFileFolder(context: Context): String { - // uuid - val folder = UUID.randomUUID().toString() - - return "${context.externalCacheDir!!.absolutePath}/$folder" - } - - fun startService(context: Context, connection: ServiceConnection?) { - Intent(context, RecorderService::class.java).also { intent -> - intent.action = Actions.START.toString() - - ContextCompat.startForegroundService(context, intent) - - if (connection != null) { - context.bindService(intent, connection, Context.BIND_AUTO_CREATE) - } - } - } - - fun stopService(context: Context) { - Intent(context, RecorderService::class.java).also { intent -> - intent.action = Actions.STOP.toString() - - context.startService(intent) - } - } - } -} - -data class Settings( - val maxDuration: Long, - val intervalDuration: Long, - val forceExactMaxDuration: Boolean, - val bitRate: Int, - val samplingRate: Int, - val outputFormat: Int, - val encoder: Int, -) { - val fileExtension: String - get() = when(outputFormat) { - MediaRecorder.OutputFormat.AAC_ADTS -> "aac" - MediaRecorder.OutputFormat.THREE_GPP -> "3gp" - MediaRecorder.OutputFormat.MPEG_4 -> "mp4" - MediaRecorder.OutputFormat.MPEG_2_TS -> "ts" - MediaRecorder.OutputFormat.WEBM -> "webm" - MediaRecorder.OutputFormat.AMR_NB -> "amr" - MediaRecorder.OutputFormat.AMR_WB -> "awb" - MediaRecorder.OutputFormat.OGG -> "ogg" - else -> "raw" - } - - companion object { - fun from(audioRecorderSettings: AudioRecorderSettings): Settings { - return Settings( - intervalDuration = audioRecorderSettings.intervalDuration, - bitRate = audioRecorderSettings.bitRate, - samplingRate = audioRecorderSettings.getSamplingRate(), - outputFormat = audioRecorderSettings.getOutputFormat(), - encoder = audioRecorderSettings.getEncoder(), - maxDuration = audioRecorderSettings.maxDuration, - forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration, - ) - } - } -} - -@Composable -fun bindToRecorderService(): Pair { - 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/components/AudioRecorder/atoms/SaveRecordingButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt new file mode 100644 index 000000000..550e2e441 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt @@ -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) + } +} \ 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 ab41188b2..2648d8fe6 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 @@ -12,15 +13,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -35,9 +33,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -45,9 +43,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import app.myzel394.alibi.R import app.myzel394.alibi.services.RecorderService -import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer +import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton import app.myzel394.alibi.ui.components.atoms.Pulsating import app.myzel394.alibi.ui.utils.KeepScreenOn import app.myzel394.alibi.ui.utils.formatDuration @@ -60,31 +58,28 @@ import java.time.ZoneId @Composable fun RecordingStatus( service: RecorderService, - saveFile: (File) -> Unit, + onSaveFile: (File) -> Unit, ) { val context = LocalContext.current var now by remember { mutableStateOf(LocalDateTime.now()) } - val start = service.recordingStart!! - val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start)) - val progress = duration / (service.settings!!.maxDuration / 1000f) + val progress = service.recordingTime.value!! / (service.settings!!.maxDuration / 1000f) LaunchedEffect(Unit) { while (true) { now = LocalDateTime.now() - delay(1000) + delay(900) } } // Only show animation when the recording has just started - val recordingJustStarted = duration < 1 + val recordingJustStarted = service.recordingTime.value!! < 1 var progressVisible by remember { mutableStateOf(!recordingJustStarted) } LaunchedEffect(Unit) { progressVisible = true } - KeepScreenOn() Column( @@ -102,7 +97,7 @@ fun RecordingStatus( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - val distance = Duration.between(service.recordingStart, now).toMillis() + val distance = Duration.between(service.recordingStart!!, now).toMillis() Pulsating { Box( @@ -167,31 +162,12 @@ fun RecordingStatus( Text(label) } } - val label = stringResource(R.string.ui_audioRecorder_action_save_label) - val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000)) - Button( + SaveRecordingButton( modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .height(BIG_PRIMARY_BUTTON_SIZE) - .graphicsLayer(alpha = alpha) - .semantics { - contentDescription = label - }, - onClick = { - RecorderService.stopService(context) - - saveFile(service.concatenateFiles()) - }, - ) { - Icon( - Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(label) - } + .alpha(alpha), + service = service, + onSaveFile = onSaveFile, + ) } } \ 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 106c9d78a..fd5446b2f 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 @@ -8,6 +8,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -49,6 +50,7 @@ import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.services.RecorderService import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer +import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton import app.myzel394.alibi.ui.components.atoms.PermissionRequester import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import java.time.format.DateTimeFormatter @@ -58,6 +60,7 @@ import java.time.format.FormatStyle fun StartRecording( connection: ServiceConnection, service: RecorderService? = null, + onStart: () -> Unit, ) { val context = LocalContext.current val saveFile = rememberFileSaverDialog("audio/*") @@ -78,6 +81,16 @@ fun StartRecording( }, onPermissionAvailable = { RecorderService.startService(context, connection) + + if (service == null) { + onStart() + } else { + // To avoid any leaks from the previous recording, we need to wait until it + // fully started + service.setOnStartedListener { + onStart() + } + } }, ) { trigger -> val label = stringResource(R.string.ui_audioRecorder_action_start_label) @@ -127,36 +140,21 @@ fun StartRecording( .fillMaxWidth(), textAlign = TextAlign.Center, ) - if (service?.recordingStart != null) + if (service?.hasRecordingAvailable == true) Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom, ) { - Button( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .height(BIG_PRIMARY_BUTTON_SIZE), - onClick = { - saveFile(service.concatenateFiles()) - }, - colors = ButtonDefaults.textButtonColors(), - ) { - Icon( - Icons.Default.Save, - contentDescription = null, - modifier = Modifier - .size(ButtonDefaults.IconSize), - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text( - stringResource( - R.string.ui_audioRecorder_action_saveOldRecording_label, - DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart!!), - ), - ) - } + SaveRecordingButton( + service = service, + onSaveFile = saveFile, + label = stringResource( + R.string.ui_audioRecorder_action_saveOldRecording_label, + DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart), + ), + + ) } else Spacer(modifier = Modifier.weight(1f)) 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 000000000..f5ca99a22 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -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(null) + private set + var amplitudes by mutableStateOf?>(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) + } +} \ 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 a9033ca4c..c5877f414 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 @@ -30,7 +30,10 @@ fun AudioRecorder( ) { val saveFile = rememberFileSaverDialog("audio/aac") val (connection, service) = bindToRecorderService() - val isRecording = service?.isRecording?.value ?: false + + var showRecorderStatus by remember { + mutableStateOf(service?.isRecording ?: false) + } Scaffold( topBar = { @@ -58,10 +61,22 @@ fun AudioRecorder( .fillMaxSize() .padding(padding), ) { - if (isRecording) - RecordingStatus(service = service!!, saveFile = saveFile) + if (showRecorderStatus && service?.recordingTime?.value != null) + RecordingStatus( + service = service, + onSaveFile = { + saveFile(it) + showRecorderStatus = false + } + ) else - StartRecording(connection = connection, service = service) + StartRecording( + connection = connection, + service = service, + onStart = { + showRecorderStatus = true + } + ) } } } \ 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 14de6f305..f47ed321d 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 @@ -51,7 +51,7 @@ fun SettingsScreen( navController: NavController ) { val (_, service) = bindToRecorderService() - val isRecording = service?.isRecording?.value ?: false + val isRecording = service?.isRecording ?: false val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState() ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de209faec..7ce351ac5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,9 @@ Delete Recording? Are you sure you want to delete this 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 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. @@ -49,5 +52,4 @@ 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 \ No newline at end of file From 0bfed1831473c4e8a83c040302d61b800fa1b113 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 19:07:54 +0200 Subject: [PATCH 2/7] refactor: Migrate foreground services to new architecture --- app/src/main/AndroidManifest.xml | 2 +- .../alibi/services/AudioRecorderService.kt | 20 +++++--- .../ExtraRecorderInformationService.kt | 25 ++++------ .../alibi/services/IntervalRecorderService.kt | 49 ++++++++++++++++--- .../alibi/services/RecorderService.kt | 22 ++++++--- .../java/app/myzel394/alibi/ui/Navigation.kt | 18 +++++-- .../atoms/RealTimeAudioVisualizer.kt | 9 ++-- .../atoms/SaveRecordingButton.kt | 4 -- .../molecules/RecordingStatus.kt | 23 +++------ .../AudioRecorder/molecules/StartRecording.kt | 29 ++--------- .../alibi/ui/models/AudioRecorderModel.kt | 48 +++++++++++++++--- .../alibi/ui/screens/AudioRecorder.kt | 27 +++------- .../alibi/ui/screens/SettingsScreen.kt | 9 ++-- 13 files changed, 163 insertions(+), 122 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b8f2dbc53..fccf7dba7 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/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt index 273431d87..02d2bce2e 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -4,11 +4,13 @@ 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}" + get() = "$folder/$counter.${settings!!.fileExtension}" private fun createRecorder(): MediaRecorder { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -18,10 +20,10 @@ class AudioRecorderService: IntervalRecorderService() { }.apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFile(filePath) - setOutputFormat(settings.outputFormat) - setAudioEncoder(settings.encoder) - setAudioEncodingBitRate(settings.bitRate) - setAudioSamplingRate(settings.samplingRate) + setOutputFormat(settings!!.outputFormat) + setAudioEncoder(settings!!.encoder) + setAudioEncodingBitRate(settings!!.bitRate) + setAudioSamplingRate(settings!!.samplingRate) } } @@ -47,9 +49,13 @@ class AudioRecorderService: IntervalRecorderService() { recorder = newRecorder } - override fun getAmplitudeAmount(): Int { - return 100 + 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 index 784ea5f0f..2d70e540c 100644 --- a/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt @@ -2,6 +2,7 @@ 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 @@ -12,12 +13,12 @@ abstract class ExtraRecorderInformationService: RecorderService() { abstract fun getAmplitudeAmount(): Int abstract fun getAmplitude(): Int - private var recordingTime = 0L + var recordingTime = 0L + private set private lateinit var recordingTimeTimer: ScheduledExecutorService var amplitudes = mutableListOf() private set - private lateinit var amplitudesTimer: Timer private val handler = Handler(Looper.getMainLooper()) @@ -29,6 +30,7 @@ abstract class ExtraRecorderInformationService: RecorderService() { it.scheduleAtFixedRate( { recordingTime += 1000 + onRecordingTimeChange?.invoke(recordingTime) }, 0, 1000, @@ -38,7 +40,12 @@ abstract class ExtraRecorderInformationService: RecorderService() { } private fun updateAmplitude() { + if (state !== RecorderState.RECORDING) { + return + } + amplitudes.add(getAmplitude()) + onAmplitudeChange?.invoke(amplitudes) // Delete old amplitudes if (amplitudes.size > getAmplitudeAmount()) { @@ -49,17 +56,7 @@ abstract class ExtraRecorderInformationService: RecorderService() { } private fun createAmplitudesTimer() { - amplitudesTimer = Timer().also { - it.scheduleAtFixedRate( - object: TimerTask() { - override fun run() { - updateAmplitude() - } - }, - 0, - 100, - ) - } + handler.postDelayed(::updateAmplitude, 100) } override fun start() { @@ -69,7 +66,6 @@ abstract class ExtraRecorderInformationService: RecorderService() { override fun pause() { recordingTimeTimer.shutdown() - amplitudesTimer.cancel() } override fun resume() { @@ -79,7 +75,6 @@ abstract class ExtraRecorderInformationService: RecorderService() { override fun stop() { recordingTimeTimer.shutdown() - amplitudesTimer.cancel() } } \ 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 index cdb006387..1004adf48 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -1,21 +1,33 @@ 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 - lateinit var settings: Settings + var settings: Settings? = null protected set private lateinit var cycleTimer: ScheduledExecutorService @@ -23,10 +35,10 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { fun createLastRecording(): LastRecording = LastRecording( folderPath = folder.absolutePath, recordingStart = recordingStart, - maxDuration = settings.maxDuration, - fileExtension = settings.fileExtension, - intervalDuration = settings.intervalDuration, - forceExactMaxDuration = settings.forceExactMaxDuration, + maxDuration = settings!!.maxDuration, + fileExtension = settings!!.fileExtension, + intervalDuration = settings!!.intervalDuration, + forceExactMaxDuration = settings!!.forceExactMaxDuration, ) // Make overrideable @@ -41,32 +53,53 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { startNewCycle() }, 0, - settings.intervalDuration, + 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() - createTimer() + scope.launch { + dataStore.data.collectLatest { preferenceSettings -> + if (settings == null) { + settings = Settings.from(preferenceSettings.audioRecorderSettings) + + createTimer() + } + } + } } override fun pause() { + super.pause() cycleTimer.shutdown() } override fun resume() { + super.resume() createTimer() } override fun stop() { + super.stop() cycleTimer.shutdown() } private fun deleteOldRecordings() { - val timeMultiplier = settings.maxDuration / settings.intervalDuration + val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration val earliestCounter = counter - timeMultiplier folder.listFiles()?.forEach { 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 f881948a2..86b800bd3 100644 --- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt @@ -26,7 +26,7 @@ abstract class RecorderService: Service() { lateinit var recordingStart: LocalDateTime private set - var state = RecorderState.RECORDING + var state = RecorderState.IDLE private set var onStateChange: ((RecorderState) -> Unit)? = null @@ -60,28 +60,34 @@ abstract class RecorderService: Service() { pause() isPaused = true } - else -> throw IllegalStateException("$newState is not a valid state. Destroy or recreate the service instead.") + RecorderState.IDLE -> stop() } state = newState onStateChange?.invoke(newState) } - override fun onCreate() { - super.onCreate() + // Must be called immediately after the service is created + fun startRecording() { + recordingStart = LocalDateTime.now() val notification = buildNotification() - startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification) - recordingStart = LocalDateTime.now() - start() + // Start + changeState(RecorderState.RECORDING) + } + + override fun onCreate() { + super.onCreate() + + startRecording() } override fun onDestroy() { super.onDestroy() - stop() + changeState(RecorderState.IDLE) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() 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 a88aa9b02..7dc6ffbbc 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -11,11 +11,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.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 +25,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 +36,8 @@ fun Navigation() { .collectAsState(initial = null) .value ?: return + audioRecorder.BindToService(context) + NavHost( modifier = Modifier .background(MaterialTheme.colorScheme.background), @@ -53,7 +59,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 +73,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 aeeaa8614..7d35b35e5 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 index 550e2e441..1933c685a 100644 --- 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 @@ -88,13 +88,9 @@ fun SaveRecordingButton( .then(modifier), onClick = { isProcessingAudio = true - RecorderService.stopService(context) scope.launch { try { - val file = service.concatenateFiles() - - onSaveFile(file) } catch (error: Exception) { Log.getStackTraceString(error) } finally { 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 2648d8fe6..19aa303b8 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 @@ -47,6 +47,7 @@ import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialo 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 @@ -57,15 +58,12 @@ import java.time.ZoneId @Composable fun RecordingStatus( - service: RecorderService, - onSaveFile: (File) -> Unit, + audioRecorder: AudioRecorderModel, ) { val context = LocalContext.current var now by remember { mutableStateOf(LocalDateTime.now()) } - val progress = service.recordingTime.value!! / (service.settings!!.maxDuration / 1000f) - LaunchedEffect(Unit) { while (true) { now = LocalDateTime.now() @@ -74,7 +72,7 @@ fun RecordingStatus( } // Only show animation when the recording has just started - val recordingJustStarted = service.recordingTime.value!! < 1 + val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L var progressVisible by remember { mutableStateOf(!recordingJustStarted) } LaunchedEffect(Unit) { progressVisible = true @@ -89,7 +87,7 @@ fun RecordingStatus( verticalArrangement = Arrangement.SpaceBetween, ) { Box {} - RealtimeAudioVisualizer(service = service) + RealtimeAudioVisualizer(audioRecorder = audioRecorder) Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -97,7 +95,7 @@ fun RecordingStatus( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - val distance = Duration.between(service.recordingStart!!, now).toMillis() + val distance = Duration.between(audioRecorder.recorderService!!.recordingStart, now).toMillis() Pulsating { Box( @@ -121,7 +119,7 @@ fun RecordingStatus( ) ) { LinearProgressIndicator( - progress = progress, + progress = audioRecorder.progress, modifier = Modifier .width(300.dp) ) @@ -137,8 +135,7 @@ fun RecordingStatus( }, onConfirm = { showDeleteDialog = false - RecorderService.stopService(context) - service.reset() + audioRecorder.stopRecording(context) }, ) } @@ -163,11 +160,5 @@ fun RecordingStatus( } } val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000)) - SaveRecordingButton( - modifier = Modifier - .alpha(alpha), - service = service, - onSaveFile = onSaveFile, - ) } } \ 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 fd5446b2f..b8cd59d76 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 @@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -52,15 +53,14 @@ import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton import app.myzel394.alibi.ui.components.atoms.PermissionRequester +import app.myzel394.alibi.ui.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, - onStart: () -> Unit, + audioRecorder: AudioRecorderModel, ) { val context = LocalContext.current val saveFile = rememberFileSaverDialog("audio/*") @@ -80,17 +80,7 @@ fun StartRecording( ) }, onPermissionAvailable = { - RecorderService.startService(context, connection) - - if (service == null) { - onStart() - } else { - // To avoid any leaks from the previous recording, we need to wait until it - // fully started - service.setOnStartedListener { - onStart() - } - } + audioRecorder.startRecording(context) }, ) { trigger -> val label = stringResource(R.string.ui_audioRecorder_action_start_label) @@ -140,21 +130,12 @@ fun StartRecording( .fillMaxWidth(), textAlign = TextAlign.Center, ) - if (service?.hasRecordingAvailable == true) + if (false) Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom, ) { - SaveRecordingButton( - service = service, - onSaveFile = saveFile, - label = stringResource( - R.string.ui_audioRecorder_action_saveOldRecording_label, - DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart), - ), - - ) } else Spacer(modifier = Modifier.weight(1f)) 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 index f5ca99a22..17c3763c5 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -5,6 +5,8 @@ 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 @@ -19,11 +21,20 @@ class AudioRecorderModel: ViewModel() { private set var recordingTime by mutableStateOf(null) private set - var amplitudes by mutableStateOf?>(null) + var amplitudes by mutableStateOf>(emptyList()) private set + var onAmplitudeChange: () -> Unit = {} + + val isInRecording: Boolean + get() = recorderState !== RecorderState.IDLE && recordingTime != null + + val progress: Float + get() = (recordingTime!! / recorderService!!.settings!!.maxDuration).toFloat() + private var intent: Intent? = null - private var recorderService: RecorderService? = null + var recorderService: AudioRecorderService? = null + private set private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { @@ -36,8 +47,12 @@ class AudioRecorderModel: ViewModel() { } recorder.onAmplitudeChange = { amps -> amplitudes = amps + onAmplitudeChange() } } + recorderState = recorderService!!.state + recordingTime = recorderService!!.recordingTime + amplitudes = recorderService!!.amplitudes } override fun onServiceDisconnected(arg0: ComponentName) { @@ -49,7 +64,7 @@ class AudioRecorderModel: ViewModel() { fun reset() { recorderState = RecorderState.IDLE recordingTime = null - amplitudes = null + amplitudes = emptyList() } fun startRecording(context: Context) { @@ -57,13 +72,30 @@ class AudioRecorderModel: ViewModel() { context.unbindService(connection) } - val intent = Intent(context, AudioRecorderService::class.java) - ContextCompat.startForegroundService(context, intent) - context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + 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) + runCatching { + context.unbindService(connection) + context.stopService(intent) + } + + reset() + } + + 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 c5877f414..3736f0c1e 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 @@ -14,26 +14,25 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +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.ui.models.AudioRecorderModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun AudioRecorder( navController: NavController, + audioRecorder: AudioRecorderModel ) { + val context = LocalContext.current val saveFile = rememberFileSaverDialog("audio/aac") - val (connection, service) = bindToRecorderService() - - var showRecorderStatus by remember { - mutableStateOf(service?.isRecording ?: false) - } Scaffold( topBar = { @@ -61,22 +60,12 @@ fun AudioRecorder( .fillMaxSize() .padding(padding), ) { - if (showRecorderStatus && service?.recordingTime?.value != null) + if (audioRecorder.isInRecording) RecordingStatus( - service = service, - onSaveFile = { - saveFile(it) - showRecorderStatus = false - } + audioRecorder = audioRecorder, ) else - StartRecording( - connection = connection, - service = service, - onStart = { - showRecorderStatus = true - } - ) + 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 f47ed321d..2a92a5483 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 ?: 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) From 09f9be8414f13bfc12138046e9d7bf5e4aa88646 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 19:50:45 +0200 Subject: [PATCH 3/7] feat: Add save recording button --- .../java/app/myzel394/alibi/db/AppSettings.kt | 1 + .../java/app/myzel394/alibi/ui/Navigation.kt | 5 ++ .../molecules/RecordingStatus.kt | 31 ++++++++- .../AudioRecorder/molecules/StartRecording.kt | 45 ++++++++----- .../alibi/ui/models/AudioRecorderModel.kt | 12 +++- .../alibi/ui/screens/AudioRecorder.kt | 66 +++++++++++++++++-- 6 files changed, 135 insertions(+), 25 deletions(-) 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 7cfd621f0..3a5d7c5f9 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt @@ -5,6 +5,7 @@ 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 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 7dc6ffbbc..8617b5222 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -9,6 +9,10 @@ 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 @@ -16,6 +20,7 @@ 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 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 19aa303b8..e382876da 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 @@ -13,12 +13,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -43,6 +46,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import app.myzel394.alibi.R import app.myzel394.alibi.services.RecorderService +import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton @@ -135,7 +139,7 @@ fun RecordingStatus( }, onConfirm = { showDeleteDialog = false - audioRecorder.stopRecording(context) + audioRecorder.stopRecording(context, saveAsLastRecording = false) }, ) } @@ -159,6 +163,31 @@ fun RecordingStatus( Text(label) } } + 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) + .alpha(alpha) + .semantics { + contentDescription = label + }, + onClick = { + audioRecorder.stopRecording(context) + audioRecorder.onRecordingSave() + }, + ) { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + 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 b8cd59d76..49a5948a8 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,14 +1,7 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -28,14 +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.saveable.autoSaver -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,10 +34,7 @@ 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.AudioRecorder.atoms.SaveRecordingButton import app.myzel394.alibi.ui.components.atoms.PermissionRequester import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.utils.rememberFileSaverDialog @@ -130,13 +113,39 @@ fun StartRecording( .fillMaxWidth(), textAlign = TextAlign.Center, ) - if (false) + 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) + .semantics { + contentDescription = label + }, + onClick = { + audioRecorder.stopRecording(context) + audioRecorder.onRecordingSave() + }, + ) { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + 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 index 17c3763c5..9757662e2 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -12,6 +12,7 @@ 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 @@ -36,6 +37,11 @@ class AudioRecorderModel: ViewModel() { 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 -> @@ -77,7 +83,11 @@ class AudioRecorderModel: ViewModel() { context.bindService(intent!!, connection, Context.BIND_AUTO_CREATE) } - fun stopRecording(context: Context) { + fun stopRecording(context: Context, saveAsLastRecording: Boolean = true) { + if (saveAsLastRecording) { + lastRecording = recorderService!!.createLastRecording() + } + runCatching { context.unbindService(connection) context.stopService(intent) 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 3736f0c1e..0e2079214 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,21 +1,30 @@ 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.ui.components.AudioRecorder.molecules.RecordingStatus @@ -23,17 +32,66 @@ 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 + audioRecorder: AudioRecorderModel, ) { - val context = LocalContext.current val saveFile = rememberFileSaverDialog("audio/aac") + 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( @@ -61,9 +119,7 @@ fun AudioRecorder( .padding(padding), ) { if (audioRecorder.isInRecording) - RecordingStatus( - audioRecorder = audioRecorder, - ) + RecordingStatus(audioRecorder = audioRecorder) else StartRecording(audioRecorder = audioRecorder) } From d125ed79d5052418776740282959a04500abae9c Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 19:51:20 +0200 Subject: [PATCH 4/7] ui: Make save button on StartRecording text button style --- .../ui/components/AudioRecorder/molecules/StartRecording.kt | 1 + 1 file changed, 1 insertion(+) 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 49a5948a8..210a1c7f7 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 @@ -131,6 +131,7 @@ fun StartRecording( .semantics { contentDescription = label }, + colors = ButtonDefaults.textButtonColors(), onClick = { audioRecorder.stopRecording(context) audioRecorder.onRecordingSave() From 0e6eff9f8550861c7799730aa6bc9dc9e5d20150 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:02:22 +0200 Subject: [PATCH 5/7] feat: Add pause / resume functionality --- .../alibi/services/AudioRecorderService.kt | 6 ++++ .../alibi/services/IntervalRecorderService.kt | 6 +++- .../molecules/RecordingStatus.kt | 29 +++++++++++++++++-- .../alibi/ui/models/AudioRecorderModel.kt | 11 +++++++ app/src/main/res/values/strings.xml | 2 ++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt index 02d2bce2e..b54f5b7c2 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -49,6 +49,12 @@ class AudioRecorderService: IntervalRecorderService() { recorder = newRecorder } + override fun pause() { + super.pause() + + resetRecorder() + } + override fun stop() { super.stop() diff --git a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt index 1004adf48..3710e64f3 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -43,6 +43,7 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { // Make overrideable open fun startNewCycle() { + counter += 1 deleteOldRecordings() } @@ -89,8 +90,11 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { } override fun resume() { - super.resume() createTimer() + + // We first want to start our timers, so the `ExtraRecorderInformationService` can fetch + // amplitudes + super.resume() } override fun stop() { 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 e382876da..300450b90 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 @@ -21,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 @@ -99,8 +103,6 @@ fun RecordingStatus( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - val distance = Duration.between(audioRecorder.recorderService!!.recordingStart, now).toMillis() - Pulsating { Box( modifier = Modifier @@ -111,7 +113,7 @@ fun RecordingStatus( } Spacer(modifier = Modifier.width(16.dp)) Text( - text = formatDuration(distance), + text = formatDuration(audioRecorder.recordingTime!!), style = MaterialTheme.typography.headlineLarge, ) } @@ -164,6 +166,27 @@ fun RecordingStatus( } } + 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) 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 index 9757662e2..23dabc910 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -30,6 +30,9 @@ class AudioRecorderModel: ViewModel() { 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() @@ -96,6 +99,14 @@ class AudioRecorderModel: ViewModel() { reset() } + fun pauseRecording() { + recorderService!!.changeState(RecorderState.PAUSED) + } + + fun resumeRecording() { + recorderService!!.changeState(RecorderState.RECORDING) + } + fun setMaxAmplitudesAmount(amount: Int) { recorderService?.amplitudesAmount = amount } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ce351ac5..58272dc15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,8 @@ 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 From e667a4ea272d2fba1286cdc77689f2744b4460de Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:46:45 +0200 Subject: [PATCH 6/7] feat: Add different notification types --- .../ExtraRecorderInformationService.kt | 29 ------- .../alibi/services/IntervalRecorderService.kt | 2 - .../alibi/services/RecorderService.kt | 79 +++++++++++++++++-- app/src/main/res/values/strings.xml | 4 + 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt index 2d70e540c..86a98ce7e 100644 --- a/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt @@ -13,32 +13,13 @@ abstract class ExtraRecorderInformationService: RecorderService() { abstract fun getAmplitudeAmount(): Int abstract fun getAmplitude(): Int - var recordingTime = 0L - private set - private lateinit var recordingTimeTimer: ScheduledExecutorService - var amplitudes = mutableListOf() private set private val handler = Handler(Looper.getMainLooper()) - var onRecordingTimeChange: ((Long) -> Unit)? = null var onAmplitudeChange: ((List) -> Unit)? = null - private fun createRecordingTimeTimer() { - recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also { - it.scheduleAtFixedRate( - { - recordingTime += 1000 - onRecordingTimeChange?.invoke(recordingTime) - }, - 0, - 1000, - TimeUnit.MILLISECONDS - ) - } - } - private fun updateAmplitude() { if (state !== RecorderState.RECORDING) { return @@ -60,21 +41,11 @@ abstract class ExtraRecorderInformationService: RecorderService() { } override fun start() { - createRecordingTimeTimer() createAmplitudesTimer() } - override fun pause() { - recordingTimeTimer.shutdown() - } - override fun resume() { - createRecordingTimeTimer() createAmplitudesTimer() } - override fun stop() { - recordingTimeTimer.shutdown() - } - } \ 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 index 3710e64f3..b2f6fa494 100644 --- a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt @@ -85,7 +85,6 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { } override fun pause() { - super.pause() cycleTimer.shutdown() } @@ -98,7 +97,6 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() { } override fun stop() { - super.stop() cycleTimer.shutdown() } 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 86b800bd3..fc6263187 100644 --- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt @@ -1,5 +1,6 @@ package app.myzel394.alibi.services +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.app.Service @@ -7,17 +8,21 @@ import android.content.Intent import android.os.Binder import android.os.IBinder import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import app.myzel394.alibi.MainActivity import app.myzel394.alibi.NotificationHelper import app.myzel394.alibi.R import app.myzel394.alibi.enums.RecorderState +import app.myzel394.alibi.ui.utils.PermissionHelper import java.time.LocalDateTime import java.time.ZoneId +import java.util.Calendar import java.util.Date import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit + abstract class RecorderService: Service() { private val binder = RecorderBinder() @@ -31,6 +36,11 @@ abstract class RecorderService: Service() { var onStateChange: ((RecorderState) -> Unit)? = null + var recordingTime = 0L + private set + private lateinit var recordingTimeTimer: ScheduledExecutorService + var onRecordingTimeChange: ((Long) -> Unit)? = null + protected abstract fun start() protected abstract fun pause() protected abstract fun resume() @@ -42,11 +52,27 @@ abstract class RecorderService: Service() { 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) { @@ -63,7 +89,22 @@ abstract class RecorderService: Service() { RecorderState.IDLE -> stop() } - state = newState + when (newState) { + RecorderState.RECORDING -> { + createRecordingTimeTimer() + } + RecorderState.PAUSED, RecorderState.IDLE -> { + recordingTimeTimer.shutdown() + } + } + + if (PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)){ + val notification = buildNotification() + NotificationManagerCompat.from(this).notify( + NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, + notification + ) + } onStateChange?.invoke(newState) } @@ -93,17 +134,45 @@ abstract class RecorderService: Service() { stopSelf() } - private fun buildNotification(): Notification { - return NotificationCompat.Builder(this, "recorder") - .setContentTitle("Recording Audio") - .setContentText("Recording audio in background") + private fun buildNotification(): Notification = when(state) { + RecorderState.RECORDING, RecorderState.IDLE -> 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) + // Convert calendar to epoch seconds + .setWhen( + Date.from( + Calendar + .getInstance() + .also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) } + .toInstant() + ).time, + ) + .setSilent(true) .setOnlyAlertOnce(true) .setUsesChronometer(true) .setChronometerCountDown(false) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + ) + .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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58272dc15..6358f39ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ 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. @@ -54,4 +56,6 @@ Define how many samples per second are taken from the audio signal Set the sampling rate Encoder + Recording paused + Audio Recording has been paused \ No newline at end of file From 9d1966e9b31765b869766391bf18006808f362ea Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 8 Aug 2023 21:47:43 +0200 Subject: [PATCH 7/7] feat: Add pause / resume and delete functionality to notifications --- .../alibi/services/RecorderService.kt | 70 +++++++++++++++++-- app/src/main/res/drawable/ic_cancel.xml | 5 ++ app/src/main/res/drawable/ic_pause.xml | 5 ++ app/src/main/res/drawable/ic_play.xml | 5 ++ 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/drawable/ic_cancel.xml create mode 100644 app/src/main/res/drawable/ic_pause.xml create mode 100644 app/src/main/res/drawable/ic_play.xml 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 fc6263187..56344290d 100644 --- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt @@ -48,6 +48,19 @@ abstract class RecorderService: Service() { override fun onBind(p0: Intent?): IBinder? = binder + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + "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 } @@ -86,7 +99,10 @@ abstract class RecorderService: Service() { pause() isPaused = true } - RecorderState.IDLE -> stop() + RecorderState.IDLE -> { + stop() + onDestroy() + } } when (newState) { @@ -98,7 +114,14 @@ abstract class RecorderService: Service() { } } - if (PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)){ + + 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, @@ -108,11 +131,10 @@ abstract class RecorderService: Service() { onStateChange?.invoke(newState) } - // Must be called immediately after the service is created fun startRecording() { recordingStart = LocalDateTime.now() - val notification = buildNotification() + val notification = buildStartNotification() startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification) // Start @@ -131,18 +153,38 @@ abstract class RecorderService: Service() { changeState(RecorderState.IDLE) stopForeground(STOP_FOREGROUND_REMOVE) + NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID) stopSelf() } + 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() + + 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 buildNotification(): Notification = when(state) { - RecorderState.RECORDING, RecorderState.IDLE -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID) + 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) - // Convert calendar to epoch seconds .setWhen( Date.from( Calendar @@ -163,6 +205,16 @@ abstract class RecorderService: Service() { 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)) @@ -183,6 +235,12 @@ abstract class RecorderService: Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) ) + .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()`") } } \ No newline at end of file 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 000000000..a0a94e3cc --- /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 000000000..4958a1154 --- /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 000000000..2217e3297 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,5 @@ + + +