Merge pull request #23 from Myzel394/add-selection

Add microphone selection
This commit is contained in:
Myzel394 2023-10-26 17:31:58 +02:00 committed by GitHub
commit bfe8e8f844
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1238 additions and 238 deletions

View File

@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Required for Bluetooth microphones -->
<uses-permission
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
@ -39,7 +43,9 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
<service
android:name=".services.AudioRecorderService"
android:foregroundServiceType="microphone" />
<!-- Change locale for Android <= 12 -->
<service

View File

@ -171,6 +171,7 @@ data class AudioRecorderSettings(
val samplingRate: Int? = null,
val outputFormat: Int? = null,
val encoder: Int? = null,
val showAllMicrophones: Boolean = false,
) {
fun getOutputFormat(): Int {
if (outputFormat != null) {
@ -251,7 +252,6 @@ data class AudioRecorderSettings(
}
fun setBitRate(bitRate: Int): AudioRecorderSettings {
println("bitRate: $bitRate")
if (bitRate !in 1000..320000) {
throw Exception("Bit rate must be between 1000 and 320000")
}
@ -299,6 +299,10 @@ data class AudioRecorderSettings(
return copy(forceExactMaxDuration = forceExactMaxDuration)
}
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
return copy(showAllMicrophones = showAllMicrophones)
}
fun isEncoderCompatible(encoder: Int): Boolean {
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
return true

View File

@ -1,26 +1,72 @@
package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaRecorder
import android.media.MediaRecorder.OnErrorListener
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat.getSystemService
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import java.lang.IllegalStateException
import java.util.concurrent.Executor
class AudioRecorderService: IntervalRecorderService() {
class AudioRecorderService : IntervalRecorderService() {
var amplitudesAmount = 1000
var selectedMicrophone: MicrophoneInfo? = null
var recorder: MediaRecorder? = null
private set
var onError: () -> 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<out AudioDeviceInfo>?) {
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<out AudioDeviceInfo>?) {
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)
}
}

View File

@ -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)

View File

@ -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,
)
}
}

View File

@ -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)
}
}
)
}

View File

@ -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)
}
}
)
}

View File

@ -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),
)
}
}
}

View File

@ -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,
)
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}

View File

@ -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),
)
}
}
}
}

View File

@ -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<AudioRecorderModel.MicrophoneConnectivityStatus?>(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,
)
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}

View File

@ -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,
)
},
)
}

View File

@ -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 <T> rememberRef(): MutableState<T?> {
// 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<T?> {
override var value: T? = null
override fun component1(): T? = value
override fun component2(): (T?) -> Unit = { value = it }
}
}
}
@Composable
fun <T> rememberPrevious(
current: T,
shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b },
): T? {
val ref = rememberRef<T>()
// 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
}

View File

@ -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<List<Int>>(emptyList())
private set
var selectedMicrophone by mutableStateOf<MicrophoneInfo?>(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)

View File

@ -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

View File

@ -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)

View File

@ -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<MicrophoneInfo> {
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<MicrophoneInfo>): List<MicrophoneInfo> {
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,
}
}

View File

@ -66,6 +66,15 @@
<string name="ui_audioRecorder_error_recording_description">Alibi encountered an error during recording. Would you like to try saving the recording?</string>
<string name="ui_settings_language_title">Language</string>
<string name="ui_settings_language_update_label">Change</string>
<string name="ui_audioRecorder_info_microphone_deviceMicrophone">Device Microphone</string>
<string name="ui_audioRecorder_info_microphone_changeExplanation">The selected microphone will be activated immediately</string>
<string name="ui_audioRecorder_error_microphoneDisconnected_title">Microphone disconnected</string>
<string name="ui_audioRecorder_error_microphoneDisconnected_message"><xliff:g name="name">%s</xliff:g> disconnected. Alibi will use the default microphone instead. We will automatically switch back to <xliff:g name="nam">%s</xliff:g> once it reconnects.</string>
<string name="ui_audioRecorder_error_microphoneReconnected_title">Microphone reconnected</string>
<string name="ui_audioRecorder_error_microphoneReconnected_message"><xliff:g name="name">%s</xliff:g> reconnected! Alibi automatically changed the microphone input to it.</string>
<string name="ui_settings_option_showAllMicrophones_title">Show hidden microphones</string>
<string name="ui_settings_option_showAllMicrophones_description">Show all microphones, including internal ones</string>
<string name="ui_audioRecorder_info_microphone_hiddenMicrophones">Hidden Microphones</string>
<string name="ui_settings_option_import_label">Import Settings</string>
<string name="ui_settings_option_export_label">Export Settings</string>
<string name="ui_settings_option_import_dialog_text">Are you sure you want to import these settings? Your current settings will be overwritten!</string>