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
- 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

View file

@ -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<ConversationModel, RecyclerView.ViewHolder>(
class ConversationsListAdapter :
ListAdapter<ConversationModelWrapper, RecyclerView.ViewHolder>(
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<Event<ConversationModel>> by lazy {
@ -48,33 +66,112 @@ class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.Vie
MutableLiveData()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val binding: ChatListCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_list_cell,
parent,
false
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)
)
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<ConversationModel, RecyclerView.Vie
selectedAdapterPosition = -1
}
inner class ViewHolder(
inner class ConversationViewHolder(
val binding: ChatListCellBinding
) : RecyclerView.ViewHolder(binding.root) {
@UiThread
@ -101,13 +198,34 @@ class ConversationsListAdapter : ListAdapter<ConversationModel, RecyclerView.Vie
}
}
private class ChatRoomDiffCallback : DiffUtil.ItemCallback<ConversationModel>() {
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<ConversationModelWrapper>() {
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
}
}
}

View file

@ -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<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.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<ArrayList<ConversationModel>>()
val conversations = MutableLiveData<ArrayList<ConversationModelWrapper>>()
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() {
@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<ConversationModel>()
var count = 0
val isFilterEmpty = filter.isEmpty()
if (!isFilterEmpty) {
magicSearch.getContactsListAsync(
filter,
corePreferences.contactsFilter,
MagicSearch.Source.All.toInt(),
MagicSearch.Aggregation.Friend
)
}
val list = arrayListOf<ConversationModelWrapper>()
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<ConversationModel>()
val newList = arrayListOf<ConversationModelWrapper>()
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<ConversationModel>()
val newList = arrayListOf<ConversationModelWrapper>()
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<ConversationModel>()
val sortedList = arrayListOf<ConversationModelWrapper>()
sortedList.addAll(conversations.value.orEmpty())
sortedList.sortByDescending {
it.chatRoom.lastUpdateTime
it.conversationModel?.chatRoom?.lastUpdateTime
}
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
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<ContactAvatarModel>()
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 ->

View file

@ -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<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
val callLogClickedEvent: MutableLiveData<Event<CallLogModel>> by lazy {
@ -48,37 +66,116 @@ class HistoryListAdapter : ListAdapter<CallLogModel, RecyclerView.ViewHolder>(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<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)
)
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<CallLogModel, RecyclerView.ViewHolder>(Ca
selectedAdapterPosition = -1
}
inner class ViewHolder(
inner class CallLogViewHolder(
val binding: HistoryListCellBinding
) : RecyclerView.ViewHolder(binding.root) {
@UiThread
@ -101,13 +198,34 @@ class HistoryListAdapter : ListAdapter<CallLogModel, RecyclerView.ViewHolder>(Ca
}
}
private class CallLogDiffCallback : DiffUtil.ItemCallback<CallLogModel>() {
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<CallLogModelWrapper>() {
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
}
}
}

View file

@ -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<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.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<ArrayList<CallLogModel>>()
val callLogs = MutableLiveData<ArrayList<CallLogModelWrapper>>()
val fetchInProgress = MutableLiveData<Boolean>()
@ -54,6 +64,20 @@ class HistoryListViewModel
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() {
@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<CallLogModel>()
var count = 0
val isFilterEmpty = filter.isEmpty()
if (!isFilterEmpty) {
magicSearch.getContactsListAsync(
filter,
corePreferences.contactsFilter,
MagicSearch.Source.All.toInt(),
MagicSearch.Aggregation.Friend
)
}
val list = arrayListOf<CallLogModelWrapper>()
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<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 isFriend = friend != null
val starred = friend?.starred == true
val name = conversationSubject

View file

@ -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

View file

@ -344,6 +344,7 @@ abstract class AddressSelectionViewModel
val contactsList = arrayListOf<ConversationContactOrSuggestionModel>()
val suggestionsList = arrayListOf<ConversationContactOrSuggestionModel>()
val requestList = arrayListOf<ConversationContactOrSuggestionModel>()
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"
)

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>