Merge pull request #5 from Myzel394/improve-architecture

Improve architecture
This commit is contained in:
Myzel394 2023-08-08 22:02:59 +02:00 committed by GitHub
commit 28e3a0623b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1005 additions and 479 deletions

View File

@ -37,7 +37,7 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service android:name=".services.RecorderService" android:foregroundServiceType="microphone" />
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone|camera" />
</application>
</manifest>

View File

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

View File

@ -2,7 +2,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<File>
get() =
File(folderPath).listFiles()?.filter {
val name = it.nameWithoutExtension
name.toIntOrNull() != null
}?.toList() ?: emptyList()
val hasRecordingAvailable: Boolean
get() = filePaths.isNotEmpty()
private fun stripConcatenatedFileToExactDuration(
outputFile: File
) {
// Move the concatenated file to a temporary file
val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
outputFile.renameTo(rawFile)
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
val session = FFmpegKit.execute(command)
if (!ReturnCode.isSuccess(session.returnCode)) {
Log.d(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
session.getState(),
session.getReturnCode(),
session.getFailStackTrace()
)
)
throw Exception("Failed to strip concatenated audio")
}
}
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
val paths = filePaths.joinToString("|")
val fileName = recordingStart
.format(ISO_DATE_TIME)
.toString()
.replace(":", "-")
.replace(".", "_")
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
if (outputFile.exists() && !forceConcatenation) {
return outputFile
}
val command = "-i 'concat:$paths' -y" +
" -acodec copy" +
" -metadata title='$fileName' " +
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
" -metadata batch_count='${filePaths.size}'" +
" -metadata batch_duration='${intervalDuration}'" +
" -metadata max_duration='${maxDuration}'" +
" $outputFile"
val session = FFmpegKit.execute(command)
if (!ReturnCode.isSuccess(session.returnCode)) {
Log.d(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
session.getState(),
session.getReturnCode(),
session.getFailStackTrace()
)
)
throw Exception("Failed to concatenate audios")
}
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
stripConcatenatedFileToExactDuration(outputFile)
}
return outputFile
}
}
@Serializable
data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L,

View File

@ -1,10 +1,17 @@
package app.myzel394.alibi.db
import androidx.datastore.core.Serializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
import java.time.LocalDateTime
class AppSettingsSerializer: Serializer<AppSettings> {
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
@ -31,3 +38,15 @@ class AppSettingsSerializer: Serializer<AppSettings> {
)
}
}
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
}

View File

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

View File

@ -0,0 +1,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
}

View File

@ -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<Int>()
private set
private val handler = Handler(Looper.getMainLooper())
var onAmplitudeChange: ((List<Int>) -> 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()
}
}

View File

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

View File

@ -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<Int>()
var recordingStart: LocalDateTime? = null
var state = RecorderState.IDLE
private set
val filePaths: List<File>
get() = File(fileFolder!!).listFiles()?.filter {
val name = it.nameWithoutExtension
var onStateChange: ((RecorderState) -> Unit)? = null
if (name.toIntOrNull() == null) {
return@filter false
}
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<ServiceConnection, RecorderService?> {
val context = LocalContext.current
var service by remember { mutableStateOf<RecorderService?>(null) }
val connection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = (binder as RecorderService.LocalBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
}
DisposableEffect(Unit) {
Intent(context, RecorderService::class.java).also { intent ->
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
onDispose {
service?.let {
context.unbindService(connection)
}
}
}
return connection to service
}

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -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))
}
}
}

View File

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

View File

@ -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<Long?>(null)
private set
var amplitudes by mutableStateOf<List<Int>>(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<LastRecording?>(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)
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM17,15.59L15.59,17 12,13.41 8.41,17 7,15.59 10.59,12 7,8.41 8.41,7 12,10.59 15.59,7 17,8.41 13.41,12 17,15.59z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,16L9,16L9,8h2v8zM15,16h-2L13,8h2v8z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM9.5,16.5v-9l7,4.5L9.5,16.5z"/>
</vector>

View File

@ -23,7 +23,14 @@
<string name="ui_audioRecorder_action_delete_label">Delete</string>
<string name="ui_audioRecorder_action_delete_confirm_title">Delete Recording?</string>
<string name="ui_audioRecorder_action_delete_confirm_message">Are you sure you want to delete this recording?</string>
<string name="ui_audioRecorder_action_pause_label">Pause Recording</string>
<string name="ui_audioRecorder_action_resume_label">Resume Recording</string>
<string name="ui_audioRecorder_action_save_label">Save Recording</string>
<string name="ui_audioRecorder_action_start_description">Alibi will continue recording in the background and store the last <xliff:g name="durationInMinutes">%s</xliff:g> minutes at your request</string>
<string name="ui_audioRecorder_action_save_processing_dialog_title">Processing</string>
<string name="ui_audioRecorder_action_save_processing_dialog_description">Processing Audio, do not close Alibi! You will be automatically prompted to save the file once it\'s ready</string>
<string name="ui_audioRecorder_state_recording_title">Recording Audio</string>
<string name="ui_audioRecorder_state_recording_description">Alibi keeps recording in the background</string>
<string name="ui_welcome_explanation_title">Welcome to Alibi!</string>
<string name="ui_welcome_explanation_message">Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it.</string>
@ -50,5 +57,6 @@
<string name="ui_settings_option_samplingRate_description">Define how many samples per second are taken from the audio signal</string>
<string name="ui_settings_option_samplingRate_explanation">Set the sampling rate</string>
<string name="ui_settings_option_encoder_title">Encoder</string>
<string name="ui_audioRecorder_action_start_description">Alibi will continue recording in the background and store the last <xliff:g name="durationInMinutes">%s</xliff:g> minutes at your request</string>
<string name="ui_audioRecorder_state_paused_title">Recording paused</string>
<string name="ui_audioRecorder_state_paused_description">Audio Recording has been paused</string>
</resources>