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() = "$folder/$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 7e278ee..8048501 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 @@ -4,10 +4,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.media.MediaRecorder 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 @@ -21,6 +18,7 @@ import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderService import kotlinx.coroutines.flow.last import kotlinx.serialization.json.Json +import app.myzel394.alibi.ui.utils.MicrophoneInfo class AudioRecorderModel : ViewModel() { var recorderState by mutableStateOf(RecorderState.IDLE) @@ -29,6 +27,8 @@ class AudioRecorderModel : ViewModel() { private set var amplitudes by mutableStateOf>(emptyList()) private set + var selectedMicrophone by mutableStateOf(null) + private set var onAmplitudeChange: () -> Unit = {} @@ -51,10 +51,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 } @@ -69,12 +78,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 } } @@ -88,6 +108,8 @@ class AudioRecorderModel : ViewModel() { recorderState = RecorderState.IDLE recordingTime = null amplitudes = emptyList() + selectedMicrophone = null + microphoneStatus = MicrophoneConnectivityStatus.CONNECTED } fun startRecording(context: Context) { @@ -139,6 +161,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 e5124b2..25f80e3 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 @@ -28,9 +28,8 @@ 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.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!