Show contacts & suggestions when filtering calls history & conversations list

This commit is contained in:
Sylvain Berfini 2026-02-10 14:04:22 +01:00
parent d41ad88590
commit 4bc67d933a
15 changed files with 1092 additions and 125 deletions

View file

@ -36,6 +36,7 @@ Group changes to describe their impact on the project, as follows:
### Changed ### Changed
- No longer follow TelecomManager audio endpoint during calls, using our own routing policy - 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 - 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...) - 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 - 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

View file

@ -19,7 +19,9 @@
*/ */
package org.linphone.ui.main.chat.adapter package org.linphone.ui.main.chat.adapter
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.core.view.doOnPreDraw import androidx.core.view.doOnPreDraw
@ -30,14 +32,30 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.linphone.R import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.Friend
import org.linphone.databinding.ChatListCellBinding 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.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.Event
import org.linphone.utils.HeaderAdapter
import org.linphone.utils.startAnimatedDrawable import org.linphone.utils.startAnimatedDrawable
class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.ViewHolder>( class ConversationsListAdapter :
ListAdapter<ConversationModelWrapper, RecyclerView.ViewHolder>(
ChatRoomDiffCallback() 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 var selectedAdapterPosition = -1
val conversationClickedEvent: MutableLiveData<Event<ConversationModel>> by lazy { val conversationClickedEvent: MutableLiveData<Event<ConversationModel>> by lazy {
@ -48,14 +66,67 @@ class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.Vie
MutableLiveData() MutableLiveData()
} }
val createConversationWithFriendClickedEvent: MutableLiveData<Event<Friend>> by lazy {
MutableLiveData()
}
val createConversationWithAddressClickedEvent: MutableLiveData<Event<Address>> 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)
)
binding.header.text = when (getItemViewType(position)) {
SUGGESTION_TYPE -> {
AppUtils.getString(R.string.generic_address_picker_suggestions_list_title)
}
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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
CONVERSATION_TYPE -> {
val binding: ChatListCellBinding = DataBindingUtil.inflate( val binding: ChatListCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
R.layout.chat_list_cell, R.layout.chat_list_cell,
parent, parent,
false false
) )
val viewHolder = ViewHolder(binding) val viewHolder = ConversationViewHolder(binding)
binding.apply { binding.apply {
lifecycleOwner = parent.findViewTreeLifecycleOwner() lifecycleOwner = parent.findViewTreeLifecycleOwner()
@ -70,11 +141,37 @@ class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.Vie
true true
} }
} }
return viewHolder 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)
}
}
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 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() { fun resetSelection() {
@ -82,7 +179,7 @@ class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.Vie
selectedAdapterPosition = -1 selectedAdapterPosition = -1
} }
inner class ViewHolder( inner class ConversationViewHolder(
val binding: ChatListCellBinding val binding: ChatListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread
@ -101,13 +198,34 @@ class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.Vie
} }
} }
private class ChatRoomDiffCallback : DiffUtil.ItemCallback<ConversationModel>() { class ContactSuggestionViewHolder(
override fun areItemsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { val binding: ChatListContactSuggestionCellBinding
return oldItem.id == newItem.id && oldItem.lastUpdateTime == newItem.lastUpdateTime ) : RecyclerView.ViewHolder(binding.root) {
@UiThread
fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) {
with(binding) {
model = conversationContactOrSuggestionModel
executePendingBindings()
}
}
} }
override fun areContentsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { private class ChatRoomDiffCallback : DiffUtil.ItemCallback<ConversationModelWrapper>() {
return oldItem.avatarModel.value?.id == newItem.avatarModel.value?.id 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: ConversationModelWrapper, newItem: ConversationModelWrapper): Boolean {
if (oldItem.isConversation && newItem.isConversation) {
return newItem.conversationModel?.avatarModel?.value?.id == oldItem.conversationModel?.avatarModel?.value?.id
}
return false
} }
} }
} }

View file

