mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
refactor: Improving architecture (current stand)
This commit is contained in:
parent
d5100501f7
commit
bc42f35eba
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -2,7 +2,13 @@ package app.myzel394.alibi.db
|
||||
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
@Serializable
|
||||
data class AppSettings(
|
||||
@ -27,6 +33,103 @@ data class AppSettings(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LastRecording(
|
||||
val folderPath: String,
|
||||
@Serializable(with = LocalDateTimeSerializer::class)
|
||||
val recordingStart: LocalDateTime,
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val fileExtension: String,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
) {
|
||||
val fileFolder: File
|
||||
get() = File(folderPath)
|
||||
|
||||
val filePaths: List<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,
|
||||
|
@ -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()
|
||||
@ -30,4 +37,16 @@ class AppSettingsSerializer: Serializer<AppSettings> {
|
||||
).encodeToByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
package app.myzel394.alibi.enums
|
||||
|
||||
enum class RecorderState {
|
||||
IDLE,
|
||||
RECORDING,
|
||||
PAUSED,
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
|
||||
class AudioRecorderService: IntervalRecorderService() {
|
||||
var recorder: MediaRecorder? = null
|
||||
private set
|
||||
|
||||
val filePath: String
|
||||
get() = "$folder/$counter.${settings.fileExtension}"
|
||||
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFile(filePath)
|
||||
setOutputFormat(settings.outputFormat)
|
||||
setAudioEncoder(settings.encoder)
|
||||
setAudioEncodingBitRate(settings.bitRate)
|
||||
setAudioSamplingRate(settings.samplingRate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRecorder() {
|
||||
runCatching {
|
||||
recorder?.let {
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startNewCycle() {
|
||||
super.startNewCycle()
|
||||
|
||||
val newRecorder = createRecorder().also {
|
||||
it.prepare()
|
||||
}
|
||||
|
||||
resetRecorder()
|
||||
|
||||
newRecorder.start()
|
||||
recorder = newRecorder
|
||||
}
|
||||
|
||||
override fun getAmplitudeAmount(): Int {
|
||||
return 100
|
||||
}
|
||||
|
||||
override fun getAmplitude(): Int = recorder?.maxAmplitude ?: 0
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class ExtraRecorderInformationService: RecorderService() {
|
||||
abstract fun getAmplitudeAmount(): Int
|
||||
abstract fun getAmplitude(): Int
|
||||
|
||||
private var recordingTime = 0L
|
||||
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||
|
||||
var amplitudes = mutableListOf<Int>()
|
||||
private set
|
||||
private lateinit var amplitudesTimer: Timer
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||
|
||||
private fun createRecordingTimeTimer() {
|
||||
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||
it.scheduleAtFixedRate(
|
||||
{
|
||||
recordingTime += 1000
|
||||
},
|
||||
0,
|
||||
1000,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAmplitude() {
|
||||
amplitudes.add(getAmplitude())
|
||||
|
||||
// Delete old amplitudes
|
||||
if (amplitudes.size > getAmplitudeAmount()) {
|
||||
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
|
||||
}
|
||||
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
private fun createAmplitudesTimer() {
|
||||
amplitudesTimer = Timer().also {
|
||||
it.scheduleAtFixedRate(
|
||||
object: TimerTask() {
|
||||
override fun run() {
|
||||
updateAmplitude()
|
||||
}
|
||||
},
|
||||
0,
|
||||
100,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
createRecordingTimeTimer()
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
recordingTimeTimer.shutdown()
|
||||
amplitudesTimer.cancel()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
createRecordingTimeTimer()
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
recordingTimeTimer.shutdown()
|
||||
amplitudesTimer.cancel()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.media.MediaRecorder
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||
import app.myzel394.alibi.db.LastRecording
|
||||
import java.io.File
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||
protected var counter = 0
|
||||
private set
|
||||
protected lateinit var folder: File
|
||||
lateinit var settings: Settings
|
||||
protected set
|
||||
|
||||
private lateinit var cycleTimer: ScheduledExecutorService
|
||||
|
||||
fun createLastRecording(): LastRecording = LastRecording(
|
||||
folderPath = folder.absolutePath,
|
||||
recordingStart = recordingStart,
|
||||
maxDuration = settings.maxDuration,
|
||||
fileExtension = settings.fileExtension,
|
||||
intervalDuration = settings.intervalDuration,
|
||||
forceExactMaxDuration = settings.forceExactMaxDuration,
|
||||
)
|
||||
|
||||
// Make overrideable
|
||||
open fun startNewCycle() {
|
||||
deleteOldRecordings()
|
||||
}
|
||||
|
||||
private fun createTimer() {
|
||||
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||
it.scheduleAtFixedRate(
|
||||
{
|
||||
startNewCycle()
|
||||
},
|
||||
0,
|
||||
settings.intervalDuration,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
folder.mkdirs()
|
||||
|
||||
createTimer()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
cycleTimer.shutdown()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
createTimer()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
cycleTimer.shutdown()
|
||||
}
|
||||
|
||||
private fun deleteOldRecordings() {
|
||||
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||
val earliestCounter = counter - timeMultiplier
|
||||
|
||||
folder.listFiles()?.forEach { file ->
|
||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
||||
|
||||
if (fileCounter < earliestCounter) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Settings(
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
val bitRate: Int,
|
||||
val samplingRate: Int,
|
||||
val outputFormat: Int,
|
||||
val encoder: Int,
|
||||
) {
|
||||
val fileExtension: String
|
||||
get() = when(outputFormat) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||
return Settings(
|
||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||
bitRate = audioRecorderSettings.bitRate,
|
||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||
encoder = audioRecorderSettings.getEncoder(),
|
||||
maxDuration = audioRecorderSettings.maxDuration,
|
||||
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,324 +1,94 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.myzel394.alibi.MainActivity
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
||||
import java.util.Date
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import java.util.UUID
|
||||
abstract class RecorderService: Service() {
|
||||
private val binder = RecorderBinder()
|
||||
|
||||
const val AMPLITUDE_UPDATE_INTERVAL = 100L
|
||||
private var isPaused: Boolean = false
|
||||
|
||||
class RecorderService: Service() {
|
||||
private val binder = LocalBinder()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var job = SupervisorJob()
|
||||
private var scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var onError: MediaRecorder.OnErrorListener? = null
|
||||
private var onAmplitudeUpdate: () -> Unit = {}
|
||||
|
||||
private var counter = 0
|
||||
var maxAmplitudes = 1000
|
||||
|
||||
var settings: Settings? = null
|
||||
lateinit var recordingStart: LocalDateTime
|
||||
private set
|
||||
|
||||
var fileFolder: String? = null
|
||||
private set
|
||||
val isRecording = mutableStateOf(false)
|
||||
|
||||
val amplitudes = mutableStateListOf<Int>()
|
||||
|
||||
var recordingStart: LocalDateTime? = null
|
||||
var state = RecorderState.RECORDING
|
||||
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
|
||||
}
|
||||
protected abstract fun start()
|
||||
protected abstract fun pause()
|
||||
protected abstract fun resume()
|
||||
protected abstract fun stop()
|
||||
|
||||
val extension = it.extension
|
||||
override fun onBind(p0: Intent?): IBinder? = binder
|
||||
|
||||
extension == settings!!.fileExtension
|
||||
}?.toList() ?: emptyList()
|
||||
inner class RecorderBinder: Binder() {
|
||||
fun getService(): RecorderService = this@RecorderService
|
||||
}
|
||||
|
||||
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()
|
||||
fun changeState(newState: RecorderState) {
|
||||
if (state == newState) {
|
||||
return
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
when (newState) {
|
||||
RecorderState.RECORDING -> {
|
||||
if (isPaused) {
|
||||
resume()
|
||||
isPaused = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
RecorderState.PAUSED -> {
|
||||
pause()
|
||||
isPaused = true
|
||||
}
|
||||
else -> throw IllegalStateException("$newState is not a valid state. Destroy or recreate the service instead.")
|
||||
}
|
||||
|
||||
state = newState
|
||||
onStateChange?.invoke(newState)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val notification = buildNotification()
|
||||
|
||||
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
|
||||
|
||||
recordingStart = LocalDateTime.now()
|
||||
start()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
stop()
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
resetCoroutineScope()
|
||||
settings = null
|
||||
recordingStart = null
|
||||
counter = 0
|
||||
amplitudes.clear()
|
||||
isRecording.value = false
|
||||
|
||||
if (fileFolder != null) {
|
||||
File(fileFolder!!).listFiles()?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
fileFolder = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripConcatenatedFileToExactDuration(
|
||||
outputFile: File
|
||||
) {
|
||||
// Move the concatenated file to a temporary file
|
||||
val rawFile = File("$fileFolder/${outputFile.nameWithoutExtension}-raw.${settings!!.fileExtension}")
|
||||
outputFile.renameTo(rawFile)
|
||||
|
||||
val command = "-sseof ${settings!!.maxDuration / -1000} -i $rawFile -y $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
|
||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||
Log.d(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.getState(),
|
||||
session.getReturnCode(),
|
||||
session.getFailStackTrace()
|
||||
)
|
||||
)
|
||||
|
||||
throw Exception("Failed to strip concatenated audio")
|
||||
}
|
||||
}
|
||||
|
||||
fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||
val paths = filePaths.joinToString("|")
|
||||
val fileName = recordingStart!!
|
||||
.format(ISO_DATE_TIME)
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "_")
|
||||
val outputFile = File("$fileFolder/$fileName.${settings!!.fileExtension}")
|
||||
|
||||
if (outputFile.exists() && !forceConcatenation) {
|
||||
return outputFile
|
||||
}
|
||||
|
||||
val command = "-i 'concat:$paths' -y" +
|
||||
" -acodec copy" +
|
||||
" -metadata title='$fileName' " +
|
||||
" -metadata date='${recordingStart!!.format(ISO_DATE_TIME)}'" +
|
||||
" -metadata batch_count='${filePaths.size}'" +
|
||||
" -metadata batch_duration='${settings!!.intervalDuration}'" +
|
||||
" -metadata max_duration='${settings!!.maxDuration}'" +
|
||||
" $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
|
||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||
Log.d(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.getState(),
|
||||
session.getReturnCode(),
|
||||
session.getFailStackTrace()
|
||||
)
|
||||
)
|
||||
|
||||
throw Exception("Failed to concatenate audios")
|
||||
}
|
||||
|
||||
val minRequiredForPossibleInExactMaxDuration = settings!!.maxDuration / settings!!.intervalDuration
|
||||
if (settings!!.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
||||
stripConcatenatedFileToExactDuration(outputFile)
|
||||
}
|
||||
|
||||
return outputFile
|
||||
}
|
||||
|
||||
private fun updateAmplitude() {
|
||||
if (!isRecording.value || mediaRecorder == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val amplitude = mediaRecorder!!.maxAmplitude
|
||||
amplitudes.add(amplitude)
|
||||
|
||||
// Delete old amplitudes
|
||||
if (amplitudes.size > maxAmplitudes) {
|
||||
amplitudes.removeRange(0, amplitudes.size - maxAmplitudes)
|
||||
}
|
||||
|
||||
onAmplitudeUpdate()
|
||||
handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL)
|
||||
}
|
||||
|
||||
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")
|
||||
private fun buildNotification(): Notification {
|
||||
return NotificationCompat.Builder(this, "recorder")
|
||||
.setContentTitle("Recording Audio")
|
||||
.setContentText("Recording audio in background")
|
||||
.setSmallIcon(R.drawable.launcher_foreground)
|
||||
@ -328,7 +98,7 @@ class RecorderService: Service() {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(false)
|
||||
.setWhen(Date.from(recordingStart!!.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||
.setShowWhen(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
@ -339,126 +109,5 @@ class RecorderService: Service() {
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
// show notification
|
||||
startForeground(getNotificationId(), notification)
|
||||
}
|
||||
|
||||
// To avoid int overflow, we'll use the number of seconds since 2023-01-01 01:01:01
|
||||
private fun getNotificationId(): Int {
|
||||
val offset = ZoneId.of("UTC").rules.getOffset(recordingStart!!)
|
||||
|
||||
return (
|
||||
recordingStart!!.toEpochSecond(offset) -
|
||||
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
|
||||
).toInt()
|
||||
}
|
||||
|
||||
private fun getFilePath(): String = "$fileFolder/$counter.${settings!!.fileExtension}"
|
||||
|
||||
inner class LocalBinder: Binder() {
|
||||
fun getService(): RecorderService = this@RecorderService
|
||||
}
|
||||
|
||||
enum class Actions {
|
||||
START,
|
||||
STOP,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getRandomFileFolder(context: Context): String {
|
||||
// uuid
|
||||
val folder = UUID.randomUUID().toString()
|
||||
|
||||
return "${context.externalCacheDir!!.absolutePath}/$folder"
|
||||
}
|
||||
|
||||
fun startService(context: Context, connection: ServiceConnection?) {
|
||||
Intent(context, RecorderService::class.java).also { intent ->
|
||||
intent.action = Actions.START.toString()
|
||||
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
|
||||
if (connection != null) {
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
Intent(context, RecorderService::class.java).also { intent ->
|
||||
intent.action = Actions.STOP.toString()
|
||||
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Settings(
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
val bitRate: Int,
|
||||
val samplingRate: Int,
|
||||
val outputFormat: Int,
|
||||
val encoder: Int,
|
||||
) {
|
||||
val fileExtension: String
|
||||
get() = when(outputFormat) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||
return Settings(
|
||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||
bitRate = audioRecorderSettings.bitRate,
|
||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||
encoder = audioRecorderSettings.getEncoder(),
|
||||
maxDuration = audioRecorderSettings.maxDuration,
|
||||
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun bindToRecorderService(): Pair<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
|
||||
}
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun SaveRecordingButton(
|
||||
modifier: Modifier = Modifier,
|
||||
service: RecorderService,
|
||||
onSaveFile: (File) -> Unit,
|
||||
label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var isProcessingAudio by remember { mutableStateOf(false) }
|
||||
|
||||
if (isProcessingAudio)
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Memory,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
LinearProgressIndicator()
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.then(modifier),
|
||||
onClick = {
|
||||
isProcessingAudio = true
|
||||
RecorderService.stopService(context)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val file = service.concatenateFiles()
|
||||
|
||||
onSaveFile(file)
|
||||
} catch (error: Exception) {
|
||||
Log.getStackTraceString(error)
|
||||
} finally {
|
||||
isProcessingAudio = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(label)
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -12,15 +13,12 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
@ -35,9 +33,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
@ -45,9 +43,9 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||
import app.myzel394.alibi.ui.utils.formatDuration
|
||||
@ -60,31 +58,28 @@ import java.time.ZoneId
|
||||
@Composable
|
||||
fun RecordingStatus(
|
||||
service: RecorderService,
|
||||
saveFile: (File) -> Unit,
|
||||
onSaveFile: (File) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
||||
|
||||
val start = service.recordingStart!!
|
||||
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
|
||||
val progress = duration / (service.settings!!.maxDuration / 1000f)
|
||||
val progress = service.recordingTime.value!! / (service.settings!!.maxDuration / 1000f)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
now = LocalDateTime.now()
|
||||
delay(1000)
|
||||
delay(900)
|
||||
}
|
||||
}
|
||||
|
||||
// Only show animation when the recording has just started
|
||||
val recordingJustStarted = duration < 1
|
||||
val recordingJustStarted = service.recordingTime.value!! < 1
|
||||
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
||||
LaunchedEffect(Unit) {
|
||||
progressVisible = true
|
||||
}
|
||||
|
||||
|
||||
KeepScreenOn()
|
||||
|
||||
Column(
|
||||
@ -102,7 +97,7 @@ fun RecordingStatus(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val distance = Duration.between(service.recordingStart, now).toMillis()
|
||||
val distance = Duration.between(service.recordingStart!!, now).toMillis()
|
||||
|
||||
Pulsating {
|
||||
Box(
|
||||
@ -167,31 +162,12 @@ fun RecordingStatus(
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
||||
|
||||
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
|
||||
Button(
|
||||
SaveRecordingButton(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.graphicsLayer(alpha = alpha)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
onClick = {
|
||||
RecorderService.stopService(context)
|
||||
|
||||
saveFile(service.concatenateFiles())
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(label)
|
||||
}
|
||||
.alpha(alpha),
|
||||
service = service,
|
||||
onSaveFile = onSaveFile,
|
||||
)
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -49,6 +50,7 @@ import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||
import java.time.format.DateTimeFormatter
|
||||
@ -58,6 +60,7 @@ import java.time.format.FormatStyle
|
||||
fun StartRecording(
|
||||
connection: ServiceConnection,
|
||||
service: RecorderService? = null,
|
||||
onStart: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val saveFile = rememberFileSaverDialog("audio/*")
|
||||
@ -78,6 +81,16 @@ fun StartRecording(
|
||||
},
|
||||
onPermissionAvailable = {
|
||||
RecorderService.startService(context, connection)
|
||||
|
||||
if (service == null) {
|
||||
onStart()
|
||||
} else {
|
||||
// To avoid any leaks from the previous recording, we need to wait until it
|
||||
// fully started
|
||||
service.setOnStartedListener {
|
||||
onStart()
|
||||
}
|
||||
}
|
||||
},
|
||||
) { trigger ->
|
||||
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
|
||||
@ -127,36 +140,21 @@ fun StartRecording(
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (service?.recordingStart != null)
|
||||
if (service?.hasRecordingAvailable == true)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||
onClick = {
|
||||
saveFile(service.concatenateFiles())
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_audioRecorder_action_saveOldRecording_label,
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart!!),
|
||||
),
|
||||
)
|
||||
}
|
||||
SaveRecordingButton(
|
||||
service = service,
|
||||
onSaveFile = saveFile,
|
||||
label = stringResource(
|
||||
R.string.ui_audioRecorder_action_saveOldRecording_label,
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart),
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
else
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
@ -0,0 +1,69 @@
|
||||
package app.myzel394.alibi.ui.models
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import app.myzel394.alibi.services.AudioRecorderService
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
|
||||
class AudioRecorderModel: ViewModel() {
|
||||
var recorderState by mutableStateOf(RecorderState.IDLE)
|
||||
private set
|
||||
var recordingTime by mutableStateOf<Long?>(null)
|
||||
private set
|
||||
var amplitudes by mutableStateOf<List<Int>?>(null)
|
||||
private set
|
||||
|
||||
private var intent: Intent? = null
|
||||
private var recorderService: RecorderService? = null
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
recorderService = ((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also {recorder ->
|
||||
recorder.onStateChange = { state ->
|
||||
recorderState = state
|
||||
}
|
||||
recorder.onRecordingTimeChange = { time ->
|
||||
recordingTime = time
|
||||
}
|
||||
recorder.onAmplitudeChange = { amps ->
|
||||
amplitudes = amps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
recorderService = null
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
recorderState = RecorderState.IDLE
|
||||
recordingTime = null
|
||||
amplitudes = null
|
||||
}
|
||||
|
||||
fun startRecording(context: Context) {
|
||||
runCatching {
|
||||
context.unbindService(connection)
|
||||
}
|
||||
|
||||
val intent = Intent(context, AudioRecorderService::class.java)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun stopRecording(context: Context) {
|
||||
context.stopService(intent)
|
||||
context.unbindService(connection)
|
||||
}
|
||||
}
|
@ -30,7 +30,10 @@ fun AudioRecorder(
|
||||
) {
|
||||
val saveFile = rememberFileSaverDialog("audio/aac")
|
||||
val (connection, service) = bindToRecorderService()
|
||||
val isRecording = service?.isRecording?.value ?: false
|
||||
|
||||
var showRecorderStatus by remember {
|
||||
mutableStateOf(service?.isRecording ?: false)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@ -58,10 +61,22 @@ fun AudioRecorder(
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
if (isRecording)
|
||||
RecordingStatus(service = service!!, saveFile = saveFile)
|
||||
if (showRecorderStatus && service?.recordingTime?.value != null)
|
||||
RecordingStatus(
|
||||
service = service,
|
||||
onSaveFile = {
|
||||
saveFile(it)
|
||||
showRecorderStatus = false
|
||||
}
|
||||
)
|
||||
else
|
||||
StartRecording(connection = connection, service = service)
|
||||
StartRecording(
|
||||
connection = connection,
|
||||
service = service,
|
||||
onStart = {
|
||||
showRecorderStatus = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -51,7 +51,7 @@ fun SettingsScreen(
|
||||
navController: NavController
|
||||
) {
|
||||
val (_, service) = bindToRecorderService()
|
||||
val isRecording = service?.isRecording?.value ?: false
|
||||
val isRecording = service?.isRecording ?: false
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState()
|
||||
)
|
||||
|
@ -23,6 +23,9 @@
|
||||
<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_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_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>
|
||||
@ -49,5 +52,4 @@
|
||||
<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>
|
||||
</resources>
|
Loading…
x
Reference in New Issue
Block a user