android: select audio source (#4040)

* android, desktop: select audio source

* improvements

* fix possible crash

* changes
This commit is contained in:
Stanislav Dmitrenko
2024-04-21 01:19:37 +07:00
committed by GitHub
parent c8c81a840b
commit 412f75219a
9 changed files with 354 additions and 73 deletions

View File

@@ -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<List<AudioDeviceInfo>>
val currentDevice: MutableState<AudioDeviceInfo?>
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<List<AudioDeviceInfo>> = mutableStateOf(emptyList())
override val currentDevice: MutableState<AudioDeviceInfo?> = mutableStateOf(null)
private val audioCallback = object: AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
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<out AudioDeviceInfo>) {
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<List<AudioDeviceInfo>> = mutableStateOf(emptyList())
override val currentDevice: MutableState<AudioDeviceInfo?> = mutableStateOf(null)
private val audioCallback = object: AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
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<out AudioDeviceInfo>) {
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
}

View File

@@ -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<out AudioDeviceInfo>) {
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<out AudioDeviceInfo>) {
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<WebRTCCallStatus>("\"${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<Boolean>) {
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<Boolean>) {
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<AudioDeviceInfo>,
currentDevice: State<AudioDeviceInfo?>,
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 = {}

View File

@@ -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 <T> ExposedDropDownSetting(
@@ -79,6 +80,60 @@ fun <T> ExposedDropDownSetting(
}
}
@Composable
fun <T> ExposedDropDownSettingWithIcon(
values: List<Triple<T, ImageResource, String>>,
selection: State<T>,
fontSize: TextUnit = 16.sp,
iconSize: Dp = 40.dp,
listIconSize: Dp = 30.dp,
iconColor: Color = MenuTextColor,
enabled: State<Boolean> = 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 <T> ExposedDropDownSettingRow(
title: String,

View File

@@ -836,6 +836,9 @@
<string name="permissions_grant_in_settings">Grant in settings</string>
<string name="permissions_find_in_settings_and_grant">Find this permission in Android settings and grant it manually.</string>
<string name="permissions_open_settings">Open settings</string>
<string name="audio_device_earpiece">Earpiece</string>
<string name="audio_device_speaker">Speaker</string>
<string name="audio_device_wired_headphones">Headphones</string>
<!-- SimpleXInfo -->
<string name="next_generation_of_private_messaging">The next generation of private messaging</string>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M451-98v-313L257.5-217.5 217-258l222.5-222L217-702.5l40.5-40.5L451-549.5v-313h28.5L691-651 520-480.5l171 171L479.5-98H451Zm57.5-451.5 102-101.5-102-99.5v201Zm0 339.5 102-99.5-102-101.5v201Z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M653-454.5V-512h142.5v57.5H653Zm32.5 290-117-86 32.5-45 117 86-32.5 45Zm-84.5-500-32.5-45 117-86 32.5 45-117 86ZM125-363v-234h157.5l196-196v626l-196-196H125Z"/></svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M327-124.5H182q-22.969 0-40.234-17.266Q124.5-159.031 124.5-182v-298q0-74.09 28-138.795 28-64.705 76-112.705t112.684-76q64.684-28 138.75-28t138.816 28q64.75 28 112.75 76t76 112.705q28 64.705 28 138.795v298q0 22.969-17.266 40.234Q800.969-124.5 778-124.5H633v-290h145V-480q0-124.193-86.937-211.096Q604.125-778 479.884-778t-211.063 86.904Q182-604.193 182-480v65.5h145v290ZM269.5-357H182v175h87.5v-175Zm421 0v175H778v-175h-87.5Zm-421 0H182h87.5Zm421 0H778h-87.5Z"/></svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M670-462.5v-163L569.5-525 544-550.5 667.5-674 544-791l25.5-25.5L670-716v-163h16.5l118 121-86 84 86 92.5-118 119H670Zm39.5-74 44-45-44-44v89Zm0-179.5 44-42-44-51.5v93.5Zm81 591Q677-125 558-180.5T338-338Q236-439 180.5-557.75T125-790.692q0-18.808 12.714-31.558Q150.429-835 169.5-835H306q14 0 23.75 9.75t13.75 24.75l26.929 123.641Q372-663.5 369.5-652q-2.5 11.5-10.229 19.226L261-533q26 44 54.688 81.658Q344.375-413.683 379-380q36.5 38 77.25 69.323Q497-279.353 542-255l95.544-98q9.456-10.5 21.357-14.25T682.5-369l117.362 25.438Q815-340 825-327.801q10 12.198 10 27.301v131q0 19.071-12.714 31.786Q809.571-125 790.5-125ZM232-585.5l81-82-23.5-110H183q1.5 41.5 13 88.25t36 103.75Zm364 358q40 19 88.166 31t93.334 14v-107l-102-21.5-79.5 83.5Zm-364-358Zm364 358Z"/></svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M480.25-84.5q-31.013 0-50.631-19.559Q410-123.619 410-155q0-21.5 10.75-39.25t30.75-28.197V-334H303q-22.969 0-40.234-17.266Q245.5-368.531 245.5-391.5v-109q-20.5-9-31.25-26.359-10.75-17.358-10.75-39.358 0-31.664 19.535-51.224Q242.569-637 273.916-637t50.965 19.559q19.619 19.56 19.619 51.352 0 22.089-10.75 39.339T303-500.5v109h148.5v-338H373l107-145 107 145h-78v338h148V-496h-41.5v-140.5h141V-496h-42v104.5q0 22.969-17.266 40.234Q679.969-334 657-334H509v111.5q19.95 10.152 30.975 28.733Q551-175.187 551-154.912q0 31.531-19.869 50.971Q511.263-84.5 480.25-84.5Z"/></svg>

After

Width:  |  Height:  |  Size: 662 B