diff --git a/app/src/main/java/org/linphone/ui/main/calls/fragment/StartCallFragment.kt b/app/src/main/java/org/linphone/ui/main/calls/fragment/StartCallFragment.kt index 5221832f7..fa5b0119c 100644 --- a/app/src/main/java/org/linphone/ui/main/calls/fragment/StartCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/calls/fragment/StartCallFragment.kt @@ -23,13 +23,58 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.doOnPreDraw import androidx.navigation.fragment.findNavController +import androidx.navigation.navGraphViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.R +import org.linphone.core.Address import org.linphone.databinding.CallStartFragmentBinding +import org.linphone.ui.main.calls.viewmodel.StartCallViewModel +import org.linphone.ui.main.calls.viewmodel.SuggestionsListViewModel +import org.linphone.ui.main.contacts.adapter.ContactsListAdapter +import org.linphone.ui.main.contacts.model.ContactAvatarModel +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.contacts.viewmodel.ContactsListViewModel import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.utils.DialogUtils class StartCallFragment : GenericFragment() { private lateinit var binding: CallStartFragmentBinding + private val viewModel: StartCallViewModel by navGraphViewModels( + R.id.startCallFragment + ) + + private val contactsListViewModel: ContactsListViewModel by navGraphViewModels( + R.id.startCallFragment + ) + + private val suggestionsListViewModel: SuggestionsListViewModel by navGraphViewModels( + R.id.startCallFragment + ) + + private lateinit var contactsAdapter: ContactsListAdapter + private lateinit var suggestionsAdapter: ContactsListAdapter + + private val listener = object : ContactNumberOrAddressClickListener { + override fun onClicked(address: Address?) { + // UI thread + if (address != null) { + coreContext.postOnCoreThread { + coreContext.startCall(address) + } + } + } + + override fun onLongPress(model: ContactNumberOrAddressModel) { + // UI thread + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -46,10 +91,111 @@ class StartCallFragment : GenericFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner + postponeEnterTransition() - binding.setCancelClickListener { + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + + binding.setBackClickListener { goBack() } + + contactsAdapter = ContactsListAdapter(viewLifecycleOwner, false) + binding.contactsList.setHasFixedSize(true) + binding.contactsList.adapter = contactsAdapter + + contactsAdapter.contactClickedEvent.observe(viewLifecycleOwner) { + it.consume { model -> + startCall(model) + } + } + + binding.contactsList.layoutManager = LinearLayoutManager(requireContext()) + + suggestionsAdapter = ContactsListAdapter(viewLifecycleOwner, false) + binding.suggestionsList.setHasFixedSize(true) + binding.suggestionsList.adapter = suggestionsAdapter + + suggestionsAdapter.contactClickedEvent.observe(viewLifecycleOwner) { + it.consume { model -> + startCall(model) + } + } + + binding.suggestionsList.layoutManager = LinearLayoutManager(requireContext()) + + contactsListViewModel.contactsList.observe( + viewLifecycleOwner + ) { + contactsAdapter.submitList(it) + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + + suggestionsListViewModel.suggestionsList.observe(viewLifecycleOwner) { + suggestionsAdapter.submitList(it) + } + + viewModel.searchFilter.observe(viewLifecycleOwner) { filter -> + contactsListViewModel.applyFilter(filter) + suggestionsListViewModel.applyFilter(filter) + } + } + + private fun startCall(model: ContactAvatarModel) { + coreContext.postOnCoreThread { core -> + val friend = model.friend + val addressesCount = friend.addresses.size + val numbersCount = friend.phoneNumbers.size + if (addressesCount == 1 && numbersCount == 0) { + val address = friend.addresses.first() + coreContext.startCall(address) + } else if (addressesCount == 1 && numbersCount == 0) { + val number = friend.phoneNumbers.first() + val address = core.interpretUrl(number, true) + if (address != null) { + coreContext.startCall(address) + } + } else { + val list = arrayListOf() + for (address in friend.addresses) { + val addressModel = ContactNumberOrAddressModel( + address, + address.asStringUriOnly(), + listener, + true + ) + list.add(addressModel) + } + + for (number in friend.phoneNumbersWithLabel) { + val address = core.interpretUrl(number.phoneNumber, true) + val addressModel = ContactNumberOrAddressModel( + address, + number.phoneNumber, + listener, + false, + number.label.orEmpty() + ) + list.add(addressModel) + } + + coreContext.postOnMainThread { + val model = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog(requireActivity(), model) + + model.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } + } + } } } diff --git a/app/src/main/java/org/linphone/ui/main/calls/viewmodel/StartCallViewModel.kt b/app/src/main/java/org/linphone/ui/main/calls/viewmodel/StartCallViewModel.kt new file mode 100644 index 000000000..4405cfebb --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/calls/viewmodel/StartCallViewModel.kt @@ -0,0 +1,27 @@ +/* + * 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.main.calls.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class StartCallViewModel : ViewModel() { + val searchFilter = MutableLiveData() +} diff --git a/app/src/main/java/org/linphone/ui/main/calls/viewmodel/SuggestionsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/calls/viewmodel/SuggestionsListViewModel.kt new file mode 100644 index 000000000..cccdf87d5 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/calls/viewmodel/SuggestionsListViewModel.kt @@ -0,0 +1,179 @@ +/* + * 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.main.calls.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import java.util.ArrayList +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences +import org.linphone.contacts.ContactsListener +import org.linphone.core.Friend +import org.linphone.core.MagicSearch +import org.linphone.core.MagicSearchListenerStub +import org.linphone.core.SearchResult +import org.linphone.core.tools.Log +import org.linphone.ui.main.contacts.model.ContactAvatarModel + +class SuggestionsListViewModel : ViewModel() { + companion object { + const val TAG = "[Suggestions List ViewModel]" + } + + val suggestionsList = MutableLiveData>() + + private var currentFilter = "" + private var previousFilter = "NotSet" + private var limitSearchToLinphoneAccounts = true + + private lateinit var magicSearch: MagicSearch + + private val magicSearchListener = object : MagicSearchListenerStub() { + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + // Core thread + Log.i("$TAG Magic search contacts available") + processMagicSearchResults(magicSearch.lastSearch) + } + } + + private val contactsListener = object : ContactsListener { + override fun onContactsLoaded() { + // Core thread + Log.i("$TAG Contacts have been (re)loaded, updating list") + applyFilter( + currentFilter, + if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", + MagicSearch.Source.CallLogs.toInt() or MagicSearch.Source.ChatRooms.toInt(), + MagicSearch.Aggregation.Friend + ) + } + } + + init { + coreContext.postOnCoreThread { core -> + coreContext.contactsManager.addListener(contactsListener) + magicSearch = core.createMagicSearch() + magicSearch.limitedSearch = false + magicSearch.addListener(magicSearchListener) + } + + applyFilter(currentFilter) + } + + override fun onCleared() { + coreContext.postOnCoreThread { + magicSearch.removeListener(magicSearchListener) + coreContext.contactsManager.removeListener(contactsListener) + } + super.onCleared() + } + + fun processMagicSearchResults(results: Array) { + // Core thread + Log.i("$TAG Processing ${results.size} results") + suggestionsList.value.orEmpty().forEach(ContactAvatarModel::destroy) + + val list = arrayListOf() + + for (result in results) { + val friend = result.friend + + val model = if (friend != null) { + ContactAvatarModel(friend) + } else { + Log.w("$TAG SearchResult [$result] has no Friend!") + val fakeFriend = + createFriendFromSearchResult(result) + ContactAvatarModel(fakeFriend) + } + model.noAlphabet.postValue(true) + + list.add(model) + } + + suggestionsList.postValue(list) + + Log.i("$TAG Processed ${results.size} results") + } + + fun applyFilter(filter: String) { + // UI thread + coreContext.postOnCoreThread { + applyFilter( + filter, + if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", + MagicSearch.Source.CallLogs.toInt() or MagicSearch.Source.ChatRooms.toInt(), + MagicSearch.Aggregation.Friend + ) + } + } + + private fun applyFilter( + filter: String, + domain: String, + sources: Int, + aggregation: MagicSearch.Aggregation + ) { + // Core thread + if (previousFilter.isNotEmpty() && ( + previousFilter.length > filter.length || + (previousFilter.length == filter.length && previousFilter != filter) + ) + ) { + magicSearch.resetSearchCache() + } + currentFilter = filter + previousFilter = filter + + Log.i( + "$TAG Asking Magic search for contacts matching filter [$filter], domain [$domain] and in sources [$sources]" + ) + magicSearch.getContactsListAsync( + filter, + domain, + sources, + aggregation + ) + } + + private fun createFriendFromSearchResult(searchResult: SearchResult): Friend { + // Core thread + val searchResultFriend = searchResult.friend + if (searchResultFriend != null) return searchResultFriend + + val friend = coreContext.core.createFriend() + + val address = searchResult.address + if (address != null) { + friend.address = address + } + + val number = searchResult.phoneNumber + if (number != null) { + friend.addPhoneNumber(number) + + if (address != null && address.username == number) { + friend.removeAddress(address) + } + } + + return friend + } +} diff --git a/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt index 63b819af7..14a351cc3 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt @@ -122,7 +122,7 @@ private class ContactDiffCallback : DiffUtil.ItemCallback() } override fun areContentsTheSame(oldItem: ContactAvatarModel, newItem: ContactAvatarModel): Boolean { - return oldItem.showFirstLetter.value == newItem.showFirstLetter.value && + return oldItem.firstContactStartingByThatLetter.value == newItem.firstContactStartingByThatLetter.value && oldItem.presenceStatus.value == newItem.presenceStatus.value } } diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt index 8b2b061ce..5562f7ec2 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt @@ -131,7 +131,9 @@ class ContactFragment : GenericFragment() { viewModel.showNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) { it.consume { - val model = NumberOrAddressPickerDialogModel(viewModel) + val model = NumberOrAddressPickerDialogModel( + viewModel.sipAddressesAndPhoneNumbers.value.orEmpty() + ) val dialog = DialogUtils.getNumberOrAddressPickerDialog(requireActivity(), model) model.dismissEvent.observe(viewLifecycleOwner) { event -> diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt index 006bd28fb..b44b198c0 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt @@ -46,7 +46,9 @@ class ContactAvatarModel(val friend: Friend) { val firstLetter: String = LinphoneUtils.getFirstLetter(friend.name.orEmpty()) - val showFirstLetter = MutableLiveData() + val firstContactStartingByThatLetter = MutableLiveData() + + val noAlphabet = MutableLiveData() private val friendListener = object : FriendListenerStub() { override fun onPresenceReceived(fr: Friend) { diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/NumberOrAddressPickerDialogModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/NumberOrAddressPickerDialogModel.kt index 7811c8a97..39a168d00 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/model/NumberOrAddressPickerDialogModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/NumberOrAddressPickerDialogModel.kt @@ -20,16 +20,15 @@ package org.linphone.ui.main.contacts.model import androidx.lifecycle.MutableLiveData -import org.linphone.ui.main.contacts.viewmodel.ContactViewModel import org.linphone.utils.Event -class NumberOrAddressPickerDialogModel(viewModel: ContactViewModel) { - val sipAddressesAndPhoneNumbers = MutableLiveData>() +class NumberOrAddressPickerDialogModel(list: List) { + val sipAddressesAndPhoneNumbers = MutableLiveData>() val dismissEvent = MutableLiveData>() init { - sipAddressesAndPhoneNumbers.value = viewModel.sipAddressesAndPhoneNumbers.value + sipAddressesAndPhoneNumbers.value = list } fun dismiss() { diff --git a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt index d99eaf91f..e9f849fb8 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt @@ -66,7 +66,7 @@ class ContactsListViewModel : ViewModel() { applyFilter( currentFilter, if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", - MagicSearch.Source.Friends.toInt(), + MagicSearch.Source.Friends.toInt() or MagicSearch.Source.LdapServers.toInt(), MagicSearch.Aggregation.Friend ) } @@ -81,6 +81,7 @@ class ContactsListViewModel : ViewModel() { magicSearch.limitedSearch = false magicSearch.addListener(magicSearchListener) } + applyFilter(currentFilter) } @@ -125,7 +126,7 @@ class ContactsListViewModel : ViewModel() { if (currentLetter != previousLetter) { previousLetter = currentLetter } - model.showFirstLetter.postValue(displayLetter) + model.firstContactStartingByThatLetter.postValue(displayLetter) list.add(model) if (friend?.starred == true) { diff --git a/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt b/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt index 4e637efbe..18cde0ce0 100644 --- a/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/voip/viewmodel/CurrentCallViewModel.kt @@ -291,7 +291,11 @@ class CurrentCallViewModel() : ViewModel() { displayedName.postValue(friend.name) contact.postValue(ContactAvatarModel(friend)) } else { - displayedName.postValue(LinphoneUtils.getDisplayName(address)) + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = LinphoneUtils.getDisplayName(address) + fakeFriend.addAddress(address) + contact.postValue(ContactAvatarModel(fakeFriend)) + displayedName.postValue(fakeFriend.name) } updateEncryption() diff --git a/app/src/main/res/drawable/shape_search_square_background.xml b/app/src/main/res/drawable/shape_search_square_background.xml index 5b5623ce0..0b0cca3fe 100644 --- a/app/src/main/res/drawable/shape_search_square_background.xml +++ b/app/src/main/res/drawable/shape_search_square_background.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/layout-land/calls_list_fragment.xml b/app/src/main/res/layout-land/calls_list_fragment.xml index 89cefa847..e2e78ee32 100644 --- a/app/src/main/res/layout-land/calls_list_fragment.xml +++ b/app/src/main/res/layout-land/calls_list_fragment.xml @@ -99,8 +99,8 @@ android:id="@+id/calls_list" android:layout_width="0dp" android:layout_height="0dp" - android:layout_marginStart="10dp" - android:layout_marginEnd="10dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" app:layout_constraintStart_toEndOf="@id/bottom_nav_bar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/top_bar" diff --git a/app/src/main/res/layout-land/contacts_list_fragment.xml b/app/src/main/res/layout-land/contacts_list_fragment.xml index 3d481792d..ea9bb4ed7 100644 --- a/app/src/main/res/layout-land/contacts_list_fragment.xml +++ b/app/src/main/res/layout-land/contacts_list_fragment.xml @@ -108,8 +108,8 @@ android:visibility="@{viewModel.showFavourites && !viewModel.isListFiltered && viewModel.favourites.size() > 0 ? View.VISIBLE : View.GONE}" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:layout_marginEnd="4dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" android:layout_marginTop="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/bottom_nav_bar" @@ -135,8 +135,6 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginTop="8dp" - android:layout_marginStart="10dp" - android:layout_marginEnd="10dp" app:layout_constraintStart_toEndOf="@id/bottom_nav_bar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/all_contacts_label" diff --git a/app/src/main/res/layout/call_list_cell.xml b/app/src/main/res/layout/call_list_cell.xml index 7704d82dd..8b9154690 100644 --- a/app/src/main/res/layout/call_list_cell.xml +++ b/app/src/main/res/layout/call_list_cell.xml @@ -26,13 +26,14 @@ android:onLongClickListener="@{onLongClickListener}" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginStart="5dp" + android:layout_marginEnd="5dp" android:background="@drawable/cell_background"> + - + app:layout_constraintTop_toBottomOf="@id/title" /> - + + + - + - + + + diff --git a/app/src/main/res/layout/calls_list_fragment.xml b/app/src/main/res/layout/calls_list_fragment.xml index 8c713d470..cf4a50d6d 100644 --- a/app/src/main/res/layout/calls_list_fragment.xml +++ b/app/src/main/res/layout/calls_list_fragment.xml @@ -99,8 +99,8 @@ android:id="@+id/calls_list" android:layout_width="0dp" android:layout_height="0dp" - android:layout_marginStart="10dp" - android:layout_marginEnd="10dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/top_bar" diff --git a/app/src/main/res/layout/contact_list_cell.xml b/app/src/main/res/layout/contact_list_cell.xml index cde8cbaa1..99f1f2109 100644 --- a/app/src/main/res/layout/contact_list_cell.xml +++ b/app/src/main/res/layout/contact_list_cell.xml @@ -23,6 +23,8 @@ android:onLongClick="@{onLongClickListener}" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginStart="4dp" + android:layout_marginEnd="16dp" android:background="@drawable/cell_background"> diff --git a/app/src/main/res/layout/contacts_list_fragment.xml b/app/src/main/res/layout/contacts_list_fragment.xml index fd4c182ee..ae9fcd23a 100644 --- a/app/src/main/res/layout/contacts_list_fragment.xml +++ b/app/src/main/res/layout/contacts_list_fragment.xml @@ -99,8 +99,8 @@ android:visibility="@{viewModel.showFavourites && !viewModel.isListFiltered && viewModel.favourites.size() > 0 ? View.VISIBLE : View.GONE}" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:layout_marginEnd="4dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" android:layout_marginTop="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -127,8 +127,6 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginTop="8dp" - android:layout_marginStart="10dp" - android:layout_marginEnd="10dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/all_contacts_label"