diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a285c245..4ec704c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Group changes to describe their impact on the project, as follows: ### Changed - No longer follow TelecomManager audio endpoint during calls, using our own routing policy +- Show matching contacts & suggestions when filtering call history list & conversations list, allowing to quickly call someone without opening the start call/conversation fragment - Join a conference using default layout instead of audio only when clicking on a meeting SIP URI - Removing an account will also remove all related data in the local database (auth info, call logs, conversations, meetings, etc...) - Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt index 42b713cea..dd09124da 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt @@ -19,7 +19,9 @@ */ package org.linphone.ui.main.chat.adapter +import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread import androidx.core.view.doOnPreDraw @@ -30,14 +32,30 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.linphone.R +import org.linphone.core.Address +import org.linphone.core.Friend import org.linphone.databinding.ChatListCellBinding +import org.linphone.databinding.ChatListContactSuggestionCellBinding +import org.linphone.databinding.GenericAddressPickerListDecorationBinding import org.linphone.ui.main.chat.model.ConversationModel +import org.linphone.ui.main.chat.model.ConversationModelWrapper +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel +import org.linphone.utils.AppUtils import org.linphone.utils.Event +import org.linphone.utils.HeaderAdapter import org.linphone.utils.startAnimatedDrawable -class ConversationsListAdapter : ListAdapter( +class ConversationsListAdapter : + ListAdapter( ChatRoomDiffCallback() -) { +), + HeaderAdapter { + companion object { + private const val CONVERSATION_TYPE = 0 + private const val CONTACT_TYPE = 1 + private const val SUGGESTION_TYPE = 2 + } + var selectedAdapterPosition = -1 val conversationClickedEvent: MutableLiveData> by lazy { @@ -48,33 +66,112 @@ class ConversationsListAdapter : ListAdapter> by lazy { + MutableLiveData() + } + + val createConversationWithAddressClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + override fun displayHeaderForPosition(position: Int): Boolean { + // Don't show header for call history section + if (position == 0 && getItemViewType(0) == CONVERSATION_TYPE) { + return false + } + + return getItemViewType(position) != getItemViewType(position - 1) + } + + override fun getHeaderViewForPosition( + context: Context, + position: Int + ): View { + val binding = GenericAddressPickerListDecorationBinding.inflate( + LayoutInflater.from(context) ) - val viewHolder = ViewHolder(binding) - binding.apply { - lifecycleOwner = parent.findViewTreeLifecycleOwner() - - setOnClickListener { - conversationClickedEvent.value = Event(model!!) + binding.header.text = when (getItemViewType(position)) { + SUGGESTION_TYPE -> { + AppUtils.getString(R.string.generic_address_picker_suggestions_list_title) } - - setOnLongClickListener { - selectedAdapterPosition = viewHolder.bindingAdapterPosition - root.isSelected = true - conversationLongClickedEvent.value = Event(model!!) - true + else -> { + AppUtils.getString(R.string.generic_address_picker_contacts_list_title) + } + } + return binding.root + } + + override fun getItemViewType(position: Int): Int { + try { + val model = getItem(position) + return if (model.isConversation) { + CONVERSATION_TYPE + } else if (model.contactModel?.friend != null) { + CONTACT_TYPE + } else { + SUGGESTION_TYPE + } + } catch (ioobe: IndexOutOfBoundsException) { + + } + return CONVERSATION_TYPE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + CONVERSATION_TYPE -> { + val binding: ChatListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_list_cell, + parent, + false + ) + val viewHolder = ConversationViewHolder(binding) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnClickListener { + conversationClickedEvent.value = Event(model!!) + } + + setOnLongClickListener { + selectedAdapterPosition = viewHolder.bindingAdapterPosition + root.isSelected = true + conversationLongClickedEvent.value = Event(model!!) + true + } + } + viewHolder + } + else -> { + val binding: ChatListContactSuggestionCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_list_contact_suggestion_cell, + parent, + false + ) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnCreateConversationClickListener { + val friend = model?.friend + if (friend != null) { + createConversationWithFriendClickedEvent.value = Event(friend) + } else { + createConversationWithAddressClickedEvent.value = Event(model!!.address) + } + } + } + ContactSuggestionViewHolder(binding) } } - return viewHolder } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - (holder as ViewHolder).bind(getItem(position)) + when (getItemViewType(position)) { + CONVERSATION_TYPE -> (holder as ConversationViewHolder).bind(getItem(position).conversationModel!!) + else -> (holder as ContactSuggestionViewHolder).bind(getItem(position).contactModel!!) + } } fun resetSelection() { @@ -82,7 +179,7 @@ class ConversationsListAdapter : ListAdapter() { - override fun areItemsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { - return oldItem.id == newItem.id && oldItem.lastUpdateTime == newItem.lastUpdateTime + class ContactSuggestionViewHolder( + val binding: ChatListContactSuggestionCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel + + executePendingBindings() + } + } + } + + private class ChatRoomDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ConversationModelWrapper, newItem: ConversationModelWrapper): Boolean { + if (oldItem.isConversation && newItem.isConversation) { + return oldItem.conversationModel?.id == newItem.conversationModel?.id && oldItem.conversationModel?.lastUpdateTime == newItem.conversationModel?.lastUpdateTime + } else if (oldItem.isContactOrSuggestion && newItem.isContactOrSuggestion) { + return oldItem.contactModel?.id == newItem.contactModel?.id + } + return false } - override fun areContentsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { - return oldItem.avatarModel.value?.id == newItem.avatarModel.value?.id + override fun areContentsTheSame(oldItem: ConversationModelWrapper, newItem: ConversationModelWrapper): Boolean { + if (oldItem.isConversation && newItem.isConversation) { + return newItem.conversationModel?.avatarModel?.value?.id == oldItem.conversationModel?.avatarModel?.value?.id + } + return false } } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt index da81cda6e..4a7733e4e 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt @@ -33,7 +33,9 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialogFragment +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.ChatListFragmentBinding import org.linphone.ui.fileviewer.FileViewerActivity @@ -41,10 +43,15 @@ import org.linphone.ui.fileviewer.MediaViewerActivity import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID import org.linphone.ui.main.chat.adapter.ConversationsListAdapter import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel +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.AbstractMainFragment import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment +import org.linphone.utils.DialogUtils import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import org.linphone.utils.RecyclerViewHeaderDecoration @UiThread class ConversationsListFragment : AbstractMainFragment() { @@ -60,6 +67,21 @@ class ConversationsListFragment : AbstractMainFragment() { private var bottomSheetDialog: BottomSheetDialogFragment? = null + private val numberOrAddressClickListener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + coreContext.postOnCoreThread { + val address = model.address + if (address != null) { + Log.i("$TAG Creating 1-1 conversation with to [${address.asStringUriOnly()}]") + listViewModel.createOneToOneChatRoomWith(address) + } + } + } + + override fun onLongPress(model: ContactNumberOrAddressModel) { } + } + private val dataObserver = object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { Log.i("$TAG [$itemCount] added, scrolling to top") @@ -121,6 +143,9 @@ class ConversationsListFragment : AbstractMainFragment() { binding.conversationsList.outlineProvider = outlineProvider binding.conversationsList.clipToOutline = true + val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter) + binding.conversationsList.addItemDecoration(headerItemDecoration) + adapter.conversationLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> val modalBottomSheet = ConversationDialogFragment( @@ -165,6 +190,35 @@ class ConversationsListFragment : AbstractMainFragment() { } } + adapter.createConversationWithFriendClickedEvent.observe(viewLifecycleOwner) { + it.consume { friend -> + coreContext.postOnCoreThread { + val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) + if (singleAvailableAddress != null) { + Log.i( + "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" + ) + listViewModel.createOneToOneChatRoomWith(singleAvailableAddress) + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + coreContext.postOnMainThread { + showNumbersOrAddressesDialog(list) + } + } + } + } + } + + adapter.createConversationWithAddressClickedEvent.observe(viewLifecycleOwner) { + it.consume { address -> + Log.i("$TAG Creating 1-1 conversation with to [${address.asStringUriOnly()}]") + listViewModel.createOneToOneChatRoomWith(address) + } + } + binding.setOnNewConversationClicked { if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { Log.i("$TAG Navigating to start conversation fragment") @@ -187,6 +241,14 @@ class ConversationsListFragment : AbstractMainFragment() { listViewModel.fetchInProgress.value = false } + listViewModel.chatRoomCreatedEvent.observe(viewLifecycleOwner) { + it.consume { conversationId -> + Log.i("$TAG Conversation [$conversationId] has been created, navigating to it") + val action = ConversationFragmentDirections.actionGlobalConversationFragment(conversationId) + binding.chatNavContainer.findNavController().navigate(action) + } + } + sharedViewModel.showConversationEvent.observe(viewLifecycleOwner) { it.consume { conversationId -> Log.i("$TAG Navigating to conversation fragment with ID [$conversationId]") @@ -251,10 +313,10 @@ class ConversationsListFragment : AbstractMainFragment() { sharedViewModel.updateConversationLastMessageEvent.observe(viewLifecycleOwner) { it.consume { conversationId -> - val model = listViewModel.conversations.value.orEmpty().find { conversationModel -> - conversationModel.id == conversationId + val model = listViewModel.conversations.value.orEmpty().find { wrapperModel -> + wrapperModel.conversationModel?.id == conversationId } - model?.updateLastMessageInfo() + model?.conversationModel?.updateLastMessageInfo() } } @@ -262,10 +324,10 @@ class ConversationsListFragment : AbstractMainFragment() { it.consume { val displayChatRoom = sharedViewModel.displayedChatRoom if (displayChatRoom != null) { - val found = listViewModel.conversations.value.orEmpty().find { model -> - model.chatRoom == displayChatRoom + val found = listViewModel.conversations.value.orEmpty().find { wrapperModel -> + wrapperModel.conversationModel?.chatRoom == displayChatRoom } - found?.updateMuteState() + found?.conversationModel?.updateMuteState() } } } @@ -277,9 +339,9 @@ class ConversationsListFragment : AbstractMainFragment() { val displayChatRoom = sharedViewModel.displayedChatRoom if (displayChatRoom != null) { val found = listViewModel.conversations.value.orEmpty().find { model -> - model.chatRoom == displayChatRoom + model.conversationModel?.chatRoom == displayChatRoom } - found?.updateUnreadCount() + found?.conversationModel?.updateUnreadCount() } listViewModel.updateUnreadMessagesCount() } @@ -343,4 +405,21 @@ class ConversationsListFragment : AbstractMainFragment() { Log.e("$TAG Failed to unregister data observer to adapter: $e") } } + + private fun showNumbersOrAddressesDialog(list: List) { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel + ) + + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModelWrapper.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModelWrapper.kt new file mode 100644 index 000000000..2739f80ab --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModelWrapper.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2026 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.chat.model + +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel + +class ConversationModelWrapper(val conversationModel: ConversationModel?, val contactModel: ConversationContactOrSuggestionModel? = null) { + val isConversation = conversationModel != null + + val isContactOrSuggestion = contactModel != null + + fun destroy() { + conversationModel?.destroy() + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt index 72bf6f349..b1843af65 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt @@ -23,17 +23,31 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.contacts.ContactsManager +import org.linphone.core.Address import org.linphone.core.ChatMessage import org.linphone.core.ChatRoom +import org.linphone.core.ChatRoomListenerStub +import org.linphone.core.Conference import org.linphone.core.Core import org.linphone.core.CoreListenerStub 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.chat.model.ConversationModel +import org.linphone.ui.main.chat.model.ConversationModelWrapper +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.ui.main.viewmodel.AbstractMainViewModel +import org.linphone.utils.AppUtils +import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import java.text.Collator +import java.util.Locale class ConversationsListViewModel @UiThread @@ -42,10 +56,51 @@ class ConversationsListViewModel private const val TAG = "[Conversations List ViewModel]" } - val conversations = MutableLiveData>() + val conversations = MutableLiveData>() val fetchInProgress = MutableLiveData() + val chatRoomCreatedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + private val tempConversationsList = ArrayList() + + private val magicSearch = coreContext.core.createMagicSearch() + + private val magicSearchListener = object : MagicSearchListenerStub() { + @WorkerThread + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + Log.i("$TAG Magic search contacts available") + val results = magicSearch.lastSearch + processMagicSearchResults(results) + fetchInProgress.postValue(false) + } + } + + private val chatRoomListener = object : ChatRoomListenerStub() { + @WorkerThread + override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) { + val state = chatRoom.state + if (state == ChatRoom.State.Instantiated) return + + val id = LinphoneUtils.getConversationId(chatRoom) + Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]") + + if (state == ChatRoom.State.Created) { + Log.i("$TAG Conversation [$id] successfully created") + chatRoom.removeListener(this) + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + } else if (state == ChatRoom.State.CreationFailed) { + Log.e("$TAG Conversation [$id] creation has failed!") + chatRoom.removeListener(this) + fetchInProgress.postValue(false) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) + } + } + } + private val coreListener = object : CoreListenerStub() { @WorkerThread override fun onChatRoomStateChanged( @@ -68,7 +123,7 @@ class ConversationsListViewModel override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) { val id = LinphoneUtils.getConversationId(chatRoom) val found = conversations.value.orEmpty().find { - it.id == id + it.conversationModel?.id == id } if (found == null) { Log.i("$TAG Message sent for a conversation not yet in the list (probably was empty), adding it") @@ -87,7 +142,7 @@ class ConversationsListViewModel ) { val id = LinphoneUtils.getConversationId(chatRoom) val found = conversations.value.orEmpty().find { - it.id == id + it.conversationModel?.id == id } if (found == null) { Log.i("$TAG Message(s) received for a conversation not yet in the list (probably was empty), adding it") @@ -104,8 +159,10 @@ class ConversationsListViewModel override fun onContactsLoaded() { Log.i("$TAG Contacts have been (re)loaded, updating list") for (model in conversations.value.orEmpty()) { - model.computeParticipants() - model.updateLastMessage() + if (model.isConversation) { + model.conversationModel?.computeParticipants() + model.conversationModel?.updateLastMessage() + } } } @@ -119,6 +176,7 @@ class ConversationsListViewModel coreContext.postOnCoreThread { core -> coreContext.contactsManager.addListener(contactsListener) core.addListener(coreListener) + magicSearch.addListener(magicSearchListener) computeChatRoomsList(currentFilter) } @@ -129,9 +187,10 @@ class ConversationsListViewModel super.onCleared() coreContext.postOnCoreThread { core -> - conversations.value.orEmpty().forEach(ConversationModel::destroy) + conversations.value.orEmpty().forEach(ConversationModelWrapper::destroy) coreContext.contactsManager.removeListener(contactsListener) core.removeListener(coreListener) + magicSearch.removeListener(magicSearchListener) } } @@ -144,14 +203,23 @@ class ConversationsListViewModel @WorkerThread private fun computeChatRoomsList(filter: String) { - conversations.value.orEmpty().forEach(ConversationModel::destroy) + conversations.value.orEmpty().forEach(ConversationModelWrapper::destroy) if (conversations.value.orEmpty().isEmpty()) { fetchInProgress.postValue(true) } - val list = arrayListOf() - var count = 0 + val isFilterEmpty = filter.isEmpty() + if (!isFilterEmpty) { + magicSearch.getContactsListAsync( + filter, + corePreferences.contactsFilter, + MagicSearch.Source.All.toInt(), + MagicSearch.Aggregation.Friend + ) + } + + val list = arrayListOf() val account = LinphoneUtils.getDefaultAccount() val chatRooms = if (filter.isEmpty()) { @@ -161,15 +229,16 @@ class ConversationsListViewModel } for (chatRoom in chatRooms.orEmpty()) { val model = ConversationModel(chatRoom) - list.add(model) - count += 1 - - if (count == 15) { - conversations.postValue(list) - } + list.add(ConversationModelWrapper(model)) } - conversations.postValue(list) + if (isFilterEmpty) { + conversations.postValue(list) + } else { + fetchInProgress.postValue(true) + tempConversationsList.clear() + tempConversationsList.addAll(list) + } } @WorkerThread @@ -193,7 +262,7 @@ class ConversationsListViewModel val currentList = conversations.value.orEmpty() val found = currentList.find { - it.chatRoom.identifier == identifier + it.conversationModel?.chatRoom?.identifier == identifier } if (found != null) { Log.w("$TAG Created chat room with identifier [$identifier] is already in the list, skipping") @@ -208,9 +277,9 @@ class ConversationsListViewModel if (found == null) return } - val newList = arrayListOf() + val newList = arrayListOf() val model = ConversationModel(chatRoom) - newList.add(model) + newList.add(ConversationModelWrapper(model)) newList.addAll(currentList) Log.i("$TAG Adding chat room with identifier [$identifier] to list") conversations.postValue(newList) @@ -221,10 +290,10 @@ class ConversationsListViewModel val currentList = conversations.value.orEmpty() val identifier = chatRoom.identifier val found = currentList.find { - it.chatRoom.identifier == identifier + it.conversationModel?.chatRoom?.identifier == identifier } if (found != null) { - val newList = arrayListOf() + val newList = arrayListOf() newList.addAll(currentList) newList.remove(found) found.destroy() @@ -241,12 +310,187 @@ class ConversationsListViewModel @WorkerThread private fun reorderChatRooms() { + if (currentFilter.isNotEmpty()) { + Log.w("$TAG List filter isn't empty, do not re-order list") + return + } + Log.i("$TAG Re-ordering conversations") - val sortedList = arrayListOf() + val sortedList = arrayListOf() sortedList.addAll(conversations.value.orEmpty()) sortedList.sortByDescending { - it.chatRoom.lastUpdateTime + it.conversationModel?.chatRoom?.lastUpdateTime } conversations.postValue(sortedList) } + + @WorkerThread + private fun processMagicSearchResults(results: Array) { + Log.i("$TAG Processing [${results.size}] results") + + val contactsList = arrayListOf() + val suggestionsList = arrayListOf() + val requestList = arrayListOf() + + val defaultAccountDomain = LinphoneUtils.getDefaultAccount()?.params?.domain + for (result in results) { + val address = result.address + val friend = result.friend + if (friend != null) { + val found = contactsList.find { it.contactModel?.friend == friend } + if (found != null) continue + + val mainAddress = address ?: LinphoneUtils.getFirstAvailableAddressForFriend(friend) + if (mainAddress != null) { + val model = ConversationContactOrSuggestionModel(mainAddress, friend = friend) + val avatarModel = coreContext.contactsManager.getContactAvatarModelForFriend( + friend + ) + model.avatarModel.postValue(avatarModel) + contactsList.add(ConversationModelWrapper(null, model)) + } else { + Log.w("$TAG Found friend [${friend.name}] in search results but no Address could be found, skipping it") + } + } else if (address != null) { + if (result.sourceFlags == MagicSearch.Source.Request.toInt()) { + val model = ConversationContactOrSuggestionModel(address) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + requestList.add(ConversationModelWrapper(null, model)) + continue + } + + val defaultAccountAddress = coreContext.core.defaultAccount?.params?.identityAddress + if (defaultAccountAddress != null && address.weakEqual(defaultAccountAddress)) { + Log.i("$TAG Removing from suggestions current default account address") + continue + } + + val model = ConversationContactOrSuggestionModel(address, defaultAccountDomain = defaultAccountDomain) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + suggestionsList.add(ConversationModelWrapper(null, model)) + } + } + + val collator = Collator.getInstance(Locale.getDefault()) + contactsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + suggestionsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + + val list = arrayListOf() + list.addAll(tempConversationsList) + list.addAll(contactsList) + list.addAll(suggestionsList) + list.addAll(requestList) + conversations.postValue(list) + Log.i( + "$TAG Processed [${results.size}] results: [${contactsList.size}] contacts and [${suggestionsList.size}] suggestions" + ) + } + + @WorkerThread + private fun getContactAvatarModelForAddress(address: Address): ContactAvatarModel { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = LinphoneUtils.getDisplayName(address) + fakeFriend.address = address + return ContactAvatarModel(fakeFriend) + } + + @WorkerThread + fun createOneToOneChatRoomWith(remote: Address) { + val core = coreContext.core + val account = core.defaultAccount + if (account == null) { + Log.e( + "$TAG No default account found, can't create conversation with [${remote.asStringUriOnly()}]!" + ) + return + } + + fetchInProgress.postValue(true) + + val params = coreContext.core.createConferenceParams(null) + params.isChatEnabled = true + params.isGroupEnabled = false + params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject) + params.account = account + + val chatParams = params.chatParams ?: return + chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default + + val sameDomain = remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain + if (account.params.instantMessagingEncryptionMandatory && sameDomain) { + Log.i("$TAG Account is in secure mode & domain matches, creating an E2E encrypted conversation") + chatParams.backend = ChatRoom.Backend.FlexisipChat + params.securityLevel = Conference.SecurityLevel.EndToEnd + } else if (!account.params.instantMessagingEncryptionMandatory) { + if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) { + Log.i( + "$TAG Account is in interop mode but LIME is available, creating an E2E encrypted conversation" + ) + chatParams.backend = ChatRoom.Backend.FlexisipChat + params.securityLevel = Conference.SecurityLevel.EndToEnd + } else { + Log.i( + "$TAG Account is in interop mode but LIME isn't available, creating a SIP simple conversation" + ) + chatParams.backend = ChatRoom.Backend.Basic + params.securityLevel = Conference.SecurityLevel.None + } + } else { + Log.e( + "$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]" + ) + fetchInProgress.postValue(false) + showRedToast(R.string.conversation_invalid_participant_due_to_security_mode_toast, R.drawable.warning_circle) + return + } + + val participants = arrayOf(remote) + val localAddress = account.params.identityAddress + val existingChatRoom = core.searchChatRoom(params, localAddress, null, participants) + if (existingChatRoom == null) { + Log.i( + "$TAG No existing 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] was found for given parameters, let's create it" + ) + val chatRoom = core.createChatRoom(params, participants) + if (chatRoom != null) { + if (chatParams.backend == ChatRoom.Backend.FlexisipChat) { + val state = chatRoom.state + if (state == ChatRoom.State.Created) { + val id = LinphoneUtils.getConversationId(chatRoom) + Log.i("$TAG 1-1 conversation [$id] has been created") + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + } else { + Log.i("$TAG Conversation isn't in Created state yet (state is [$state]), wait for it") + chatRoom.addListener(chatRoomListener) + } + } else { + val id = LinphoneUtils.getConversationId(chatRoom) + Log.i("$TAG Conversation successfully created [$id]") + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + } + } else { + Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!") + fetchInProgress.postValue(false) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) + } + } else { + Log.w( + "$TAG A 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] for given parameters already exists!" + ) + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(existingChatRoom))) + } + } } 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 3f23c1552..18a9631fe 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 @@ -153,6 +153,7 @@ class ContactsListViewModel showFavourites.value = corePreferences.showFavoriteContacts showFilter.value = !corePreferences.hidePhoneNumbers && !corePreferences.hideSipAddresses disableAddContact.value = corePreferences.disableAddContact + isListFiltered.value = false coreContext.postOnCoreThread { core -> domainFilter = corePreferences.contactsFilter @@ -169,9 +170,7 @@ class ContactsListViewModel favouritesMagicSearch.limitedSearch = false favouritesMagicSearch.addListener(favouritesMagicSearchListener) - coreContext.postOnMainThread { - applyFilter(currentFilter) - } + applyFilter(currentFilter, domainFilter) } } @@ -353,7 +352,6 @@ class ContactsListViewModel Log.i("$TAG Processing [${results.size}] results, favourites is [$favourites]") val list = arrayListOf() - var count = 0 val collator = Collator.getInstance(Locale.getDefault()) val hideEmptyContacts = corePreferences.hideContactsWithoutPhoneNumberOrSipAddress @@ -384,19 +382,10 @@ class ContactsListViewModel coreContext.contactsManager.getContactAvatarModelForAddress(result.address) } model.refreshSortingName() - - list.add(model) - count += 1 - val starred = friend?.starred == true model.isFavourite.postValue(starred) - if (!favourites && firstLoad && count == 20) { - list.sortWith { model1, model2 -> - collator.compare(model1.getNameToUseForSorting(), model2.getNameToUseForSorting()) - } - contactsList.postValue(list) - } + list.add(model) } list.sortWith { model1, model2 -> diff --git a/app/src/main/java/org/linphone/ui/main/history/adapter/HistoryListAdapter.kt b/app/src/main/java/org/linphone/ui/main/history/adapter/HistoryListAdapter.kt index b42282ab4..fcfb49e8a 100644 --- a/app/src/main/java/org/linphone/ui/main/history/adapter/HistoryListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/history/adapter/HistoryListAdapter.kt @@ -19,7 +19,9 @@ */ package org.linphone.ui.main.history.adapter +import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread import androidx.databinding.DataBindingUtil @@ -29,11 +31,27 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.linphone.R +import org.linphone.core.Address +import org.linphone.core.Friend +import org.linphone.databinding.GenericAddressPickerListDecorationBinding import org.linphone.databinding.HistoryListCellBinding +import org.linphone.databinding.HistoryListContactSuggestionCellBinding import org.linphone.ui.main.history.model.CallLogModel +import org.linphone.ui.main.history.model.CallLogModelWrapper +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel +import org.linphone.utils.AppUtils import org.linphone.utils.Event +import org.linphone.utils.HeaderAdapter + +class HistoryListAdapter : + ListAdapter(CallLogDiffCallback()), + HeaderAdapter { + companion object { + private const val CALL_LOG_TYPE = 0 + private const val CONTACT_TYPE = 1 + private const val SUGGESTION_TYPE = 2 + } -class HistoryListAdapter : ListAdapter(CallLogDiffCallback()) { var selectedAdapterPosition = -1 val callLogClickedEvent: MutableLiveData> by lazy { @@ -48,37 +66,116 @@ class HistoryListAdapter : ListAdapter(Ca MutableLiveData() } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val binding: HistoryListCellBinding = DataBindingUtil.inflate( - LayoutInflater.from(parent.context), - R.layout.history_list_cell, - parent, - false + val callFriendClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + val callAddressClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + override fun displayHeaderForPosition(position: Int): Boolean { + // Don't show header for call history section + if (position == 0 && getItemViewType(0) == CALL_LOG_TYPE) { + return false + } + + return getItemViewType(position) != getItemViewType(position - 1) + } + + override fun getHeaderViewForPosition( + context: Context, + position: Int + ): View { + val binding = GenericAddressPickerListDecorationBinding.inflate( + LayoutInflater.from(context) ) - val viewHolder = ViewHolder(binding) - binding.apply { - lifecycleOwner = parent.findViewTreeLifecycleOwner() - - setOnClickListener { - callLogClickedEvent.value = Event(model!!) + binding.header.text = when (getItemViewType(position)) { + SUGGESTION_TYPE -> { + AppUtils.getString(R.string.generic_address_picker_suggestions_list_title) } - - setOnLongClickListener { - selectedAdapterPosition = viewHolder.bindingAdapterPosition - root.isSelected = true - callLogLongClickedEvent.value = Event(model!!) - true - } - - setOnCallClickListener { - callLogCallBackClickedEvent.value = Event(model!!) + else -> { + AppUtils.getString(R.string.generic_address_picker_contacts_list_title) + } + } + return binding.root + } + + override fun getItemViewType(position: Int): Int { + try { + val model = getItem(position) + return if (model.isCallLog) { + CALL_LOG_TYPE + } else if (model.contactModel?.friend != null) { + CONTACT_TYPE + } else { + SUGGESTION_TYPE + } + } catch (ioobe: IndexOutOfBoundsException) { + + } + return CALL_LOG_TYPE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + CALL_LOG_TYPE -> { + val binding: HistoryListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.history_list_cell, + parent, + false + ) + val viewHolder = CallLogViewHolder(binding) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnClickListener { + callLogClickedEvent.value = Event(model!!) + } + + setOnLongClickListener { + selectedAdapterPosition = viewHolder.bindingAdapterPosition + root.isSelected = true + callLogLongClickedEvent.value = Event(model!!) + true + } + + setOnCallClickListener { + callLogCallBackClickedEvent.value = Event(model!!) + } + } + viewHolder + } + else -> { + val binding: HistoryListContactSuggestionCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.history_list_contact_suggestion_cell, + parent, + false + ) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnCallClickListener { + val friend = model?.friend + if (friend != null) { + callFriendClickedEvent.value = Event(friend) + } else { + callAddressClickedEvent.value = Event(model!!.address) + } + } + } + ContactSuggestionViewHolder(binding) } } - return viewHolder } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - (holder as ViewHolder).bind(getItem(position)) + when (getItemViewType(position)) { + CALL_LOG_TYPE -> (holder as CallLogViewHolder).bind(getItem(position).callLogModel!!) + else -> (holder as ContactSuggestionViewHolder).bind(getItem(position).contactModel!!) + } } fun resetSelection() { @@ -86,7 +183,7 @@ class HistoryListAdapter : ListAdapter(Ca selectedAdapterPosition = -1 } - inner class ViewHolder( + inner class CallLogViewHolder( val binding: HistoryListCellBinding ) : RecyclerView.ViewHolder(binding.root) { @UiThread @@ -101,13 +198,34 @@ class HistoryListAdapter : ListAdapter(Ca } } - private class CallLogDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: CallLogModel, newItem: CallLogModel): Boolean { - return oldItem.id == newItem.id && oldItem.timestamp == newItem.timestamp + class ContactSuggestionViewHolder( + val binding: HistoryListContactSuggestionCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel + + executePendingBindings() + } + } + } + + private class CallLogDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CallLogModelWrapper, newItem: CallLogModelWrapper): Boolean { + if (oldItem.isCallLog && newItem.isCallLog) { + return oldItem.callLogModel?.id == newItem.callLogModel?.id && oldItem.callLogModel?.timestamp == newItem.callLogModel?.timestamp + } else if (oldItem.isContactOrSuggestion && newItem.isContactOrSuggestion) { + return oldItem.contactModel?.id == newItem.contactModel?.id + } + return false } - override fun areContentsTheSame(oldItem: CallLogModel, newItem: CallLogModel): Boolean { - return newItem.avatarModel.compare(oldItem.avatarModel) + override fun areContentsTheSame(oldItem: CallLogModelWrapper, newItem: CallLogModelWrapper): Boolean { + if (oldItem.isCallLog && newItem.isCallLog) { + return newItem.callLogModel?.avatarModel?.compare(oldItem.callLogModel?.avatarModel) == true + } + return false } } } diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt index 4cb7b6d93..bd7b9f70a 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt @@ -33,9 +33,13 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetDialogFragment 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.HistoryListFragmentBinding import org.linphone.ui.GenericActivity +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.AbstractMainFragment import org.linphone.ui.main.history.adapter.HistoryListAdapter import org.linphone.utils.ConfirmationDialogModel @@ -43,6 +47,8 @@ import org.linphone.ui.main.history.viewmodel.HistoryListViewModel import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils +import org.linphone.utils.RecyclerViewHeaderDecoration @UiThread class HistoryListFragment : AbstractMainFragment() { @@ -58,6 +64,21 @@ class HistoryListFragment : AbstractMainFragment() { private var bottomSheetDialog: BottomSheetDialogFragment? = null + private val numberOrAddressClickListener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + coreContext.postOnCoreThread { + val address = model.address + if (address != null) { + Log.i("$TAG Starting call to [${address.asStringUriOnly()}]") + coreContext.startAudioCall(address) + } + } + } + + override fun onLongPress(model: ContactNumberOrAddressModel) { } + } + override fun onDefaultAccountChanged() { Log.i( "$TAG Default account changed, updating avatar in top bar & re-computing call logs" @@ -104,6 +125,9 @@ class HistoryListFragment : AbstractMainFragment() { binding.historyList.outlineProvider = outlineProvider binding.historyList.clipToOutline = true + val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter) + binding.historyList.addItemDecoration(headerItemDecoration) + adapter.callLogLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> val modalBottomSheet = HistoryMenuDialogFragment( @@ -142,7 +166,7 @@ class HistoryListFragment : AbstractMainFragment() { { // onDeleteCallLog Log.i("$TAG Deleting call log with ref key or call ID [${model.id}]") model.delete() - listViewModel.applyFilter() + listViewModel.filter() } ) modalBottomSheet.show(parentFragmentManager, HistoryMenuDialogFragment.TAG) @@ -182,6 +206,35 @@ class HistoryListFragment : AbstractMainFragment() { } } + adapter.callFriendClickedEvent.observe(viewLifecycleOwner) { + it.consume { friend -> + coreContext.postOnCoreThread { + val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) + if (singleAvailableAddress != null) { + Log.i( + "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" + ) + coreContext.startAudioCall(singleAvailableAddress) + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + coreContext.postOnMainThread { + showNumbersOrAddressesDialog(list) + } + } + } + } + } + + adapter.callAddressClickedEvent.observe(viewLifecycleOwner) { + it.consume { address -> + Log.i("$TAG Starting call to [${address.asStringUriOnly()}]") + coreContext.startAudioCall(address) + } + } + listViewModel.callLogs.observe(viewLifecycleOwner) { adapter.submitList(it) @@ -308,4 +361,21 @@ class HistoryListFragment : AbstractMainFragment() { dialog.show() } + + private fun showNumbersOrAddressesDialog(list: List) { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel + ) + + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/main/history/model/CallLogModelWrapper.kt b/app/src/main/java/org/linphone/ui/main/history/model/CallLogModelWrapper.kt new file mode 100644 index 000000000..76beaecff --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/history/model/CallLogModelWrapper.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 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.history.model + +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel + +class CallLogModelWrapper(val callLogModel: CallLogModel?, val contactModel: ConversationContactOrSuggestionModel? = null) { + val isCallLog = callLogModel != null + + val isContactOrSuggestion = contactModel != null +} diff --git a/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt b/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt index dc8151cab..9fea4f8a8 100644 --- a/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt @@ -23,17 +23,27 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.contacts.ContactsManager +import org.linphone.core.Address import org.linphone.core.CallLog import org.linphone.core.Core import org.linphone.core.CoreListenerStub import org.linphone.core.Friend import org.linphone.core.GlobalState +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 import org.linphone.ui.main.history.model.CallLogModel +import org.linphone.ui.main.history.model.CallLogModelWrapper +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.ui.main.viewmodel.AbstractMainViewModel import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import java.text.Collator +import java.util.Locale class HistoryListViewModel @UiThread @@ -42,7 +52,7 @@ class HistoryListViewModel private const val TAG = "[History List ViewModel]" } - val callLogs = MutableLiveData>() + val callLogs = MutableLiveData>() val fetchInProgress = MutableLiveData() @@ -54,6 +64,20 @@ class HistoryListViewModel MutableLiveData() } + private val tempCallLogsList = ArrayList() + + private val magicSearch = coreContext.core.createMagicSearch() + + private val magicSearchListener = object : MagicSearchListenerStub() { + @WorkerThread + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + Log.i("$TAG Magic search contacts available") + val results = magicSearch.lastSearch + processMagicSearchResults(results) + fetchInProgress.postValue(false) + } + } + private val coreListener = object : CoreListenerStub() { @WorkerThread override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) { @@ -88,6 +112,7 @@ class HistoryListViewModel coreContext.postOnCoreThread { core -> coreContext.contactsManager.addListener(contactsListener) core.addListener(coreListener) + magicSearch.addListener(magicSearchListener) computeCallLogsList(currentFilter) } @@ -100,6 +125,7 @@ class HistoryListViewModel coreContext.postOnCoreThread { core -> coreContext.contactsManager.removeListener(contactsListener) core.removeListener(coreListener) + magicSearch.removeListener(magicSearchListener) } } @@ -141,8 +167,17 @@ class HistoryListViewModel fetchInProgress.postValue(true) } - val list = arrayListOf() - var count = 0 + val isFilterEmpty = filter.isEmpty() + if (!isFilterEmpty) { + magicSearch.getContactsListAsync( + filter, + corePreferences.contactsFilter, + MagicSearch.Source.All.toInt(), + MagicSearch.Aggregation.Friend + ) + } + + val list = arrayListOf() val account = LinphoneUtils.getDefaultAccount() // Fetch all call logs if only one account to workaround no history issue @@ -156,17 +191,18 @@ class HistoryListViewModel for (callLog in logs) { val model = CallLogModel(callLog) if (isCallLogMatchingFilter(model, filter)) { - list.add(model) - count += 1 - } - - if (count == 20) { - callLogs.postValue(list) + list.add(CallLogModelWrapper(model)) } } Log.i("$TAG Fetched [${list.size}] call log(s)") - callLogs.postValue(list) + if (isFilterEmpty) { + callLogs.postValue(list) + } else { + fetchInProgress.postValue(true) + tempCallLogsList.clear() + tempCallLogsList.addAll(list) + } } @WorkerThread @@ -176,4 +212,84 @@ class HistoryListViewModel val friendName = model.avatarModel.friend.name ?: LinphoneUtils.getDisplayName(model.address) return friendName.contains(filter, ignoreCase = true) || model.address.asStringUriOnly().contains(filter, ignoreCase = true) } + + @WorkerThread + private fun processMagicSearchResults(results: Array) { + Log.i("$TAG Processing [${results.size}] results") + + val contactsList = arrayListOf() + val suggestionsList = arrayListOf() + val requestList = arrayListOf() + + val defaultAccountDomain = LinphoneUtils.getDefaultAccount()?.params?.domain + for (result in results) { + val address = result.address + val friend = result.friend + if (friend != null) { + val found = contactsList.find { it.contactModel?.friend == friend } + if (found != null) continue + + val mainAddress = address ?: LinphoneUtils.getFirstAvailableAddressForFriend(friend) + if (mainAddress != null) { + val model = ConversationContactOrSuggestionModel(mainAddress, friend = friend) + val avatarModel = coreContext.contactsManager.getContactAvatarModelForFriend( + friend + ) + model.avatarModel.postValue(avatarModel) + contactsList.add(CallLogModelWrapper(null, model)) + } else { + Log.w("$TAG Found friend [${friend.name}] in search results but no Address could be found, skipping it") + } + } else if (address != null) { + if (result.sourceFlags == MagicSearch.Source.Request.toInt()) { + val model = ConversationContactOrSuggestionModel(address) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + requestList.add(CallLogModelWrapper(null, model)) + continue + } + + val defaultAccountAddress = coreContext.core.defaultAccount?.params?.identityAddress + if (defaultAccountAddress != null && address.weakEqual(defaultAccountAddress)) { + Log.i("$TAG Removing from suggestions current default account address") + continue + } + + val model = ConversationContactOrSuggestionModel(address, defaultAccountDomain = defaultAccountDomain) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + suggestionsList.add(CallLogModelWrapper(null, model)) + } + } + + val collator = Collator.getInstance(Locale.getDefault()) + contactsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + suggestionsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + + val list = arrayListOf() + list.addAll(tempCallLogsList) + list.addAll(contactsList) + list.addAll(suggestionsList) + list.addAll(requestList) + callLogs.postValue(list) + Log.i( + "$TAG Processed [${results.size}] results: [${contactsList.size}] contacts and [${suggestionsList.size}] suggestions" + ) + } + + @WorkerThread + private fun getContactAvatarModelForAddress(address: Address): ContactAvatarModel { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = LinphoneUtils.getDisplayName(address) + fakeFriend.address = address + return ContactAvatarModel(fakeFriend) + } } diff --git a/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt b/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt index 865de117c..ff0a4dbac 100644 --- a/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt +++ b/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt @@ -41,6 +41,8 @@ class ConversationContactOrSuggestionModel ) { val id = friend?.refKey ?: address.asStringUriOnly().hashCode() + val isFriend = friend != null + val starred = friend?.starred == true val name = conversationSubject diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt index bf509514b..c61cdf098 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt @@ -241,10 +241,12 @@ open class AbstractMainViewModel @UiThread fun applyFilter(filter: String = currentFilter) { - Log.i("$TAG New filter set by user [$filter]") - currentFilter = filter - isFilterEmpty.postValue(filter.isEmpty()) - filter() + if (currentFilter != filter) { + Log.i("$TAG New filter set by user [$filter]") + currentFilter = filter + isFilterEmpty.postValue(filter.isEmpty()) + filter() + } } @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt index 0899f086c..a4a414a03 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt @@ -344,6 +344,7 @@ abstract class AddressSelectionViewModel val contactsList = arrayListOf() val suggestionsList = arrayListOf() + val requestList = arrayListOf() for (result in results) { val address = result.address @@ -373,7 +374,7 @@ abstract class AddressSelectionViewModel } val avatarModel = getContactAvatarModelForAddress(address) model.avatarModel.postValue(avatarModel) - suggestionsList.add(model) + requestList.add(model) continue } @@ -408,6 +409,7 @@ abstract class AddressSelectionViewModel list.addAll(favoritesList) list.addAll(contactsList) list.addAll(suggestionsList) + list.addAll(requestList) searchInProgress.postValue(false) modelsList.postValue(list) @@ -517,7 +519,7 @@ abstract class AddressSelectionViewModel @UiThread fun handleClickOnContactModel(model: ConversationContactOrSuggestionModel) { if (model.selected.value == true) { - org.linphone.core.tools.Log.i( + Log.i( "$TAG User clicked on already selected item [${model.name}], removing it from selection" ) val found = selection.value.orEmpty().find { @@ -529,14 +531,14 @@ abstract class AddressSelectionViewModel } return } else { - org.linphone.core.tools.Log.e("$TAG Failed to find already selected entry matching the one clicked") + Log.e("$TAG Failed to find already selected entry matching the one clicked") } } coreContext.postOnCoreThread { core -> val friend = model.friend if (friend == null) { - org.linphone.core.tools.Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]") + Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]") val fakeFriend = core.createFriend() fakeFriend.addAddress(model.address) onAddressSelected(model.address, fakeFriend) @@ -545,13 +547,13 @@ abstract class AddressSelectionViewModel val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) if (singleAvailableAddress != null) { - org.linphone.core.tools.Log.i( + Log.i( "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" ) onAddressSelected(singleAvailableAddress, friend) } else { val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) - org.linphone.core.tools.Log.i( + Log.i( "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" ) diff --git a/app/src/main/res/layout/chat_list_contact_suggestion_cell.xml b/app/src/main/res/layout/chat_list_contact_suggestion_cell.xml new file mode 100644 index 000000000..c6ff59311 --- /dev/null +++ b/app/src/main/res/layout/chat_list_contact_suggestion_cell.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/history_list_contact_suggestion_cell.xml b/app/src/main/res/layout/history_list_contact_suggestion_cell.xml new file mode 100644 index 000000000..3d0555338 --- /dev/null +++ b/app/src/main/res/layout/history_list_contact_suggestion_cell.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file