Started blind call transfer

This commit is contained in:
Sylvain Berfini 2023-09-25 14:24:57 +02:00
parent a60c66ad33
commit 027e5dd61b
14 changed files with 502 additions and 245 deletions

View file

@ -168,6 +168,11 @@ class ContactsManager @UiThread constructor(context: Context) {
} }
} }
@WorkerThread
fun findDisplayName(address: Address): String {
return findContactByAddress(address)?.name ?: LinphoneUtils.getDisplayName(address)
}
@WorkerThread @WorkerThread
fun onCoreStarted(core: Core) { fun onCoreStarted(core: Core) {
core.addListener(coreListener) core.addListener(coreListener)

View file

@ -30,8 +30,10 @@ import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.emoji2.text.EmojiCompat import androidx.emoji2.text.EmojiCompat
import androidx.lifecycle.MutableLiveData
import java.util.* import java.util.*
import org.linphone.BuildConfig import org.linphone.BuildConfig
import org.linphone.LinphoneApplication
import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.contacts.ContactsManager import org.linphone.contacts.ContactsManager
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
@ -39,6 +41,7 @@ import org.linphone.notifications.NotificationsManager
import org.linphone.telecom.TelecomManager import org.linphone.telecom.TelecomManager
import org.linphone.ui.call.CallActivity import org.linphone.ui.call.CallActivity
import org.linphone.utils.ActivityMonitor import org.linphone.utils.ActivityMonitor
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
class CoreContext @UiThread constructor(val context: Context) : HandlerThread("Core Thread") { class CoreContext @UiThread constructor(val context: Context) : HandlerThread("Core Thread") {
@ -68,6 +71,10 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
private val mainThread = Handler(Looper.getMainLooper()) private val mainThread = Handler(Looper.getMainLooper())
val greenToastToShowEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
MutableLiveData<Event<Pair<String, Int>>>()
}
@SuppressLint("HandlerLeak") @SuppressLint("HandlerLeak")
private lateinit var coreThread: Handler private lateinit var coreThread: Handler
@ -77,6 +84,7 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
Log.i("$TAG Global state changed [$state]") Log.i("$TAG Global state changed [$state]")
} }
@WorkerThread
override fun onConfiguringStatus( override fun onConfiguringStatus(
core: Core, core: Core,
status: Config.ConfiguringState?, status: Config.ConfiguringState?,
@ -103,6 +111,25 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
} }
} }
} }
@WorkerThread
override fun onTransferStateChanged(core: Core, transfered: Call, state: Call.State) {
Log.i(
"$TAG Transferred call [${transfered.remoteAddress.asStringUriOnly()}] state changed [$state]"
)
if (state == Call.State.Connected) {
// TODO FIXME: Remote is call being transferred, not transferee !
val displayName = contactsManager.findDisplayName(transfered.remoteAddress)
val message = context.getString(
org.linphone.R.string.toast_call_transfer_successful,
displayName
)
val icon = org.linphone.R.drawable.transfer
greenToastToShowEvent.postValue(Event(Pair(message, icon)))
}
}
} }
init { init {

View file

@ -29,6 +29,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.children
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -58,6 +59,8 @@ import org.linphone.utils.slideInToastFromTopForDuration
class CallActivity : AppCompatActivity() { class CallActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "[Call Activity]" private const val TAG = "[Call Activity]"
private const val PERSISTENT_TOAST_TAG = "PERSISTENT"
} }
private lateinit var binding: CallActivityBinding private lateinit var binding: CallActivityBinding
@ -108,15 +111,16 @@ class CallActivity : AppCompatActivity() {
callViewModel.showLowWifiSignalEvent.observe(this) { callViewModel.showLowWifiSignalEvent.observe(this) {
it.consume { show -> it.consume { show ->
if (show) { if (show) {
showRedToast( showPersistentRedToast(
getString(R.string.toast_alert_low_wifi_signal), getString(R.string.toast_alert_low_wifi_signal),
R.drawable.wifi_low R.drawable.wifi_low
) )
} else { } else {
hideRedToast() removePersistentRedToasts()
showGreenToast( showGreenToast(
getString(R.string.toast_alert_low_wifi_signal_cleared), getString(R.string.toast_alert_low_wifi_signal_cleared),
R.drawable.wifi_high R.drawable.wifi_high,
2000
) )
} }
} }
@ -125,20 +129,39 @@ class CallActivity : AppCompatActivity() {
callViewModel.showLowCellularSignalEvent.observe(this) { callViewModel.showLowCellularSignalEvent.observe(this) {
it.consume { show -> it.consume { show ->
if (show) { if (show) {
showRedToast( showPersistentRedToast(
getString(R.string.toast_alert_low_cellular_signal), getString(R.string.toast_alert_low_cellular_signal),
R.drawable.cell_signal_low R.drawable.cell_signal_low
) )
} else { } else {
hideRedToast() removePersistentRedToasts()
showGreenToast( showGreenToast(
getString(R.string.toast_alert_low_cellular_signal_cleared), getString(R.string.toast_alert_low_cellular_signal_cleared),
R.drawable.cell_signal_full R.drawable.cell_signal_full,
2000
) )
} }
} }
} }
callViewModel.transferInProgressEvent.observe(this) {
it.consume { remote ->
showGreenToast(
getString(R.string.toast_call_transfer_in_progress, remote),
R.drawable.transfer
)
}
}
callViewModel.transferFailedEvent.observe(this) {
it.consume { remote ->
showRedToast(
getString(R.string.toast_call_transfer_failed, remote),
R.drawable.warning_circle
)
}
}
callsViewModel.showIncomingCallEvent.observe(this) { callsViewModel.showIncomingCallEvent.observe(this) {
it.consume { it.consume {
val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment() val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment()
@ -245,29 +268,44 @@ class CallActivity : AppCompatActivity() {
) )
} }
private fun showRedToast(message: String, @DrawableRes icon: Int) { private fun showRedToast(message: String, @DrawableRes icon: Int, duration: Long = 4000) {
val redToast = AppUtils.getRedToast(this, binding.toastsArea, message, icon) val redToast = AppUtils.getRedToast(this, binding.toastsArea, message, icon)
binding.toastsArea.addView(redToast.root) binding.toastsArea.addView(redToast.root)
redToast.root.slideInToastFromTopForDuration(
binding.toastsArea as ViewGroup,
lifecycleScope,
duration
)
}
private fun showPersistentRedToast(message: String, @DrawableRes icon: Int) {
val redToast = AppUtils.getRedToast(this, binding.toastsArea, message, icon)
redToast.root.tag = PERSISTENT_TOAST_TAG
binding.toastsArea.addView(redToast.root)
redToast.root.slideInToastFromTop( redToast.root.slideInToastFromTop(
binding.toastsArea as ViewGroup, binding.toastsArea as ViewGroup,
true true
) )
} }
private fun hideRedToast() { private fun removePersistentRedToasts() {
// TODO: improve for (child in binding.toastsArea.children) {
binding.toastsArea.removeAllViews() if (child.tag == PERSISTENT_TOAST_TAG) {
binding.toastsArea.removeView(child)
}
}
} }
private fun showGreenToast(message: String, @DrawableRes icon: Int) { private fun showGreenToast(message: String, @DrawableRes icon: Int, duration: Long = 4000) {
val greenToast = AppUtils.getGreenToast(this, binding.toastsArea, message, icon) val greenToast = AppUtils.getGreenToast(this, binding.toastsArea, message, icon)
binding.toastsArea.addView(greenToast.root) binding.toastsArea.addView(greenToast.root)
greenToast.root.slideInToastFromTopForDuration( greenToast.root.slideInToastFromTopForDuration(
binding.toastsArea as ViewGroup, binding.toastsArea as ViewGroup,
lifecycleScope, lifecycleScope,
2000 duration
) )
} }

View file

@ -0,0 +1,266 @@
/*
* 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.call.fragment
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.view.doOnPreDraw
import androidx.navigation.fragment.findNavController
import androidx.navigation.navGraphViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers
import org.linphone.core.Address
import org.linphone.core.tools.Log
import org.linphone.databinding.StartCallFragmentBinding
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel
import org.linphone.ui.main.history.adapter.ContactsAndSuggestionsListAdapter
import org.linphone.ui.main.history.model.ContactOrSuggestionModel
import org.linphone.ui.main.history.viewmodel.StartCallViewModel
import org.linphone.ui.main.model.isInSecureMode
import org.linphone.utils.DialogUtils
import org.linphone.utils.RecyclerViewHeaderDecoration
import org.linphone.utils.hideKeyboard
import org.linphone.utils.setKeyboardInsetListener
import org.linphone.utils.showKeyboard
abstract class AbstractNewTransferCallFragment : GenericCallFragment() {
companion object {
private const val TAG = "[New/Transfer Call Fragment]"
}
private lateinit var binding: StartCallFragmentBinding
private val viewModel: StartCallViewModel by navGraphViewModels(
R.id.call_nav_graph
)
private lateinit var adapter: ContactsAndSuggestionsListAdapter
private val listener = object : ContactNumberOrAddressClickListener {
@UiThread
override fun onClicked(model: ContactNumberOrAddressModel) {
val address = model.address
if (address != null) {
coreContext.postOnCoreThread {
action(address)
}
}
}
@UiThread
override fun onLongPress(model: ContactNumberOrAddressModel) {
}
}
private var numberOrAddressPickerDialog: Dialog? = null
abstract val title: String
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = StartCallFragmentBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postponeEnterTransition()
binding.lifecycleOwner = viewLifecycleOwner
viewModel.title.value = title
binding.viewModel = viewModel
binding.hideGroupChatButton = true
binding.setBackClickListener {
findNavController().popBackStack()
}
binding.setHideNumpadClickListener {
viewModel.hideNumpad()
}
adapter = ContactsAndSuggestionsListAdapter(viewLifecycleOwner)
binding.contactsAndSuggestionsList.setHasFixedSize(true)
binding.contactsAndSuggestionsList.adapter = adapter
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter, true)
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
adapter.contactClickedEvent.observe(viewLifecycleOwner) {
it.consume { model ->
startCall(model)
}
}
binding.contactsAndSuggestionsList.layoutManager = LinearLayoutManager(requireContext())
viewModel.contactsAndSuggestionsList.observe(
viewLifecycleOwner
) {
Log.i("$TAG Contacts & suggestions list is ready with [${it.size}] items")
val count = adapter.itemCount
adapter.submitList(it)
if (count == 0 && it.isNotEmpty()) {
(view.parent as? ViewGroup)?.doOnPreDraw {
startPostponedEnterTransition()
}
}
}
viewModel.searchFilter.observe(viewLifecycleOwner) { filter ->
val trimmed = filter.trim()
viewModel.applyFilter(trimmed)
}
viewModel.removedCharacterAtCurrentPositionEvent.observe(viewLifecycleOwner) {
it.consume {
val selectionStart = binding.searchBar.selectionStart
val selectionEnd = binding.searchBar.selectionEnd
if (selectionStart > 0) {
binding.searchBar.text =
binding.searchBar.text?.delete(
selectionStart - 1,
selectionEnd
)
binding.searchBar.setSelection(selectionStart - 1)
}
}
}
viewModel.appendDigitToSearchBarEvent.observe(viewLifecycleOwner) {
it.consume { digit ->
val newValue = "${binding.searchBar.text}$digit"
binding.searchBar.setText(newValue)
binding.searchBar.setSelection(newValue.length)
}
}
viewModel.requestKeyboardVisibilityChangedEvent.observe(viewLifecycleOwner) {
it.consume { show ->
if (show) {
// To automatically open keyboard
binding.searchBar.showKeyboard(requireActivity().window)
} else {
binding.searchBar.requestFocus()
binding.searchBar.hideKeyboard()
}
}
}
viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
val standardBottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
if (visible) {
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
} else {
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
binding.root.setKeyboardInsetListener { keyboardVisible ->
if (keyboardVisible) {
viewModel.isNumpadVisible.value = false
}
}
}
override fun onPause() {
super.onPause()
numberOrAddressPickerDialog?.dismiss()
numberOrAddressPickerDialog = null
}
@WorkerThread
abstract fun action(address: Address)
private fun startCall(model: ContactOrSuggestionModel) {
coreContext.postOnCoreThread { core ->
val friend = model.friend
if (friend == null) {
action(model.address)
return@postOnCoreThread
}
val addressesCount = friend.addresses.size
val numbersCount = friend.phoneNumbers.size
// Do not consider phone numbers if default account is in secure mode
val enablePhoneNumbers = core.defaultAccount?.isInSecureMode() != true
if (addressesCount == 1 && (numbersCount == 0 || !enablePhoneNumbers)) {
Log.i(
"$TAG Only 1 SIP address found for contact [${friend.name}], starting call directly"
)
val address = friend.addresses.first()
action(address)
} else if (addressesCount == 0 && numbersCount == 1 && enablePhoneNumbers) {
val number = friend.phoneNumbers.first()
val address = core.interpretUrl(number, true)
if (address != null) {
Log.i(
"$TAG Only 1 phone number found for contact [${friend.name}], starting call directly"
)
action(address)
} else {
Log.e("$TAG Failed to interpret phone number [$number] as SIP address")
}
} else {
val list = friend.getListOfSipAddressesAndPhoneNumbers(listener)
Log.i(
"$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog"
)
coreContext.postOnMainThread {
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
val dialog =
DialogUtils.getNumberOrAddressPickerDialog(
requireActivity(),
numberOrAddressModel
)
numberOrAddressPickerDialog = dialog
numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event ->
event.consume {
dialog.dismiss()
}
}
dialog.show()
}
}
}
}
}

View file

@ -135,6 +135,11 @@ class ActiveCallFragment : GenericCallFragment() {
findNavController().navigate(action) findNavController().navigate(action)
} }
binding.setTransferClickListener {
val action = ActiveCallFragmentDirections.actionActiveCallFragmentToTransferCallFragment()
findNavController().navigate(action)
}
binding.setCallsListClickListener { binding.setCallsListClickListener {
val action = ActiveCallFragmentDirections.actionActiveCallFragmentToCallsListFragment() val action = ActiveCallFragmentDirections.actionActiveCallFragmentToCallsListFragment()
findNavController().navigate(action) findNavController().navigate(action)

View file

@ -19,250 +19,30 @@
*/ */
package org.linphone.ui.call.fragment package org.linphone.ui.call.fragment
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.core.view.doOnPreDraw import androidx.annotation.WorkerThread
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.navGraphViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers import org.linphone.core.Address
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.StartCallFragmentBinding
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel
import org.linphone.ui.main.fragment.GenericFragment
import org.linphone.ui.main.history.adapter.ContactsAndSuggestionsListAdapter
import org.linphone.ui.main.history.model.ContactOrSuggestionModel
import org.linphone.ui.main.history.viewmodel.StartCallViewModel
import org.linphone.ui.main.model.isInSecureMode
import org.linphone.utils.DialogUtils
import org.linphone.utils.RecyclerViewHeaderDecoration
import org.linphone.utils.hideKeyboard
import org.linphone.utils.setKeyboardInsetListener
import org.linphone.utils.showKeyboard
@UiThread @UiThread
class NewCallFragment : GenericFragment() { class NewCallFragment : AbstractNewTransferCallFragment() {
companion object { companion object {
private const val TAG = "[New Call Fragment]" private const val TAG = "[New Call Fragment]"
} }
private lateinit var binding: StartCallFragmentBinding override val title: String
get() = getString(R.string.call_action_start_new_call)
private val viewModel: StartCallViewModel by navGraphViewModels( @WorkerThread
R.id.call_nav_graph override fun action(address: Address) {
) Log.i("$TAG Calling [${address.asStringUriOnly()}]")
coreContext.startCall(address)
private lateinit var adapter: ContactsAndSuggestionsListAdapter coreContext.postOnMainThread {
findNavController().popBackStack()
private val listener = object : ContactNumberOrAddressClickListener {
@UiThread
override fun onClicked(model: ContactNumberOrAddressModel) {
val address = model.address
if (address != null) {
coreContext.postOnCoreThread {
coreContext.startCall(address)
}
findNavController().popBackStack()
}
}
@UiThread
override fun onLongPress(model: ContactNumberOrAddressModel) {
}
}
private var numberOrAddressPickerDialog: Dialog? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = StartCallFragmentBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postponeEnterTransition()
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.hideGroupChatButton = true
binding.setBackClickListener {
goBack()
}
binding.setHideNumpadClickListener {
viewModel.hideNumpad()
}
adapter = ContactsAndSuggestionsListAdapter(viewLifecycleOwner)
binding.contactsAndSuggestionsList.setHasFixedSize(true)
binding.contactsAndSuggestionsList.adapter = adapter
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter, true)
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
adapter.contactClickedEvent.observe(viewLifecycleOwner) {
it.consume { model ->
startCall(model)
}
}
binding.contactsAndSuggestionsList.layoutManager = LinearLayoutManager(requireContext())
viewModel.contactsAndSuggestionsList.observe(
viewLifecycleOwner
) {
Log.i("$TAG Contacts & suggestions list is ready with [${it.size}] items")
val count = adapter.itemCount
adapter.submitList(it)
if (count == 0 && it.isNotEmpty()) {
(view.parent as? ViewGroup)?.doOnPreDraw {
startPostponedEnterTransition()
}
}
}
viewModel.searchFilter.observe(viewLifecycleOwner) { filter ->
val trimmed = filter.trim()
viewModel.applyFilter(trimmed)
}
viewModel.removedCharacterAtCurrentPositionEvent.observe(viewLifecycleOwner) {
it.consume {
val selectionStart = binding.searchBar.selectionStart
val selectionEnd = binding.searchBar.selectionEnd
if (selectionStart > 0) {
binding.searchBar.text =
binding.searchBar.text?.delete(
selectionStart - 1,
selectionEnd
)
binding.searchBar.setSelection(selectionStart - 1)
}
}
}
viewModel.appendDigitToSearchBarEvent.observe(viewLifecycleOwner) {
it.consume { digit ->
val newValue = "${binding.searchBar.text}$digit"
binding.searchBar.setText(newValue)
binding.searchBar.setSelection(newValue.length)
}
}
viewModel.requestKeyboardVisibilityChangedEvent.observe(viewLifecycleOwner) {
it.consume { show ->
if (show) {
// To automatically open keyboard
binding.searchBar.showKeyboard(requireActivity().window)
} else {
binding.searchBar.requestFocus()
binding.searchBar.hideKeyboard()
}
}
}
viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
val standardBottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
if (visible) {
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
} else {
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
binding.root.setKeyboardInsetListener { keyboardVisible ->
if (keyboardVisible) {
viewModel.isNumpadVisible.value = false
}
}
}
override fun onPause() {
super.onPause()
numberOrAddressPickerDialog?.dismiss()
numberOrAddressPickerDialog = null
}
private fun startCall(model: ContactOrSuggestionModel) {
coreContext.postOnCoreThread { core ->
val friend = model.friend
if (friend == null) {
coreContext.startCall(model.address)
coreContext.postOnMainThread {
findNavController().popBackStack()
}
return@postOnCoreThread
}
val addressesCount = friend.addresses.size
val numbersCount = friend.phoneNumbers.size
// Do not consider phone numbers if default account is in secure mode
val enablePhoneNumbers = core.defaultAccount?.isInSecureMode() != true
if (addressesCount == 1 && (numbersCount == 0 || !enablePhoneNumbers)) {
Log.i(
"$TAG Only 1 SIP address found for contact [${friend.name}], starting call directly"
)
val address = friend.addresses.first()
coreContext.startCall(address)
coreContext.postOnMainThread {
findNavController().popBackStack()
}
} else if (addressesCount == 0 && numbersCount == 1 && enablePhoneNumbers) {
val number = friend.phoneNumbers.first()
val address = core.interpretUrl(number, true)
if (address != null) {
Log.i(
"$TAG Only 1 phone number found for contact [${friend.name}], starting call directly"
)
coreContext.startCall(address)
coreContext.postOnMainThread {
findNavController().popBackStack()
}
} else {
Log.e("$TAG Failed to interpret phone number [$number] as SIP address")
}
} else {
val list = friend.getListOfSipAddressesAndPhoneNumbers(listener)
Log.i(
"$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog"
)
coreContext.postOnMainThread {
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
val dialog =
DialogUtils.getNumberOrAddressPickerDialog(
requireActivity(),
numberOrAddressModel
)
numberOrAddressPickerDialog = dialog
numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event ->
event.consume {
dialog.dismiss()
}
}
dialog.show()
}
}
} }
} }
} }

View file

@ -0,0 +1,62 @@
/*
* 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.call.fragment
import android.os.Bundle
import android.view.View
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.tools.Log
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
@UiThread
class TransferCallFragment : AbstractNewTransferCallFragment() {
companion object {
private const val TAG = "[Transfer Call Fragment]"
}
override val title: String
get() = getString(R.string.call_transfer_title)
private lateinit var callViewModel: CurrentCallViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
}
@WorkerThread
override fun action(address: Address) {
Log.i("$TAG Transferring current call to [${address.asStringUriOnly()}]")
callViewModel.blindTransferCallTo(address)
coreContext.postOnMainThread {
findNavController().popBackStack()
}
}
}

View file

@ -30,6 +30,7 @@ import java.util.Locale
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.Alert import org.linphone.core.Alert
import org.linphone.core.AlertListenerStub import org.linphone.core.AlertListenerStub
import org.linphone.core.AudioDevice import org.linphone.core.AudioDevice
@ -115,6 +116,14 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
val transferInProgressEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val transferFailedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val numpadModel: NumpadModel val numpadModel: NumpadModel
val appendDigitToSearchBarEvent: MutableLiveData<Event<String>> by lazy { val appendDigitToSearchBarEvent: MutableLiveData<Event<String>> by lazy {
@ -247,6 +256,22 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
} }
} }
} }
@WorkerThread
override fun onTransferStateChanged(core: Core, transfered: Call, state: Call.State) {
Log.i(
"$TAG Transferred call [${transfered.remoteAddress.asStringUriOnly()}] state changed [$state]"
)
// TODO FIXME: Remote is call being transferred, not transferee !
if (state == Call.State.OutgoingProgress) {
val displayName = coreContext.contactsManager.findDisplayName(transfered.remoteAddress)
transferInProgressEvent.postValue(Event(displayName))
} else if (LinphoneUtils.isCallEnding(state)) {
val displayName = coreContext.contactsManager.findDisplayName(transfered.remoteAddress)
transferFailedEvent.postValue(Event(displayName))
}
}
} }
private val alertListener = object : AlertListenerStub() { private val alertListener = object : AlertListenerStub() {
@ -489,6 +514,22 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
showNumpadBottomSheetEvent.value = Event(true) showNumpadBottomSheetEvent.value = Event(true)
} }
@WorkerThread
fun blindTransferCallTo(to: Address) {
if (::currentCall.isInitialized) {
Log.i(
"$TAG Call [${currentCall.remoteAddress.asStringUriOnly()}] is being blindly transferred to [${to.asStringUriOnly()}]"
)
if (currentCall.transferTo(to) == 0) {
Log.i("$TAG Blind call transfer is successful")
} else {
Log.e("$TAG Failed to make blind call transfer!")
val displayName = coreContext.contactsManager.findDisplayName(to)
transferFailedEvent.postValue(Event(displayName))
}
}
}
@WorkerThread @WorkerThread
private fun showZrtpSasDialog(authToken: String) { private fun showZrtpSasDialog(authToken: String) {
val toRead: String val toRead: String

View file

@ -106,6 +106,14 @@ class MainActivity : AppCompatActivity() {
POST_NOTIFICATIONS_PERMISSION_REQUEST POST_NOTIFICATIONS_PERMISSION_REQUEST
) )
} }
coreContext.greenToastToShowEvent.observe(this) {
it.consume { pair ->
val message = pair.first
val icon = pair.second
showGreenToast(message, icon)
}
}
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(

View file

@ -96,6 +96,8 @@ class StartCallFragment : GenericFragment() {
postponeEnterTransition() postponeEnterTransition()
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
viewModel.title.value = getString(R.string.history_call_start_title)
binding.viewModel = viewModel binding.viewModel = viewModel
binding.setBackClickListener { binding.setBackClickListener {

View file

@ -45,6 +45,8 @@ class StartCallViewModel @UiThread constructor() : ViewModel() {
private const val TAG = "[Start Call ViewModel]" private const val TAG = "[Start Call ViewModel]"
} }
val title = MutableLiveData<String>()
val searchFilter = MutableLiveData<String>() val searchFilter = MutableLiveData<String>()
val contactsAndSuggestionsList = MutableLiveData<ArrayList<ContactOrSuggestionModel>>() val contactsAndSuggestionsList = MutableLiveData<ArrayList<ContactOrSuggestionModel>>()

View file

@ -14,6 +14,9 @@
<variable <variable
name="hideGroupChatButton" name="hideGroupChatButton"
type="Boolean" /> type="Boolean" />
<variable
name="title"
type="String" />
<variable <variable
name="viewModel" name="viewModel"
type="org.linphone.ui.main.history.viewmodel.StartCallViewModel" /> type="org.linphone.ui.main.history.viewmodel.StartCallViewModel" />
@ -48,7 +51,7 @@
android:layout_height="@dimen/top_bar_height" android:layout_height="@dimen/top_bar_height"
android:layout_marginStart="10dp" android:layout_marginStart="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:text="@string/history_call_start_title" android:text="@{viewModel.title, default=@string/history_call_start_title}"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/back" app:layout_constraintStart_toEndOf="@id/back"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />

View file

@ -54,6 +54,12 @@
app:enterAnim="@anim/slide_in" app:enterAnim="@anim/slide_in"
app:popExitAnim="@anim/slide_out" app:popExitAnim="@anim/slide_out"
app:launchSingleTop="true" /> app:launchSingleTop="true" />
<action
android:id="@+id/action_activeCallFragment_to_transferCallFragment"
app:destination="@id/transferCallFragment"
app:enterAnim="@anim/slide_in"
app:popExitAnim="@anim/slide_out"
app:launchSingleTop="true" />
<action <action
android:id="@+id/action_activeCallFragment_to_callsListFragment" android:id="@+id/action_activeCallFragment_to_callsListFragment"
app:destination="@id/callsListFragment" app:destination="@id/callsListFragment"
@ -72,7 +78,15 @@
android:id="@+id/newCallFragment" android:id="@+id/newCallFragment"
android:name="org.linphone.ui.call.fragment.NewCallFragment" android:name="org.linphone.ui.call.fragment.NewCallFragment"
android:label="NewCallFragment" android:label="NewCallFragment"
tools:layout="@layout/start_call_fragment" /> tools:layout="@layout/start_call_fragment" >
</fragment>
<fragment
android:id="@+id/transferCallFragment"
android:name="org.linphone.ui.call.fragment.TransferCallFragment"
android:label="TransferCallFragment"
tools:layout="@layout/start_call_fragment" >
</fragment>
<fragment <fragment
android:id="@+id/callsListFragment" android:id="@+id/callsListFragment"

View file

@ -290,6 +290,10 @@
<string name="call_state_paused">Paused</string> <string name="call_state_paused">Paused</string>
<string name="call_state_ended">Ended</string> <string name="call_state_ended">Ended</string>
<string name="calls_list_title">Calls list</string> <string name="calls_list_title">Calls list</string>
<string name="call_transfer_title">Transfer call to</string>
<string name="toast_call_transfer_in_progress">Call is being transferred to %s</string>
<string name="toast_call_transfer_successful">Call has been transferred to %s</string>
<string name="toast_call_transfer_failed">Call transfer to %s failed!</string>
<!-- Keep <u></u> in following strings translations! --> <!-- Keep <u></u> in following strings translations! -->
<string name="welcome_carousel_skip"><u>Skip</u></string> <string name="welcome_carousel_skip"><u>Skip</u></string>