@ -33,7 +33,9 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.ChatListFragmentBinding import org.linphone.databinding.ChatListFragmentBinding
import org.linphone.ui.fileviewer.FileViewerActivity 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.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID
import org.linphone.ui.main.chat.adapter.ConversationsListAdapter import org.linphone.ui.main.chat.adapter.ConversationsListAdapter
import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel 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.fragment.AbstractMainFragment
import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment
import org.linphone.utils.DialogUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import org.linphone.utils.RecyclerViewHeaderDecoration
@UiThread @UiThread
class ConversationsListFragment : AbstractMainFragment() { class ConversationsListFragment : AbstractMainFragment() {
@ -60,6 +67,21 @@ class ConversationsListFragment : AbstractMainFragment() {
private var bottomSheetDialog: BottomSheetDialogFragment? = null 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() { private val dataObserver = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
Log.i("$TAG [$itemCount] added, scrolling to top") Log.i("$TAG [$itemCount] added, scrolling to top")
@ -121,6 +143,9 @@ class ConversationsListFragment : AbstractMainFragment() {
binding.conversationsList.outlineProvider = outlineProvider binding.conversationsList.outlineProvider = outlineProvider
binding.conversationsList.clipToOutline = true binding.conversationsList.clipToOutline = true
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
binding.conversationsList.addItemDecoration(headerItemDecoration)
adapter.conversationLongClickedEvent.observe(viewLifecycleOwner) { adapter.conversationLongClickedEvent.observe(viewLifecycleOwner) {
it.consume { model -> it.consume { model ->
val modalBottomSheet = ConversationDialogFragment( 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 { binding.setOnNewConversationClicked {
if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { if (findNavController().currentDestination?.id == R.id.conversationsListFragment) {
Log.i("$TAG Navigating to start conversation fragment") Log.i("$TAG Navigating to start conversation fragment")
@ -187,6 +241,14 @@ class ConversationsListFragment : AbstractMainFragment() {
listViewModel.fetchInProgress.value = false 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) { sharedViewModel.showConversationEvent.observe(viewLifecycleOwner) {
it.consume { conversationId -> it.consume { conversationId ->
Log.i("$TAG Navigating to conversation fragment with ID [$conversationId]") Log.i("$TAG Navigating to conversation fragment with ID [$conversationId]")
@ -251,10 +313,10 @@ class ConversationsListFragment : AbstractMainFragment() {
sharedViewModel.updateConversationLastMessageEvent.observe(viewLifecycleOwner) { sharedViewModel.updateConversationLastMessageEvent.observe(viewLifecycleOwner) {
it.consume { conversationId -> it.consume { conversationId ->
val model = listViewModel.conversations.value.orEmpty().find { conversationModel -> val model = listViewModel.conversations.value.orEmpty().find { wrapperModel ->
conversationModel.id == conversationId wrapperModel.conversationModel?.id == conversationId
} }
model?.updateLastMessageInfo() model?.conversationModel?.updateLastMessageInfo()
} }
} }
@ -262,10 +324,10 @@ class ConversationsListFragment : AbstractMainFragment() {
it.consume { it.consume {
val displayChatRoom = sharedViewModel.displayedChatRoom val displayChatRoom = sharedViewModel.displayedChatRoom
if (displayChatRoom != null) { if (displayChatRoom != null) {
val found = listViewModel.conversations.value.orEmpty().find { model -> val found = listViewModel.conversations.value.orEmpty().find { wrapperModel ->
model.chatRoom == displayChatRoom wrapperModel.conversationModel?.chatRoom == displayChatRoom
} }
found?.updateMuteState() found?.conversationModel?.updateMuteState()
} }
} }
} }
@ -277,9 +339,9 @@ class ConversationsListFragment : AbstractMainFragment() {
val displayChatRoom = sharedViewModel.displayedChatRoom val displayChatRoom = sharedViewModel.displayedChatRoom
if (displayChatRoom != null) { if (displayChatRoom != null) {
val found = listViewModel.conversations.value.orEmpty().find { model -> val found = listViewModel.conversations.value.orEmpty().find { model ->
model.chatRoom == displayChatRoom model.conversationModel?.chatRoom == displayChatRoom
} }
found?.updateUnreadCount() found?.conversationModel?.updateUnreadCount()
} }
listViewModel.updateUnreadMessagesCount() listViewModel.updateUnreadMessagesCount()
} }
@ -343,4 +405,21 @@ class ConversationsListFragment : AbstractMainFragment() {
Log.e("$TAG Failed to unregister data observer to adapter: $e") Log.e("$TAG Failed to unregister data observer to adapter: $e")
} }
} }
private fun showNumbersOrAddressesDialog(list: List<ContactNumberOrAddressModel>) {
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
val dialog =
DialogUtils.getNumberOrAddressPickerDialog(
requireActivity(),
numberOrAddressModel
)
numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event ->
event.consume {
dialog.dismiss()
}
}
dialog.show()
}
} }

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -23,17 +23,31 @@ import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R import org.linphone.R
import org.linphone.contacts.ContactsManager import org.linphone.contacts.ContactsManager
import org.linphone.core.Address
import org.linphone.core.ChatMessage import org.linphone.core.ChatMessage
import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomListenerStub
import org.linphone.core.Conference
import org.linphone.core.Core import org.linphone.core.Core
import org.linphone.core.CoreListenerStub import org.linphone.core.CoreListenerStub
import org.linphone.core.Friend 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.core.tools.Log
import org.linphone.ui.main.chat.model.ConversationModel 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.ui.main.viewmodel.AbstractMainViewModel
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import java.text.Collator
import java.util.Locale
class ConversationsListViewModel class ConversationsListViewModel
@UiThread @UiThread
@ -42,10 +56,51 @@ class ConversationsListViewModel
private const val TAG = "[Conversations List ViewModel]" private const val TAG = "[Conversations List ViewModel]"
} }
val conversations = MutableLiveData<ArrayList<ConversationModel>>() val conversations = MutableLiveData<ArrayList<ConversationModelWrapper>>()
val fetchInProgress = MutableLiveData<Boolean>() val fetchInProgress = MutableLiveData<Boolean>()
val chatRoomCreatedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData()
}
private val tempConversationsList = ArrayList<ConversationModelWrapper>()
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() { private val coreListener = object : CoreListenerStub() {
@WorkerThread @WorkerThread
override fun onChatRoomStateChanged( override fun onChatRoomStateChanged(
@ -68,7 +123,7 @@ class ConversationsListViewModel
override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) { override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
val id = LinphoneUtils.getConversationId(chatRoom) val id = LinphoneUtils.getConversationId(chatRoom)
val found = conversations.value.orEmpty().find { val found = conversations.value.orEmpty().find {
it.id == id it.conversationModel?.id == id
} }
if (found == null) { if (found == null) {
Log.i("$TAG Message sent for a conversation not yet in the list (probably was empty), adding it") 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 id = LinphoneUtils.getConversationId(chatRoom)
val found = conversations.value.orEmpty().find { val found = conversations.value.orEmpty().find {
it.id == id it.conversationModel?.id == id
} }
if (found == null) { if (found == null) {
Log.i("$TAG Message(s) received for a conversation not yet in the list (probably was empty), adding it") 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() { override fun onContactsLoaded() {
Log.i("$TAG Contacts have been (re)loaded, updating list") Log.i("$TAG Contacts have been (re)loaded, updating list")
for (model in conversations.value.orEmpty()) { for (model in conversations.value.orEmpty()) {
model.computeParticipants() if (model.isConversation) {
model.updateLastMessage() model.conversationModel?.computeParticipants()
model.conversationModel?.updateLastMessage()
}
} }
} }
@ -119,6 +176,7 @@ class ConversationsListViewModel
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
coreContext.contactsManager.addListener(contactsListener) coreContext.contactsManager.addListener(contactsListener)
core.addListener(coreListener) core.addListener(coreListener)
magicSearch.addListener(magicSearchListener)
computeChatRoomsList(currentFilter) computeChatRoomsList(currentFilter)
} }
@ -129,9 +187,10 @@ class ConversationsListViewModel
super.onCleared() super.onCleared()
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
conversations.value.orEmpty().forEach(ConversationModel::destroy) conversations.value.orEmpty().forEach(ConversationModelWrapper::destroy)
coreContext.contactsManager.removeListener(contactsListener) coreContext.contactsManager.removeListener(contactsListener)
core.removeListener(coreListener) core.removeListener(coreListener)
magicSearch.removeListener(magicSearchListener)
} }
} }
@ -144,14 +203,23 @@ class ConversationsListViewModel
@WorkerThread @WorkerThread
private fun computeChatRoomsList(filter: String) { private fun computeChatRoomsList(filter: String) {
conversations.value.orEmpty().forEach(ConversationModel::destroy) conversations.value.orEmpty().forEach(ConversationModelWrapper::destroy)
if (conversations.value.orEmpty().isEmpty()) { if (conversations.value.orEmpty().isEmpty()) {
fetchInProgress.postValue(true) fetchInProgress.postValue(true)
} }
val list = arrayListOf<ConversationModel>() val isFilterEmpty = filter.isEmpty()
var count = 0 if (!isFilterEmpty) {
magicSearch.getContactsListAsync(
filter,
corePreferences.contactsFilter,
MagicSearch.Source.All.toInt(),
MagicSearch.Aggregation.Friend
)
}
val list = arrayListOf<ConversationModelWrapper>()
val account = LinphoneUtils.getDefaultAccount() val account = LinphoneUtils.getDefaultAccount()
val chatRooms = if (filter.isEmpty()) { val chatRooms = if (filter.isEmpty()) {
@ -161,15 +229,16 @@ class ConversationsListViewModel
} }
for (chatRoom in chatRooms.orEmpty()) { for (chatRoom in chatRooms.orEmpty()) {
val model = ConversationModel(chatRoom) val model = ConversationModel(chatRoom)
list.add(model) list.add(ConversationModelWrapper(model))
count += 1
if (count == 15) {
conversations.postValue(list)
}
} }
if (isFilterEmpty) {
conversations.postValue(list) conversations.postValue(list)
} else {
fetchInProgress.postValue(true)
tempConversationsList.clear()
tempConversationsList.addAll(list)
}
} }
@WorkerThread @WorkerThread
@ -193,7 +262,7 @@ class ConversationsListViewModel
val currentList = conversations.value.orEmpty() val currentList = conversations.value.orEmpty()
val found = currentList.find { val found = currentList.find {
it.chatRoom.identifier == identifier it.conversationModel?.chatRoom?.identifier == identifier
} }
if (found != null) { if (found != null) {
Log.w("$TAG Created chat room with identifier [$identifier] is already in the list, skipping") 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 if (found == null) return
} }
val newList = arrayListOf<ConversationModel>() val newList = arrayListOf<ConversationModelWrapper>()
val model = ConversationModel(chatRoom) val model = ConversationModel(chatRoom)
newList.add(model) newList.add(ConversationModelWrapper(model))
newList.addAll(currentList) newList.addAll(currentList)
Log.i("$TAG Adding chat room with identifier [$identifier] to list") Log.i("$TAG Adding chat room with identifier [$identifier] to list")
conversations.postValue(newList) conversations.postValue(newList)
@ -221,10 +290,10 @@ class ConversationsListViewModel
val currentList = conversations.value.orEmpty() val currentList = conversations.value.orEmpty()
val identifier = chatRoom.identifier val identifier = chatRoom.identifier
val found = currentList.find { val found = currentList.find {
it.chatRoom.identifier == identifier it.conversationModel?.chatRoom?.identifier == identifier
} }
if (found != null) { if (found != null) {
val newList = arrayListOf<ConversationModel>() val newList = arrayListOf<ConversationModelWrapper>()
newList.addAll(currentList) newList.addAll(currentList)
newList.remove(found) newList.remove(found)
found.destroy() found.destroy()
@ -241,12 +310,187 @@ class ConversationsListViewModel
@WorkerThread @WorkerThread
private fun reorderChatRooms() { 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") Log.i("$TAG Re-ordering conversations")
val sortedList = arrayListOf<ConversationModel>() val sortedList = arrayListOf<ConversationModelWrapper>()
sortedList.addAll(conversations.value.orEmpty()) sortedList.addAll(conversations.value.orEmpty())
sortedList.sortByDescending { sortedList.sortByDescending {
it.chatRoom.lastUpdateTime it.conversationModel?.chatRoom?.lastUpdateTime
} }
conversations.postValue(sortedList) conversations.postValue(sortedList)
} }
@WorkerThread
private fun processMagicSearchResults(results: Array<SearchResult>) {
Log.i("$TAG Processing [${results.size}] results")
val contactsList = arrayListOf<ConversationModelWrapper>()
val suggestionsList = arrayListOf<ConversationModelWrapper>()
val requestList = arrayListOf<ConversationModelWrapper>()
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<ConversationModelWrapper>()
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)))
}
}
} }

View file

@ -153,6 +153,7 @@ class ContactsListViewModel
showFavourites.value = corePreferences.showFavoriteContacts showFavourites.value = corePreferences.showFavoriteContacts
showFilter.value = !corePreferences.hidePhoneNumbers && !corePreferences.hideSipAddresses showFilter.value = !corePreferences.hidePhoneNumbers && !corePreferences.hideSipAddresses
disableAddContact.value = corePreferences.disableAddContact disableAddContact.value = corePreferences.disableAddContact
isListFiltered.value = false
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
domainFilter = corePreferences.contactsFilter domainFilter = corePreferences.contactsFilter
@ -169,9 +170,7 @@ class ContactsListViewModel
favouritesMagicSearch.limitedSearch = false favouritesMagicSearch.limitedSearch = false
favouritesMagicSearch.addListener(favouritesMagicSearchListener) favouritesMagicSearch.addListener(favouritesMagicSearchListener)
coreContext.postOnMainThread { applyFilter(currentFilter, domainFilter)
applyFilter(currentFilter)
}
} }
} }
@ -353,7 +352,6 @@ class ContactsListViewModel
Log.i("$TAG Processing [${results.size}] results, favourites is [$favourites]") Log.i("$TAG Processing [${results.size}] results, favourites is [$favourites]")
val list = arrayListOf<ContactAvatarModel>() val list = arrayListOf<ContactAvatarModel>()
var count = 0
val collator = Collator.getInstance(Locale.getDefault()) val collator = Collator.getInstance(Locale.getDefault())
val hideEmptyContacts = corePreferences.hideContactsWithoutPhoneNumberOrSipAddress val hideEmptyContacts = corePreferences.hideContactsWithoutPhoneNumberOrSipAddress
@ -384,19 +382,10 @@ class ContactsListViewModel
coreContext.contactsManager.getContactAvatarModelForAddress(result.address) coreContext.contactsManager.getContactAvatarModelForAddress(result.address)
} }
model.refreshSortingName() model.refreshSortingName()
list.add(model)
count += 1
val starred = friend?.starred == true val starred = friend?.starred == true
model.isFavourite.postValue(starred) model.isFavourite.postValue(starred)
if (!favourites && firstLoad && count == 20) { list.add(model)
list.sortWith { model1, model2 ->
collator.compare(model1.getNameToUseForSorting(), model2.getNameToUseForSorting())
}
contactsList.postValue(list)
}
} }
list.sortWith { model1, model2 -> list.sortWith { model1, model2 ->

View file

@ -19,7 +19,9 @@
*/ */
package org.linphone.ui.main.history.adapter package org.linphone.ui.main.history.adapter
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
@ -29,11 +31,27 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.linphone.R 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.HistoryListCellBinding
import org.linphone.databinding.HistoryListContactSuggestionCellBinding
import org.linphone.ui.main.history.model.CallLogModel 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.Event
import org.linphone.utils.HeaderAdapter
class HistoryListAdapter :
ListAdapter<CallLogModelWrapper, RecyclerView.ViewHolder>(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<CallLogModel, RecyclerView.ViewHolder>(CallLogDiffCallback()) {
var selectedAdapterPosition = -1 var selectedAdapterPosition = -1
val callLogClickedEvent: MutableLiveData<Event<CallLogModel>> by lazy { val callLogClickedEvent: MutableLiveData<Event<CallLogModel>> by lazy {
@ -48,14 +66,67 @@ class HistoryListAdapter : ListAdapter<CallLogModel, RecyclerView.ViewHolder>(Ca
MutableLiveData() MutableLiveData()
} }
val callFriendClickedEvent: MutableLiveData<Event<Friend>> by lazy {
MutableLiveData()
}
val callAddressClickedEvent: MutableLiveData<Event<Address>> 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)
)
binding.header.text = when (getItemViewType(position)) {
SUGGESTION_TYPE -> {
AppUtils.getString(R.string.generic_address_picker_suggestions_list_title)
}
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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
CALL_LOG_TYPE -> {
val binding: HistoryListCellBinding = DataBindingUtil.inflate( val binding: HistoryListCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
R.layout.history_list_cell, R.layout.history_list_cell,
parent, parent,
false false
) )
val viewHolder = ViewHolder(binding) val viewHolder = CallLogViewHolder(binding)
binding.apply { binding.apply {
lifecycleOwner = parent.findViewTreeLifecycleOwner() lifecycleOwner = parent.findViewTreeLifecycleOwner()
@ -74,11 +145,37 @@ class HistoryListAdapter : ListAdapter<CallLogModel, RecyclerView.ViewHolder>(Ca
callLogCallBackClickedEvent.value = Event(model!!) callLogCallBackClickedEvent.value = Event(model!!)
} }
} }
return viewHolder 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)
}
}
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 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() { fun resetSelection() {
@ -86,7 +183,7 @@ class HistoryListAdapter : ListAdapter<CallLogModel, RecyclerView.ViewHolder>(Ca
selectedAdapterPosition = -1 selectedAdapterPosition = -1
} }
inner class ViewHolder( inner class CallLogViewHolder(
val binding: HistoryListCellBinding val binding: HistoryListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread
@ -101,13 +198,34 @@ class HistoryListAdapter : ListAdapter<CallLogModel, RecyclerView.ViewHolder>(Ca
} }
} }
private class CallLogDiffCallback : DiffUtil.ItemCallback<CallLogModel>() { class ContactSuggestionViewHolder(
override fun areItemsTheSame(oldItem: CallLogModel, newItem: CallLogModel): Boolean { val binding: HistoryListContactSuggestionCellBinding
return oldItem.id == newItem.id && oldItem.timestamp == newItem.timestamp ) : RecyclerView.ViewHolder(binding.root) {
@UiThread
fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) {
with(binding) {
model = conversationContactOrSuggestionModel
executePendingBindings()
}
}
} }
override fun areContentsTheSame(oldItem: CallLogModel, newItem: CallLogModel): Boolean { private class CallLogDiffCallback : DiffUtil.ItemCallback<CallLogModelWrapper>() {
return newItem.avatarModel.compare(oldItem.avatarModel) 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: CallLogModelWrapper, newItem: CallLogModelWrapper): Boolean {
if (oldItem.isCallLog && newItem.isCallLog) {
return newItem.callLogModel?.avatarModel?.compare(oldItem.callLogModel?.avatarModel) == true
}
return false
} }
} }
} }

