diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6d91da0..8f67b7c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
@@ -39,7 +43,9 @@
-
+
Unit = {}
+ var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
+ var onMicrophoneDisconnected: () -> Unit = {}
+ var onMicrophoneReconnected: () -> Unit = {}
val filePath: String
get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
+ /// Tell Android to use the correct bluetooth microphone, if any selected
+ private fun startAudioDevice() {
+ if (selectedMicrophone == null) {
+ return
+ }
+
+ val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
+ } else {
+ audioManger.startBluetoothSco()
+ }
+ }
+
+ private fun clearAudioDevice() {
+ val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManger.clearCommunicationDevice()
+ } else {
+ audioManger.stopBluetoothSco()
+ }
+ }
+
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
+ // Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
+ // and Redmi Buds 3 Pro:
+ // - MIC: Uses the bottom microphone of the phone (17)
+ // - CAMCORDER: Uses the top microphone of the phone (2)
+ // - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
+ // - DEFAULT: Uses the bottom microphone of the phone (17)
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(filePath)
setOutputFormat(settings!!.outputFormat)
@@ -39,6 +85,7 @@ class AudioRecorderService : IntervalRecorderService() {
it.stop()
it.release()
}
+ clearAudioDevice()
}
}
@@ -50,6 +97,7 @@ class AudioRecorderService : IntervalRecorderService() {
}
resetRecorder()
+ startAudioDevice()
try {
recorder = newRecorder
@@ -59,6 +107,12 @@ class AudioRecorderService : IntervalRecorderService() {
}
}
+ override fun start() {
+ super.start()
+
+ registerMicrophoneListener()
+ }
+
override fun pause() {
super.pause()
@@ -69,6 +123,8 @@ class AudioRecorderService : IntervalRecorderService() {
super.stop()
resetRecorder()
+ selectedMicrophone = null
+ unregisterMicrophoneListener()
}
override fun getAmplitudeAmount(): Int = amplitudesAmount
@@ -82,4 +138,66 @@ class AudioRecorderService : IntervalRecorderService() {
0
}
}
+
+ fun changeMicrophone(microphone: MicrophoneInfo?) {
+ selectedMicrophone = microphone
+ onSelectedMicrophoneChange(microphone)
+
+ if (state == RecorderState.RECORDING) {
+ startNewCycle()
+ }
+ }
+
+ private val audioDeviceCallback = object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array?) {
+ super.onAudioDevicesAdded(addedDevices)
+
+ if (selectedMicrophone == null) {
+ return;
+ }
+
+ // We can't compare the ID, as it seems to be changing on each reconnect
+ val newDevice = addedDevices?.find {
+ it.productName == selectedMicrophone!!.deviceInfo.productName &&
+ it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
+ it.type == selectedMicrophone!!.deviceInfo.type && (
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ it.address == selectedMicrophone!!.deviceInfo.address
+ } else true
+ )
+ }
+ if (newDevice != null) {
+ changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
+
+ onMicrophoneReconnected()
+ }
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array?) {
+ super.onAudioDevicesRemoved(removedDevices)
+
+ if (selectedMicrophone == null) {
+ return;
+ }
+
+ if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
+ onMicrophoneDisconnected()
+ }
+ }
+ }
+
+ private fun registerMicrophoneListener() {
+ val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
+
+ audioManager.registerAudioDeviceCallback(
+ audioDeviceCallback,
+ Handler(Looper.getMainLooper())
+ )
+ }
+
+ private fun unregisterMicrophoneListener() {
+ val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
+
+ audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
index f3a24ce..02b5797 100644
--- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
+++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
@@ -2,26 +2,19 @@ package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.app.Notification
-import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
-import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
-import app.myzel394.alibi.MainActivity
import app.myzel394.alibi.NotificationHelper
-import app.myzel394.alibi.R
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.ui.utils.PermissionHelper
import kotlinx.serialization.json.Json
import java.time.LocalDateTime
-import java.time.ZoneId
-import java.util.Calendar
-import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
@@ -115,10 +108,7 @@ abstract class RecorderService : Service() {
isPaused = true
}
- RecorderState.IDLE -> {
- stop()
- onDestroy()
- }
+ else -> {}
}
when (newState) {
@@ -170,6 +160,7 @@ abstract class RecorderService : Service() {
override fun onDestroy() {
super.onDestroy()
+ stop()
changeState(RecorderState.IDLE)
stopForeground(STOP_FOREGROUND_REMOVE)
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/DeleteButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/DeleteButton.kt
new file mode 100644
index 0000000..9c4b4df
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/DeleteButton.kt
@@ -0,0 +1,59 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import app.myzel394.alibi.R
+
+@Composable
+fun DeleteButton(
+ modifier: Modifier = Modifier,
+ onDelete: () -> Unit,
+) {
+ var showDeleteDialog by remember { mutableStateOf(false) }
+
+ if (showDeleteDialog) {
+ ConfirmDeletionDialog(
+ onDismiss = {
+ showDeleteDialog = false
+ },
+ onConfirm = {
+ showDeleteDialog = false
+ onDelete()
+ },
+ )
+ }
+ val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
+ Button(
+ modifier = Modifier
+ .semantics {
+ contentDescription = label
+ }
+ .then(modifier),
+ onClick = {
+ showDeleteDialog = true
+ },
+ colors = ButtonDefaults.textButtonColors(),
+ ) {
+ Text(
+ label,
+ fontSize = MaterialTheme.typography.bodySmall.fontSize,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneDisconnectedDialog.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneDisconnectedDialog.kt
new file mode 100644
index 0000000..e15cba5
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneDisconnectedDialog.kt
@@ -0,0 +1,63 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import app.myzel394.alibi.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MicrophoneDisconnectedDialog(
+ microphoneName: String,
+ onClose: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onClose,
+ title = {
+ Text(
+ stringResource(
+ R.string.ui_audioRecorder_error_microphoneDisconnected_title,
+ ),
+ textAlign = TextAlign.Center,
+ )
+ },
+ text = {
+ Text(
+ stringResource(
+ R.string.ui_audioRecorder_error_microphoneDisconnected_message,
+ microphoneName,
+ microphoneName,
+ )
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.MicOff,
+ contentDescription = null,
+ )
+ },
+ confirmButton = {
+ val label = stringResource(R.string.dialog_close_neutral_label)
+
+ Button(
+ modifier = Modifier
+ .semantics {
+ contentDescription = label
+ },
+ onClick = onClose,
+ ) {
+ Text(label)
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneReconnectedDialog.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneReconnectedDialog.kt
new file mode 100644
index 0000000..b8825ac
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneReconnectedDialog.kt
@@ -0,0 +1,63 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import app.myzel394.alibi.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MicrophoneReconnectedDialog(
+ microphoneName: String,
+ onClose: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onClose,
+ title = {
+ Text(
+ stringResource(
+ R.string.ui_audioRecorder_error_microphoneReconnected_title,
+ ),
+ textAlign = TextAlign.Center,
+ )
+ },
+ text = {
+ Text(
+ stringResource(
+ R.string.ui_audioRecorder_error_microphoneReconnected_message,
+ microphoneName,
+ )
+ )
+ },
+ icon = {
+ Icon(
+ Icons.Default.Star,
+ contentDescription = null,
+ )
+ },
+ confirmButton = {
+ val label = stringResource(R.string.dialog_close_neutral_label)
+
+ Button(
+ modifier = Modifier
+ .semantics {
+ contentDescription = label
+ },
+ onClick = onClose,
+ ) {
+ Text(label)
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneSelectionButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneSelectionButton.kt
new file mode 100644
index 0000000..47b0ca2
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneSelectionButton.kt
@@ -0,0 +1,91 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import android.os.Build
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+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.MicExternalOn
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import app.myzel394.alibi.ui.utils.MicrophoneInfo
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import app.myzel394.alibi.R
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppSettings
+
+@Composable
+fun MicrophoneSelectionButton(
+ microphone: MicrophoneInfo? = null,
+ selected: Boolean = false,
+ selectedAsFallback: Boolean = false,
+ disabled: Boolean = false,
+ onSelect: () -> Unit,
+) {
+ val dataStore = LocalContext.current.dataStore
+ val settings = dataStore
+ .data
+ .collectAsState(initial = AppSettings.getDefaultInstance())
+ .value
+
+ // Copied from Android's [FilledButtonTokens]
+ val disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+
+ Button(
+ onClick = onSelect,
+ enabled = !disabled,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp),
+ colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing),
+ ) {
+ MicrophoneTypeInfo(
+ type = microphone?.type ?: MicrophoneInfo.MicrophoneType.PHONE,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Column {
+ Text(
+ text = microphone?.name
+ ?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone),
+ fontSize = MaterialTheme.typography.bodyLarge.fontSize,
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && settings.audioRecorderSettings.showAllMicrophones && microphone?.deviceInfo?.address?.isNotBlank() == true)
+ Text(
+ microphone.deviceInfo.address.toString(),
+ fontSize = MaterialTheme.typography.bodySmall.toSpanStyle().fontSize,
+ color = if (disabled) disabledTextColor else if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.secondary,
+ )
+ }
+ if (selectedAsFallback)
+ Icon(
+ Icons.Default.MicExternalOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier
+ .size(ButtonDefaults.IconSize),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneTypeIcon.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneTypeIcon.kt
new file mode 100644
index 0000000..e54a5a1
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/MicrophoneTypeIcon.kt
@@ -0,0 +1,32 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import android.R
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BluetoothAudio
+import androidx.compose.material.icons.filled.Memory
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.MicExternalOn
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Smartphone
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import app.myzel394.alibi.ui.utils.MicrophoneInfo
+
+@Composable
+fun MicrophoneTypeInfo(
+ modifier: Modifier = Modifier,
+ type: MicrophoneInfo.MicrophoneType,
+) {
+ Icon(
+ imageVector = when (type) {
+ MicrophoneInfo.MicrophoneType.BLUETOOTH -> Icons.Filled.BluetoothAudio
+ MicrophoneInfo.MicrophoneType.WIRED -> Icons.Filled.MicExternalOn
+ MicrophoneInfo.MicrophoneType.PHONE -> Icons.Filled.Smartphone
+ MicrophoneInfo.MicrophoneType.OTHER -> Icons.Filled.Mic
+ },
+ modifier = modifier,
+ contentDescription = null,
+ )
+}
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/PauseResumeButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/PauseResumeButton.kt
new file mode 100644
index 0000000..653645c
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/PauseResumeButton.kt
@@ -0,0 +1,39 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LargeFloatingActionButton
+import androidx.compose.material3.SmallFloatingActionButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import app.myzel394.alibi.R
+
+@Composable
+fun PauseResumeButton(
+ modifier: Modifier = Modifier,
+ isPaused: Boolean,
+ onChange: () -> Unit,
+) {
+ val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
+ val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
+
+ FloatingActionButton(
+ modifier = Modifier
+ .semantics {
+ contentDescription = if (isPaused) resumeLabel else pauseLabel
+ }
+ .then(modifier),
+ onClick = onChange,
+ ) {
+ Icon(
+ if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+ contentDescription = null,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RecordingTime.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RecordingTime.kt
new file mode 100644
index 0000000..3e3a21f
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/RecordingTime.kt
@@ -0,0 +1,45 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import app.myzel394.alibi.ui.components.atoms.Pulsating
+import app.myzel394.alibi.ui.models.AudioRecorderModel
+import app.myzel394.alibi.ui.utils.formatDuration
+
+@Composable
+fun RecordingTime(
+ time: Long,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Pulsating {
+ Box(
+ modifier = Modifier
+ .size(16.dp)
+ .clip(CircleShape)
+ .background(Color.Red)
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = formatDuration(time),
+ style = MaterialTheme.typography.headlineLarge,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveButton.kt
new file mode 100644
index 0000000..0a65bcc
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/SaveButton.kt
@@ -0,0 +1,53 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.atoms
+
+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.Save
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+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.ui.BIG_PRIMARY_BUTTON_SIZE
+
+@Composable
+fun SaveButton(
+ modifier: Modifier = Modifier,
+ onSave: () -> Unit,
+) {
+ val label = stringResource(R.string.ui_audioRecorder_action_save_label)
+
+ Button(
+ modifier = Modifier
+ .semantics {
+ contentDescription = label
+ }
+ .then(modifier),
+ onClick = onSave,
+ colors = ButtonDefaults.textButtonColors(),
+ ) {
+ Icon(
+ Icons.Default.Save,
+ contentDescription = null,
+ modifier = Modifier
+ .size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.width(ButtonDefaults.IconSpacing))
+ Text(
+ label,
+ fontSize = MaterialTheme.typography.bodySmall.fontSize,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/MicrophoneSelection.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/MicrophoneSelection.kt
new file mode 100644
index 0000000..df055ce
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/MicrophoneSelection.kt
@@ -0,0 +1,214 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.molecules
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+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.text.style.TextAlign
+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.ui.components.AudioRecorder.atoms.MicrophoneSelectionButton
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneTypeInfo
+import app.myzel394.alibi.ui.components.atoms.MessageBox
+import app.myzel394.alibi.ui.components.atoms.MessageType
+import app.myzel394.alibi.ui.models.AudioRecorderModel
+import app.myzel394.alibi.ui.utils.MicrophoneInfo
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MicrophoneSelection(
+ audioRecorder: AudioRecorderModel
+) {
+ val context = LocalContext.current
+
+ var showSelection by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val sheetState = rememberModalBottomSheetState()
+
+ val dataStore = LocalContext.current.dataStore
+ val settings = dataStore
+ .data
+ .collectAsState(initial = AppSettings.getDefaultInstance())
+ .value
+
+ val allMicrophones = MicrophoneInfo.fetchDeviceMicrophones(context)
+ val visibleMicrophones = MicrophoneInfo.filterMicrophones(allMicrophones)
+ val hiddenMicrophones = allMicrophones - visibleMicrophones.toSet()
+
+ val isTryingToReconnect =
+ audioRecorder.selectedMicrophone != null && audioRecorder.microphoneStatus == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED
+
+ val shownMicrophones = if (isTryingToReconnect && visibleMicrophones.isEmpty()) {
+ listOf(audioRecorder.selectedMicrophone!!)
+ } else {
+ visibleMicrophones
+ }
+
+ if (showSelection) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ showSelection = false
+ },
+ sheetState = sheetState,
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(48.dp),
+ ) {
+ Text(
+ stringResource(R.string.ui_audioRecorder_info_microphone_changeExplanation),
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ )
+
+ if (isTryingToReconnect)
+ MessageBox(
+ type = MessageType.INFO,
+ message = stringResource(
+ R.string.ui_audioRecorder_error_microphoneDisconnected_message,
+ audioRecorder.selectedMicrophone?.name ?: "",
+ audioRecorder.selectedMicrophone?.name ?: "",
+ )
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .padding(horizontal = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ item {
+ MicrophoneSelectionButton(
+ selected = audioRecorder.selectedMicrophone == null,
+ selectedAsFallback = isTryingToReconnect,
+ onSelect = {
+ audioRecorder.changeMicrophone(null)
+ showSelection = false
+ }
+ )
+ }
+
+ items(shownMicrophones.size) {
+ val microphone = shownMicrophones[it]
+
+ MicrophoneSelectionButton(
+ microphone = microphone,
+ selected = audioRecorder.selectedMicrophone == microphone,
+ disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
+ onSelect = {
+ audioRecorder.changeMicrophone(microphone)
+ showSelection = false
+ }
+ )
+ }
+
+ if (settings.audioRecorderSettings.showAllMicrophones) {
+ item {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(vertical = 32.dp),
+ ) {
+ Divider(
+ modifier = Modifier
+ .weight(1f)
+ )
+ Text(
+ stringResource(R.string.ui_audioRecorder_info_microphone_hiddenMicrophones),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ textAlign = TextAlign.Center,
+ )
+ Divider(
+ modifier = Modifier
+ .weight(1f),
+ )
+ }
+ }
+
+ items(hiddenMicrophones.size) {
+ val microphone = hiddenMicrophones[it]
+
+ MicrophoneSelectionButton(
+ microphone = microphone,
+ selected = audioRecorder.selectedMicrophone == microphone,
+ onSelect = {
+ audioRecorder.changeMicrophone(microphone)
+ showSelection = false
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // We need to show a placeholder box to keep the the rest aligned correctly
+ Box {}
+ }
+
+ if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
+ Button(
+ onClick = {
+ showSelection = true
+ },
+ colors = ButtonDefaults.textButtonColors(),
+ ) {
+ MicrophoneTypeInfo(
+ type = audioRecorder.selectedMicrophone?.type
+ ?: MicrophoneInfo.MicrophoneType.PHONE,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
+ Text(
+ text = audioRecorder.selectedMicrophone.let {
+ it?.name
+ ?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone)
+ }
+ )
+ if (isTryingToReconnect) {
+ Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/MicrophoneStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/MicrophoneStatus.kt
new file mode 100644
index 0000000..8966224
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/MicrophoneStatus.kt
@@ -0,0 +1,61 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.molecules
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.platform.LocalContext
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppSettings
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
+import app.myzel394.alibi.ui.effects.rememberPrevious
+import app.myzel394.alibi.ui.models.AudioRecorderModel
+import app.myzel394.alibi.ui.utils.MicrophoneInfo
+
+@Composable
+fun MicrophoneStatus(
+ audioRecorder: AudioRecorderModel,
+) {
+ val microphoneStatus = audioRecorder.microphoneStatus
+ val previousStatus = rememberPrevious(microphoneStatus)
+
+ var showMicrophoneStatusDialog by remember {
+ // null = no dialog
+ // `MicrophoneConnectivityStatus.CONNECTED` = Reconnected dialog
+ // `MicrophoneConnectivityStatus.DISCONNECTED` = Disconnected dialog
+ mutableStateOf(null)
+ }
+
+ LaunchedEffect(microphoneStatus) {
+ if (microphoneStatus != previousStatus && showMicrophoneStatusDialog == null && previousStatus != null && audioRecorder.selectedMicrophone != null) {
+ showMicrophoneStatusDialog = microphoneStatus
+ }
+ }
+
+ if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED) {
+ MicrophoneDisconnectedDialog(
+ onClose = {
+ showMicrophoneStatusDialog = null
+ },
+ microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
+ )
+ }
+
+ if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.CONNECTED) {
+ MicrophoneReconnectedDialog(
+ onClose = {
+ showMicrophoneStatusDialog = null
+ },
+ microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
+ )
+ }
+
+ MicrophoneSelection(
+ audioRecorder = audioRecorder,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt
deleted file mode 100644
index f1d832b..0000000
--- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/RecordingStatus.kt
+++ /dev/null
@@ -1,219 +0,0 @@
-package app.myzel394.alibi.ui.components.AudioRecorder.molecules
-
-import androidx.compose.animation.AnimatedVisibility
-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
-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.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
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.alpha
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-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 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
-import java.io.File
-import java.time.Duration
-import java.time.LocalDateTime
-import java.time.ZoneId
-
-@Composable
-fun RecordingStatus(
- audioRecorder: AudioRecorderModel,
-) {
- val context = LocalContext.current
-
- var now by remember { mutableStateOf(LocalDateTime.now()) }
-
- LaunchedEffect(Unit) {
- while (true) {
- now = LocalDateTime.now()
- delay(900)
- }
- }
-
- // Only show animation when the recording has just started
- val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
- var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
- LaunchedEffect(Unit) {
- progressVisible = true
- }
-
- KeepScreenOn()
-
- Column(
- modifier = Modifier
- .fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceBetween,
- ) {
- Box {}
- RealtimeAudioVisualizer(audioRecorder = audioRecorder)
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
- ) {
- Pulsating {
- Box(
- modifier = Modifier
- .size(16.dp)
- .clip(CircleShape)
- .background(Color.Red)
- )
- }
- Spacer(modifier = Modifier.width(16.dp))
- Text(
- text = formatDuration(audioRecorder.recordingTime!!),
- style = MaterialTheme.typography.headlineLarge,
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
- AnimatedVisibility(
- visible = progressVisible,
- enter = expandHorizontally(
- tween(1000)
- )
- ) {
- LinearProgressIndicator(
- progress = audioRecorder.progress,
- modifier = Modifier
- .width(300.dp)
- )
- }
- Spacer(modifier = Modifier.height(32.dp))
-
- var showDeleteDialog by remember { mutableStateOf(false) }
-
- if (showDeleteDialog) {
- ConfirmDeletionDialog(
- onDismiss = {
- showDeleteDialog = false
- },
- onConfirm = {
- showDeleteDialog = false
- audioRecorder.stopRecording(context, saveAsLastRecording = false)
- },
- )
- }
- val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
- Button(
- modifier = Modifier
- .semantics {
- contentDescription = label
- },
- onClick = {
- showDeleteDialog = true
- },
- colors = ButtonDefaults.textButtonColors(),
- ) {
- Icon(
- Icons.Default.Delete,
- contentDescription = null,
- modifier = Modifier.size(ButtonDefaults.IconSize),
- )
- Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
- Text(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)
- .alpha(alpha)
- .semantics {
- contentDescription = label
- },
- onClick = {
- runCatching {
- audioRecorder.stopRecording(context)
- }
- audioRecorder.onRecordingSave()
- },
- ) {
- Icon(
- Icons.Default.Save,
- contentDescription = null,
- modifier = Modifier.size(ButtonDefaults.IconSize),
- )
- Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
- Text(stringResource(R.string.ui_audioRecorder_action_save_label))
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt
new file mode 100644
index 0000000..ba4d330
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/RecordingStatus.kt
@@ -0,0 +1,147 @@
+package app.myzel394.alibi.ui.components.AudioRecorder.organisms
+
+import androidx.compose.animation.AnimatedVisibility
+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.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.width
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppSettings
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.DeleteButton
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.PauseResumeButton
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RecordingTime
+import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveButton
+import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneSelection
+import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneStatus
+import app.myzel394.alibi.ui.effects.rememberPrevious
+import app.myzel394.alibi.ui.models.AudioRecorderModel
+import app.myzel394.alibi.ui.utils.KeepScreenOn
+import app.myzel394.alibi.ui.utils.MicrophoneInfo
+import kotlinx.coroutines.delay
+import java.time.LocalDateTime
+
+@Composable
+fun RecordingStatus(
+ audioRecorder: AudioRecorderModel,
+) {
+ val context = LocalContext.current
+
+ var now by remember { mutableStateOf(LocalDateTime.now()) }
+
+ LaunchedEffect(Unit) {
+ while (true) {
+ now = LocalDateTime.now()
+ delay(900)
+ }
+ }
+
+ // Only show animation when the recording has just started
+ val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
+ var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
+ LaunchedEffect(Unit) {
+ progressVisible = true
+ }
+
+ KeepScreenOn()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Box {}
+ RealtimeAudioVisualizer(audioRecorder = audioRecorder)
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ RecordingTime(audioRecorder.recordingTime!!)
+ Spacer(modifier = Modifier.height(16.dp))
+ AnimatedVisibility(
+ visible = progressVisible,
+ enter = expandHorizontally(
+ tween(1000)
+ )
+ ) {
+ LinearProgressIndicator(
+ progress = audioRecorder.progress,
+ modifier = Modifier
+ .width(300.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ DeleteButton(
+ onDelete = {
+ audioRecorder.stopRecording(context, saveAsLastRecording = false)
+ }
+ )
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ ) {
+ PauseResumeButton(
+ isPaused = audioRecorder.isPaused,
+ onChange = {
+ if (audioRecorder.isPaused) {
+ audioRecorder.resumeRecording()
+ } else {
+ audioRecorder.pauseRecording()
+ }
+ },
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ SaveButton(
+ onSave = {
+ runCatching {
+ audioRecorder.stopRecording(context)
+ }
+ audioRecorder.onRecordingSave()
+ }
+ )
+ }
+ }
+
+ MicrophoneStatus(audioRecorder)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/ShowAllMicrophonesTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/ShowAllMicrophonesTile.kt
new file mode 100644
index 0000000..f2a1d8e
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/ShowAllMicrophonesTile.kt
@@ -0,0 +1,57 @@
+package app.myzel394.alibi.ui.components.SettingsScreen.atoms
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.MicExternalOn
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Switch
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import app.myzel394.alibi.R
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppSettings
+import app.myzel394.alibi.ui.components.atoms.SettingsTile
+import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
+import kotlinx.coroutines.launch
+
+
+@Composable
+fun ShowAllMicrophonesTile() {
+ val scope = rememberCoroutineScope()
+ val dataStore = LocalContext.current.dataStore
+ val settings = dataStore
+ .data
+ .collectAsState(initial = AppSettings.getDefaultInstance())
+ .value
+
+ fun updateValue(showAllMicrophones: Boolean) {
+ scope.launch {
+ dataStore.updateData {
+ it.setAudioRecorderSettings(
+ it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
+ )
+ }
+ }
+ }
+
+
+ SettingsTile(
+ title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
+ description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
+ leading = {
+ Icon(
+ Icons.Default.MicExternalOn,
+ contentDescription = null,
+ )
+ },
+ trailing = {
+ Switch(
+ checked = settings.audioRecorderSettings.showAllMicrophones,
+ onCheckedChange = ::updateValue,
+ )
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/effects/remember-previous.kt b/app/src/main/java/app/myzel394/alibi/ui/effects/remember-previous.kt
new file mode 100644
index 0000000..7dc2adc
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/effects/remember-previous.kt
@@ -0,0 +1,41 @@
+package app.myzel394.alibi.ui.effects
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
+
+/**
+ * Returns a dummy MutableState that does not cause render when setting it
+ */
+@Composable
+private fun rememberRef(): MutableState {
+ // for some reason it always recreated the value with vararg keys,
+ // leaving out the keys as a parameter for remember for now
+ return remember() {
+ object : MutableState {
+ override var value: T? = null
+
+ override fun component1(): T? = value
+
+ override fun component2(): (T?) -> Unit = { value = it }
+ }
+ }
+}
+
+@Composable
+fun rememberPrevious(
+ current: T,
+ shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b },
+): T? {
+ val ref = rememberRef()
+
+ // launched after render, so the current render will have the old value anyway
+ SideEffect {
+ if (shouldUpdate(ref.value, current)) {
+ ref.value = current
+ }
+ }
+
+ return ref.value
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
index b754a81..2df981f 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
@@ -16,6 +16,7 @@ import app.myzel394.alibi.services.AudioRecorderService
import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.services.RecorderService
import kotlinx.serialization.json.Json
+import app.myzel394.alibi.ui.utils.MicrophoneInfo
class AudioRecorderModel : ViewModel() {
var recorderState by mutableStateOf(RecorderState.IDLE)
@@ -24,6 +25,8 @@ class AudioRecorderModel : ViewModel() {
private set
var amplitudes by mutableStateOf>(emptyList())
private set
+ var selectedMicrophone by mutableStateOf(null)
+ private set
var onAmplitudeChange: () -> Unit = {}
@@ -46,10 +49,19 @@ class AudioRecorderModel : ViewModel() {
var onError: () -> Unit = {}
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
+ var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED
+ private set
+
+ enum class MicrophoneConnectivityStatus {
+ CONNECTED,
+ DISCONNECTED
+ }
+
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
recorderService =
((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder ->
+ // Update UI when the service changes
recorder.onStateChange = { state ->
recorderState = state
}
@@ -64,12 +76,23 @@ class AudioRecorderModel : ViewModel() {
recorderService!!.createLastRecording()
onError()
}
+ recorder.onSelectedMicrophoneChange = { microphone ->
+ selectedMicrophone = microphone
+ }
+ recorder.onMicrophoneDisconnected = {
+ microphoneStatus = MicrophoneConnectivityStatus.DISCONNECTED
+ }
+ recorder.onMicrophoneReconnected = {
+ microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
+ }
}.also {
+ // Init UI from the service
it.startRecording()
recorderState = it.state
recordingTime = it.recordingTime
amplitudes = it.amplitudes
+ selectedMicrophone = it.selectedMicrophone
}
}
@@ -83,6 +106,8 @@ class AudioRecorderModel : ViewModel() {
recorderState = RecorderState.IDLE
recordingTime = null
amplitudes = emptyList()
+ selectedMicrophone = null
+ microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
}
fun startRecording(context: Context) {
@@ -134,6 +159,14 @@ class AudioRecorderModel : ViewModel() {
recorderService?.amplitudesAmount = amount
}
+ fun changeMicrophone(microphone: MicrophoneInfo?) {
+ recorderService!!.changeMicrophone(microphone)
+
+ if (microphone == null) {
+ microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
+ }
+ }
+
fun bindToService(context: Context) {
Intent(context, AudioRecorderService::class.java).also { intent ->
context.bindService(intent, connection, 0)
diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
index ec10049..4c0eddd 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
@@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
-import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus
+import app.myzel394.alibi.ui.components.AudioRecorder.organisms.RecordingStatus
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
index 0c2e972..abdac0a 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
@@ -50,6 +50,7 @@ import app.myzel394.alibi.ui.components.SettingsScreen.atoms.IntervalDurationTil
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.OutputFormatTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile
+import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ShowAllMicrophonesTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ThemeSelector
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
import app.myzel394.alibi.ui.components.atoms.MessageBox
@@ -160,6 +161,7 @@ fun SettingsScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp)
)
+ ShowAllMicrophonesTile()
BitrateTile()
SamplingRateTile()
EncoderTile(snackbarHostState = snackbarHostState)
diff --git a/app/src/main/java/app/myzel394/alibi/ui/utils/available-microphones.kt b/app/src/main/java/app/myzel394/alibi/ui/utils/available-microphones.kt
new file mode 100644
index 0000000..c36c91b
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/utils/available-microphones.kt
@@ -0,0 +1,95 @@
+package app.myzel394.alibi.ui.utils
+
+import android.bluetooth.BluetoothAdapter
+import android.content.Context
+import android.media.AudioDeviceInfo
+import android.media.AudioManager
+import android.os.Build
+import android.util.Log
+
+val ALLOWED_MICROPHONE_TYPES =
+ setOf(
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
+ AudioDeviceInfo.TYPE_USB_DEVICE,
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
+ AudioDeviceInfo.TYPE_IP,
+ AudioDeviceInfo.TYPE_DOCK,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ AudioDeviceInfo.TYPE_DOCK_ANALOG
+ } else {
+ },
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ AudioDeviceInfo.TYPE_BLE_HEADSET
+ } else {
+ },
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ AudioDeviceInfo.TYPE_REMOTE_SUBMIX
+ } else {
+ },
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ AudioDeviceInfo.TYPE_USB_HEADSET
+ } else {
+ },
+ )
+
+data class MicrophoneInfo(
+ val deviceInfo: AudioDeviceInfo,
+) {
+ val name: String
+ get() = deviceInfo.productName.toString()
+
+ val type: MicrophoneType
+ get() = when (deviceInfo.type) {
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> MicrophoneType.BLUETOOTH
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> MicrophoneType.WIRED
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> MicrophoneType.PHONE
+ else -> MicrophoneType.OTHER
+ }
+
+ companion object {
+ fun fromDeviceInfo(deviceInfo: AudioDeviceInfo): MicrophoneInfo {
+ return MicrophoneInfo(deviceInfo)
+ }
+
+ fun fetchDeviceMicrophones(context: Context): List {
+ return try {
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManager.availableCommunicationDevices.map(::fromDeviceInfo)
+ } else {
+ audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).map(::fromDeviceInfo)
+ }
+ } catch (error: Exception) {
+ Log.getStackTraceString(error)
+
+ emptyList()
+ }
+ }
+
+ /// Filter microphones to only show normal ones
+ fun filterMicrophones(microphones: List): List {
+ return microphones.filter {
+ it.deviceInfo.isSource && (
+ ALLOWED_MICROPHONE_TYPES.contains(it.deviceInfo.type) ||
+ // `type` doesn't seem to be reliably as its sometimes -2147483644 even
+ // for valid microphones
+ (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
+ it.deviceInfo.type == -2147483644 &&
+ BluetoothAdapter.checkBluetoothAddress(it.deviceInfo.address) &&
+ it.deviceInfo.productName.isNotBlank()
+ )
+ )
+ }
+ }
+ }
+
+
+ enum class MicrophoneType {
+ BLUETOOTH,
+ WIRED,
+ PHONE,
+ OTHER,
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e3fd669..bfa6fb6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -66,6 +66,15 @@
Alibi encountered an error during recording. Would you like to try saving the recording?
Language
Change
+ Device Microphone
+ The selected microphone will be activated immediately
+ Microphone disconnected
+ %s disconnected. Alibi will use the default microphone instead. We will automatically switch back to %s once it reconnects.
+ Microphone reconnected
+ %s reconnected! Alibi automatically changed the microphone input to it.
+ Show hidden microphones
+ Show all microphones, including internal ones
+ Hidden Microphones
Import Settings
Export Settings
Are you sure you want to import these settings? Your current settings will be overwritten!