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