From d1029af180afb8d81e73bbf9131a20654dd4a479 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Mon, 18 Sep 2023 10:37:39 +0200 Subject: [PATCH] Started new call fragment while in call --- .../org/linphone/telecom/TelecomManager.kt | 34 ++- .../ui/voip/fragment/ActiveCallFragment.kt | 16 ++ .../ui/voip/fragment/NewCallFragment.kt | 268 ++++++++++++++++++ app/src/main/res/drawable/chat_text.xml | 2 +- .../layout-land/voip_call_extra_actions.xml | 29 +- .../main/res/layout/call_start_fragment.xml | 12 +- .../res/layout/voip_active_call_fragment.xml | 14 +- .../res/layout/voip_call_extra_actions.xml | 29 +- .../main/res/navigation/voip_nav_graph.xml | 15 +- 9 files changed, 388 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/voip/fragment/NewCallFragment.kt diff --git a/app/src/main/java/org/linphone/telecom/TelecomManager.kt b/app/src/main/java/org/linphone/telecom/TelecomManager.kt index a87ba5385..4bf99493a 100644 --- a/app/src/main/java/org/linphone/telecom/TelecomManager.kt +++ b/app/src/main/java/org/linphone/telecom/TelecomManager.kt @@ -61,7 +61,11 @@ class TelecomManager @WorkerThread constructor(context: Context) { } else { CallAttributesCompat.DIRECTION_INCOMING } - val type = CallAttributesCompat.CALL_TYPE_AUDIO_CALL or CallAttributesCompat.CALL_TYPE_VIDEO_CALL + val type = if (core.isVideoEnabled) { + CallAttributesCompat.CALL_TYPE_VIDEO_CALL + } else { + CallAttributesCompat.CALL_TYPE_AUDIO_CALL + } val capabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE or CallAttributesCompat.SUPPORTS_TRANSFER val callAttributes = CallAttributesCompat( @@ -71,21 +75,27 @@ class TelecomManager @WorkerThread constructor(context: Context) { type, capabilities ) + Log.i("$TAG Adding call to Telecom's CallsManager with attributes [$callAttributes]") + scope.launch { - callsManager.addCall(callAttributes) { - val callbacks = TelecomCallControlCallback(call, this, scope) + try { + callsManager.addCall(callAttributes) { + val callbacks = TelecomCallControlCallback(call, this, scope) - coreContext.postOnCoreThread { - val callId = call.callLog.callId.orEmpty() - if (callId.isNotEmpty()) { - Log.i("$TAG Storing our callbacks for call ID [$callId]") - map[callId] = callbacks + coreContext.postOnCoreThread { + val callId = call.callLog.callId.orEmpty() + if (callId.isNotEmpty()) { + Log.i("$TAG Storing our callbacks for call ID [$callId]") + map[callId] = callbacks + } } - } - setCallback(callbacks) - // We must first call setCallback on callControlScope before using it - callbacks.onCallControlCallbackSet() + setCallback(callbacks) + // We must first call setCallback on callControlScope before using it + callbacks.onCallControlCallbackSet() + } + } catch (e: Exception) { + Log.e("$TAG Failed to add call to Telecom's CallsManager!") } } } diff --git a/app/src/main/java/org/linphone/ui/voip/fragment/ActiveCallFragment.kt b/app/src/main/java/org/linphone/ui/voip/fragment/ActiveCallFragment.kt index 54f68c992..cb285f645 100644 --- a/app/src/main/java/org/linphone/ui/voip/fragment/ActiveCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/voip/fragment/ActiveCallFragment.kt @@ -26,9 +26,12 @@ import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils import androidx.annotation.UiThread import androidx.constraintlayout.widget.ConstraintSet import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController import androidx.window.layout.FoldingFeature import com.google.android.material.bottomsheet.BottomSheetBehavior import org.linphone.LinphoneApplication.Companion.coreContext @@ -80,6 +83,14 @@ class ActiveCallFragment : GenericCallFragment() { } } + override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { + if (findNavController().currentDestination?.id == R.id.newCallFragment) { + // Holds fragment in place while new contact fragment slides over it + return AnimationUtils.loadAnimation(activity, R.anim.hold) + } + return AnimationUtils.loadAnimation(activity, R.anim.hold) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -102,6 +113,11 @@ class ActiveCallFragment : GenericCallFragment() { val bottomSheetBehavior = BottomSheetBehavior.from(binding.bottomBar.root) bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + binding.setNewCallClickListener { + val action = ActiveCallFragmentDirections.actionActiveCallFragmentToNewCallFragment() + findNavController().navigate(action) + } + sharedViewModel = requireActivity().run { ViewModelProvider(this)[SharedCallViewModel::class.java] } diff --git a/app/src/main/java/org/linphone/ui/voip/fragment/NewCallFragment.kt b/app/src/main/java/org/linphone/ui/voip/fragment/NewCallFragment.kt new file mode 100644 index 000000000..bb30b8409 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/voip/fragment/NewCallFragment.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.voip.fragment + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +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.tools.Log +import org.linphone.databinding.CallStartFragmentBinding +import org.linphone.ui.main.calls.adapter.ContactsAndSuggestionsListAdapter +import org.linphone.ui.main.calls.model.ContactOrSuggestionModel +import org.linphone.ui.main.calls.viewmodel.StartCallViewModel +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.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 +class NewCallFragment : GenericFragment() { + companion object { + private const val TAG = "[New Call Fragment]" + } + + private lateinit var binding: CallStartFragmentBinding + + private val viewModel: StartCallViewModel by navGraphViewModels( + R.id.voip_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 { + 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 = CallStartFragmentBinding.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() + } + } + } + } +} diff --git a/app/src/main/res/drawable/chat_text.xml b/app/src/main/res/drawable/chat_text.xml index 40d711414..a2adf78df 100644 --- a/app/src/main/res/drawable/chat_text.xml +++ b/app/src/main/res/drawable/chat_text.xml @@ -5,5 +5,5 @@ android:viewportHeight="256"> + android:fillColor="#4e6074"/> diff --git a/app/src/main/res/layout-land/voip_call_extra_actions.xml b/app/src/main/res/layout-land/voip_call_extra_actions.xml index 9806d5311..336bcf291 100644 --- a/app/src/main/res/layout-land/voip_call_extra_actions.xml +++ b/app/src/main/res/layout-land/voip_call_extra_actions.xml @@ -5,6 +5,15 @@ + + + @@ -30,6 +39,7 @@ + @@ -23,7 +26,7 @@ + android:background="@color/white"> + + + + @@ -205,7 +214,10 @@ android:id="@+id/bottom_bar" android:visibility="@{viewModel.fullScreenMode || viewModel.pipMode ? View.INVISIBLE : View.VISIBLE}" layout="@layout/voip_call_extra_actions" - bind:viewModel="@{viewModel}"/> + bind:viewModel="@{viewModel}" + bind:transferClickListener="@{transferClickListener}" + bind:newCallClickListener="@{newCallClickListener}" + bind:callsListClickListener="@{callsListClickListener}"/> diff --git a/app/src/main/res/layout/voip_call_extra_actions.xml b/app/src/main/res/layout/voip_call_extra_actions.xml index 051c982ee..c97d3acda 100644 --- a/app/src/main/res/layout/voip_call_extra_actions.xml +++ b/app/src/main/res/layout/voip_call_extra_actions.xml @@ -5,6 +5,15 @@ + + + @@ -30,6 +39,7 @@ + tools:layout="@layout/voip_active_call_fragment"> + + + + \ No newline at end of file