View file

@ -33,9 +33,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.HistoryListFragmentBinding import org.linphone.databinding.HistoryListFragmentBinding
import org.linphone.ui.GenericActivity 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.fragment.AbstractMainFragment
import org.linphone.ui.main.history.adapter.HistoryListAdapter import org.linphone.ui.main.history.adapter.HistoryListAdapter
import org.linphone.utils.ConfirmationDialogModel 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.AppUtils
import org.linphone.utils.DialogUtils import org.linphone.utils.DialogUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.RecyclerViewHeaderDecoration
@UiThread @UiThread
class HistoryListFragment : AbstractMainFragment() { class HistoryListFragment : AbstractMainFragment() {
@ -58,6 +64,21 @@ class HistoryListFragment : AbstractMainFragment() {
private var bottomSheetDialog: BottomSheetDialogFragment? = null 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() { override fun onDefaultAccountChanged() {
Log.i( Log.i(
"$TAG Default account changed, updating avatar in top bar & re-computing call logs" "$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.outlineProvider = outlineProvider
binding.historyList.clipToOutline = true binding.historyList.clipToOutline = true
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
binding.historyList.addItemDecoration(headerItemDecoration)
adapter.callLogLongClickedEvent.observe(viewLifecycleOwner) { adapter.callLogLongClickedEvent.observe(viewLifecycleOwner) {
it.consume { model -> it.consume { model ->
val modalBottomSheet = HistoryMenuDialogFragment( val modalBottomSheet = HistoryMenuDialogFragment(
@ -142,7 +166,7 @@ class HistoryListFragment : AbstractMainFragment() {
{ // onDeleteCallLog { // onDeleteCallLog
Log.i("$TAG Deleting call log with ref key or call ID [${model.id}]") Log.i("$TAG Deleting call log with ref key or call ID [${model.id}]")
model.delete() model.delete()
listViewModel.applyFilter() listViewModel.filter()
} }
) )
modalBottomSheet.show(parentFragmentManager, HistoryMenuDialogFragment.TAG) 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) { listViewModel.callLogs.observe(viewLifecycleOwner) {
adapter.submitList(it) adapter.submitList(it)
@ -308,4 +361,21 @@ class HistoryListFragment : AbstractMainFragment() {
dialog.show() dialog.show()
} }
private fun showNumbersOrAddressesDialog(list: List<ContactNumberOrAddressModel>) {
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
val dialog =
DialogUtils.getNumberOrAddressPickerDialog(
requireActivity(),
numberOrAddressModel
)
numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event ->
event.consume {
dialog.dismiss()
}
}
dialog.show()
}
} }

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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
}

View file

@ -23,17 +23,27 @@ import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.contacts.ContactsManager import org.linphone.contacts.ContactsManager
import org.linphone.core.Address
import org.linphone.core.CallLog import org.linphone.core.CallLog
import org.linphone.core.Core import org.linphone.core.Core
import org.linphone.core.CoreListenerStub import org.linphone.core.CoreListenerStub
import org.linphone.core.Friend import org.linphone.core.Friend
import org.linphone.core.GlobalState 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.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.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.ui.main.viewmodel.AbstractMainViewModel
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import java.text.Collator
import java.util.Locale
class HistoryListViewModel class HistoryListViewModel
@UiThread @UiThread
@ -42,7 +52,7 @@ class HistoryListViewModel
private const val TAG = "[History List ViewModel]" private const val TAG = "[History List ViewModel]"
} }
val callLogs = MutableLiveData<ArrayList<CallLogModel>>() val callLogs = MutableLiveData<ArrayList<CallLogModelWrapper>>()
val fetchInProgress = MutableLiveData<Boolean>() val fetchInProgress = MutableLiveData<Boolean>()
@ -54,6 +64,20 @@ class HistoryListViewModel
MutableLiveData() MutableLiveData()
} }
private val tempCallLogsList = ArrayList<CallLogModelWrapper>()
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() { private val coreListener = object : CoreListenerStub() {
@WorkerThread @WorkerThread
override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) { override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) {
@ -88,6 +112,7 @@ class HistoryListViewModel
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
coreContext.contactsManager.addListener(contactsListener) coreContext.contactsManager.addListener(contactsListener)
core.addListener(coreListener) core.addListener(coreListener)
magicSearch.addListener(magicSearchListener)
computeCallLogsList(currentFilter) computeCallLogsList(currentFilter)
} }
@ -100,6 +125,7 @@ class HistoryListViewModel
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
coreContext.contactsManager.removeListener(contactsListener) coreContext.contactsManager.removeListener(contactsListener)
core.removeListener(coreListener) core.removeListener(coreListener)
magicSearch.removeListener(magicSearchListener)
} }
} }
@ -141,8 +167,17 @@ class HistoryListViewModel
fetchInProgress.postValue(true) fetchInProgress.postValue(true)
} }
val list = arrayListOf<CallLogModel>() val isFilterEmpty = filter.isEmpty()
var count = 0 if (!isFilterEmpty) {
magicSearch.getContactsListAsync(
filter,
corePreferences.contactsFilter,
MagicSearch.Source.All.toInt(),
MagicSearch.Aggregation.Friend
)
}
val list = arrayListOf<CallLogModelWrapper>()
val account = LinphoneUtils.getDefaultAccount() val account = LinphoneUtils.getDefaultAccount()
// Fetch all call logs if only one account to workaround no history issue // Fetch all call logs if only one account to workaround no history issue
@ -156,17 +191,18 @@ class HistoryListViewModel
for (callLog in logs) { for (callLog in logs) {
val model = CallLogModel(callLog) val model = CallLogModel(callLog)
if (isCallLogMatchingFilter(model, filter)) { if (isCallLogMatchingFilter(model, filter)) {
list.add(model) list.add(CallLogModelWrapper(model))
count += 1
}
if (count == 20) {
callLogs.postValue(list)
} }
} }
Log.i("$TAG Fetched [${list.size}] call log(s)") Log.i("$TAG Fetched [${list.size}] call log(s)")
if (isFilterEmpty) {
callLogs.postValue(list) callLogs.postValue(list)
} else {
fetchInProgress.postValue(true)
tempCallLogsList.clear()
tempCallLogsList.addAll(list)
}
} }
@WorkerThread @WorkerThread
@ -176,4 +212,84 @@ class HistoryListViewModel
val friendName = model.avatarModel.friend.name ?: LinphoneUtils.getDisplayName(model.address) val friendName = model.avatarModel.friend.name ?: LinphoneUtils.getDisplayName(model.address)
return friendName.contains(filter, ignoreCase = true) || model.address.asStringUriOnly().contains(filter, ignoreCase = true) return friendName.contains(filter, ignoreCase = true) || model.address.asStringUriOnly().contains(filter, ignoreCase = true)
} }
@WorkerThread
private fun processMagicSearchResults(results: Array<SearchResult>) {
Log.i("$TAG Processing [${results.size}] results")
val contactsList = arrayListOf<CallLogModelWrapper>()
val suggestionsList = arrayListOf<CallLogModelWrapper>()
val requestList = arrayListOf<CallLogModelWrapper>()
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<CallLogModelWrapper>()
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)
}
} }

