Added output audio devices selection menu

This commit is contained in:
Sylvain Berfini 2023-08-25 10:18:04 +02:00
parent e0f6121dc9
commit ca5648ed03
14 changed files with 283 additions and 23 deletions

View file

@ -131,7 +131,7 @@ class ContactsListFragment : GenericFragment() {
it.consume { model ->
val modalBottomSheet = ContactsListMenuDialogFragment(
model.friend.starred,
{ // ondDismiss
{ // onDismiss
adapter.resetSelection()
},
{ // onFavourite

View file

@ -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<AudioDeviceModel>) {
val modalBottomSheet = AudioDevicesMenuDialogFragment(devicesList)
modalBottomSheet.show(supportFragmentManager, AudioDevicesMenuDialogFragment.TAG)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<AudioDeviceModel>,
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
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -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<Boolean>()
val isHeadsetEnabled = MutableLiveData<Boolean>()
val isBluetoothEnabled = MutableLiveData<Boolean>()
val fullScreenMode = MutableLiveData<Boolean>()
// To synchronize chronometers in UI
val callDuration = MutableLiveData<Int>()
val showAudioDevicesListEvent: MutableLiveData<Event<ArrayList<AudioDeviceModel>>> by lazy {
MutableLiveData<Event<ArrayList<AudioDeviceModel>>>()
}
// ZRTP related
val isRemoteDeviceTrusted = MutableLiveData<Boolean>()
@ -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<AudioDeviceModel>()
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)
}
}

View file

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

View file

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

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M213.66,165.66a8,8 0,0 1,-11.32 0L128,91.31 53.66,165.66a8,8 0,0 1,-11.32 -11.32l80,-80a8,8 0,0 1,11.32 0l80,80A8,8 0,0 1,213.66 165.66Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M188.8,169.6 L133.33,128 188.8,86.4a8,8 0,0 0,0 -12.8l-64,-48A8,8 0,0 0,112 32v80L60.8,73.6a8,8 0,0 0,-9.6 12.8L106.67,128 51.2,169.6a8,8 0,1 0,9.6 12.8L112,144v80a8,8 0,0 0,12.8 6.4l64,-48a8,8 0,0 0,0 -12.8ZM128,48l42.67,32L128,112ZM128,208L128,144l42.67,32Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M216,104a8,8 0,0 1,-16 0,72 72,0 0,0 -144,0c0,26.7 8.53,34.92 17.57,43.64C82.21,156 92,165.41 92,188a36,36 0,0 0,36 36c10.24,0 18.45,-4.16 25.83,-13.09a8,8 0,1 1,12.34 10.18C155.81,233.64 143,240 128,240a52.06,52.06 0,0 1,-52 -52c0,-15.79 -5.68,-21.27 -13.54,-28.84C52.46,149.5 40,137.5 40,104a88,88 0,0 1,176 0ZM177.87,161.08A8,8 0,0 0,166.93 164,8 8,0 0,1 152,160c0,-9.33 4.82,-15.76 10.4,-23.2 6.37,-8.5 13.6,-18.13 13.6,-32.8a48,48 0,0 0,-96 0,8 8,0 0,0 16,0 32,32 0,0 1,64 0c0,9.33 -4.82,15.76 -10.4,23.2 -6.37,8.5 -13.6,18.13 -13.6,32.8a24,24 0,0 0,44.78 12A8,8 0,0 0,177.87 161.08Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M201.89,54.66A103.43,103.43 0,0 0,128.79 24L128,24A104,104 0,0 0,24 128v56a24,24 0,0 0,24 24L64,208a24,24 0,0 0,24 -24L88,144a24,24 0,0 0,-24 -24L40.36,120A88.12,88.12 0,0 1,190.54 65.93,87.39 87.39,0 0,1 215.65,120L192,120a24,24 0,0 0,-24 24v40a24,24 0,0 0,24 24h24a24,24 0,0 1,-24 24L136,232a8,8 0,0 0,0 16h56a40,40 0,0 0,40 -40L232,128A103.41,103.41 0,0 0,201.89 54.66ZM64,136a8,8 0,0 1,8 8v40a8,8 0,0 1,-8 8L48,192a8,8 0,0 1,-8 -8L40,136ZM192,192a8,8 0,0 1,-8 -8L184,144a8,8 0,0 1,8 -8h24v56Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<variable
name="model"
type="org.linphone.ui.voip.model.AudioDeviceModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{() -> model.onClicked()}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/separator">
<androidx.appcompat.widget.AppCompatTextView
style="@style/context_menu_action_label_style"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{model.name, default=`Piel 6 Ori`}"
android:background="@drawable/menu_item_background"
android:layout_marginBottom="1dp"
android:drawableStart="@{model.isHeadset ? @drawable/headset : model.isBluetooth ? @drawable/bluetooth : model.isSpeaker ? @drawable/speaker_high : @drawable/ear, default=@drawable/speaker_high}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<variable
name="devices"
type="java.util.List" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
entries="@{devices}"
layout="@{@layout/voip_audio_device_list_cell}">
</LinearLayout>
</layout>

View file

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