From 412f75219acff5fd8b4e116fd47ccca385735db7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 21 Apr 2024 01:19:37 +0700 Subject: [PATCH] android: select audio source (#4040) * android, desktop: select audio source * improvements * fix possible crash * changes --- .../views/call/CallAudioDeviceManager.kt | 235 ++++++++++++++++++ .../common/views/call/CallView.android.kt | 129 +++++----- .../helpers/ExposedDropDownSettingRow.kt | 55 ++++ .../commonMain/resources/MR/base/strings.xml | 3 + .../resources/MR/images/ic_bluetooth.svg | 1 + .../MR/images/ic_brand_awareness_filled.svg | 1 + .../resources/MR/images/ic_headphones.svg | 1 + .../MR/images/ic_phone_bluetooth_speaker.svg | 1 + .../commonMain/resources/MR/images/ic_usb.svg | 1 + 9 files changed, 354 insertions(+), 73 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt new file mode 100644 index 0000000000..9c6f76461d --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt @@ -0,0 +1,235 @@ +package chat.simplex.common.views.call + +import android.content.Context +import android.media.* +import android.media.AudioManager.OnCommunicationDeviceChangedListener +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.* +import chat.simplex.common.platform.* +import dev.icerock.moko.resources.ImageResource +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import java.util.concurrent.Executors + +interface CallAudioDeviceManagerInterface { + val devices: State> + val currentDevice: MutableState + fun start() + fun stop() + // AudioDeviceInfo.AudioDeviceType + fun selectLastExternalDeviceOrDefault(speaker: Boolean, keepAnyNonEarpiece: Boolean) + // AudioDeviceInfo.AudioDeviceType + fun selectDevice(id: Int) + + companion object { + fun new(): CallAudioDeviceManagerInterface = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PostSCallAudioDeviceManager() + } else { + PreSCallAudioDeviceManager() + } + } +} + +@RequiresApi(Build.VERSION_CODES.S) +class PostSCallAudioDeviceManager: CallAudioDeviceManagerInterface { + private val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + override val devices: MutableState> = mutableStateOf(emptyList()) + override val currentDevice: MutableState = mutableStateOf(null) + + private val audioCallback = object: AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}") + super.onAudioDevicesAdded(addedDevices) + val oldDevices = devices.value + devices.value = am.availableCommunicationDevices + Log.d(TAG, "Added audio devices2: ${devices.value.map { it.type }}") + + if (devices.value.size - oldDevices.size > 0) { + selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.supportsVideo() == true, false) + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}") + super.onAudioDevicesRemoved(removedDevices) + devices.value = am.availableCommunicationDevices + } + } + + private val listener: OnCommunicationDeviceChangedListener = OnCommunicationDeviceChangedListener { device -> + devices.value = am.availableCommunicationDevices + currentDevice.value = device + } + + override fun start() { + am.registerAudioDeviceCallback(audioCallback, null) + am.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), listener) + } + + override fun stop() { + am.unregisterAudioDeviceCallback(audioCallback) + am.removeOnCommunicationDeviceChangedListener(listener) + } + + override fun selectLastExternalDeviceOrDefault(speaker: Boolean, keepAnyNonEarpiece: Boolean) { + Log.d(TAG, "selectLastExternalDeviceOrDefault: set audio mode, speaker enabled: $speaker") + am.mode = AudioManager.MODE_IN_COMMUNICATION + val commDevice = am.communicationDevice + if (keepAnyNonEarpiece && commDevice != null && commDevice.type != AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) { + // some external device or speaker selected already, no need to change it + return + } + + val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + val externalDevice = devices.value.lastOrNull { it.type != AudioDeviceInfo.TYPE_BUILTIN_SPEAKER && it.type != AudioDeviceInfo.TYPE_BUILTIN_EARPIECE } + // External device already selected + if (externalDevice != null && externalDevice.type == am.communicationDevice?.type) { + return + } + if (externalDevice != null) { + am.setCommunicationDevice(externalDevice) + } else if (am.communicationDevice?.type != preferredSecondaryDevice) { + am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let { + am.setCommunicationDevice(it) + } + } + } + + override fun selectDevice(id: Int) { + am.mode = AudioManager.MODE_IN_COMMUNICATION + val device = devices.value.lastOrNull { it.id == id } + if (device != null && am.communicationDevice?.id != id ) { + am.setCommunicationDevice(device) + } + } +} + +class PreSCallAudioDeviceManager: CallAudioDeviceManagerInterface { + private val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + override val devices: MutableState> = mutableStateOf(emptyList()) + override val currentDevice: MutableState = mutableStateOf(null) + + private val audioCallback = object: AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}") + super.onAudioDevicesAdded(addedDevices) + val wasSize = devices.value.size + devices.value += addedDevices.filter { it.hasSupportedType() } + val addedCount = devices.value.size - wasSize + //if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) { + // Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12 + selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.supportsVideo() == true, false) + //} + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}") + super.onAudioDevicesRemoved(removedDevices) + val wasSize = devices.value.size + devices.value = devices.value.filterNot { removedDevices.any { rm -> rm.id == it.id } } + //val removedCount = wasSize - devices.value.size + //if (devices.value.count { it.hasSupportedType() } == 2 && chatModel.activeCall.value?.callState == CallState.Connected) { + // Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12 + selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.supportsVideo() == true, true) + //} + } + } + + override fun start() { + am.registerAudioDeviceCallback(audioCallback, null) + } + + override fun stop() { + am.unregisterAudioDeviceCallback(audioCallback) + } + + override fun selectLastExternalDeviceOrDefault(speaker: Boolean, keepAnyNonEarpiece: Boolean) { + Log.d(TAG, "selectLastExternalDeviceOrDefault: set audio mode, speaker enabled: $speaker") + val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + val externalDevice = devices.value.lastOrNull { it.hasSupportedType() && it.isSource && it.isSink && it.type != AudioDeviceInfo.TYPE_BUILTIN_SPEAKER && it.type != AudioDeviceInfo.TYPE_BUILTIN_EARPIECE } + if (externalDevice != null) { + selectDevice(externalDevice.id) + } else { + am.stopBluetoothSco() + am.isWiredHeadsetOn = false + am.isSpeakerphoneOn = preferredSecondaryDevice == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + am.isBluetoothScoOn = false + val newCurrentDevice = devices.value.firstOrNull { it.type == preferredSecondaryDevice } + adaptToCurrentlyActiveDevice(newCurrentDevice) + } + } + + override fun selectDevice(id: Int) { + val device = devices.value.lastOrNull { it.id == id } + val isExternalDevice = device != null && device.type != AudioDeviceInfo.TYPE_BUILTIN_SPEAKER && device.type != AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + if (isExternalDevice) { + am.isSpeakerphoneOn = false + if (device?.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES) { + am.isWiredHeadsetOn = true + am.stopBluetoothSco() + am.isBluetoothScoOn = false + } else { + am.isWiredHeadsetOn = false + am.startBluetoothSco() + am.isBluetoothScoOn = true + } + adaptToCurrentlyActiveDevice(device) + } else { + am.stopBluetoothSco() + am.isWiredHeadsetOn = false + am.isSpeakerphoneOn = device?.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + am.isBluetoothScoOn = false + adaptToCurrentlyActiveDevice(device) + } + } + + private fun adaptToCurrentlyActiveDevice(newCurrentDevice: AudioDeviceInfo?) { + currentDevice.value = newCurrentDevice + } + + private fun AudioDeviceInfo.hasSupportedType(): Boolean = when (type) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> true + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> true + + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> true + AudioDeviceInfo.TYPE_BLE_HEADSET -> true + AudioDeviceInfo.TYPE_BLE_SPEAKER -> true + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> true + else -> false + } +} + +val AudioDeviceInfo.icon: ImageResource + get() = when (this.type) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> MR.images.ic_volume_down + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> MR.images.ic_volume_up + + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER -> MR.images.ic_bluetooth + + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> MR.images.ic_headphones + + AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE -> MR.images.ic_usb + else -> MR.images.ic_brand_awareness_filled + } + +val AudioDeviceInfo.name: StringResource? + get() = when (this.type) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> MR.strings.audio_device_earpiece + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> MR.strings.audio_device_speaker + + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER -> null // Use product name instead + + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> MR.strings.audio_device_wired_headphones + + AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE -> null // Use product name instead + else -> null // Use product name instead + } + diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index af261e2a98..a93f79f7bc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -26,8 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -39,14 +38,14 @@ import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat import chat.simplex.common.helpers.showAllowPermissionInSettingsAlert import chat.simplex.common.model.* -import chat.simplex.common.ui.theme.* -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.Contact import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import com.google.accompanist.permissions.* import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -71,7 +70,6 @@ fun activeCallDestroyWebView() = withApi { actual fun ActiveCallView() { val call = remember { chatModel.activeCall }.value val scope = rememberCoroutineScope() - val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val proximityLock = remember { val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager) if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { @@ -87,41 +85,16 @@ actual fun ActiveCallView() { wasConnected.value = true } } + val callAudioDeviceManager = remember { CallAudioDeviceManagerInterface.new() } DisposableEffect(Unit) { - val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager - var btDeviceCount = 0 - val audioCallback = object: AudioDeviceCallback() { - override fun onAudioDevicesAdded(addedDevices: Array) { - Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}") - super.onAudioDevicesAdded(addedDevices) - val addedCount = addedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } - btDeviceCount += addedCount - audioViaBluetooth.value = btDeviceCount > 0 - if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) { - // Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12 - setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth) - } - } - override fun onAudioDevicesRemoved(removedDevices: Array) { - Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}") - super.onAudioDevicesRemoved(removedDevices) - val removedCount = removedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } - btDeviceCount -= removedCount - audioViaBluetooth.value = btDeviceCount > 0 - if (btDeviceCount == 0 && chatModel.activeCall.value?.callState == CallState.Connected) { - // Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12 - setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth) - } - } - } - am.registerAudioDeviceCallback(audioCallback, null) + callAudioDeviceManager.start() onDispose { CallSoundsPlayer.stop() if (wasConnected.value) { CallSoundsPlayer.vibrate() } dropAudioManagerOverrides() - am.unregisterAudioDeviceCallback(audioCallback) + callAudioDeviceManager.stop() if (proximityLock?.isHeld == true) { proximityLock.release() } @@ -147,7 +120,7 @@ actual fun ActiveCallView() { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } - setCallSound(call.soundSpeaker, audioViaBluetooth) + callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.soundSpeaker, true) CallSoundsPlayer.startConnectingCallSound(scope) activeCallWaitDeliveryReceipt(scope) } @@ -168,7 +141,7 @@ actual fun ActiveCallView() { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } - setCallSound(call.soundSpeaker, audioViaBluetooth) + callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.soundSpeaker, true) } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -177,7 +150,7 @@ actual fun ActiveCallView() { is WCallResponse.Connected -> { updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) } scope.launch { - setCallSound(call.soundSpeaker, audioViaBluetooth) + callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.soundSpeaker, true) } } is WCallResponse.End -> { @@ -223,7 +196,7 @@ actual fun ActiveCallView() { else -> false } if (call != null && showOverlay) { - ActiveCallOverlay(call, chatModel, audioViaBluetooth) + ActiveCallOverlay(call, chatModel, callAudioDeviceManager) } } val context = LocalContext.current @@ -249,19 +222,21 @@ actual fun ActiveCallView() { } @Composable -private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetooth: MutableState) { +private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, callAudioDeviceManager: CallAudioDeviceManagerInterface) { ActiveCallOverlayLayout( call = call, - speakerCanBeEnabled = !audioViaBluetooth.value, + devices = remember { callAudioDeviceManager.devices }.value, + currentDevice = remember { callAudioDeviceManager.currentDevice }, dismiss = { withBGApi { chatModel.callManager.endCall(call) } }, toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) }, + selectDevice = { callAudioDeviceManager.selectDevice(it.id) }, toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) }, toggleSound = { var call = chatModel.activeCall.value if (call != null) { call = call.copy(soundSpeaker = !call.soundSpeaker) chatModel.activeCall.value = call - setCallSound(call.soundSpeaker, audioViaBluetooth) + callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.soundSpeaker, true) } }, flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) } @@ -272,42 +247,18 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot fun ActiveCallOverlayDisabled(call: Call) { ActiveCallOverlayLayout( call = call, - speakerCanBeEnabled = false, + devices = emptyList(), + currentDevice = remember { mutableStateOf(null) }, enabled = false, dismiss = {}, toggleAudio = {}, + selectDevice = {}, toggleVideo = {}, toggleSound = {}, flipCamera = {} ) } -private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState) { - val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager - Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker") - am.mode = AudioManager.MODE_IN_COMMUNICATION - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val btDevice = am.availableCommunicationDevices.lastOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } - val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE - if (btDevice != null) { - am.setCommunicationDevice(btDevice) - } else if (am.communicationDevice?.type != preferredSecondaryDevice) { - am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let { - am.setCommunicationDevice(it) - } - } - } else { - if (audioViaBluetooth.value) { - am.isSpeakerphoneOn = false - am.startBluetoothSco() - } else { - am.stopBluetoothSco() - am.isSpeakerphoneOn = speaker - } - am.isBluetoothScoOn = am.isBluetoothScoAvailableOffCall && audioViaBluetooth.value - } -} - private fun dropAudioManagerOverrides() { val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager am.mode = AudioManager.MODE_NORMAL @@ -323,10 +274,12 @@ private fun dropAudioManagerOverrides() { @Composable private fun ActiveCallOverlayLayout( call: Call, - speakerCanBeEnabled: Boolean, + devices: List, + currentDevice: State, enabled: Boolean = true, dismiss: () -> Unit, toggleAudio: () -> Unit, + selectDevice: (AudioDeviceInfo) -> Unit, toggleVideo: () -> Unit, toggleSound: () -> Unit, flipCamera: () -> Unit @@ -339,6 +292,32 @@ private fun ActiveCallOverlayLayout( } } Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + @Composable + fun SelectSoundDevice() { + if (devices.size == 2 && + devices.all { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE || it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } || + currentDevice.value == null || + devices.none { it.id == currentDevice.value?.id } + ) { + ToggleSoundButton(call, enabled, toggleSound) + } else { + ExposedDropDownSettingWithIcon( + devices.map { Triple(it, it.icon, if (it.name != null) generalGetString(it.name!!) else it.productName.toString()) }, + currentDevice, + fontSize = 18.sp, + iconSize = 40.dp, + listIconSize = 30.dp, + iconColor = Color(0xFFFFFFD8), + minWidth = 300.dp, + onSelected = { + if (it != null) { + selectDevice(it) + } + } + ) + } + } + when (media) { CallMediaType.Video -> { VideoCallInfoView(call) @@ -347,7 +326,7 @@ private fun ActiveCallOverlayLayout( } Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { ToggleAudioButton(call, enabled, toggleAudio) - Spacer(Modifier.size(40.dp)) + SelectSoundDevice() IconButton(onClick = dismiss, enabled = enabled) { Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp)) } @@ -385,7 +364,7 @@ private fun ActiveCallOverlayLayout( } Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(Modifier.padding(end = 32.dp)) { - ToggleSoundButton(call, speakerCanBeEnabled && enabled, toggleSound) + SelectSoundDevice() } } } @@ -780,9 +759,11 @@ fun PreviewActiveCallOverlayVideo() { RTCIceCandidate(RTCIceCandidateType.Host, "tcp") ) ), - speakerCanBeEnabled = true, + devices = emptyList(), + currentDevice = remember { mutableStateOf(null) }, dismiss = {}, toggleAudio = {}, + selectDevice = {}, toggleVideo = {}, toggleSound = {}, flipCamera = {} @@ -807,9 +788,11 @@ fun PreviewActiveCallOverlayAudio() { RTCIceCandidate(RTCIceCandidateType.Host, "udp") ) ), - speakerCanBeEnabled = true, + devices = emptyList(), + currentDevice = remember { mutableStateOf(null) }, dismiss = {}, toggleAudio = {}, + selectDevice = {}, toggleVideo = {}, toggleSound = {}, flipCamera = {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 043aa5ec84..7e57bda928 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.* import chat.simplex.res.MR import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import dev.icerock.moko.resources.ImageResource @Composable fun ExposedDropDownSetting( @@ -79,6 +80,60 @@ fun ExposedDropDownSetting( } } +@Composable +fun ExposedDropDownSettingWithIcon( + values: List>, + selection: State, + fontSize: TextUnit = 16.sp, + iconSize: Dp = 40.dp, + listIconSize: Dp = 30.dp, + iconColor: Color = MenuTextColor, + enabled: State = mutableStateOf(true), + minWidth: Dp = 200.dp, + onSelected: (T) -> Unit +) { + val expanded = remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded.value, + onExpandedChange = { + expanded.value = !expanded.value && enabled.value + } + ) { + Row( + Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + val choice = values.first { it.first == selection.value } + Icon(painterResource(choice.second), choice.third, Modifier.size(iconSize), tint = iconColor) + } + DefaultExposedDropdownMenu( + modifier = Modifier.widthIn(min = minWidth), + expanded = expanded, + ) { + values.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + onSelected(selectionOption.first) + expanded.value = false + }, + contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) + ) { + Icon(painterResource(selectionOption.second), selectionOption.third, Modifier.size(listIconSize)) + Spacer(Modifier.width(15.dp)) + Text( + selectionOption.third, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MenuTextColor, + fontSize = fontSize, + ) + } + } + } + } +} + @Composable fun ExposedDropDownSettingRow( title: String, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7c7cb2c384..7d09492010 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -836,6 +836,9 @@ Grant in settings Find this permission in Android settings and grant it manually. Open settings + Earpiece + Speaker + Headphones The next generation of private messaging diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg new file mode 100644 index 0000000000..53bc5becaa --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg new file mode 100644 index 0000000000..fef2b1d2ce --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg new file mode 100644 index 0000000000..2bda1e9d74 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg new file mode 100644 index 0000000000..513bb38c40 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg new file mode 100644 index 0000000000..068bfc1a82 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg @@ -0,0 +1 @@ + \ No newline at end of file