diff --git a/app/src/main/java/org/linphone/ui/TopBarViewModel.kt b/app/src/main/java/org/linphone/ui/TopBarViewModel.kt index 4cb1e17eb..d6823c4f3 100644 --- a/app/src/main/java/org/linphone/ui/TopBarViewModel.kt +++ b/app/src/main/java/org/linphone/ui/TopBarViewModel.kt @@ -21,9 +21,14 @@ package org.linphone.ui import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.MagicSearch +import org.linphone.core.MagicSearchListenerStub +import org.linphone.core.SearchResult +import org.linphone.core.tools.Log import org.linphone.utils.Event -open class TopBarViewModel : ViewModel() { +abstract class TopBarViewModel : ViewModel() { val title = MutableLiveData() val searchBarVisible = MutableLiveData() @@ -34,21 +39,73 @@ open class TopBarViewModel : ViewModel() { MutableLiveData>() } + private var previousFilter = "NotSet" + + private lateinit var magicSearch: MagicSearch + + private val magicSearchListener = object : MagicSearchListenerStub() { + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + // Core thread + Log.i("[Contacts] Magic search contacts available") + processMagicSearchResults(magicSearch.lastSearch) + } + } + init { searchBarVisible.value = false + coreContext.postOnCoreThread { core -> + magicSearch = core.createMagicSearch() + magicSearch.limitedSearch = false + magicSearch.addListener(magicSearchListener) + } + } + + override fun onCleared() { + coreContext.postOnCoreThread { core -> + magicSearch.removeListener(magicSearchListener) + } + super.onCleared() } fun openSearchBar() { + // UI thread searchBarVisible.value = true focusSearchBarEvent.value = Event(true) } fun closeSearchBar() { + // UI thread searchBarVisible.value = false focusSearchBarEvent.value = Event(false) } fun clearFilter() { + // UI thread searchFilter.value = "" } + + fun applyFilter(domain: String, sources: Int, aggregation: MagicSearch.Aggregation) { + // Core thread + val filterValue = searchFilter.value.orEmpty() + if (previousFilter.isNotEmpty() && ( + previousFilter.length > filterValue.length || + (previousFilter.length == filterValue.length && previousFilter != filterValue) + ) + ) { + magicSearch.resetSearchCache() + } + previousFilter = filterValue + + Log.i( + "[Contacts] Asking Magic search for contacts matching filter [$filterValue], domain [$domain] and in sources [$sources]" + ) + magicSearch.getContactsListAsync( + filterValue, + domain, + sources, + aggregation + ) + } + + abstract fun processMagicSearchResults(results: Array) } diff --git a/app/src/main/java/org/linphone/ui/contacts/ContactsFragment.kt b/app/src/main/java/org/linphone/ui/contacts/ContactsFragment.kt index 5ad1c56cb..b9c091ab8 100644 --- a/app/src/main/java/org/linphone/ui/contacts/ContactsFragment.kt +++ b/app/src/main/java/org/linphone/ui/contacts/ContactsFragment.kt @@ -26,15 +26,20 @@ import android.view.View import android.view.ViewGroup import android.view.animation.Animation import android.view.animation.AnimationUtils +import androidx.core.view.doOnPreDraw import androidx.fragment.app.Fragment import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.navigation.navGraphViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.slidingpanelayout.widget.SlidingPaneLayout import androidx.transition.AutoTransition import org.linphone.R import org.linphone.databinding.ContactsFragmentBinding import org.linphone.ui.MainActivity +import org.linphone.ui.contacts.adapter.ContactsListAdapter import org.linphone.ui.contacts.viewmodel.ContactsListViewModel +import org.linphone.utils.SlidingPaneBackPressedCallback import org.linphone.utils.hideKeyboard import org.linphone.utils.setKeyboardInsetListener import org.linphone.utils.showKeyboard @@ -44,6 +49,7 @@ class ContactsFragment : Fragment() { private val listViewModel: ContactsListViewModel by navGraphViewModels( R.id.contactsFragment ) + private lateinit var adapter: ContactsListAdapter override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { if (findNavController().currentDestination?.id == R.id.newContactFragment) { @@ -66,6 +72,8 @@ class ContactsFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + postponeEnterTransition() + binding.lifecycleOwner = viewLifecycleOwner binding.viewModel = listViewModel @@ -74,7 +82,48 @@ class ContactsFragment : Fragment() { listViewModel.bottomNavBarVisible.value = !portraitOrientation || !keyboardVisible } - // postponeEnterTransition() + binding.root.doOnPreDraw { + val slidingPane = binding.slidingPaneLayout + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + SlidingPaneBackPressedCallback(slidingPane) + ) + slidingPane.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED + } + + adapter = ContactsListAdapter(viewLifecycleOwner) + binding.contactsList.setHasFixedSize(true) + binding.contactsList.adapter = adapter + + val layoutManager = LinearLayoutManager(requireContext()) + binding.contactsList.layoutManager = layoutManager + + listViewModel.contactsList.observe( + viewLifecycleOwner + ) { + adapter.submitList(it) + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + + listViewModel.searchFilter.observe( + viewLifecycleOwner + ) { + listViewModel.applyFilter() + } + + listViewModel.focusSearchBarEvent.observe(viewLifecycleOwner) { + it.consume { show -> + if (show) { + // To automatically open keyboard + binding.topBar.search.showKeyboard(requireActivity().window) + } else { + binding.topBar.search.hideKeyboard() + } + } + } binding.setOnNewContactClicked { if (findNavController().currentDestination?.id == R.id.contactsFragment) { @@ -97,20 +146,5 @@ class ContactsFragment : Fragment() { binding.setOnAvatarClickListener { (requireActivity() as MainActivity).toggleDrawerMenu() } - - listViewModel.focusSearchBarEvent.observe(viewLifecycleOwner) { - it.consume { show -> - if (show) { - // To automatically open keyboard - binding.topBar.search.showKeyboard(requireActivity().window) - } else { - binding.topBar.search.hideKeyboard() - } - } - } - - /*(view.parent as? ViewGroup)?.doOnPreDraw { - startPostponedEnterTransition() - }*/ } } diff --git a/app/src/main/java/org/linphone/ui/contacts/adapter/ConversationsListAdapter.kt b/app/src/main/java/org/linphone/ui/contacts/adapter/ConversationsListAdapter.kt new file mode 100644 index 000000000..254560874 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/contacts/adapter/ConversationsListAdapter.kt @@ -0,0 +1,63 @@ +package org.linphone.ui.contacts.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.linphone.R +import org.linphone.databinding.ContactListCellBinding +import org.linphone.ui.contacts.model.ContactModel + +class ContactsListAdapter( + private val viewLifecycleOwner: LifecycleOwner +) : ListAdapter(ContactDiffCallback()) { + var selectedAdapterPosition = -1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding: ContactListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.contact_list_cell, + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as ViewHolder).bind(getItem(position)) + } + + fun resetSelection() { + notifyItemChanged(selectedAdapterPosition) + selectedAdapterPosition = -1 + } + + inner class ViewHolder( + val binding: ContactListCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(contactModel: ContactModel) { + with(binding) { + model = contactModel + + lifecycleOwner = viewLifecycleOwner + + binding.root.isSelected = bindingAdapterPosition == selectedAdapterPosition + + executePendingBindings() + } + } + } +} + +private class ContactDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ContactModel, newItem: ContactModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ContactModel, newItem: ContactModel): Boolean { + return true + } +} diff --git a/app/src/main/java/org/linphone/ui/contacts/model/ContactModel.kt b/app/src/main/java/org/linphone/ui/contacts/model/ContactModel.kt new file mode 100644 index 000000000..91ff27acd --- /dev/null +++ b/app/src/main/java/org/linphone/ui/contacts/model/ContactModel.kt @@ -0,0 +1,73 @@ +/* + * 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.contacts.model + +import android.content.ContentUris +import android.net.Uri +import android.provider.ContactsContract +import androidx.lifecycle.MutableLiveData +import org.linphone.core.ConsolidatedPresence +import org.linphone.core.Friend +import org.linphone.core.FriendListenerStub + +class ContactModel(val friend: Friend) { + val id = friend.refKey + + val presenceStatus = MutableLiveData() + + val name = MutableLiveData() + + val avatar = getAvatarUri() + + private val friendListener = object : FriendListenerStub() { + override fun onPresenceReceived(fr: Friend) { + presenceStatus.postValue(fr.consolidatedPresence) + } + } + + init { + name.postValue(friend.name) + presenceStatus.postValue(friend.consolidatedPresence) + + friend.addListener(friendListener) + + presenceStatus.postValue(ConsolidatedPresence.Offline) + } + + fun destroy() { + friend.removeListener(friendListener) + } + + private fun getAvatarUri(): Uri? { + val refKey = friend.refKey + if (refKey != null) { + val lookupUri = ContentUris.withAppendedId( + ContactsContract.Contacts.CONTENT_URI, + refKey.toLong() + ) + return Uri.withAppendedPath( + lookupUri, + ContactsContract.Contacts.Photo.CONTENT_DIRECTORY + ) + } + + return null + } +} diff --git a/app/src/main/java/org/linphone/ui/contacts/viewmodel/ContactsListViewModel.kt b/app/src/main/java/org/linphone/ui/contacts/viewmodel/ContactsListViewModel.kt index cceba449d..ea9865c10 100644 --- a/app/src/main/java/org/linphone/ui/contacts/viewmodel/ContactsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/contacts/viewmodel/ContactsListViewModel.kt @@ -20,13 +20,102 @@ package org.linphone.ui.contacts.viewmodel import androidx.lifecycle.MutableLiveData +import java.util.ArrayList +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.contacts.ContactsListener +import org.linphone.core.Friend +import org.linphone.core.MagicSearch +import org.linphone.core.SearchResult +import org.linphone.core.tools.Log import org.linphone.ui.TopBarViewModel +import org.linphone.ui.contacts.model.ContactModel class ContactsListViewModel : TopBarViewModel() { val bottomNavBarVisible = MutableLiveData() + val contactsList = MutableLiveData>() + + private val contactsListener = object : ContactsListener { + override fun onContactsLoaded() { + // Core thread + applyFilter() + } + } + init { title.value = "Contacts" bottomNavBarVisible.value = true + coreContext.postOnCoreThread { + coreContext.contactsManager.addListener(contactsListener) + } + applyFilter() + } + + override fun onCleared() { + coreContext.postOnCoreThread { + coreContext.contactsManager.removeListener(contactsListener) + } + super.onCleared() + } + + override fun processMagicSearchResults(results: Array) { + // Core thread + Log.i("[Contacts List] Processing ${results.size} results") + contactsList.value.orEmpty().forEach(ContactModel::destroy) + + val list = arrayListOf() + + for (result in results) { + val friend = result.friend + + val viewModel = if (friend != null) { + ContactModel(friend) + } else { + Log.w("[Contacts] SearchResult [$result] has no Friend!") + val fakeFriend = + createFriendFromSearchResult(result) + ContactModel(fakeFriend) + } + + list.add(viewModel) + } + + contactsList.postValue(list) + + Log.i("[Contacts] Processed ${results.size} results") + } + + fun applyFilter() { + coreContext.postOnCoreThread { + applyFilter( + "", + MagicSearch.Source.Friends.toInt(), + MagicSearch.Aggregation.Friend + ) + } + } + + 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/conversations/viewmodel/ConversationsListViewModel.kt b/app/src/main/java/org/linphone/ui/conversations/viewmodel/ConversationsListViewModel.kt index 994bce3a6..da6535e15 100644 --- a/app/src/main/java/org/linphone/ui/conversations/viewmodel/ConversationsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/conversations/viewmodel/ConversationsListViewModel.kt @@ -27,6 +27,7 @@ import org.linphone.core.ChatMessage import org.linphone.core.ChatRoom import org.linphone.core.Core import org.linphone.core.CoreListenerStub +import org.linphone.core.SearchResult import org.linphone.core.tools.Log import org.linphone.ui.TopBarViewModel import org.linphone.ui.conversations.data.ChatRoomData @@ -113,6 +114,10 @@ class ConversationsListViewModel : TopBarViewModel() { super.onCleared() } + override fun processMagicSearchResults(results: Array) { + TODO("Not yet implemented") + } + private fun addChatRoomToList(chatRoom: ChatRoom) { val index = findChatRoomIndex(chatRoom) if (index != -1) { diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 3b906161f..dd3bc6209 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -32,22 +32,22 @@ import androidx.core.view.doOnLayout import androidx.databinding.BindingAdapter import coil.load import coil.transform.CircleCropTransformation -import com.google.android.material.textfield.TextInputLayout import org.linphone.R import org.linphone.contacts.ContactData +import org.linphone.ui.contacts.model.ContactModel /** * This file contains all the data binding necessary for the app */ -fun TextInputLayout.showKeyboard(window: Window) { +fun View.showKeyboard(window: Window) { this.requestFocus() /*val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)*/ WindowCompat.getInsetsController(window, this).show(WindowInsetsCompat.Type.ime()) } -fun TextInputLayout.hideKeyboard() { +fun View.hideKeyboard() { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(this.windowToken, 0) } @@ -93,7 +93,19 @@ fun AppCompatTextView.setDrawableTint(color: Int) { } @BindingAdapter("coilContact") -fun loadContactPictureWithCoil(imageView: ImageView, contact: ContactData?) { +fun loadContactPictureWithCoil2(imageView: ImageView, contact: ContactData?) { + if (contact == null) { + imageView.load(R.drawable.contact_avatar) + } else { + imageView.load(contact.avatar) { + transformations(CircleCropTransformation()) + error(R.drawable.contact_avatar) + } + } +} + +@BindingAdapter("contactAvatar") +fun loadContactPictureWithCoil(imageView: ImageView, contact: ContactModel?) { if (contact == null) { imageView.load(R.drawable.contact_avatar) } else { diff --git a/app/src/main/java/org/linphone/utils/SlidingPaneBackPressCallback.kt b/app/src/main/java/org/linphone/utils/SlidingPaneBackPressCallback.kt new file mode 100644 index 000000000..87cb0c6d4 --- /dev/null +++ b/app/src/main/java/org/linphone/utils/SlidingPaneBackPressCallback.kt @@ -0,0 +1,57 @@ +/* + * 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.utils + +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.slidingpanelayout.widget.SlidingPaneLayout +import org.linphone.core.tools.Log + +class SlidingPaneBackPressedCallback(private val slidingPaneLayout: SlidingPaneLayout) : + OnBackPressedCallback( + slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen + ), + SlidingPaneLayout.PanelSlideListener { + + init { + Log.d( + "[Master Fragment] SlidingPane isSlideable = ${slidingPaneLayout.isSlideable}, isOpen = ${slidingPaneLayout.isOpen}" + ) + slidingPaneLayout.addPanelSlideListener(this) + } + + override fun handleOnBackPressed() { + Log.d("[Master Fragment] handleOnBackPressed, closing sliding pane") + slidingPaneLayout.hideKeyboard() + slidingPaneLayout.closePane() + } + + override fun onPanelOpened(panel: View) { + Log.d("[Master Fragment] onPanelOpened") + isEnabled = true + } + + override fun onPanelClosed(panel: View) { + Log.d("[Master Fragment] onPanelClosed") + isEnabled = false + } + + override fun onPanelSlide(panel: View, slideOffset: Float) { } +} diff --git a/app/src/main/res/drawable/conversation_cell_background.xml b/app/src/main/res/drawable/cell_background.xml similarity index 100% rename from app/src/main/res/drawable/conversation_cell_background.xml rename to app/src/main/res/drawable/cell_background.xml diff --git a/app/src/main/res/layout/chat_room_list_cell.xml b/app/src/main/res/layout/chat_room_list_cell.xml index aea3327f8..b34e47ee2 100644 --- a/app/src/main/res/layout/chat_room_list_cell.xml +++ b/app/src/main/res/layout/chat_room_list_cell.xml @@ -18,7 +18,7 @@ android:onLongClick="@{() -> data.onLongClicked()}" android:layout_marginTop="5dp" android:layout_marginBottom="5dp" - android:background="@drawable/conversation_cell_background"> + android:background="@drawable/cell_background"> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/contacts_fragment.xml b/app/src/main/res/layout/contacts_fragment.xml index 3fdf18a04..fd63d7d11 100644 --- a/app/src/main/res/layout/contacts_fragment.xml +++ b/app/src/main/res/layout/contacts_fragment.xml @@ -29,6 +29,12 @@ android:layout_height="match_parent" android:background="@color/primary_color"> + +