diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b8f2dbc..fccf7db 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -37,7 +37,7 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt b/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt
index b69cbed..679cee7 100644
--- a/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt
+++ b/app/src/main/java/app/myzel394/alibi/NotificationHelper.kt
@@ -7,10 +7,13 @@ import android.os.Build
import androidx.annotation.RequiresApi
object NotificationHelper {
+ const val RECORDER_CHANNEL_ID = "recorder"
+ const val RECORDER_CHANNEL_NOTIFICATION_ID = 1
+
@RequiresApi(Build.VERSION_CODES.O)
fun createChannels(context: Context) {
val channel = NotificationChannel(
- "recorder",
+ RECORDER_CHANNEL_ID,
context.resources.getString(R.string.notificationChannels_recorder_name),
android.app.NotificationManager.IMPORTANCE_LOW,
)
@@ -19,4 +22,5 @@ object NotificationHelper {
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
+
}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt
index 039773e..3a5d7c5 100644
--- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt
+++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt
@@ -2,7 +2,14 @@ package app.myzel394.alibi.db
import android.media.MediaRecorder
import android.os.Build
+import android.util.Log
+import com.arthenica.ffmpegkit.FFmpegKit
+import com.arthenica.ffmpegkit.ReturnCode
+import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
+import java.io.File
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@Serializable
data class AppSettings(
@@ -27,6 +34,103 @@ data class AppSettings(
}
}
+@Serializable
+data class LastRecording(
+ val folderPath: String,
+ @Serializable(with = LocalDateTimeSerializer::class)
+ val recordingStart: LocalDateTime,
+ val maxDuration: Long,
+ val intervalDuration: Long,
+ val fileExtension: String,
+ val forceExactMaxDuration: Boolean,
+) {
+ val fileFolder: File
+ get() = File(folderPath)
+
+ val filePaths: List
+ get() =
+ File(folderPath).listFiles()?.filter {
+ val name = it.nameWithoutExtension
+
+ name.toIntOrNull() != null
+ }?.toList() ?: emptyList()
+
+ val hasRecordingAvailable: Boolean
+ get() = filePaths.isNotEmpty()
+
+ private fun stripConcatenatedFileToExactDuration(
+ outputFile: File
+ ) {
+ // Move the concatenated file to a temporary file
+ val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
+ outputFile.renameTo(rawFile)
+
+ val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
+
+ val session = FFmpegKit.execute(command)
+
+ if (!ReturnCode.isSuccess(session.returnCode)) {
+ Log.d(
+ "Audio Concatenation",
+ String.format(
+ "Command failed with state %s and rc %s.%s",
+ session.getState(),
+ session.getReturnCode(),
+ session.getFailStackTrace()
+ )
+ )
+
+ throw Exception("Failed to strip concatenated audio")
+ }
+ }
+
+ suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
+ val paths = filePaths.joinToString("|")
+ val fileName = recordingStart
+ .format(ISO_DATE_TIME)
+ .toString()
+ .replace(":", "-")
+ .replace(".", "_")
+ val outputFile = File("$fileFolder/$fileName.${fileExtension}")
+
+ if (outputFile.exists() && !forceConcatenation) {
+ return outputFile
+ }
+
+ val command = "-i 'concat:$paths' -y" +
+ " -acodec copy" +
+ " -metadata title='$fileName' " +
+ " -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
+ " -metadata batch_count='${filePaths.size}'" +
+ " -metadata batch_duration='${intervalDuration}'" +
+ " -metadata max_duration='${maxDuration}'" +
+ " $outputFile"
+
+ val session = FFmpegKit.execute(command)
+
+ if (!ReturnCode.isSuccess(session.returnCode)) {
+ Log.d(
+ "Audio Concatenation",
+ String.format(
+ "Command failed with state %s and rc %s.%s",
+ session.getState(),
+ session.getReturnCode(),
+ session.getFailStackTrace()
+ )
+ )
+
+ throw Exception("Failed to concatenate audios")
+ }
+
+ val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
+ if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
+ stripConcatenatedFileToExactDuration(outputFile)
+ }
+
+ return outputFile
+ }
+}
+
@Serializable
data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L,
diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt
index 9a43ec0..ca91a75 100644
--- a/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt
+++ b/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt
@@ -1,10 +1,17 @@
package app.myzel394.alibi.db
import androidx.datastore.core.Serializer
+import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
+import java.time.LocalDateTime
class AppSettingsSerializer: Serializer {
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
@@ -30,4 +37,16 @@ class AppSettingsSerializer: Serializer {
).encodeToByteArray()
)
}
-}
\ No newline at end of file
+}
+
+class LocalDateTimeSerializer: KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): LocalDateTime {
+ return LocalDateTime.parse(decoder.decodeString())
+ }
+
+ override fun serialize(encoder: Encoder, value: LocalDateTime) {
+ encoder.encodeString(value.toString())
+ }
+}
diff --git a/app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt b/app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt
new file mode 100644
index 0000000..ed3cb12
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/enums/RecorderState.kt
@@ -0,0 +1,7 @@
+package app.myzel394.alibi.enums
+
+enum class RecorderState {
+ IDLE,
+ RECORDING,
+ PAUSED,
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt
new file mode 100644
index 0000000..b54f5b7
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt
@@ -0,0 +1,67 @@
+package app.myzel394.alibi.services
+
+import android.media.MediaRecorder
+import android.os.Build
+
+class AudioRecorderService: IntervalRecorderService() {
+ var amplitudesAmount = 1000
+
+ var recorder: MediaRecorder? = null
+ private set
+
+ val filePath: String
+ get() = "$folder/$counter.${settings!!.fileExtension}"
+
+ private fun createRecorder(): MediaRecorder {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(this)
+ } else {
+ MediaRecorder()
+ }.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFile(filePath)
+ setOutputFormat(settings!!.outputFormat)
+ setAudioEncoder(settings!!.encoder)
+ setAudioEncodingBitRate(settings!!.bitRate)
+ setAudioSamplingRate(settings!!.samplingRate)
+ }
+ }
+
+ private fun resetRecorder() {
+ runCatching {
+ recorder?.let {
+ it.stop()
+ it.release()
+ }
+ }
+ }
+
+ override fun startNewCycle() {
+ super.startNewCycle()
+
+ val newRecorder = createRecorder().also {
+ it.prepare()
+ }
+
+ resetRecorder()
+
+ newRecorder.start()
+ recorder = newRecorder
+ }
+
+ override fun pause() {
+ super.pause()
+
+ resetRecorder()
+ }
+
+ override fun stop() {
+ super.stop()
+
+ resetRecorder()
+ }
+
+ override fun getAmplitudeAmount(): Int = amplitudesAmount
+
+ override fun getAmplitude(): Int = recorder?.maxAmplitude ?: 0
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt
new file mode 100644
index 0000000..86a98ce
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/services/ExtraRecorderInformationService.kt
@@ -0,0 +1,51 @@
+package app.myzel394.alibi.services
+
+import android.os.Handler
+import android.os.Looper
+import app.myzel394.alibi.enums.RecorderState
+import java.util.Timer
+import java.util.TimerTask
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
+
+abstract class ExtraRecorderInformationService: RecorderService() {
+ abstract fun getAmplitudeAmount(): Int
+ abstract fun getAmplitude(): Int
+
+ var amplitudes = mutableListOf()
+ private set
+
+ private val handler = Handler(Looper.getMainLooper())
+
+ var onAmplitudeChange: ((List) -> Unit)? = null
+
+ private fun updateAmplitude() {
+ if (state !== RecorderState.RECORDING) {
+ return
+ }
+
+ amplitudes.add(getAmplitude())
+ onAmplitudeChange?.invoke(amplitudes)
+
+ // Delete old amplitudes
+ if (amplitudes.size > getAmplitudeAmount()) {
+ amplitudes.drop(amplitudes.size - getAmplitudeAmount())
+ }
+
+ handler.postDelayed(::updateAmplitude, 100)
+ }
+
+ private fun createAmplitudesTimer() {
+ handler.postDelayed(::updateAmplitude, 100)
+ }
+
+ override fun start() {
+ createAmplitudesTimer()
+ }
+
+ override fun resume() {
+ createAmplitudesTimer()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt
new file mode 100644
index 0000000..b2f6fa4
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/services/IntervalRecorderService.kt
@@ -0,0 +1,152 @@
+package app.myzel394.alibi.services
+
+import android.content.Context
+import android.media.MediaRecorder
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AudioRecorderSettings
+import app.myzel394.alibi.db.LastRecording
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import java.io.File
+import java.time.LocalDateTime
+import java.util.Timer
+import java.util.TimerTask
+import java.util.UUID
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
+
+abstract class IntervalRecorderService: ExtraRecorderInformationService() {
+ private var job = SupervisorJob()
+ private var scope = CoroutineScope(Dispatchers.IO + job)
+
+ protected var counter = 0
+ private set
+ protected lateinit var folder: File
+ var settings: Settings? = null
+ protected set
+
+ private lateinit var cycleTimer: ScheduledExecutorService
+
+ fun createLastRecording(): LastRecording = LastRecording(
+ folderPath = folder.absolutePath,
+ recordingStart = recordingStart,
+ maxDuration = settings!!.maxDuration,
+ fileExtension = settings!!.fileExtension,
+ intervalDuration = settings!!.intervalDuration,
+ forceExactMaxDuration = settings!!.forceExactMaxDuration,
+ )
+
+ // Make overrideable
+ open fun startNewCycle() {
+ counter += 1
+ deleteOldRecordings()
+ }
+
+ private fun createTimer() {
+ cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
+ it.scheduleAtFixedRate(
+ {
+ startNewCycle()
+ },
+ 0,
+ settings!!.intervalDuration,
+ TimeUnit.MILLISECONDS
+ )
+ }
+ }
+
+ private fun getRandomFileFolder(): String {
+ // uuid
+ val folder = UUID.randomUUID().toString()
+
+ return "${externalCacheDir!!.absolutePath}/$folder"
+ }
+
+ override fun start() {
+ super.start()
+
+ folder = File(getRandomFileFolder())
+ folder.mkdirs()
+
+ scope.launch {
+ dataStore.data.collectLatest { preferenceSettings ->
+ if (settings == null) {
+ settings = Settings.from(preferenceSettings.audioRecorderSettings)
+
+ createTimer()
+ }
+ }
+ }
+ }
+
+ override fun pause() {
+ cycleTimer.shutdown()
+ }
+
+ override fun resume() {
+ createTimer()
+
+ // We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
+ // amplitudes
+ super.resume()
+ }
+
+ override fun stop() {
+ cycleTimer.shutdown()
+ }
+
+ private fun deleteOldRecordings() {
+ val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
+ val earliestCounter = counter - timeMultiplier
+
+ folder.listFiles()?.forEach { file ->
+ val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
+
+ if (fileCounter < earliestCounter) {
+ file.delete()
+ }
+ }
+ }
+
+ data class Settings(
+ val maxDuration: Long,
+ val intervalDuration: Long,
+ val forceExactMaxDuration: Boolean,
+ val bitRate: Int,
+ val samplingRate: Int,
+ val outputFormat: Int,
+ val encoder: Int,
+ ) {
+ val fileExtension: String
+ get() = when(outputFormat) {
+ MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
+ MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
+ MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
+ MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
+ MediaRecorder.OutputFormat.WEBM -> "webm"
+ MediaRecorder.OutputFormat.AMR_NB -> "amr"
+ MediaRecorder.OutputFormat.AMR_WB -> "awb"
+ MediaRecorder.OutputFormat.OGG -> "ogg"
+ else -> "raw"
+ }
+
+ companion object {
+ fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
+ return Settings(
+ intervalDuration = audioRecorderSettings.intervalDuration,
+ bitRate = audioRecorderSettings.bitRate,
+ samplingRate = audioRecorderSettings.getSamplingRate(),
+ outputFormat = audioRecorderSettings.getOutputFormat(),
+ encoder = audioRecorderSettings.getEncoder(),
+ maxDuration = audioRecorderSettings.maxDuration,
+ forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
index 9e6bd4d..9e91a5a 100644
--- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
+++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
@@ -1,334 +1,231 @@
package app.myzel394.alibi.services
+import android.annotation.SuppressLint
+import android.app.Notification
import android.app.PendingIntent
import android.app.Service
-import android.content.ComponentName
-import android.content.Context
import android.content.Intent
-import android.content.ServiceConnection
-import android.media.MediaRecorder
import android.os.Binder
-import android.os.Build
-import android.os.Handler
import android.os.IBinder
-import android.os.Looper
-import android.util.Log
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationCompat
-import androidx.core.content.ContextCompat
+import androidx.core.app.NotificationManagerCompat
import app.myzel394.alibi.MainActivity
+import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
-import app.myzel394.alibi.dataStore
-import app.myzel394.alibi.db.AudioRecorderSettings
-import com.arthenica.ffmpegkit.FFmpegKit
-import com.arthenica.ffmpegkit.ReturnCode
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import java.io.File
+import app.myzel394.alibi.enums.RecorderState
+import app.myzel394.alibi.ui.utils.PermissionHelper
import java.time.LocalDateTime
import java.time.ZoneId
-import java.time.format.DateTimeFormatter.ISO_DATE_TIME
+import java.util.Calendar
import java.util.Date
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
-import java.util.UUID
-const val AMPLITUDE_UPDATE_INTERVAL = 100L
+abstract class RecorderService: Service() {
+ private val binder = RecorderBinder()
-class RecorderService: Service() {
- private val binder = LocalBinder()
- private val handler = Handler(Looper.getMainLooper())
- private var job = SupervisorJob()
- private var scope = CoroutineScope(Dispatchers.IO + job)
+ private var isPaused: Boolean = false
- private var mediaRecorder: MediaRecorder? = null
- private var onError: MediaRecorder.OnErrorListener? = null
- private var onAmplitudeUpdate: () -> Unit = {}
-
- private var counter = 0
- var maxAmplitudes = 1000
-
- var settings: Settings? = null
+ lateinit var recordingStart: LocalDateTime
private set
- var fileFolder: String? = null
- private set
- val isRecording = mutableStateOf(false)
-
- val amplitudes = mutableStateListOf()
-
- var recordingStart: LocalDateTime? = null
+ var state = RecorderState.IDLE
private set
- val filePaths: List
- get() = File(fileFolder!!).listFiles()?.filter {
- val name = it.nameWithoutExtension
+ var onStateChange: ((RecorderState) -> Unit)? = null
- if (name.toIntOrNull() == null) {
- return@filter false
- }
+ var recordingTime = 0L
+ private set
+ private lateinit var recordingTimeTimer: ScheduledExecutorService
+ var onRecordingTimeChange: ((Long) -> Unit)? = null
- val extension = it.extension
+ protected abstract fun start()
+ protected abstract fun pause()
+ protected abstract fun resume()
+ protected abstract fun stop()
- extension == settings!!.fileExtension
- }?.toList() ?: emptyList()
-
- override fun onBind(p0: Intent?): IBinder = binder
+ override fun onBind(p0: Intent?): IBinder? = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
- Actions.START.toString() -> start()
- Actions.STOP.toString() -> stop()
+ "changeState" -> {
+ val newState = intent.getStringExtra("newState")?.let {
+ RecorderState.valueOf(it)
+ } ?: RecorderState.IDLE
+ changeState(newState)
+ }
}
return super.onStartCommand(intent, flags, startId)
}
+ inner class RecorderBinder: Binder() {
+ fun getService(): RecorderService = this@RecorderService
+ }
+
+ private fun createRecordingTimeTimer() {
+ recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
+ it.scheduleAtFixedRate(
+ {
+ recordingTime += 1000
+ onRecordingTimeChange?.invoke(recordingTime)
+ },
+ 0,
+ 1000,
+ TimeUnit.MILLISECONDS
+ )
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun changeState(newState: RecorderState) {
+ if (state == newState) {
+ return
+ }
+
+ state = newState
+ when (newState) {
+ RecorderState.RECORDING -> {
+ if (isPaused) {
+ resume()
+ isPaused = false
+ } else {
+ start()
+ }
+ }
+ RecorderState.PAUSED -> {
+ pause()
+ isPaused = true
+ }
+ RecorderState.IDLE -> {
+ stop()
+ onDestroy()
+ }
+ }
+
+ when (newState) {
+ RecorderState.RECORDING -> {
+ createRecordingTimeTimer()
+ }
+ RecorderState.PAUSED, RecorderState.IDLE -> {
+ recordingTimeTimer.shutdown()
+ }
+ }
+
+
+ if (
+ arrayOf(
+ RecorderState.RECORDING,
+ RecorderState.PAUSED
+ ).contains(newState) &&
+ PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
+ ){
+ val notification = buildNotification()
+ NotificationManagerCompat.from(this).notify(
+ NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
+ notification
+ )
+ }
+ onStateChange?.invoke(newState)
+ }
+
+ fun startRecording() {
+ recordingStart = LocalDateTime.now()
+
+ val notification = buildStartNotification()
+ startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
+
+ // Start
+ changeState(RecorderState.RECORDING)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ startRecording()
+ }
+
override fun onDestroy() {
super.onDestroy()
- scope.cancel()
- }
-
- fun setOnAmplitudeUpdateListener(onAmplitudeUpdate: () -> Unit) {
- this.onAmplitudeUpdate = onAmplitudeUpdate
- }
-
- private fun start() {
- reset()
- fileFolder = getRandomFileFolder(this)
-
- // Create folder
- File(this.fileFolder!!).mkdirs()
-
- scope.launch {
- dataStore.data.collectLatest { preferenceSettings ->
- if (settings == null) {
- settings = Settings.from(preferenceSettings.audioRecorderSettings)
- recordingStart = LocalDateTime.now()
- isRecording.value = true
-
- showNotification()
- startNewRecording()
- updateAmplitude()
- }
- }
- }
- }
-
- private fun resetCoroutineScope() {
- // Reset `scope`
- scope.cancel()
- job = SupervisorJob()
- scope = CoroutineScope(Dispatchers.IO + job)
- }
-
- private fun stop() {
- isRecording.value = false
- mediaRecorder?.apply {
- runCatching {
- stop()
- release()
- }
- }
+ changeState(RecorderState.IDLE)
stopForeground(STOP_FOREGROUND_REMOVE)
+ NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
stopSelf()
}
- fun reset() {
- resetCoroutineScope()
- settings = null
- recordingStart = null
- counter = 0
- amplitudes.clear()
- isRecording.value = false
+ private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
+ .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
+ .setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
+ .setSmallIcon(R.drawable.launcher_foreground)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .build()
- if (fileFolder != null) {
- File(fileFolder!!).listFiles()?.forEach {
- it.delete()
- }
-
- fileFolder = null
- }
+ private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent {
+ return PendingIntent.getService(
+ this,
+ requestCode,
+ Intent(this, AudioRecorderService::class.java).apply {
+ action = "changeState"
+ putExtra("newState", newState.name)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
}
- private fun stripConcatenatedFileToExactDuration(
- outputFile: File
- ) {
- // Move the concatenated file to a temporary file
- val rawFile = File("$fileFolder/${outputFile.nameWithoutExtension}-raw.${settings!!.fileExtension}")
- outputFile.renameTo(rawFile)
-
- val command = "-sseof ${settings!!.maxDuration / -1000} -i $rawFile -y $outputFile"
-
- val session = FFmpegKit.execute(command)
-
- if (!ReturnCode.isSuccess(session.returnCode)) {
- Log.d(
- "Audio Concatenation",
- String.format(
- "Command failed with state %s and rc %s.%s",
- session.getState(),
- session.getReturnCode(),
- session.getFailStackTrace()
- )
- )
-
- throw Exception("Failed to strip concatenated audio")
- }
- }
-
- fun concatenateFiles(forceConcatenation: Boolean = false): File {
- val paths = filePaths.joinToString("|")
- val fileName = recordingStart!!
- .format(ISO_DATE_TIME)
- .toString()
- .replace(":", "-")
- .replace(".", "_")
- val outputFile = File("$fileFolder/$fileName.${settings!!.fileExtension}")
-
- if (outputFile.exists() && !forceConcatenation) {
- return outputFile
- }
-
- val command = "-i 'concat:$paths' -y" +
- " -acodec copy" +
- " -metadata title='$fileName' " +
- " -metadata date='${recordingStart!!.format(ISO_DATE_TIME)}'" +
- " -metadata batch_count='${filePaths.size}'" +
- " -metadata batch_duration='${settings!!.intervalDuration}'" +
- " -metadata max_duration='${settings!!.maxDuration}'" +
- " $outputFile"
-
- val session = FFmpegKit.execute(command)
-
- if (!ReturnCode.isSuccess(session.returnCode)) {
- Log.d(
- "Audio Concatenation",
- String.format(
- "Command failed with state %s and rc %s.%s",
- session.getState(),
- session.getReturnCode(),
- session.getFailStackTrace()
- )
- )
-
- throw Exception("Failed to concatenate audios")
- }
-
- val minRequiredForPossibleInExactMaxDuration = settings!!.maxDuration / settings!!.intervalDuration
- if (settings!!.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
- stripConcatenatedFileToExactDuration(outputFile)
- }
-
- return outputFile
- }
-
- private fun updateAmplitude() {
- if (!isRecording.value || mediaRecorder == null) {
- return
- }
-
- val amplitude = mediaRecorder!!.maxAmplitude
- amplitudes.add(amplitude)
-
- // Delete old amplitudes
- if (amplitudes.size > maxAmplitudes) {
- amplitudes.removeRange(0, amplitudes.size - maxAmplitudes)
- }
-
- onAmplitudeUpdate()
- handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL)
- }
-
- private fun startNewRecording() {
- if (!isRecording.value) {
- return
- }
-
- deleteOldRecordings()
-
- val newRecorder = createRecorder()
-
- newRecorder.prepare()
-
- runCatching {
- mediaRecorder?.let {
- it.stop()
- it.release()
- }
- }
-
- newRecorder.start()
- mediaRecorder = newRecorder
-
- counter++
-
- handler.postDelayed(this::startNewRecording, settings!!.intervalDuration)
- }
-
- private fun deleteOldRecordings() {
- val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
- val earliestCounter = counter - timeMultiplier
-
- File(fileFolder!!).listFiles()?.forEach { file ->
- val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
-
- if (fileCounter < earliestCounter) {
- file.delete()
- }
- }
- }
-
- private fun createRecorder(): MediaRecorder {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- MediaRecorder(this)
- } else {
- MediaRecorder()
- }.apply {
- setAudioSource(MediaRecorder.AudioSource.MIC)
- setOutputFile(getFilePath())
- setOutputFormat(settings!!.outputFormat)
- setAudioEncoder(settings!!.encoder)
- setAudioEncodingBitRate(settings!!.bitRate)
- setAudioSamplingRate(settings!!.samplingRate)
-
- setOnErrorListener { mr, what, extra ->
- onError?.onError(mr, what, extra)
-
- this@RecorderService.stop()
- }
- }
- }
-
- private fun showNotification() {
- if (!isRecording.value) {
- return
- }
-
- val notification = NotificationCompat.Builder(this, "recorder")
- .setContentTitle("Recording Audio")
- .setContentText("Recording audio in background")
+ private fun buildNotification(): Notification = when(state) {
+ RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
+ .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
+ .setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(true)
+ .setWhen(
+ Date.from(
+ Calendar
+ .getInstance()
+ .also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
+ .toInstant()
+ ).time,
+ )
+ .setSilent(true)
.setOnlyAlertOnce(true)
.setUsesChronometer(true)
.setChronometerCountDown(false)
- .setWhen(Date.from(recordingStart!!.atZone(ZoneId.systemDefault()).toInstant()).time)
+ .setContentIntent(
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ )
+ .addAction(
+ R.drawable.ic_cancel,
+ getString(R.string.ui_audioRecorder_action_delete_label),
+ getNotificationChangeStateIntent(RecorderState.IDLE, 1),
+ )
+ .addAction(
+ R.drawable.ic_pause,
+ getString(R.string.ui_audioRecorder_action_pause_label),
+ getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
+ )
+ .build()
+ RecorderState.PAUSED -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
+ .setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title))
+ .setContentText(getString(R.string.ui_audioRecorder_state_paused_description))
+ .setSmallIcon(R.drawable.launcher_foreground)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setOngoing(false)
+ .setOnlyAlertOnce(true)
+ .setUsesChronometer(false)
+ .setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
.setShowWhen(true)
.setContentIntent(
PendingIntent.getActivity(
@@ -338,127 +235,12 @@ class RecorderService: Service() {
PendingIntent.FLAG_IMMUTABLE,
)
)
- .build()
-
- // show notification
- startForeground(getNotificationId(), notification)
- }
-
- // To avoid int overflow, we'll use the number of seconds since 2023-01-01 01:01:01
- private fun getNotificationId(): Int {
- val offset = ZoneId.of("UTC").rules.getOffset(recordingStart!!)
-
- return (
- recordingStart!!.toEpochSecond(offset) -
- LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
- ).toInt()
- }
-
- private fun getFilePath(): String = "$fileFolder/$counter.${settings!!.fileExtension}"
-
- inner class LocalBinder: Binder() {
- fun getService(): RecorderService = this@RecorderService
- }
-
- enum class Actions {
- START,
- STOP,
- }
-
- companion object {
- fun getRandomFileFolder(context: Context): String {
- // uuid
- val folder = UUID.randomUUID().toString()
-
- return "${context.externalCacheDir!!.absolutePath}/$folder"
- }
-
- fun startService(context: Context, connection: ServiceConnection?) {
- Intent(context, RecorderService::class.java).also { intent ->
- intent.action = Actions.START.toString()
-
- ContextCompat.startForegroundService(context, intent)
-
- if (connection != null) {
- context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
- }
- }
- }
-
- fun stopService(context: Context) {
- Intent(context, RecorderService::class.java).also { intent ->
- intent.action = Actions.STOP.toString()
-
- context.startService(intent)
- }
- }
- }
-}
-
-data class Settings(
- val maxDuration: Long,
- val intervalDuration: Long,
- val forceExactMaxDuration: Boolean,
- val bitRate: Int,
- val samplingRate: Int,
- val outputFormat: Int,
- val encoder: Int,
-) {
- val fileExtension: String
- get() = when(outputFormat) {
- MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
- MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
- MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
- MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
- MediaRecorder.OutputFormat.WEBM -> "webm"
- MediaRecorder.OutputFormat.AMR_NB -> "amr"
- MediaRecorder.OutputFormat.AMR_WB -> "awb"
- MediaRecorder.OutputFormat.OGG -> "ogg"
- else -> "raw"
- }
-
- companion object {
- fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
- return Settings(
- intervalDuration = audioRecorderSettings.intervalDuration,
- bitRate = audioRecorderSettings.bitRate,
- samplingRate = audioRecorderSettings.getSamplingRate(),
- outputFormat = audioRecorderSettings.getOutputFormat(),
- encoder = audioRecorderSettings.getEncoder(),
- maxDuration = audioRecorderSettings.maxDuration,
- forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
+ .addAction(
+ R.drawable.ic_play,
+ getString(R.string.ui_audioRecorder_action_resume_label),
+ getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
)
- }
+ .build()
+ else -> throw IllegalStateException("Invalid state passed to `buildNotification()`")
}
-}
-
-@Composable
-fun bindToRecorderService(): Pair {
- val context = LocalContext.current
- var service by remember { mutableStateOf(null) }
-
- val connection = remember {
- object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
- service = (binder as RecorderService.LocalBinder).getService()
- }
-
- override fun onServiceDisconnected(name: ComponentName?) {
- }
- }
- }
-
- DisposableEffect(Unit) {
- Intent(context, RecorderService::class.java).also { intent ->
- context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
- }
-
- onDispose {
- service?.let {
- context.unbindService(connection)
- }
- }
- }
-
- return connection to service
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt
index a88aa9b..8617b52 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt
@@ -9,13 +9,20 @@ import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.LastRecording
import app.myzel394.alibi.ui.enums.Screen
+import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.screens.AudioRecorder
import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen
@@ -23,7 +30,9 @@ import app.myzel394.alibi.ui.screens.WelcomeScreen
const val SCALE_IN = 1.25f
@Composable
-fun Navigation() {
+fun Navigation(
+ audioRecorder: AudioRecorderModel = viewModel()
+) {
val navController = rememberNavController()
val context = LocalContext.current
val settings = context
@@ -32,6 +41,8 @@ fun Navigation() {
.collectAsState(initial = null)
.value ?: return
+ audioRecorder.BindToService(context)
+
NavHost(
modifier = Modifier
.background(MaterialTheme.colorScheme.background),
@@ -53,7 +64,10 @@ fun Navigation() {
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
- AudioRecorder(navController = navController)
+ AudioRecorder(
+ navController = navController,
+ audioRecorder = audioRecorder,
+ )
}
composable(
Screen.Settings.route,
@@ -64,7 +78,10 @@ fun Navigation() {
scaleOut(targetScale = 1 / SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
- SettingsScreen(navController = navController)
+ SettingsScreen(
+ navController = navController,
+ audioRecorder = audioRecorder,
+ )
}
}
}
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt
index aeeaa86..7d35b35 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt
@@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.MAX_AMPLITUDE
+import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.clamp
import kotlinx.coroutines.launch
import kotlin.math.ceil
@@ -34,10 +35,10 @@ private const val GROW_END = BOX_DIFF * 4
@Composable
fun RealtimeAudioVisualizer(
- service: RecorderService,
+ audioRecorder: AudioRecorderModel,
) {
val scope = rememberCoroutineScope()
- val amplitudes = service.amplitudes
+ val amplitudes = audioRecorder.amplitudes!!
val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f)
@@ -47,7 +48,7 @@ fun RealtimeAudioVisualizer(
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
- service.setOnAmplitudeUpdateListener {
+ audioRecorder.onAmplitudeChange = {
scope.launch {
animationProgress.snapTo(0f)
animationProgress.animateTo(
@@ -66,7 +67,7 @@ fun RealtimeAudioVisualizer(
LaunchedEffect(screenWidth) {
// Add 1 to allow the visualizer to overflow the screen
- service.maxAmplitudes = ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1
+ audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
}
Canvas(
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt
new file mode 100644
index 0000000..1933c68
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveRecordingButton.kt
@@ -0,0 +1,110 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import android.util.Log
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Memory
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import app.myzel394.alibi.R
+import app.myzel394.alibi.services.RecorderService
+import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
+import kotlinx.coroutines.launch
+import java.io.File
+
+@Composable
+fun SaveRecordingButton(
+ modifier: Modifier = Modifier,
+ service: RecorderService,
+ onSaveFile: (File) -> Unit,
+ label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var isProcessingAudio by remember { mutableStateOf(false) }
+
+ if (isProcessingAudio)
+ AlertDialog(
+ onDismissRequest = { },
+ icon = {
+ Icon(
+ Icons.Default.Memory,
+ contentDescription = null,
+ )
+ },
+ title = {
+ Text(
+ stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
+ )
+ },
+ text = {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ LinearProgressIndicator()
+ }
+ },
+ confirmButton = {}
+ )
+ Button(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()
+ .height(BIG_PRIMARY_BUTTON_SIZE)
+ .semantics {
+ contentDescription = label
+ }
+ .then(modifier),
+ onClick = {
+ isProcessingAudio = true
+
+ scope.launch {
+ try {
+ } catch (error: Exception) {
+ Log.getStackTraceString(error)
+ } finally {
+ isProcessingAudio = false
+ }
+ }
+ },
+ ) {
+ Icon(
+ Icons.Default.Save,
+ contentDescription = null,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
+ Text(label)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt
index ab41188..300450b 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,10 +21,14 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -35,9 +40,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -48,7 +53,9 @@ import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.Pulsating
+import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.formatDuration
import kotlinx.coroutines.delay
@@ -59,32 +66,26 @@ import java.time.ZoneId
@Composable
fun RecordingStatus(
- service: RecorderService,
- saveFile: (File) -> Unit,
+ audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
var now by remember { mutableStateOf(LocalDateTime.now()) }
- val start = service.recordingStart!!
- val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
- val progress = duration / (service.settings!!.maxDuration / 1000f)
-
LaunchedEffect(Unit) {
while (true) {
now = LocalDateTime.now()
- delay(1000)
+ delay(900)
}
}
// Only show animation when the recording has just started
- val recordingJustStarted = duration < 1
+ val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
LaunchedEffect(Unit) {
progressVisible = true
}
-
KeepScreenOn()
Column(
@@ -94,7 +95,7 @@ fun RecordingStatus(
verticalArrangement = Arrangement.SpaceBetween,
) {
Box {}
- RealtimeAudioVisualizer(service = service)
+ RealtimeAudioVisualizer(audioRecorder = audioRecorder)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -102,8 +103,6 @@ fun RecordingStatus(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
- val distance = Duration.between(service.recordingStart, now).toMillis()
-
Pulsating {
Box(
modifier = Modifier
@@ -114,7 +113,7 @@ fun RecordingStatus(
}
Spacer(modifier = Modifier.width(16.dp))
Text(
- text = formatDuration(distance),
+ text = formatDuration(audioRecorder.recordingTime!!),
style = MaterialTheme.typography.headlineLarge,
)
}
@@ -126,7 +125,7 @@ fun RecordingStatus(
)
) {
LinearProgressIndicator(
- progress = progress,
+ progress = audioRecorder.progress,
modifier = Modifier
.width(300.dp)
)
@@ -142,8 +141,7 @@ fun RecordingStatus(
},
onConfirm = {
showDeleteDialog = false
- RecorderService.stopService(context)
- service.reset()
+ audioRecorder.stopRecording(context, saveAsLastRecording = false)
},
)
}
@@ -167,22 +165,43 @@ fun RecordingStatus(
Text(label)
}
}
- val label = stringResource(R.string.ui_audioRecorder_action_save_label)
-
+
+ val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
+ val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
+ LargeFloatingActionButton(
+ modifier = Modifier
+ .semantics {
+ contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel
+ },
+ onClick = {
+ if (audioRecorder.isPaused) {
+ audioRecorder.resumeRecording()
+ } else {
+ audioRecorder.pauseRecording()
+ }
+ },
+ ) {
+ Icon(
+ if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+ contentDescription = null,
+ )
+ }
+
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
+ val label = stringResource(R.string.ui_audioRecorder_action_save_label)
+
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
- .graphicsLayer(alpha = alpha)
+ .alpha(alpha)
.semantics {
contentDescription = label
},
onClick = {
- RecorderService.stopService(context)
-
- saveFile(service.concatenateFiles())
+ audioRecorder.stopRecording(context)
+ audioRecorder.onRecordingSave()
},
) {
Icon(
@@ -191,7 +210,7 @@ fun RecordingStatus(
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
- Text(label)
+ Text(stringResource(R.string.ui_audioRecorder_action_save_label))
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt
index c8ee01e..7954b2c 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt
@@ -1,12 +1,6 @@
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import android.Manifest
-import android.content.ServiceConnection
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateContentSize
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.expandHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -27,13 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -46,18 +34,16 @@ import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
-import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
-import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
+import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun StartRecording(
- connection: ServiceConnection,
- service: RecorderService? = null,
+ audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/*")
@@ -72,7 +58,7 @@ fun StartRecording(
permission = Manifest.permission.RECORD_AUDIO,
icon = Icons.Default.Mic,
onPermissionAvailable = {
- RecorderService.startService(context, connection)
+ audioRecorder.startRecording(context)
},
) { trigger ->
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
@@ -122,37 +108,40 @@ fun StartRecording(
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
- if (service?.recordingStart != null)
+ if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {
+ val label = stringResource(
+ R.string.ui_audioRecorder_action_saveOldRecording_label,
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart),
+ )
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
- .height(BIG_PRIMARY_BUTTON_SIZE),
- onClick = {
- saveFile(service.concatenateFiles())
- },
+ .height(BIG_PRIMARY_BUTTON_SIZE)
+ .semantics {
+ contentDescription = label
+ },
colors = ButtonDefaults.textButtonColors(),
+ onClick = {
+ audioRecorder.stopRecording(context)
+ audioRecorder.onRecordingSave()
+ },
) {
Icon(
Icons.Default.Save,
contentDescription = null,
- modifier = Modifier
- .size(ButtonDefaults.IconSize),
+ modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
- Text(
- stringResource(
- R.string.ui_audioRecorder_action_saveOldRecording_label,
- DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart!!),
- ),
- )
+ Text(label)
}
}
+ }
else
Spacer(modifier = Modifier.weight(1f))
}
diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
new file mode 100644
index 0000000..23dabc9
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
@@ -0,0 +1,122 @@
+package app.myzel394.alibi.ui.models
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.ViewModel
+import app.myzel394.alibi.db.LastRecording
+import app.myzel394.alibi.enums.RecorderState
+import app.myzel394.alibi.services.AudioRecorderService
+import app.myzel394.alibi.services.RecorderService
+
+class AudioRecorderModel: ViewModel() {
+ var recorderState by mutableStateOf(RecorderState.IDLE)
+ private set
+ var recordingTime by mutableStateOf(null)
+ private set
+ var amplitudes by mutableStateOf>(emptyList())
+ private set
+
+ var onAmplitudeChange: () -> Unit = {}
+
+ val isInRecording: Boolean
+ get() = recorderState !== RecorderState.IDLE && recordingTime != null
+
+ val isPaused: Boolean
+ get() = recorderState === RecorderState.PAUSED
+
+ val progress: Float
+ get() = (recordingTime!! / recorderService!!.settings!!.maxDuration).toFloat()
+
+ private var intent: Intent? = null
+ var recorderService: AudioRecorderService? = null
+ private set
+
+ var lastRecording: LastRecording? by mutableStateOf(null)
+ private set
+
+ var onRecordingSave: () -> Unit = {}
+
+ private val connection = object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ recorderService = ((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also {recorder ->
+ recorder.onStateChange = { state ->
+ recorderState = state
+ }
+ recorder.onRecordingTimeChange = { time ->
+ recordingTime = time
+ }
+ recorder.onAmplitudeChange = { amps ->
+ amplitudes = amps
+ onAmplitudeChange()
+ }
+ }
+ recorderState = recorderService!!.state
+ recordingTime = recorderService!!.recordingTime
+ amplitudes = recorderService!!.amplitudes
+ }
+
+ override fun onServiceDisconnected(arg0: ComponentName) {
+ recorderService = null
+ reset()
+ }
+ }
+
+ fun reset() {
+ recorderState = RecorderState.IDLE
+ recordingTime = null
+ amplitudes = emptyList()
+ }
+
+ fun startRecording(context: Context) {
+ runCatching {
+ context.unbindService(connection)
+ }
+
+ intent = Intent(context, AudioRecorderService::class.java)
+ ContextCompat.startForegroundService(context, intent!!)
+ context.bindService(intent!!, connection, Context.BIND_AUTO_CREATE)
+ }
+
+ fun stopRecording(context: Context, saveAsLastRecording: Boolean = true) {
+ if (saveAsLastRecording) {
+ lastRecording = recorderService!!.createLastRecording()
+ }
+
+ runCatching {
+ context.unbindService(connection)
+ context.stopService(intent)
+ }
+
+ reset()
+ }
+
+ fun pauseRecording() {
+ recorderService!!.changeState(RecorderState.PAUSED)
+ }
+
+ fun resumeRecording() {
+ recorderService!!.changeState(RecorderState.RECORDING)
+ }
+
+ fun setMaxAmplitudesAmount(amount: Int) {
+ recorderService?.amplitudesAmount = amount
+ }
+
+ @Composable
+ fun BindToService(context: Context) {
+ LaunchedEffect(Unit) {
+ Intent(context, AudioRecorderService::class.java).also { intent ->
+ context.bindService(intent, connection, 0)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
index a9033ca..0e20792 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
@@ -1,37 +1,97 @@
package app.myzel394.alibi.ui.screens
+import android.util.Log
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import app.myzel394.alibi.services.bindToRecorderService
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import app.myzel394.alibi.R
+import app.myzel394.alibi.db.LastRecording
+import app.myzel394.alibi.ui.models.AudioRecorderModel
+import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorder(
navController: NavController,
+ audioRecorder: AudioRecorderModel,
) {
val saveFile = rememberFileSaverDialog("audio/aac")
- val (connection, service) = bindToRecorderService()
- val isRecording = service?.isRecording?.value ?: false
+ val scope = rememberCoroutineScope()
+ var isProcessingAudio by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ audioRecorder.onRecordingSave = {
+ scope.launch {
+ isProcessingAudio = true
+
+ try {
+ val file = audioRecorder.lastRecording!!.concatenateFiles()
+
+ saveFile(file)
+ } catch (error: Exception) {
+ Log.getStackTraceString(error)
+ } finally {
+ isProcessingAudio = false
+ }
+ }
+ }
+ }
+
+ if (isProcessingAudio)
+ AlertDialog(
+ onDismissRequest = { },
+ icon = {
+ Icon(
+ Icons.Default.Memory,
+ contentDescription = null,
+ )
+ },
+ title = {
+ Text(
+ stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
+ )
+ },
+ text = {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ LinearProgressIndicator()
+ }
+ },
+ confirmButton = {}
+ )
Scaffold(
topBar = {
TopAppBar(
@@ -58,10 +118,10 @@ fun AudioRecorder(
.fillMaxSize()
.padding(padding),
) {
- if (isRecording)
- RecordingStatus(service = service!!, saveFile = saveFile)
+ if (audioRecorder.isInRecording)
+ RecordingStatus(audioRecorder = audioRecorder)
else
- StartRecording(connection = connection, service = service)
+ StartRecording(audioRecorder = audioRecorder)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
index 14de6f3..2a92a54 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
@@ -32,7 +32,6 @@ import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
-import app.myzel394.alibi.services.bindToRecorderService
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile
@@ -43,15 +42,15 @@ import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
+import app.myzel394.alibi.ui.models.AudioRecorderModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
- navController: NavController
+ navController: NavController,
+ audioRecorder: AudioRecorderModel,
) {
- val (_, service) = bindToRecorderService()
- val isRecording = service?.isRecording?.value ?: false
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
@@ -91,7 +90,7 @@ fun SettingsScreen(
.value
// Show alert
- if (isRecording)
+ if (audioRecorder.isInRecording)
Box(
modifier = Modifier
.padding(16.dp)
diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml
new file mode 100644
index 0000000..a0a94e3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cancel.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml
new file mode 100644
index 0000000..4958a11
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pause.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml
new file mode 100644
index 0000000..2217e32
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 824f89e..451b430 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -23,7 +23,14 @@
Delete
Delete Recording?
Are you sure you want to delete this recording?
+ Pause Recording
+ Resume Recording
Save Recording
+ Alibi will continue recording in the background and store the last %s minutes at your request
+ Processing
+ Processing Audio, do not close Alibi! You will be automatically prompted to save the file once it\'s ready
+ Recording Audio
+ Alibi keeps recording in the background
Welcome to Alibi!
Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it.
@@ -50,5 +57,6 @@
Define how many samples per second are taken from the audio signal
Set the sampling rate
Encoder
- Alibi will continue recording in the background and store the last %s minutes at your request
+ Recording paused
+ Audio Recording has been paused
\ No newline at end of file