Use self managed telecom manager mode for Android 8+
This commit is contained in:
parent
3aa1abc72c
commit
e341bb20e7
13 changed files with 649 additions and 12 deletions
|
@ -16,6 +16,7 @@ Group changes to describe their impact on the project, as follows:
|
||||||
- Reply to chat message feature (with original message preview)
|
- Reply to chat message feature (with original message preview)
|
||||||
- Voice recordings in chat feature
|
- Voice recordings in chat feature
|
||||||
- Allow video recording in chat file sharing
|
- Allow video recording in chat file sharing
|
||||||
|
- Notify incoming/outgoing calls on bluetooth devices using self-managed connections from telecom manager API
|
||||||
- New video call UI on foldable device like Galaxy Z Fold
|
- New video call UI on foldable device like Galaxy Z Fold
|
||||||
- Setting to automatically record all calls
|
- Setting to automatically record all calls
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||||
|
|
||||||
<!-- Helps filling phone number and country code in assistant -->
|
<!-- Helps filling phone number and country code in assistant & for Telecom Manager -->
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
|
||||||
<!-- Needed for auto start at boot and to ensure the service won't be killed by OS while in call -->
|
<!-- Needed for auto start at boot and to ensure the service won't be killed by OS while in call -->
|
||||||
|
@ -25,6 +25,9 @@
|
||||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
|
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
|
||||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||||
|
|
||||||
|
<!-- Needed for Telecom Manager -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
|
||||||
|
|
||||||
<!-- Needed for overlay -->
|
<!-- Needed for overlay -->
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
|
@ -176,6 +179,14 @@
|
||||||
android:resource="@xml/authenticator" />
|
android:resource="@xml/authenticator" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service android:name=".telecom.TelecomConnectionService"
|
||||||
|
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.telecom.ConnectionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- Receivers -->
|
<!-- Receivers -->
|
||||||
|
|
||||||
<receiver android:name=".core.CorePushReceiver"
|
<receiver android:name=".core.CorePushReceiver"
|
||||||
|
|
|
@ -19,20 +19,26 @@
|
||||||
*/
|
*/
|
||||||
package org.linphone.activities.main.settings.fragments
|
package org.linphone.activities.main.settings.fragments
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||||
import org.linphone.R
|
import org.linphone.R
|
||||||
import org.linphone.activities.main.settings.viewmodels.CallSettingsViewModel
|
import org.linphone.activities.main.settings.viewmodels.CallSettingsViewModel
|
||||||
import org.linphone.activities.navigateToEmptySetting
|
import org.linphone.activities.navigateToEmptySetting
|
||||||
import org.linphone.compatibility.Compatibility
|
import org.linphone.compatibility.Compatibility
|
||||||
|
import org.linphone.core.tools.Log
|
||||||
import org.linphone.databinding.SettingsCallFragmentBinding
|
import org.linphone.databinding.SettingsCallFragmentBinding
|
||||||
import org.linphone.mediastream.Version
|
import org.linphone.mediastream.Version
|
||||||
|
import org.linphone.telecom.TelecomHelper
|
||||||
import org.linphone.utils.Event
|
import org.linphone.utils.Event
|
||||||
|
import org.linphone.utils.PermissionHelper
|
||||||
|
|
||||||
class CallSettingsFragment : GenericSettingFragment<SettingsCallFragmentBinding>() {
|
class CallSettingsFragment : GenericSettingFragment<SettingsCallFragmentBinding>() {
|
||||||
private lateinit var viewModel: CallSettingsViewModel
|
private lateinit var viewModel: CallSettingsViewModel
|
||||||
|
@ -83,14 +89,73 @@ class CallSettingsFragment : GenericSettingFragment<SettingsCallFragmentBinding>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
viewModel.enableTelecomManagerEvent.observe(
|
||||||
|
viewLifecycleOwner,
|
||||||
|
{
|
||||||
|
it.consume {
|
||||||
|
if (!PermissionHelper.get().hasTelecomManagerPermissions()) {
|
||||||
|
val permissions = arrayOf(
|
||||||
|
Manifest.permission.READ_PHONE_STATE,
|
||||||
|
Manifest.permission.MANAGE_OWN_CALLS
|
||||||
|
)
|
||||||
|
requestPermissions(permissions, 1)
|
||||||
|
} else if (!TelecomHelper.exists()) {
|
||||||
|
Log.w("[Telecom Helper] Doesn't exists yet, creating it")
|
||||||
|
TelecomHelper.create(requireContext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.goToAndroidNotificationSettingsEvent.observe(
|
||||||
|
viewLifecycleOwner,
|
||||||
|
{
|
||||||
|
it.consume {
|
||||||
|
if (Build.VERSION.SDK_INT >= Version.API26_O_80) {
|
||||||
|
val i = Intent()
|
||||||
|
i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS
|
||||||
|
i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
|
||||||
|
i.putExtra(
|
||||||
|
Settings.EXTRA_CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_service_id)
|
||||||
|
)
|
||||||
|
i.addCategory(Intent.CATEGORY_DEFAULT)
|
||||||
|
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||||
|
i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||||
|
startActivity(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
|
||||||
if (!Compatibility.canDrawOverlay(requireContext())) {
|
if (requestCode == 0 && !Compatibility.canDrawOverlay(requireContext())) {
|
||||||
viewModel.systemWideOverlayListener.onBoolValueChanged(false)
|
viewModel.overlayListener.onBoolValueChanged(false)
|
||||||
|
} else if (requestCode == 1) {
|
||||||
|
if (!TelecomHelper.exists()) {
|
||||||
|
Log.w("[Telecom Helper] Doesn't exists yet, creating it")
|
||||||
|
TelecomHelper.create(requireContext())
|
||||||
}
|
}
|
||||||
|
updateTelecomManagerAccount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateTelecomManagerAccount() {
|
||||||
|
if (!TelecomHelper.exists()) {
|
||||||
|
Log.e("[Telecom Helper] Doesn't exists, can't update account!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// We have to refresh the account object otherwise isAccountEnabled will always return false...
|
||||||
|
val account = TelecomHelper.get().findExistingAccount(requireContext())
|
||||||
|
TelecomHelper.get().updateAccount(account)
|
||||||
|
val enabled = TelecomHelper.get().isAccountEnabled()
|
||||||
|
viewModel.useTelecomManager.value = enabled
|
||||||
|
corePreferences.useTelecomManager = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun goBack() {
|
override fun goBack() {
|
||||||
|
@ -100,4 +165,22 @@ class CallSettingsFragment : GenericSettingFragment<SettingsCallFragmentBinding>
|
||||||
navigateToEmptySetting()
|
navigateToEmptySetting()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
for (index in grantResults.indices) {
|
||||||
|
val result = grantResults[index]
|
||||||
|
if (result != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.w("[Call Settings] ${permissions[index]} permission denied but required for telecom manager")
|
||||||
|
viewModel.useTelecomManager.value = false
|
||||||
|
corePreferences.useTelecomManager = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TelecomHelper.create(requireContext())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,12 +20,13 @@
|
||||||
package org.linphone.activities.main.settings.viewmodels
|
package org.linphone.activities.main.settings.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import java.lang.NumberFormatException
|
|
||||||
import org.linphone.R
|
import org.linphone.R
|
||||||
import org.linphone.activities.main.settings.SettingListenerStub
|
import org.linphone.activities.main.settings.SettingListenerStub
|
||||||
import org.linphone.core.MediaEncryption
|
import org.linphone.core.MediaEncryption
|
||||||
import org.linphone.mediastream.Version
|
import org.linphone.mediastream.Version
|
||||||
|
import org.linphone.telecom.TelecomHelper
|
||||||
import org.linphone.utils.Event
|
import org.linphone.utils.Event
|
||||||
|
import org.linphone.utils.PermissionHelper
|
||||||
|
|
||||||
class CallSettingsViewModel : GenericSettingsViewModel() {
|
class CallSettingsViewModel : GenericSettingsViewModel() {
|
||||||
val deviceRingtoneListener = object : SettingListenerStub() {
|
val deviceRingtoneListener = object : SettingListenerStub() {
|
||||||
|
@ -62,6 +63,28 @@ class CallSettingsViewModel : GenericSettingsViewModel() {
|
||||||
}
|
}
|
||||||
val encryptionMandatory = MutableLiveData<Boolean>()
|
val encryptionMandatory = MutableLiveData<Boolean>()
|
||||||
|
|
||||||
|
val useTelecomManagerListener = object : SettingListenerStub() {
|
||||||
|
override fun onBoolValueChanged(newValue: Boolean) {
|
||||||
|
if (newValue &&
|
||||||
|
(
|
||||||
|
!PermissionHelper.get().hasTelecomManagerPermissions() ||
|
||||||
|
!TelecomHelper.exists() ||
|
||||||
|
!TelecomHelper.get().isAccountEnabled()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
enableTelecomManagerEvent.value = Event(true)
|
||||||
|
} else {
|
||||||
|
if (!newValue && TelecomHelper.exists()) TelecomHelper.get().removeAccount()
|
||||||
|
prefs.useTelecomManager = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val useTelecomManager = MutableLiveData<Boolean>()
|
||||||
|
val enableTelecomManagerEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||||
|
MutableLiveData<Event<Boolean>>()
|
||||||
|
}
|
||||||
|
val api26OrHigher = MutableLiveData<Boolean>()
|
||||||
|
|
||||||
val fullScreenListener = object : SettingListenerStub() {
|
val fullScreenListener = object : SettingListenerStub() {
|
||||||
override fun onBoolValueChanged(newValue: Boolean) {
|
override fun onBoolValueChanged(newValue: Boolean) {
|
||||||
prefs.fullScreenCallUI = newValue
|
prefs.fullScreenCallUI = newValue
|
||||||
|
@ -193,6 +216,9 @@ class CallSettingsViewModel : GenericSettingsViewModel() {
|
||||||
initEncryptionList()
|
initEncryptionList()
|
||||||
encryptionMandatory.value = core.isMediaEncryptionMandatory
|
encryptionMandatory.value = core.isMediaEncryptionMandatory
|
||||||
|
|
||||||
|
useTelecomManager.value = prefs.useTelecomManager
|
||||||
|
api26OrHigher.value = Version.sdkAboveOrEqual(Version.API26_O_80)
|
||||||
|
|
||||||
fullScreen.value = prefs.fullScreenCallUI
|
fullScreen.value = prefs.fullScreenCallUI
|
||||||
overlay.value = prefs.showCallOverlay
|
overlay.value = prefs.showCallOverlay
|
||||||
systemWideOverlay.value = prefs.systemWideCallOverlay
|
systemWideOverlay.value = prefs.systemWideCallOverlay
|
||||||
|
|
|
@ -61,6 +61,7 @@ import org.linphone.contact.ContactsManager
|
||||||
import org.linphone.core.tools.Log
|
import org.linphone.core.tools.Log
|
||||||
import org.linphone.mediastream.Version
|
import org.linphone.mediastream.Version
|
||||||
import org.linphone.notifications.NotificationsManager
|
import org.linphone.notifications.NotificationsManager
|
||||||
|
import org.linphone.telecom.TelecomHelper
|
||||||
import org.linphone.utils.*
|
import org.linphone.utils.*
|
||||||
import org.linphone.utils.Event
|
import org.linphone.utils.Event
|
||||||
|
|
||||||
|
@ -135,6 +136,7 @@ class CoreContext(val context: Context, coreConfig: Config) {
|
||||||
) {
|
) {
|
||||||
Log.i("[Context] Call state changed [$state]")
|
Log.i("[Context] Call state changed [$state]")
|
||||||
if (state == Call.State.IncomingReceived || state == Call.State.IncomingEarlyMedia) {
|
if (state == Call.State.IncomingReceived || state == Call.State.IncomingEarlyMedia) {
|
||||||
|
if (!corePreferences.useTelecomManager) {
|
||||||
var gsmCallActive = false
|
var gsmCallActive = false
|
||||||
if (::phoneStateListener.isInitialized) {
|
if (::phoneStateListener.isInitialized) {
|
||||||
gsmCallActive = phoneStateListener.isInCall()
|
gsmCallActive = phoneStateListener.isInCall()
|
||||||
|
@ -145,6 +147,7 @@ class CoreContext(val context: Context, coreConfig: Config) {
|
||||||
call.decline(Reason.Busy)
|
call.decline(Reason.Busy)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Starting SDK 24 (Android 7.0) we rely on the fullscreen intent of the call incoming notification
|
// Starting SDK 24 (Android 7.0) we rely on the fullscreen intent of the call incoming notification
|
||||||
if (Version.sdkStrictlyBelow(Version.API24_NOUGAT_70)) {
|
if (Version.sdkStrictlyBelow(Version.API24_NOUGAT_70)) {
|
||||||
|
@ -294,6 +297,11 @@ class CoreContext(val context: Context, coreConfig: Config) {
|
||||||
|
|
||||||
notificationsManager.onCoreReady()
|
notificationsManager.onCoreReady()
|
||||||
|
|
||||||
|
if (Version.sdkAboveOrEqual(Version.API26_O_80) && corePreferences.useTelecomManager) {
|
||||||
|
Log.i("[Context] Creating telecom helper")
|
||||||
|
TelecomHelper.create(context)
|
||||||
|
}
|
||||||
|
|
||||||
core.addListener(listener)
|
core.addListener(listener)
|
||||||
|
|
||||||
if (isPush) {
|
if (isPush) {
|
||||||
|
@ -326,6 +334,10 @@ class CoreContext(val context: Context, coreConfig: Config) {
|
||||||
}
|
}
|
||||||
notificationsManager.destroy()
|
notificationsManager.destroy()
|
||||||
contactsManager.destroy()
|
contactsManager.destroy()
|
||||||
|
if (TelecomHelper.exists()) {
|
||||||
|
Log.i("[Context] Destroying telecom helper")
|
||||||
|
TelecomHelper.get().destroy()
|
||||||
|
}
|
||||||
|
|
||||||
core.stop()
|
core.stop()
|
||||||
core.removeListener(listener)
|
core.removeListener(listener)
|
||||||
|
|
|
@ -289,6 +289,13 @@ class CorePreferences constructor(private val context: Context) {
|
||||||
config.setBool("app", "auto_start_call_record", value)
|
config.setBool("app", "auto_start_call_record", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var useTelecomManager: Boolean
|
||||||
|
// Some permissions are required, so keep it to false so user has to manually enable it and give permissions
|
||||||
|
get() = config.getBool("app", "use_self_managed_telecom_manager", false)
|
||||||
|
set(value) {
|
||||||
|
config.setBool("app", "use_self_managed_telecom_manager", value)
|
||||||
|
}
|
||||||
|
|
||||||
var fullScreenCallUI: Boolean
|
var fullScreenCallUI: Boolean
|
||||||
get() = config.getBool("app", "full_screen_call", true)
|
get() = config.getBool("app", "full_screen_call", true)
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|
105
app/src/main/java/org/linphone/telecom/NativeCallWrapper.kt
Normal file
105
app/src/main/java/org/linphone/telecom/NativeCallWrapper.kt
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2021 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.telecom
|
||||||
|
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.telecom.CallAudioState
|
||||||
|
import android.telecom.Connection
|
||||||
|
import android.telecom.DisconnectCause
|
||||||
|
import android.telecom.StatusHints
|
||||||
|
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||||
|
import org.linphone.R
|
||||||
|
import org.linphone.core.Call
|
||||||
|
import org.linphone.core.tools.Log
|
||||||
|
import org.linphone.utils.AudioRouteUtils
|
||||||
|
|
||||||
|
class NativeCallWrapper(var callId: String) : Connection() {
|
||||||
|
init {
|
||||||
|
val capabilities = connectionCapabilities or CAPABILITY_MUTE or CAPABILITY_SUPPORT_HOLD or CAPABILITY_HOLD
|
||||||
|
connectionCapabilities = capabilities
|
||||||
|
audioModeIsVoip = true
|
||||||
|
statusHints = StatusHints(
|
||||||
|
"",
|
||||||
|
Icon.createWithResource(coreContext.context, R.drawable.linphone_logo_tinted),
|
||||||
|
Bundle()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(state: Int) {
|
||||||
|
Log.i("[Connection] Telecom state changed [$state] for call with id: $callId")
|
||||||
|
super.onStateChanged(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAnswer(videoState: Int) {
|
||||||
|
Log.i("[Connection] Answering telecom call with id: $callId")
|
||||||
|
getCall()?.accept()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHold() {
|
||||||
|
Log.i("[Connection] Pausing telecom call with id: $callId")
|
||||||
|
getCall()?.pause()
|
||||||
|
setOnHold()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnhold() {
|
||||||
|
Log.i("[Connection] Resuming telecom call with id: $callId")
|
||||||
|
getCall()?.resume()
|
||||||
|
setActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallAudioStateChanged(state: CallAudioState) {
|
||||||
|
Log.i("[Connection] Audio state changed: $state")
|
||||||
|
|
||||||
|
val call = getCall()
|
||||||
|
call?.microphoneMuted = state.isMuted
|
||||||
|
when (state.route) {
|
||||||
|
CallAudioState.ROUTE_EARPIECE -> AudioRouteUtils.routeAudioToEarpiece(call)
|
||||||
|
CallAudioState.ROUTE_SPEAKER -> AudioRouteUtils.routeAudioToSpeaker(call)
|
||||||
|
CallAudioState.ROUTE_BLUETOOTH -> AudioRouteUtils.routeAudioToBluetooth(call)
|
||||||
|
CallAudioState.ROUTE_WIRED_HEADSET -> AudioRouteUtils.routeAudioToHeadset(call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayDtmfTone(c: Char) {
|
||||||
|
Log.i("[Connection] Sending DTMF [$c] in telecom call with id: $callId")
|
||||||
|
getCall()?.sendDtmf(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisconnect() {
|
||||||
|
Log.i("[Connection] Terminating telecom call with id: $callId")
|
||||||
|
getCall()?.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAbort() {
|
||||||
|
Log.i("[Connection] Aborting telecom call with id: $callId")
|
||||||
|
getCall()?.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReject() {
|
||||||
|
Log.i("[Connection] Rejecting telecom call with id: $callId")
|
||||||
|
setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
|
||||||
|
getCall()?.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCall(): Call? {
|
||||||
|
return coreContext.core.getCallByCallid(callId)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,180 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2021 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.telecom
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.telecom.*
|
||||||
|
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||||
|
import org.linphone.core.Call
|
||||||
|
import org.linphone.core.Core
|
||||||
|
import org.linphone.core.CoreListenerStub
|
||||||
|
import org.linphone.core.tools.Log
|
||||||
|
|
||||||
|
class TelecomConnectionService : ConnectionService() {
|
||||||
|
private val connections = arrayListOf<NativeCallWrapper>()
|
||||||
|
|
||||||
|
private val listener: CoreListenerStub = object : CoreListenerStub() {
|
||||||
|
override fun onCallStateChanged(
|
||||||
|
core: Core,
|
||||||
|
call: Call,
|
||||||
|
state: Call.State?,
|
||||||
|
message: String
|
||||||
|
) {
|
||||||
|
Log.i("[Telecom Connection Service] call [${call.callLog.callId}] state changed: $state")
|
||||||
|
when (call.state) {
|
||||||
|
Call.State.OutgoingProgress -> {
|
||||||
|
for (connection in connections) {
|
||||||
|
if (connection.callId.isEmpty()) {
|
||||||
|
connection.callId = core.currentCall?.callLog?.callId ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Call.State.End, Call.State.Released -> onCallEnded(call)
|
||||||
|
Call.State.Connected -> onCallConnected(call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
Log.i("[Telecom Connection Service] onCreate()")
|
||||||
|
coreContext.core.addListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: Intent?): Boolean {
|
||||||
|
Log.i("[Telecom Connection Service] onUnbind()")
|
||||||
|
coreContext.core.removeListener(listener)
|
||||||
|
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOutgoingConnection(
|
||||||
|
connectionManagerPhoneAccount: PhoneAccountHandle,
|
||||||
|
request: ConnectionRequest
|
||||||
|
): Connection {
|
||||||
|
val accountHandle = request.accountHandle
|
||||||
|
val componentName = ComponentName(applicationContext, this.javaClass)
|
||||||
|
|
||||||
|
return if (accountHandle != null && componentName == accountHandle.componentName) {
|
||||||
|
Log.i("[Telecom Connection Service] Creating outgoing connection")
|
||||||
|
|
||||||
|
val extras = request.extras
|
||||||
|
var callId = extras.getString("Call-ID")
|
||||||
|
val displayName = extras.getString("DisplayName")
|
||||||
|
if (callId == null) {
|
||||||
|
callId = coreContext.core.currentCall?.callLog?.callId ?: ""
|
||||||
|
}
|
||||||
|
Log.i("[Telecom Connection Service] Outgoing connection is for call [$callId] with display name [$displayName]")
|
||||||
|
|
||||||
|
// Prevents user dialing back from native dialer app history
|
||||||
|
if (callId.isEmpty() && displayName.isNullOrEmpty()) {
|
||||||
|
Log.e("[Telecom Connection Service] Looks like a call was made from native dialer history, aborting")
|
||||||
|
return Connection.createFailedConnection(DisconnectCause(DisconnectCause.OTHER))
|
||||||
|
}
|
||||||
|
|
||||||
|
val connection = NativeCallWrapper(callId)
|
||||||
|
connection.setDialing()
|
||||||
|
|
||||||
|
val providedHandle = request.address
|
||||||
|
connection.setAddress(providedHandle, TelecomManager.PRESENTATION_ALLOWED)
|
||||||
|
connection.setCallerDisplayName(displayName, TelecomManager.PRESENTATION_ALLOWED)
|
||||||
|
Log.i("[Telecom Connection Service] Address is $providedHandle")
|
||||||
|
|
||||||
|
connections.add(connection)
|
||||||
|
connection
|
||||||
|
} else {
|
||||||
|
Log.e("[Telecom Connection Service] Error: $accountHandle $componentName")
|
||||||
|
Connection.createFailedConnection(
|
||||||
|
DisconnectCause(
|
||||||
|
DisconnectCause.ERROR,
|
||||||
|
"Invalid inputs: $accountHandle $componentName"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateIncomingConnection(
|
||||||
|
connectionManagerPhoneAccount: PhoneAccountHandle,
|
||||||
|
request: ConnectionRequest
|
||||||
|
): Connection {
|
||||||
|
val accountHandle = request.accountHandle
|
||||||
|
val componentName = ComponentName(applicationContext, this.javaClass)
|
||||||
|
|
||||||
|
return if (accountHandle != null && componentName == accountHandle.componentName) {
|
||||||
|
Log.i("[Telecom Connection Service] Creating incoming connection")
|
||||||
|
|
||||||
|
val extras = request.extras
|
||||||
|
val incomingExtras = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS)
|
||||||
|
var callId = incomingExtras?.getString("Call-ID")
|
||||||
|
val displayName = incomingExtras?.getString("DisplayName")
|
||||||
|
if (callId == null) {
|
||||||
|
callId = coreContext.core.currentCall?.callLog?.callId ?: ""
|
||||||
|
}
|
||||||
|
Log.i("[Telecom Connection Service] Incoming connection is for call [$callId] with display name [$displayName]")
|
||||||
|
|
||||||
|
val connection = NativeCallWrapper(callId)
|
||||||
|
connection.setRinging()
|
||||||
|
|
||||||
|
val providedHandle =
|
||||||
|
incomingExtras?.getParcelable<Uri>(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS)
|
||||||
|
connection.setAddress(providedHandle, TelecomManager.PRESENTATION_ALLOWED)
|
||||||
|
connection.setCallerDisplayName(displayName, TelecomManager.PRESENTATION_ALLOWED)
|
||||||
|
Log.i("[Telecom Connection Service] Address is $providedHandle")
|
||||||
|
|
||||||
|
connections.add(connection)
|
||||||
|
connection
|
||||||
|
} else {
|
||||||
|
Log.e("[Telecom Connection Service] Error: $accountHandle $componentName")
|
||||||
|
Connection.createFailedConnection(
|
||||||
|
DisconnectCause(
|
||||||
|
DisconnectCause.ERROR,
|
||||||
|
"Invalid inputs: $accountHandle $componentName"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getConnectionForCallId(callId: String): NativeCallWrapper? {
|
||||||
|
return connections.find { connection ->
|
||||||
|
connection.callId == callId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCallEnded(call: Call) {
|
||||||
|
val connection = getConnectionForCallId(call.callLog.callId)
|
||||||
|
connection ?: return
|
||||||
|
|
||||||
|
connections.remove(connection)
|
||||||
|
connection.setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
|
||||||
|
connection.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCallConnected(call: Call) {
|
||||||
|
val connection = getConnectionForCallId(call.callLog.callId)
|
||||||
|
connection ?: return
|
||||||
|
|
||||||
|
if (connection.state != Connection.STATE_HOLDING) {
|
||||||
|
connection.setActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
194
app/src/main/java/org/linphone/telecom/TelecomHelper.kt
Normal file
194
app/src/main/java/org/linphone/telecom/TelecomHelper.kt
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2021 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.telecom
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.annotation.TargetApi
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.telecom.PhoneAccount
|
||||||
|
import android.telecom.PhoneAccountHandle
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.telecom.TelecomManager.*
|
||||||
|
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||||
|
import org.linphone.R
|
||||||
|
import org.linphone.contact.Contact
|
||||||
|
import org.linphone.core.Call
|
||||||
|
import org.linphone.core.Core
|
||||||
|
import org.linphone.core.CoreListenerStub
|
||||||
|
import org.linphone.core.tools.Log
|
||||||
|
import org.linphone.utils.LinphoneUtils
|
||||||
|
import org.linphone.utils.PermissionHelper
|
||||||
|
import org.linphone.utils.SingletonHolder
|
||||||
|
|
||||||
|
@TargetApi(26)
|
||||||
|
class TelecomHelper private constructor(context: Context) {
|
||||||
|
companion object : SingletonHolder<TelecomHelper, Context>(::TelecomHelper)
|
||||||
|
|
||||||
|
private val telecomManager: TelecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
||||||
|
|
||||||
|
private var account: PhoneAccount = initPhoneAccount(context)
|
||||||
|
|
||||||
|
private val listener: CoreListenerStub = object : CoreListenerStub() {
|
||||||
|
override fun onFirstCallStarted(core: Core) {
|
||||||
|
val call = core.calls.firstOrNull()
|
||||||
|
call ?: return
|
||||||
|
|
||||||
|
if (call.dir == Call.Dir.Incoming) {
|
||||||
|
onIncomingCall(call)
|
||||||
|
} else {
|
||||||
|
onOutgoingCall(call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
coreContext.core.addListener(listener)
|
||||||
|
Log.i("[Telecom Helper] Created")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
coreContext.core.removeListener(listener)
|
||||||
|
Log.i("[Telecom Helper] Destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAccountEnabled(): Boolean {
|
||||||
|
val enabled = account.isEnabled
|
||||||
|
Log.i("[Telecom Helper] Is account enabled ? $enabled")
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun findExistingAccount(context: Context): PhoneAccount? {
|
||||||
|
if (PermissionHelper.get().hasReadPhoneState()) {
|
||||||
|
var account: PhoneAccount? = null
|
||||||
|
val phoneAccountHandleList: List<PhoneAccountHandle> =
|
||||||
|
telecomManager.selfManagedPhoneAccounts
|
||||||
|
val connectionService = ComponentName(context, TelecomConnectionService::class.java)
|
||||||
|
for (phoneAccountHandle in phoneAccountHandleList) {
|
||||||
|
val phoneAccount: PhoneAccount = telecomManager.getPhoneAccount(phoneAccountHandle)
|
||||||
|
if (phoneAccountHandle.componentName == connectionService) {
|
||||||
|
Log.i("[Telecom Helper] Found existing phone account: $phoneAccount")
|
||||||
|
account = phoneAccount
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAccount(newAccount: PhoneAccount?) {
|
||||||
|
if (newAccount != null) {
|
||||||
|
Log.i("[Telecom Helper] Updating account object: $newAccount")
|
||||||
|
account = newAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAccount() {
|
||||||
|
if (account.isEnabled) {
|
||||||
|
Log.w("[Telecom Helper] Unregistering phone account handler from telecom manager")
|
||||||
|
telecomManager.unregisterPhoneAccount(account.accountHandle)
|
||||||
|
} else {
|
||||||
|
Log.w("[Telecom Helper] Account wasn't enabled, skipping...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initPhoneAccount(context: Context): PhoneAccount {
|
||||||
|
val account: PhoneAccount? = findExistingAccount(context)
|
||||||
|
if (account == null) {
|
||||||
|
Log.i("[Telecom Helper] Phone account not found, let's create it")
|
||||||
|
return createAccount(context)
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAccount(context: Context): PhoneAccount {
|
||||||
|
val accountHandle = PhoneAccountHandle(
|
||||||
|
ComponentName(context, TelecomConnectionService::class.java),
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
val identity = coreContext.core.defaultAccount?.params?.identityAddress?.asStringUriOnly() ?: ""
|
||||||
|
val account = PhoneAccount.builder(accountHandle, context.getString(R.string.app_name))
|
||||||
|
.setAddress(Uri.parse(identity))
|
||||||
|
.setIcon(Icon.createWithResource(context, R.drawable.linphone_logo_tinted))
|
||||||
|
.setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)
|
||||||
|
.setHighlightColor(context.getColor(R.color.primary_color))
|
||||||
|
.setShortDescription(context.getString(R.string.app_description))
|
||||||
|
.setSupportedUriSchemes(listOf(PhoneAccount.SCHEME_SIP))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
telecomManager.registerPhoneAccount(account)
|
||||||
|
Log.i("[Telecom Helper] Phone account created: $account")
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onIncomingCall(call: Call) {
|
||||||
|
Log.i("[Telecom Helper] Incoming call received from ${call.remoteAddress.asStringUriOnly()}")
|
||||||
|
|
||||||
|
val extras = prepareBundle(call)
|
||||||
|
telecomManager.addNewIncomingCall(
|
||||||
|
account.accountHandle,
|
||||||
|
Bundle().apply {
|
||||||
|
putBundle(EXTRA_INCOMING_CALL_EXTRAS, extras)
|
||||||
|
putParcelable(EXTRA_PHONE_ACCOUNT_HANDLE, account.accountHandle)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun onOutgoingCall(call: Call) {
|
||||||
|
Log.i("[Telecom Helper] Outgoing call started to ${call.remoteAddress.asStringUriOnly()}")
|
||||||
|
|
||||||
|
val extras = prepareBundle(call)
|
||||||
|
telecomManager.placeCall(
|
||||||
|
Uri.parse(call.remoteAddress.asStringUriOnly()),
|
||||||
|
Bundle().apply {
|
||||||
|
putBundle(EXTRA_OUTGOING_CALL_EXTRAS, extras)
|
||||||
|
putParcelable(EXTRA_PHONE_ACCOUNT_HANDLE, account.accountHandle)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareBundle(call: Call): Bundle {
|
||||||
|
val extras = Bundle()
|
||||||
|
val address = call.remoteAddress
|
||||||
|
|
||||||
|
if (call.dir == Call.Dir.Outgoing) {
|
||||||
|
extras.putString(
|
||||||
|
EXTRA_CALL_BACK_NUMBER,
|
||||||
|
call.callLog.fromAddress.asStringUriOnly()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putParcelable(EXTRA_INCOMING_CALL_ADDRESS, Uri.parse(address.asStringUriOnly()))
|
||||||
|
}
|
||||||
|
|
||||||
|
extras.putString("Call-ID", call.callLog.callId)
|
||||||
|
|
||||||
|
val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress)
|
||||||
|
val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress)
|
||||||
|
extras.putString("DisplayName", displayName)
|
||||||
|
|
||||||
|
return extras
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,4 +69,9 @@ class PermissionHelper private constructor(private val context: Context) {
|
||||||
fun hasRecordAudioPermission(): Boolean {
|
fun hasRecordAudioPermission(): Boolean {
|
||||||
return hasPermission(Manifest.permission.RECORD_AUDIO)
|
return hasPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasTelecomManagerPermissions(): Boolean {
|
||||||
|
return hasPermission(Manifest.permission.READ_PHONE_STATE) &&
|
||||||
|
hasPermission(Manifest.permission.MANAGE_OWN_CALLS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,6 +94,15 @@
|
||||||
linphone:listener="@{viewModel.encryptionMandatoryListener}"
|
linphone:listener="@{viewModel.encryptionMandatoryListener}"
|
||||||
linphone:checked="@={viewModel.encryptionMandatory}"
|
linphone:checked="@={viewModel.encryptionMandatory}"
|
||||||
linphone:enabled="@{viewModel.encryptionIndex != 0}" />
|
linphone:enabled="@{viewModel.encryptionIndex != 0}" />
|
||||||
|
|
||||||
|
<include
|
||||||
|
layout="@layout/settings_widget_switch"
|
||||||
|
linphone:title="@{@string/call_settings_use_telecom_manager_title}"
|
||||||
|
linphone:subtitle="@{@string/call_settings_use_telecom_manager_summary}"
|
||||||
|
android:visibility="@{viewModel.api26OrHigher ? View.VISIBLE : View.GONE}"
|
||||||
|
linphone:listener="@{viewModel.useTelecomManagerListener}"
|
||||||
|
linphone:checked="@={viewModel.useTelecomManager}"/>
|
||||||
|
|
||||||
<include
|
<include
|
||||||
layout="@layout/settings_widget_switch"
|
layout="@layout/settings_widget_switch"
|
||||||
linphone:title="@{@string/call_settings_full_screen_title}"
|
linphone:title="@{@string/call_settings_full_screen_title}"
|
||||||
|
|
|
@ -622,4 +622,6 @@
|
||||||
<string name="call_settings_auto_start_recording_title">Enregistrer automatiquement les appels</string>
|
<string name="call_settings_auto_start_recording_title">Enregistrer automatiquement les appels</string>
|
||||||
<string name="advanced_settings_disable_fragment_security_title">Désactiver le mode sécurisé de l\'interface</string>
|
<string name="advanced_settings_disable_fragment_security_title">Désactiver le mode sécurisé de l\'interface</string>
|
||||||
<string name="advanced_settings_disable_fragment_security_summary">Autorise l\'écran à être capturé/enregistré sur les vues sensibles</string>
|
<string name="advanced_settings_disable_fragment_security_summary">Autorise l\'écran à être capturé/enregistré sur les vues sensibles</string>
|
||||||
|
<string name="call_settings_use_telecom_manager_title">Améliore les intéractions avec les périphériques bluetooth</string>
|
||||||
|
<string name="call_settings_use_telecom_manager_summary">Nécessite des permissions supplémentaires</string>
|
||||||
</resources>
|
</resources>
|
|
@ -400,6 +400,8 @@
|
||||||
<string name="call_settings_media_encryption_zrtp">ZRTP</string>
|
<string name="call_settings_media_encryption_zrtp">ZRTP</string>
|
||||||
<string name="call_settings_media_encryption_dtls">DTLS</string>
|
<string name="call_settings_media_encryption_dtls">DTLS</string>
|
||||||
<string name="call_settings_encryption_mandatory_title">Media encryption mandatory</string>
|
<string name="call_settings_encryption_mandatory_title">Media encryption mandatory</string>
|
||||||
|
<string name="call_settings_use_telecom_manager_title">Improve interactions with bluetooth devices</string>
|
||||||
|
<string name="call_settings_use_telecom_manager_summary">Requires some extra permissions</string>
|
||||||
<string name="call_settings_full_screen_title">Full screen app while in call</string>
|
<string name="call_settings_full_screen_title">Full screen app while in call</string>
|
||||||
<string name="call_settings_full_screen_summary">Hides status and navigation bars</string>
|
<string name="call_settings_full_screen_summary">Hides status and navigation bars</string>
|
||||||
<string name="call_settings_overlay_title">Overlay call notification</string>
|
<string name="call_settings_overlay_title">Overlay call notification</string>
|
||||||
|
|
Loading…
Reference in a new issue