mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-17 11:28:06 +00:00
Added output audio devices selection menu
This commit is contained in:
parent
e0f6121dc9
commit
ca5648ed03
14 changed files with 283 additions and 23 deletions
|
|
@ -131,7 +131,7 @@ class ContactsListFragment : GenericFragment() {
|
|||
it.consume { model ->
|
||||
val modalBottomSheet = ContactsListMenuDialogFragment(
|
||||
model.friend.starred,
|
||||
{ // ondDismiss
|
||||
{ // onDismiss
|
||||
adapter.resetSelection()
|
||||
},
|
||||
{ // onFavourite
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
9
app/src/main/res/drawable/bluetooth.xml
Normal file
9
app/src/main/res/drawable/bluetooth.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ear.xml
Normal file
9
app/src/main/res/drawable/ear.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/headset.xml
Normal file
9
app/src/main/res/drawable/headset.xml
Normal 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>
|
||||
32
app/src/main/res/layout/voip_audio_device_list_cell.xml
Normal file
32
app/src/main/res/layout/voip_audio_device_list_cell.xml
Normal 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>
|
||||
21
app/src/main/res/layout/voip_audio_devices_menu.xml
Normal file
21
app/src/main/res/layout/voip_audio_devices_menu.xml
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue