diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt index 4b3c558ae..4d6b63155 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt @@ -131,7 +131,7 @@ class ContactsListFragment : GenericFragment() { it.consume { model -> val modalBottomSheet = ContactsListMenuDialogFragment( model.friend.starred, - { // ondDismiss + { // onDismiss adapter.resetSelection() }, { // onFavourite diff --git a/app/src/main/java/org/linphone/ui/voip/VoipActivity.kt b/app/src/main/java/org/linphone/ui/voip/VoipActivity.kt index 7db104fc8..231c4143d 100644 --- a/app/src/main/java/org/linphone/ui/voip/VoipActivity.kt +++ b/app/src/main/java/org/linphone/ui/voip/VoipActivity.kt @@ -38,9 +38,12 @@ import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.VoipActivityBinding import org.linphone.ui.voip.fragment.ActiveCallFragmentDirections +import org.linphone.ui.voip.fragment.AudioDevicesMenuDialogFragment import org.linphone.ui.voip.fragment.IncomingCallFragmentDirections import org.linphone.ui.voip.fragment.OutgoingCallFragmentDirections +import org.linphone.ui.voip.model.AudioDeviceModel import org.linphone.ui.voip.viewmodel.CallsViewModel +import org.linphone.ui.voip.viewmodel.CurrentCallViewModel import org.linphone.ui.voip.viewmodel.SharedCallViewModel import org.linphone.utils.slideInToastFromTopForDuration @@ -54,6 +57,7 @@ class VoipActivity : AppCompatActivity() { private lateinit var sharedViewModel: SharedCallViewModel private lateinit var callsViewModel: CallsViewModel + private lateinit var callViewModel: CurrentCallViewModel override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, true) @@ -80,6 +84,16 @@ class VoipActivity : AppCompatActivity() { ViewModelProvider(this)[CallsViewModel::class.java] } + callViewModel = run { + ViewModelProvider(this)[CurrentCallViewModel::class.java] + } + + callViewModel.showAudioDevicesListEvent.observe(this) { + it.consume { devices -> + showAudioRoutesMenu(devices) + } + } + callsViewModel.showIncomingCallEvent.observe(this) { it.consume { val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment() @@ -144,4 +158,9 @@ class VoipActivity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, true) } } + + private fun showAudioRoutesMenu(devicesList: List) { + val modalBottomSheet = AudioDevicesMenuDialogFragment(devicesList) + modalBottomSheet.show(supportFragmentManager, AudioDevicesMenuDialogFragment.TAG) + } } diff --git a/app/src/main/java/org/linphone/ui/voip/fragment/AudioDevicesMenuDialogFragment.kt b/app/src/main/java/org/linphone/ui/voip/fragment/AudioDevicesMenuDialogFragment.kt new file mode 100644 index 000000000..203b3a4f4 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/voip/fragment/AudioDevicesMenuDialogFragment.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.voip.fragment + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.linphone.databinding.VoipAudioDevicesMenuBinding +import org.linphone.ui.voip.model.AudioDeviceModel + +@UiThread +class AudioDevicesMenuDialogFragment( + private val devicesList: List, + private val onDismiss: (() -> Unit)? = null +) : BottomSheetDialogFragment() { + companion object { + const val TAG = "AudioDevicesMenuDialogFragment" + } + + override fun onCancel(dialog: DialogInterface) { + onDismiss?.invoke() + super.onCancel(dialog) + } + + override fun onDismiss(dialog: DialogInterface) { + onDismiss?.invoke() + super.onDismiss(dialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = VoipAudioDevicesMenuBinding.inflate(layoutInflater) + + for (device in devicesList) { + device.dismissDialog = { + dismiss() + } + } + view.devices = devicesList + + return view.root + } +} diff --git a/app/src/main/java/org/linphone/ui/voip/model/AudioDeviceModel.kt b/app/src/main/java/org/linphone/ui/voip/model/AudioDeviceModel.kt new file mode 100644 index 000000000..06d6151a0 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/voip/model/AudioDeviceModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.voip.model + +import org.linphone.core.AudioDevice + +data class AudioDeviceModel( + val audioDevice: AudioDevice, + val name: String, + val isSpeaker: Boolean, + val isHeadset: Boolean, + val isBluetooth: Boolean, + private val onAudioDeviceSelected: (() -> Unit)? = null +) { + var dismissDialog: (() -> Unit)? = null + + fun onClicked() { + onAudioDeviceSelected?.invoke() + dismissDialog?.invoke() + } +} diff --git a/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt b/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt index a56b61de1..a79981685 100644 --- a/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt @@ -37,6 +37,7 @@ import org.linphone.core.MediaDirection import org.linphone.core.MediaEncryption import org.linphone.core.tools.Log import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.voip.model.AudioDeviceModel import org.linphone.utils.AudioRouteUtils import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils @@ -62,11 +63,19 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { val isSpeakerEnabled = MutableLiveData() + val isHeadsetEnabled = MutableLiveData() + + val isBluetoothEnabled = MutableLiveData() + val fullScreenMode = MutableLiveData() // To synchronize chronometers in UI val callDuration = MutableLiveData() + val showAudioDevicesListEvent: MutableLiveData>> by lazy { + MutableLiveData>>() + } + // ZRTP related val isRemoteDeviceTrusted = MutableLiveData() @@ -130,8 +139,8 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { @WorkerThread override fun onAudioDeviceChanged(call: Call, audioDevice: AudioDevice) { - Log.i("$TAG Audio device changed [$audioDevice]") - isSpeakerEnabled.postValue(AudioRouteUtils.isSpeakerAudioRouteCurrentlyUsed(call)) + Log.i("$TAG Audio device changed [${audioDevice.id}]") + updateOutputAudioDevice(audioDevice) } } @@ -149,7 +158,6 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { call = currentCall Log.i("$TAG Found call [$call]") configureCall(call) - isSpeakerEnabled.postValue(AudioRouteUtils.isSpeakerAudioRouteCurrentlyUsed(call)) } else { Log.e("$TAG Failed to find call!") } @@ -219,16 +227,40 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { @UiThread fun changeAudioOutputDevice() { - // TODO: display list of all output devices if more then earpiece & - val routeAudioToSpeaker = isSpeakerEnabled.value != true - coreContext.postOnCoreThread { - if (::call.isInitialized) { - if (routeAudioToSpeaker) { - AudioRouteUtils.routeAudioToSpeaker(call) - } else { - AudioRouteUtils.routeAudioToEarpiece(call) + coreContext.postOnCoreThread { core -> + val audioDevices = core.audioDevices + val list = arrayListOf() + for (device in audioDevices) { + // Only list output audio devices + if (!device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) continue + + val isSpeaker = device.type == AudioDevice.Type.Speaker + val isHeadset = device.type == AudioDevice.Type.Headset || device.type == AudioDevice.Type.Headphones + val isBluetooth = device.type == AudioDevice.Type.Bluetooth + val model = AudioDeviceModel(device, device.id, isSpeaker, isHeadset, isBluetooth) { + // onSelected + coreContext.postOnCoreThread { + Log.i("$TAG Selected audio device with ID [${device.id}]") + if (::call.isInitialized) { + call.outputAudioDevice = device + } + } + } + list.add(model) + Log.i("$TAG Found audio device [$device]") + } + + if (list.size > 2) { + showAudioDevicesListEvent.postValue(Event(list)) + } else { + if (::call.isInitialized) { + if (routeAudioToSpeaker) { + AudioRouteUtils.routeAudioToSpeaker(call) + } else { + AudioRouteUtils.routeAudioToEarpiece(call) + } } } } @@ -346,6 +378,9 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { } isMicrophoneMuted.postValue(call.microphoneMuted) + val audioDevice = call.outputAudioDevice + updateOutputAudioDevice(audioDevice) + isOutgoing.postValue(call.dir == Call.Dir.Outgoing) val address = call.remoteAddress.clone() @@ -367,4 +402,12 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { updateEncryption() callDuration.postValue(call.duration) } + + private fun updateOutputAudioDevice(audioDevice: AudioDevice?) { + isSpeakerEnabled.postValue(audioDevice?.type == AudioDevice.Type.Speaker) + isHeadsetEnabled.postValue( + audioDevice?.type == AudioDevice.Type.Headphones || audioDevice?.type == AudioDevice.Type.Headset + ) + isBluetoothEnabled.postValue(audioDevice?.type == AudioDevice.Type.Bluetooth) + } } diff --git a/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt b/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt index 7d4bc2ec9..bdfe11453 100644 --- a/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt +++ b/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt @@ -64,6 +64,19 @@ class AudioRouteUtils { routeAudioTo(call, arrayListOf(AudioDevice.Type.Speaker)) } + @WorkerThread + fun routeAudioToBluetooth(call: Call? = null) { + routeAudioTo(call, arrayListOf(AudioDevice.Type.Bluetooth)) + } + + @WorkerThread + fun routeAudioToHeadset(call: Call? = null) { + routeAudioTo( + call, + arrayListOf(AudioDevice.Type.Headphones, AudioDevice.Type.Headset) + ) + } + @WorkerThread private fun routeAudioTo( call: Call?, diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 34495dcf4..9b0c4aba0 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -52,6 +52,7 @@ import org.linphone.core.tools.Log import org.linphone.ui.main.MainActivity import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.ui.main.model.AccountModel +import org.linphone.ui.voip.VoipActivity /** * This file contains all the data binding necessary for the app @@ -79,7 +80,15 @@ fun setEntries( binding.setVariable(BR.model, entry) // This is a bit hacky... - binding.lifecycleOwner = viewGroup.context as MainActivity + if (viewGroup.context as? MainActivity != null) { + binding.lifecycleOwner = viewGroup.context as MainActivity + } else if (viewGroup.context as? VoipActivity != null) { + binding.lifecycleOwner = viewGroup.context as VoipActivity + } else { + Log.e( + "[Data Binding Utils] Failed to cast viewGroup's context as an Activity, lifecycle owner hasn't be set!" + ) + } viewGroup.addView(binding.root) } diff --git a/app/src/main/res/drawable-up/caret.xml b/app/src/main/res/drawable-up/caret.xml deleted file mode 100644 index a8354428f..000000000 --- a/app/src/main/res/drawable-up/caret.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/bluetooth.xml b/app/src/main/res/drawable/bluetooth.xml new file mode 100644 index 000000000..3ccfc7629 --- /dev/null +++ b/app/src/main/res/drawable/bluetooth.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ear.xml b/app/src/main/res/drawable/ear.xml new file mode 100644 index 000000000..df1aaf5c5 --- /dev/null +++ b/app/src/main/res/drawable/ear.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/headset.xml b/app/src/main/res/drawable/headset.xml new file mode 100644 index 000000000..9b333af8c --- /dev/null +++ b/app/src/main/res/drawable/headset.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/voip_audio_device_list_cell.xml b/app/src/main/res/layout/voip_audio_device_list_cell.xml new file mode 100644 index 000000000..4e1cefbbf --- /dev/null +++ b/app/src/main/res/layout/voip_audio_device_list_cell.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/voip_audio_devices_menu.xml b/app/src/main/res/layout/voip_audio_devices_menu.xml new file mode 100644 index 000000000..98f66775a --- /dev/null +++ b/app/src/main/res/layout/voip_audio_devices_menu.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/voip_call_main_actions.xml b/app/src/main/res/layout/voip_call_main_actions.xml index e4917bd41..4c9687aa2 100644 --- a/app/src/main/res/layout/voip_call_main_actions.xml +++ b/app/src/main/res/layout/voip_call_main_actions.xml @@ -81,7 +81,7 @@ android:layout_height="@dimen/voip_button_size" android:layout_marginEnd="30dp" android:padding="@dimen/voip_button_icon_padding" - android:src="@{viewModel.isSpeakerEnabled ? @drawable/speaker_high : @drawable/speaker_slash, default=@drawable/speaker_slash}" + android:src="@{viewModel.isHeadsetEnabled ? @drawable/headset : viewModel.isBluetoothEnabled ? @drawable/bluetooth : viewModel.isSpeakerEnabled ? @drawable/speaker_high : @drawable/speaker_slash, default=@drawable/speaker_slash}" android:background="@drawable/in_call_button_background" app:tint="@color/white" app:layout_constraintBottom_toBottomOf="parent"