View file

@ -41,6 +41,8 @@ class ConversationContactOrSuggestionModel
) { ) {
val id = friend?.refKey ?: address.asStringUriOnly().hashCode() val id = friend?.refKey ?: address.asStringUriOnly().hashCode()
val isFriend = friend != null
val starred = friend?.starred == true val starred = friend?.starred == true
val name = conversationSubject val name = conversationSubject

View file

@ -241,11 +241,13 @@ open class AbstractMainViewModel
@UiThread @UiThread
fun applyFilter(filter: String = currentFilter) { fun applyFilter(filter: String = currentFilter) {
if (currentFilter != filter) {
Log.i("$TAG New filter set by user [$filter]") Log.i("$TAG New filter set by user [$filter]")
currentFilter = filter currentFilter = filter
isFilterEmpty.postValue(filter.isEmpty()) isFilterEmpty.postValue(filter.isEmpty())
filter() filter()
} }
}
@UiThread @UiThread
open fun filter() { open fun filter() {

View file

@ -344,6 +344,7 @@ abstract class AddressSelectionViewModel
val contactsList = arrayListOf<ConversationContactOrSuggestionModel>() val contactsList = arrayListOf<ConversationContactOrSuggestionModel>()
val suggestionsList = arrayListOf<ConversationContactOrSuggestionModel>() val suggestionsList = arrayListOf<ConversationContactOrSuggestionModel>()
val requestList = arrayListOf<ConversationContactOrSuggestionModel>()
for (result in results) { for (result in results) {
val address = result.address val address = result.address
@ -373,7 +374,7 @@ abstract class AddressSelectionViewModel
} }
val avatarModel = getContactAvatarModelForAddress(address) val avatarModel = getContactAvatarModelForAddress(address)
model.avatarModel.postValue(avatarModel) model.avatarModel.postValue(avatarModel)
suggestionsList.add(model) requestList.add(model)
continue continue
} }
@ -408,6 +409,7 @@ abstract class AddressSelectionViewModel
list.addAll(favoritesList) list.addAll(favoritesList)
list.addAll(contactsList) list.addAll(contactsList)
list.addAll(suggestionsList) list.addAll(suggestionsList)
list.addAll(requestList)
searchInProgress.postValue(false) searchInProgress.postValue(false)
modelsList.postValue(list) modelsList.postValue(list)
@ -517,7 +519,7 @@ abstract class AddressSelectionViewModel
@UiThread @UiThread
fun handleClickOnContactModel(model: ConversationContactOrSuggestionModel) { fun handleClickOnContactModel(model: ConversationContactOrSuggestionModel) {
if (model.selected.value == true) { 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" "$TAG User clicked on already selected item [${model.name}], removing it from selection"
) )
val found = selection.value.orEmpty().find { val found = selection.value.orEmpty().find {
@ -529,14 +531,14 @@ abstract class AddressSelectionViewModel
} }
return return
} else { } 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 -> coreContext.postOnCoreThread { core ->
val friend = model.friend val friend = model.friend
if (friend == null) { 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() val fakeFriend = core.createFriend()
fakeFriend.addAddress(model.address) fakeFriend.addAddress(model.address)
onAddressSelected(model.address, fakeFriend) onAddressSelected(model.address, fakeFriend)
@ -545,13 +547,13 @@ abstract class AddressSelectionViewModel
val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend)
if (singleAvailableAddress != null) { 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" "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it"
) )
onAddressSelected(singleAvailableAddress, friend) onAddressSelected(singleAvailableAddress, friend)
} else { } else {
val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) 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" "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog"
) )

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:bind="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="onCreateConversationClickListener"
type="View.OnClickListener" />
<variable
name="model"
type="org.linphone.ui.main.model.ConversationContactOrSuggestionModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{onCreateConversationClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:background="@drawable/primary_cell_background">
<include
android:id="@+id/avatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
layout="@layout/contact_avatar"
bind:model="@{model.avatarModel}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@{model.isFriend ? model.name : model.sipUri, default=`john.doe@sip.linphone.org`}"
android:textSize="14sp"
android:maxLines="1"
android:ellipsize="end"
android:textColor="?attr/color_main2_800"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintEnd_toStartOf="@id/call"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<ImageView
android:id="@+id/call"
android:onClick="@{onCreateConversationClickListener}"
android:layout_width="@dimen/large_icon_size"
android:layout_height="@dimen/large_icon_size"
android:padding="5dp"
android:layout_marginEnd="6dp"
android:src="@drawable/chat_teardrop_plus"
android:background="@drawable/circle_transparent_button_background"
android:contentDescription="@string/content_description_chat_create"
app:tint="?attr/color_main2_500"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/name"
app:layout_constraintBottom_toBottomOf="@id/name" />
<View
android:id="@+id/separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginEnd="10dp"
android:background="?attr/color_separator"
android:visibility="gone"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:bind="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="onCallClickListener"
type="View.OnClickListener" />
<variable
name="model"
type="org.linphone.ui.main.model.ConversationContactOrSuggestionModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{onCallClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:background="@drawable/primary_cell_background">
<include
android:id="@+id/avatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
layout="@layout/contact_avatar"
bind:model="@{model.avatarModel}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@{model.isFriend ? model.name : model.sipUri, default=`john.doe@sip.linphone.org`}"
android:textSize="14sp"
android:maxLines="1"
android:ellipsize="end"
android:textColor="?attr/color_main2_800"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintEnd_toStartOf="@id/call"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<ImageView
android:id="@+id/call"
android:onClick="@{onCallClickListener}"
android:layout_width="@dimen/large_icon_size"
android:layout_height="@dimen/large_icon_size"
android:padding="5dp"
android:layout_marginEnd="6dp"
android:src="@drawable/phone"
android:background="@drawable/circle_transparent_button_background"
android:contentDescription="@string/content_description_call_start"
app:tint="?attr/color_main2_500"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/name"
app:layout_constraintBottom_toBottomOf="@id/name" />
<View
android:id="@+id/separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginEnd="10dp"
android:background="?attr/color_separator"
android:visibility="gone"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>