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" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<service android:name=".services.RecorderService" android:foregroundServiceType="microphone" /> <service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone|camera" />
</application> </application>
</manifest> </manifest>

View File

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

View File

@ -2,7 +2,14 @@ package app.myzel394.alibi.db
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@Serializable @Serializable
data class AppSettings( data class AppSettings(
@ -27,6 +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 @Serializable
data class AudioRecorderSettings( data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L, val maxDuration: Long = 30 * 60 * 1000L,

View File

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

View File

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

View File

@ -0,0 +1,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 package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.media.MediaRecorder
import android.os.Binder import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.app.NotificationManagerCompat
import app.myzel394.alibi.MainActivity import app.myzel394.alibi.MainActivity
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.db.AudioRecorderSettings import app.myzel394.alibi.ui.utils.PermissionHelper
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter.ISO_DATE_TIME import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import java.util.UUID
const val AMPLITUDE_UPDATE_INTERVAL = 100L abstract class RecorderService: Service() {
private val binder = RecorderBinder()
class RecorderService: Service() { private var isPaused: Boolean = false
private val binder = LocalBinder()
private val handler = Handler(Looper.getMainLooper())
private var job = SupervisorJob()
private var scope = CoroutineScope(Dispatchers.IO + job)
private var mediaRecorder: MediaRecorder? = null lateinit var recordingStart: LocalDateTime
private var onError: MediaRecorder.OnErrorListener? = null
private var onAmplitudeUpdate: () -> Unit = {}
private var counter = 0
var maxAmplitudes = 1000
var settings: Settings? = null
private set private set
var fileFolder: String? = null var state = RecorderState.IDLE
private set
val isRecording = mutableStateOf(false)
val amplitudes = mutableStateListOf<Int>()
var recordingStart: LocalDateTime? = null
private set private set
val filePaths: List<File> var onStateChange: ((RecorderState) -> Unit)? = null
get() = File(fileFolder!!).listFiles()?.filter {
val name = it.nameWithoutExtension
if (name.toIntOrNull() == null) { var recordingTime = 0L
return@filter false 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 override fun onBind(p0: Intent?): IBinder? = binder
}?.toList() ?: emptyList()
override fun onBind(p0: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
Actions.START.toString() -> start() "changeState" -> {
Actions.STOP.toString() -> stop() val newState = intent.getStringExtra("newState")?.let {
RecorderState.valueOf(it)
} ?: RecorderState.IDLE
changeState(newState)
}
} }
return super.onStartCommand(intent, flags, startId) 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
scope.cancel() changeState(RecorderState.IDLE)
}
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()
}
}
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
stopSelf() stopSelf()
} }
fun reset() { private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
resetCoroutineScope() .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
settings = null .setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
recordingStart = null .setSmallIcon(R.drawable.launcher_foreground)
counter = 0 .setPriority(NotificationCompat.PRIORITY_LOW)
amplitudes.clear() .setCategory(NotificationCompat.CATEGORY_SERVICE)
isRecording.value = false .build()
if (fileFolder != null) { private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent {
File(fileFolder!!).listFiles()?.forEach { return PendingIntent.getService(
it.delete() this,
} requestCode,
Intent(this, AudioRecorderService::class.java).apply {
fileFolder = null 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 { private fun buildNotification(): Notification = when(state) {
val paths = filePaths.joinToString("|") RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
val fileName = recordingStart!! .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
.format(ISO_DATE_TIME) .setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
.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")
.setSmallIcon(R.drawable.launcher_foreground) .setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(true) .setOngoing(true)
.setWhen(
Date.from(
Calendar
.getInstance()
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
.toInstant()
).time,
)
.setSilent(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setUsesChronometer(true) .setUsesChronometer(true)
.setChronometerCountDown(false) .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) .setShowWhen(true)
.setContentIntent( .setContentIntent(
PendingIntent.getActivity( PendingIntent.getActivity(
@ -338,127 +235,12 @@ class RecorderService: Service() {
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
) )
) )
.build() .addAction(
R.drawable.ic_play,
// show notification getString(R.string.ui_audioRecorder_action_resume_label),
startForeground(getNotificationId(), notification) getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
}
// 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,
) )
.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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.LastRecording
import app.myzel394.alibi.ui.enums.Screen 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.AudioRecorder
import app.myzel394.alibi.ui.screens.SettingsScreen import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen import app.myzel394.alibi.ui.screens.WelcomeScreen
@ -23,7 +30,9 @@ import app.myzel394.alibi.ui.screens.WelcomeScreen
const val SCALE_IN = 1.25f const val SCALE_IN = 1.25f
@Composable @Composable
fun Navigation() { fun Navigation(
audioRecorder: AudioRecorderModel = viewModel()
) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val settings = context val settings = context
@ -32,6 +41,8 @@ fun Navigation() {
.collectAsState(initial = null) .collectAsState(initial = null)
.value ?: return .value ?: return
audioRecorder.BindToService(context)
NavHost( NavHost(
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
@ -53,7 +64,10 @@ fun Navigation() {
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150)) scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
} }
) { ) {
AudioRecorder(navController = navController) AudioRecorder(
navController = navController,
audioRecorder = audioRecorder,
)
} }
composable( composable(
Screen.Settings.route, Screen.Settings.route,
@ -64,7 +78,10 @@ fun Navigation() {
scaleOut(targetScale = 1 / SCALE_IN) + fadeOut(tween(durationMillis = 150)) 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 androidx.compose.ui.unit.dp
import app.myzel394.alibi.services.RecorderService import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.MAX_AMPLITUDE import app.myzel394.alibi.ui.MAX_AMPLITUDE
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.clamp import app.myzel394.alibi.ui.utils.clamp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.ceil import kotlin.math.ceil
@ -34,10 +35,10 @@ private const val GROW_END = BOX_DIFF * 4
@Composable @Composable
fun RealtimeAudioVisualizer( fun RealtimeAudioVisualizer(
service: RecorderService, audioRecorder: AudioRecorderModel,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val amplitudes = service.amplitudes val amplitudes = audioRecorder.amplitudes!!
val primary = MaterialTheme.colorScheme.primary val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f) val primaryMuted = primary.copy(alpha = 0.3f)
@ -47,7 +48,7 @@ fun RealtimeAudioVisualizer(
val animationProgress = remember { Animatable(0f) } val animationProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
service.setOnAmplitudeUpdateListener { audioRecorder.onAmplitudeChange = {
scope.launch { scope.launch {
animationProgress.snapTo(0f) animationProgress.snapTo(0f)
animationProgress.animateTo( animationProgress.animateTo(
@ -66,7 +67,7 @@ fun RealtimeAudioVisualizer(
LaunchedEffect(screenWidth) { LaunchedEffect(screenWidth) {
// Add 1 to allow the visualizer to overflow the screen // 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( 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.core.tween
import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -20,10 +21,14 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -35,9 +40,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@ -48,7 +53,9 @@ import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.Pulsating import app.myzel394.alibi.ui.components.atoms.Pulsating
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.KeepScreenOn import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.formatDuration import app.myzel394.alibi.ui.utils.formatDuration
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -59,32 +66,26 @@ import java.time.ZoneId
@Composable @Composable
fun RecordingStatus( fun RecordingStatus(
service: RecorderService, audioRecorder: AudioRecorderModel,
saveFile: (File) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var now by remember { mutableStateOf(LocalDateTime.now()) } var now by remember { mutableStateOf(LocalDateTime.now()) }
val start = service.recordingStart!!
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
val progress = duration / (service.settings!!.maxDuration / 1000f)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
now = LocalDateTime.now() now = LocalDateTime.now()
delay(1000) delay(900)
} }
} }
// Only show animation when the recording has just started // Only show animation when the recording has just started
val recordingJustStarted = duration < 1 val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
var progressVisible by remember { mutableStateOf(!recordingJustStarted) } var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
progressVisible = true progressVisible = true
} }
KeepScreenOn() KeepScreenOn()
Column( Column(
@ -94,7 +95,7 @@ fun RecordingStatus(
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
) { ) {
Box {} Box {}
RealtimeAudioVisualizer(service = service) RealtimeAudioVisualizer(audioRecorder = audioRecorder)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -102,8 +103,6 @@ fun RecordingStatus(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
) { ) {
val distance = Duration.between(service.recordingStart, now).toMillis()
Pulsating { Pulsating {
Box( Box(
modifier = Modifier modifier = Modifier
@ -114,7 +113,7 @@ fun RecordingStatus(
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Text( Text(
text = formatDuration(distance), text = formatDuration(audioRecorder.recordingTime!!),
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
) )
} }
@ -126,7 +125,7 @@ fun RecordingStatus(
) )
) { ) {
LinearProgressIndicator( LinearProgressIndicator(
progress = progress, progress = audioRecorder.progress,
modifier = Modifier modifier = Modifier
.width(300.dp) .width(300.dp)
) )
@ -142,8 +141,7 @@ fun RecordingStatus(
}, },
onConfirm = { onConfirm = {
showDeleteDialog = false showDeleteDialog = false
RecorderService.stopService(context) audioRecorder.stopRecording(context, saveAsLastRecording = false)
service.reset()
}, },
) )
} }
@ -167,22 +165,43 @@ fun RecordingStatus(
Text(label) 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 alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
Button( Button(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.fillMaxWidth() .fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE) .height(BIG_PRIMARY_BUTTON_SIZE)
.graphicsLayer(alpha = alpha) .alpha(alpha)
.semantics { .semantics {
contentDescription = label contentDescription = label
}, },
onClick = { onClick = {
RecorderService.stopService(context) audioRecorder.stopRecording(context)
audioRecorder.onRecordingSave()
saveFile(service.concatenateFiles())
}, },
) { ) {
Icon( Icon(
@ -191,7 +210,7 @@ fun RecordingStatus(
modifier = Modifier.size(ButtonDefaults.IconSize), modifier = Modifier.size(ButtonDefaults.IconSize),
) )
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) 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 package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import android.Manifest 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -27,13 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.R
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings 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.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.components.atoms.PermissionRequester
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
@Composable @Composable
fun StartRecording( fun StartRecording(
connection: ServiceConnection, audioRecorder: AudioRecorderModel,
service: RecorderService? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/*") val saveFile = rememberFileSaverDialog("audio/*")
@ -72,7 +58,7 @@ fun StartRecording(
permission = Manifest.permission.RECORD_AUDIO, permission = Manifest.permission.RECORD_AUDIO,
icon = Icons.Default.Mic, icon = Icons.Default.Mic,
onPermissionAvailable = { onPermissionAvailable = {
RecorderService.startService(context, connection) audioRecorder.startRecording(context)
}, },
) { trigger -> ) { trigger ->
val label = stringResource(R.string.ui_audioRecorder_action_start_label) val label = stringResource(R.string.ui_audioRecorder_action_start_label)
@ -122,35 +108,38 @@ fun StartRecording(
.fillMaxWidth(), .fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (service?.recordingStart != null) if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.Bottom,
) { ) {
val label = stringResource(
R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart),
)
Button( Button(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.fillMaxWidth() .fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE), .height(BIG_PRIMARY_BUTTON_SIZE)
onClick = { .semantics {
saveFile(service.concatenateFiles()) contentDescription = label
}, },
colors = ButtonDefaults.textButtonColors(), colors = ButtonDefaults.textButtonColors(),
onClick = {
audioRecorder.stopRecording(context)
audioRecorder.onRecordingSave()
},
) { ) {
Icon( Icon(
Icons.Default.Save, Icons.Default.Save,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier.size(ButtonDefaults.IconSize),
.size(ButtonDefaults.IconSize),
) )
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text( Text(label)
stringResource( }
R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart!!),
),
)
} }
} }
else else

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 package app.myzel394.alibi.ui.screens
import android.util.Log
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController 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.RecordingStatus
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording
import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import app.myzel394.alibi.R 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AudioRecorder( fun AudioRecorder(
navController: NavController, navController: NavController,
audioRecorder: AudioRecorderModel,
) { ) {
val saveFile = rememberFileSaverDialog("audio/aac") val saveFile = rememberFileSaverDialog("audio/aac")
val (connection, service) = bindToRecorderService() val scope = rememberCoroutineScope()
val isRecording = service?.isRecording?.value ?: false
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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -58,10 +118,10 @@ fun AudioRecorder(
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { ) {
if (isRecording) if (audioRecorder.isInRecording)
RecordingStatus(service = service!!, saveFile = saveFile) RecordingStatus(audioRecorder = audioRecorder)
else 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.R
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings 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.BitrateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile 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.GlobalSwitch
import app.myzel394.alibi.ui.components.atoms.MessageBox import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.models.AudioRecorderModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
navController: NavController navController: NavController,
audioRecorder: AudioRecorderModel,
) { ) {
val (_, service) = bindToRecorderService()
val isRecording = service?.isRecording?.value ?: false
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState() rememberTopAppBarState()
) )
@ -91,7 +90,7 @@ fun SettingsScreen(
.value .value
// Show alert // Show alert
if (isRecording) if (audioRecorder.isInRecording)
Box( Box(
modifier = Modifier modifier = Modifier
.padding(16.dp) .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_label">Delete</string>
<string name="ui_audioRecorder_action_delete_confirm_title">Delete Recording?</string> <string name="ui_audioRecorder_action_delete_confirm_title">Delete Recording?</string>
<string name="ui_audioRecorder_action_delete_confirm_message">Are you sure you want to delete this recording?</string> <string name="ui_audioRecorder_action_delete_confirm_message">Are you sure you want to delete this recording?</string>
<string name="ui_audioRecorder_action_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_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_title">Welcome to Alibi!</string>
<string name="ui_welcome_explanation_message">Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it.</string> <string name="ui_welcome_explanation_message">Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it.</string>
@ -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_description">Define how many samples per second are taken from the audio signal</string>
<string name="ui_settings_option_samplingRate_explanation">Set the sampling rate</string> <string name="ui_settings_option_samplingRate_explanation">Set the sampling rate</string>
<string name="ui_settings_option_encoder_title">Encoder</string> <string name="ui_settings_option_encoder_title">Encoder</string>
<string name="ui_audioRecorder_action_start_description">Alibi will continue recording in the background and store the last <xliff:g name="durationInMinutes">%s</xliff:g> minutes at your request</string> <string name="ui_audioRecorder_state_paused_title">Recording paused</string>
<string name="ui_audioRecorder_state_paused_description">Audio Recording has been paused</string>
</resources> </resources>