From 5b80833f30307bb758a654a7ba577e57a3d36ec4 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Fri, 14 Jun 2024 10:09:54 +0200 Subject: [PATCH] Reworked chat message forward UI/UX --- .../org/linphone/contacts/ContactsManager.kt | 2 +- ...ationsContactsAndSuggestionsListAdapter.kt | 213 +++++++ .../ConversationForwardMessageFragment.kt | 190 +++++++ .../chat/fragment/ConversationFragment.kt | 8 +- .../fragment/ConversationsListFragment.kt | 12 - .../ConversationContactOrSuggestionModel.kt | 57 ++ .../ConversationForwardMessageViewModel.kt | 527 ++++++++++++++++++ .../SendMessageInConversationViewModel.kt | 4 + .../viewmodel/AddressSelectionViewModel.kt | 2 +- .../layout/chat_bubble_content_grid_cell.xml | 2 +- ...chat_message_forward_contact_list_cell.xml | 92 +++ ...message_forward_conversation_list_cell.xml | 74 +++ .../layout/chat_message_forward_fragment.xml | 110 ++++ ...t_message_forward_suggestion_list_cell.xml | 62 +++ .../main/res/navigation/chat_nav_graph.xml | 25 + app/src/main/res/values-fr/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + 17 files changed, 1373 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsContactsAndSuggestionsListAdapter.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationForwardMessageFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/model/ConversationContactOrSuggestionModel.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt create mode 100644 app/src/main/res/layout/chat_message_forward_contact_list_cell.xml create mode 100644 app/src/main/res/layout/chat_message_forward_conversation_list_cell.xml create mode 100644 app/src/main/res/layout/chat_message_forward_fragment.xml create mode 100644 app/src/main/res/layout/chat_message_forward_suggestion_list_cell.xml diff --git a/app/src/main/java/org/linphone/contacts/ContactsManager.kt b/app/src/main/java/org/linphone/contacts/ContactsManager.kt index 7015a152c..821531fb8 100644 --- a/app/src/main/java/org/linphone/contacts/ContactsManager.kt +++ b/app/src/main/java/org/linphone/contacts/ContactsManager.kt @@ -749,7 +749,7 @@ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddress // phone numbers are disabled is secure mode unless linked to a SIP address val defaultAccount = LinphoneUtils.getDefaultAccount() - val enablePhoneNumbers = hasPresenceInfo || isEndToEndEncryptionMandatory() == false + val enablePhoneNumbers = hasPresenceInfo || !isEndToEndEncryptionMandatory() val address = presenceAddress ?: core.interpretUrl( number.phoneNumber, LinphoneUtils.applyInternationalPrefix(defaultAccount) diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsContactsAndSuggestionsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsContactsAndSuggestionsListAdapter.kt new file mode 100644 index 000000000..0b005a4ee --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsContactsAndSuggestionsListAdapter.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.main.chat.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 +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.linphone.R +import org.linphone.databinding.ChatMessageForwardContactListCellBinding +import org.linphone.databinding.ChatMessageForwardConversationListCellBinding +import org.linphone.databinding.ChatMessageForwardSuggestionListCellBinding +import org.linphone.databinding.StartCallSuggestionListDecorationBinding +import org.linphone.ui.main.chat.model.ConversationContactOrSuggestionModel +import org.linphone.utils.AppUtils +import org.linphone.utils.Event +import org.linphone.utils.HeaderAdapter + +class ConversationsContactsAndSuggestionsListAdapter : + ListAdapter( + ConversationContactOrSuggestionDiffCallback() + ), + HeaderAdapter { + companion object { + private const val CONTACT_TYPE = 0 + private const val SUGGESTION_TYPE = 1 + private const val CONVERSATION_TYPE = 2 + } + + val onClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + override fun displayHeaderForPosition(position: Int): Boolean { + if (position == 0) { // Conversations + return true + } + + return getItemViewType(position) != getItemViewType(position - 1) + } + + override fun getHeaderViewForPosition(context: Context, position: Int): View { + val binding = StartCallSuggestionListDecorationBinding.inflate(LayoutInflater.from(context)) + binding.header.text = when (getItemViewType(position)) { + CONVERSATION_TYPE -> { + AppUtils.getString(R.string.conversation_message_forward_conversations_list_title) + } + SUGGESTION_TYPE -> { + AppUtils.getString(R.string.history_call_start_suggestions_list_title) + } + else -> { + AppUtils.getString(R.string.history_call_start_contacts_list_title) + } + } + return binding.root + } + + override fun getItemViewType(position: Int): Int { + val model = getItem(position) + return if (model.localAddress != null) { + CONVERSATION_TYPE + } else if (model.friend != null) { + CONTACT_TYPE + } else { + SUGGESTION_TYPE + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + CONVERSATION_TYPE -> { + val binding: ChatMessageForwardConversationListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_message_forward_conversation_list_cell, + parent, + false + ) + binding.lifecycleOwner = parent.findViewTreeLifecycleOwner() + ConversationViewHolder(binding) + } + CONTACT_TYPE -> { + val binding: ChatMessageForwardContactListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_message_forward_contact_list_cell, + parent, + false + ) + binding.lifecycleOwner = parent.findViewTreeLifecycleOwner() + ContactViewHolder(binding) + } + else -> { + val binding: ChatMessageForwardSuggestionListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_message_forward_suggestion_list_cell, + parent, + false + ) + binding.lifecycleOwner = parent.findViewTreeLifecycleOwner() + SuggestionViewHolder(binding) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (getItemViewType(position)) { + CONVERSATION_TYPE -> (holder as ConversationViewHolder).bind(getItem(position)) + CONTACT_TYPE -> (holder as ContactViewHolder).bind(getItem(position)) + else -> (holder as SuggestionViewHolder).bind(getItem(position)) + } + } + + inner class ConversationViewHolder( + val binding: ChatMessageForwardConversationListCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel + + setOnClickListener { + onClickedEvent.value = Event(conversationContactOrSuggestionModel) + } + + executePendingBindings() + } + } + } + + inner class ContactViewHolder( + val binding: ChatMessageForwardContactListCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel.avatarModel.value + + setOnClickListener { + onClickedEvent.value = Event(conversationContactOrSuggestionModel) + } + + val previousItem = bindingAdapterPosition - 1 + val previousLetter = if (previousItem >= 0) { + getItem(previousItem).name[0].toString() + } else { + "" + } + + val currentLetter = conversationContactOrSuggestionModel.name[0].toString() + val displayLetter = previousLetter.isEmpty() || currentLetter != previousLetter + firstContactStartingByThatLetter = displayLetter + + executePendingBindings() + } + } + } + + inner class SuggestionViewHolder( + val binding: ChatMessageForwardSuggestionListCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel + + setOnClickListener { + onClickedEvent.value = Event(conversationContactOrSuggestionModel) + } + + executePendingBindings() + } + } + } + + private class ConversationContactOrSuggestionDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ConversationContactOrSuggestionModel, + newItem: ConversationContactOrSuggestionModel + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: ConversationContactOrSuggestionModel, + newItem: ConversationContactOrSuggestionModel + ): Boolean { + return false + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationForwardMessageFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationForwardMessageFragment.kt new file mode 100644 index 000000000..4b9ca79be --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationForwardMessageFragment.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.main.chat.fragment + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.core.view.doOnPreDraw +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatMessageForwardFragmentBinding +import org.linphone.ui.main.chat.adapter.ConversationsContactsAndSuggestionsListAdapter +import org.linphone.ui.main.chat.viewmodel.ConversationForwardMessageViewModel +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel +import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel +import org.linphone.ui.main.fragment.SlidingPaneChildFragment +import org.linphone.utils.DialogUtils +import org.linphone.utils.Event +import org.linphone.utils.RecyclerViewHeaderDecoration + +@UiThread +class ConversationForwardMessageFragment : SlidingPaneChildFragment() { + companion object { + private const val TAG = "[Conversation Forward Message Fragment]" + } + + private lateinit var binding: ChatMessageForwardFragmentBinding + + private lateinit var viewModel: ConversationForwardMessageViewModel + + private lateinit var adapter: ConversationsContactsAndSuggestionsListAdapter + + private var numberOrAddressPickerDialog: Dialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = ConversationsContactsAndSuggestionsListAdapter() + } + + override fun goBack(): Boolean { + sharedViewModel.messageToForwardEvent.value?.consume { + Log.w("$TAG Cancelling message forward") + viewModel.showRedToastEvent.postValue( + Event( + Pair(R.string.conversation_message_forward_cancelled_toast, R.drawable.forward) + ) + ) + } + + return findNavController().popBackStack() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatMessageForwardFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + postponeEnterTransition() + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + viewModel = ViewModelProvider(this)[ConversationForwardMessageViewModel::class.java] + binding.viewModel = viewModel + observeToastEvents(viewModel) + + binding.setBackClickListener { + goBack() + } + + binding.contactsList.setHasFixedSize(true) + binding.contactsList.layoutManager = LinearLayoutManager(requireContext()) + + val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter) + binding.contactsList.addItemDecoration(headerItemDecoration) + + viewModel.conversationsContactsAndSuggestionsList.observe( + viewLifecycleOwner + ) { + Log.i( + "$TAG Conversations, contacts & suggestions list is ready with [${it.size}] items" + ) + adapter.submitList(it) + + if (binding.contactsList.adapter != adapter) { + binding.contactsList.adapter = adapter + } + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + + viewModel.chatRoomCreatedEvent.observe(viewLifecycleOwner) { + it.consume { pair -> + Log.i( + "$TAG Navigating to conversation [${pair.second}] with local address [${pair.first}]" + ) + + if (findNavController().currentDestination?.id == R.id.conversationForwardMessageFragment) { + val localSipUri = pair.first + val remoteSipUri = pair.second + val action = ConversationForwardMessageFragmentDirections.actionConversationForwardMessageFragmentToConversationFragment( + localSipUri, + remoteSipUri + ) + findNavController().navigate(action) + } + } + } + + adapter.onClickedEvent.observe(viewLifecycleOwner) { + it.consume { model -> + viewModel.handleClickOnModel(model) + } + } + + viewModel.searchFilter.observe(viewLifecycleOwner) { filter -> + val trimmed = filter.trim() + viewModel.applyFilter(trimmed) + } + + viewModel.showNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) { + it.consume { list -> + showNumberOrAddressPickerDialog(list) + } + } + + viewModel.hideNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) { + it.consume { + numberOrAddressPickerDialog?.dismiss() + numberOrAddressPickerDialog = null + } + } + } + + override fun onPause() { + super.onPause() + + numberOrAddressPickerDialog?.dismiss() + numberOrAddressPickerDialog = null + } + + private fun showNumberOrAddressPickerDialog(list: ArrayList) { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel + ) + numberOrAddressPickerDialog = dialog + + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt index 73e1e61ea..86eade847 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt @@ -1056,13 +1056,17 @@ class ConversationFragment : SlidingPaneChildFragment() { } layout.setForwardClickListener { - Log.i("$TAG Forwarding message, going back to conversations list") + Log.i("$TAG Forwarding message") // Remove observer before setting the message to forward // as we don't want to forward it in this chat room sharedViewModel.messageToForwardEvent.removeObservers(viewLifecycleOwner) sharedViewModel.messageToForwardEvent.postValue(Event(chatMessageModel)) dismissDialog() - goBack() + + if (findNavController().currentDestination?.id == R.id.conversationFragment) { + val action = ConversationFragmentDirections.actionConversationFragmentToConversationForwardMessageFragment() + findNavController().navigate(action) + } } layout.setReplyClickListener { diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt index 586391133..999513be8 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt @@ -254,18 +254,6 @@ class ConversationsListFragment : AbstractMainFragment() { } } - sharedViewModel.messageToForwardEvent.observe(viewLifecycleOwner) { event -> - if (!event.consumed()) { - // Do not consume it yet - val message = getString( - R.string.conversations_message_waiting_to_be_forwarded_toast - ) - val icon = R.drawable.forward - (requireActivity() as GenericActivity).showGreenToast(message, icon) - Log.i("$TAG Found a message waiting to be forwarded") - } - } - sharedViewModel.filesToShareFromIntent.observe(viewLifecycleOwner) { filesToShare -> val count = filesToShare.size if (count > 0) { diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ConversationContactOrSuggestionModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationContactOrSuggestionModel.kt new file mode 100644 index 000000000..71ee77e62 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationContactOrSuggestionModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.main.chat.model + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.core.Address +import org.linphone.core.Friend +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.utils.AppUtils +import org.linphone.utils.LinphoneUtils + +class ConversationContactOrSuggestionModel @WorkerThread constructor( + val address: Address, + val localAddress: Address? = null, + private val conversationSubject: String? = null, + val friend: Friend? = null, + private val onClicked: ((Address) -> Unit)? = null +) { + val id = friend?.refKey ?: address.asStringUriOnly().hashCode() + + val name = conversationSubject + ?: if (friend != null) { + friend.name ?: LinphoneUtils.getDisplayName(address) + } else { + address.username.orEmpty() + } + + val sipUri = address.asStringUriOnly() + + val initials = AppUtils.getInitials(conversationSubject ?: name) + + val avatarModel = MutableLiveData() + + @UiThread + fun onClicked() { + onClicked?.invoke(address) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt new file mode 100644 index 000000000..e9d7ff0b1 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt @@ -0,0 +1,527 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.ui.main.chat.viewmodel + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import java.text.Collator +import java.util.Locale +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences +import org.linphone.R +import org.linphone.contacts.ContactsManager +import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers +import org.linphone.core.Address +import org.linphone.core.ChatRoom +import org.linphone.core.ChatRoomListenerStub +import org.linphone.core.ChatRoomParams +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.GenericViewModel +import org.linphone.ui.main.chat.model.ConversationContactOrSuggestionModel +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel +import org.linphone.ui.main.model.isEndToEndEncryptionMandatory +import org.linphone.utils.AppUtils +import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils + +class ConversationForwardMessageViewModel @UiThread constructor() : GenericViewModel() { + companion object { + private const val TAG = "[Conversation Forward Message ViewModel]" + } + + protected var magicSearchSourceFlags = MagicSearch.Source.All.toInt() + + private var currentFilter = "" + private var previousFilter = "NotSet" + + val searchFilter = MutableLiveData() + + val conversationsContactsAndSuggestionsList = MutableLiveData>() + + private var limitSearchToLinphoneAccounts = true + + private lateinit var magicSearch: MagicSearch + + val operationInProgress = MutableLiveData() + + val chatRoomCreatedEvent: MutableLiveData>> by lazy { + MutableLiveData>>() + } + + val showNumberOrAddressPickerDialogEvent: MutableLiveData>> by lazy { + MutableLiveData>>() + } + + val hideNumberOrAddressPickerDialogEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + private val magicSearchListener = object : MagicSearchListenerStub() { + @WorkerThread + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + Log.i("$TAG Magic search contacts available") + processMagicSearchResults(magicSearch.lastSearch) + } + } + + private val contactsListener = object : ContactsManager.ContactsListener { + @WorkerThread + override fun onContactsLoaded() { + Log.i("$TAG Contacts have been (re)loaded, updating list") + applyFilter( + currentFilter, + if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", + magicSearchSourceFlags + ) + } + } + + private val listener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + val address = model.address + coreContext.postOnCoreThread { + if (address != null) { + Log.i("$TAG Selected address is [${model.address.asStringUriOnly()}]") + onAddressSelected(model.address) + } + } + } + + @UiThread + override fun onLongPress(model: ContactNumberOrAddressModel) { + } + } + + private val chatRoomListener = object : ChatRoomListenerStub() { + @WorkerThread + override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) { + val state = chatRoom.state + val id = LinphoneUtils.getChatRoomId(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) + operationInProgress.postValue(false) + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + } else if (state == ChatRoom.State.CreationFailed) { + Log.e("$TAG Conversation [$id] creation has failed!") + chatRoom.removeListener(this) + operationInProgress.postValue(false) + showRedToastEvent.postValue( + Event( + Pair( + R.string.conversation_failed_to_create_toast, + R.drawable.warning_circle + ) + ) + ) + } + } + } + + init { + coreContext.postOnCoreThread { core -> + limitSearchToLinphoneAccounts = isEndToEndEncryptionMandatory() + + coreContext.contactsManager.addListener(contactsListener) + magicSearch = core.createMagicSearch() + magicSearch.limitedSearch = false + magicSearch.addListener(magicSearchListener) + } + + applyFilter(currentFilter) + } + + @UiThread + override fun onCleared() { + coreContext.postOnCoreThread { + magicSearch.removeListener(magicSearchListener) + coreContext.contactsManager.removeListener(contactsListener) + } + super.onCleared() + } + + @UiThread + fun clearFilter() { + if (searchFilter.value.orEmpty().isNotEmpty()) { + searchFilter.value = "" + } + } + + @UiThread + fun applyFilter(filter: String) { + coreContext.postOnCoreThread { + applyFilter( + filter, + if (limitSearchToLinphoneAccounts) corePreferences.defaultDomain else "", + magicSearchSourceFlags + ) + } + } + + @WorkerThread + private fun applyFilter( + filter: String, + domain: String, + sources: Int + ) { + if (previousFilter.isNotEmpty() && ( + previousFilter.length > filter.length || + (previousFilter.length == filter.length && previousFilter != filter) + ) + ) { + magicSearch.resetSearchCache() + } + currentFilter = filter + previousFilter = filter + + Log.i( + "$TAG Asking Magic search for contacts matching filter [$filter], domain [$domain] and in sources [$sources]" + ) + magicSearch.getContactsListAsync( + filter, + domain, + sources, + MagicSearch.Aggregation.Friend + ) + } + + @WorkerThread + private fun processMagicSearchResults(results: Array) { + Log.i("$TAG Processing [${results.size}] results") + + val conversationsList = arrayListOf() + for (chatRoom in LinphoneUtils.getDefaultAccount()?.chatRooms.orEmpty()) { + // Only get group conversations + if (!chatRoom.currentParams.isGroupEnabled) { + continue + } + + val found = if (currentFilter.isEmpty()) { + null + } else { + chatRoom.participants.find { + // Search in address but also in contact name if exists + val model = + coreContext.contactsManager.getContactAvatarModelForAddress(it.address) + model.contactName?.contains( + currentFilter, + ignoreCase = true + ) == true || it.address.asStringUriOnly().contains( + currentFilter, + ignoreCase = true + ) + } + } + if ( + currentFilter.isEmpty() || + found != null || + chatRoom.peerAddress.asStringUriOnly().contains(currentFilter, ignoreCase = true) || + chatRoom.subject.orEmpty().contains(currentFilter, ignoreCase = true) + ) { + val localAddress = chatRoom.localAddress + val remoteAddress = chatRoom.peerAddress + val model = ConversationContactOrSuggestionModel( + remoteAddress, + localAddress, + chatRoom.subject + ) + + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = chatRoom.subject + val avatarModel = ContactAvatarModel(fakeFriend) + avatarModel.defaultToConversationIcon.postValue(true) + + model.avatarModel.postValue(avatarModel) + conversationsList.add(model) + } + } + + val contactsList = arrayListOf() + val suggestionsList = arrayListOf() + + for (result in results) { + val address = result.address + if (address != null) { + val friend = coreContext.contactsManager.findContactByAddress(address) + if (friend != null) { + val model = ConversationContactOrSuggestionModel(address, friend = friend) + val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress( + address + ) + model.avatarModel.postValue(avatarModel) + + contactsList.add(model) + } else { + // If user-input generated result (always last) already exists, don't show it again + if (result.sourceFlags == MagicSearch.Source.Request.toInt()) { + val found = suggestionsList.find { + it.address.weakEqual(address) + } + if (found != null) { + Log.i( + "$TAG Result generated from user input is a duplicate of an existing solution, preventing double" + ) + 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) { + coreContext.startAudioCall(address) + } + + suggestionsList.add(model) + } + } + } + + val collator = Collator.getInstance(Locale.getDefault()) + contactsList.sortWith { model1, model2 -> + collator.compare(model1.name, model2.name) + } + suggestionsList.sortWith { model1, model2 -> + collator.compare(model1.name, model2.name) + } + + val list = arrayListOf() + list.addAll(conversationsList) + list.addAll(contactsList) + list.addAll(suggestionsList) + conversationsContactsAndSuggestionsList.postValue(list) + Log.i( + "$TAG Processed [${results.size}] results, including [${conversationsList.size}] conversations, [${contactsList.size}] contacts and [${suggestionsList.size}] suggestions" + ) + } + + @WorkerThread + private fun onAddressSelected(address: Address) { + hideNumberOrAddressPickerDialogEvent.postValue(Event(true)) + + createOneToOneChatRoomWith(address) + + if (searchFilter.value.orEmpty().isNotEmpty()) { + // Clear filter after it was used + coreContext.postOnMainThread { + clearFilter() + } + } + } + + @UiThread + fun handleClickOnModel(model: ConversationContactOrSuggestionModel) { + coreContext.postOnCoreThread { core -> + if (model.localAddress != null) { + Log.i("$TAG User clicked on an existing conversation") + chatRoomCreatedEvent.postValue( + Event( + Pair( + model.localAddress.asStringUriOnly(), + model.address.asStringUriOnly() + ) + ) + ) + if (searchFilter.value.orEmpty().isNotEmpty()) { + // Clear filter after it was used + coreContext.postOnMainThread { + clearFilter() + } + } + return@postOnCoreThread + } + + val friend = model.friend + if (friend == null) { + Log.i("$TAG Friend is null, using address [${model.address}]") + onAddressSelected(model.address) + return@postOnCoreThread + } + + val addressesCount = friend.addresses.size + val numbersCount = friend.phoneNumbers.size + + // Do not consider phone numbers if default account is in secure mode + val enablePhoneNumbers = !isEndToEndEncryptionMandatory() + + if (addressesCount == 1 && (numbersCount == 0 || !enablePhoneNumbers)) { + val address = friend.addresses.first() + Log.i("$TAG Only 1 SIP address found for contact [${friend.name}], using it") + onAddressSelected(address) + } else if (addressesCount == 0 && numbersCount == 1 && enablePhoneNumbers) { + val number = friend.phoneNumbers.first() + val address = core.interpretUrl(number, LinphoneUtils.applyInternationalPrefix()) + if (address != null) { + Log.i("$TAG Only 1 phone number found for contact [${friend.name}], using it") + onAddressSelected(address) + } else { + Log.e("$TAG Failed to interpret phone number [$number] as SIP address") + } + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(listener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + + showNumberOrAddressPickerDialogEvent.postValue(Event(list)) + coreContext.postOnMainThread { + } + } + } + } + + @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 + } + + operationInProgress.postValue(true) + + val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams() + params.isGroupEnabled = false + params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject) + params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default + + val sameDomain = remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain + if (isEndToEndEncryptionMandatory() && sameDomain) { + Log.i("$TAG Account is in secure mode & domain matches, creating a E2E conversation") + params.backend = ChatRoom.Backend.FlexisipChat + params.isEncryptionEnabled = true + } else if (!isEndToEndEncryptionMandatory()) { + if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) { + Log.i( + "$TAG Account is in interop mode but LIME is available, creating a E2E conversation" + ) + params.backend = ChatRoom.Backend.FlexisipChat + params.isEncryptionEnabled = true + } else { + Log.i( + "$TAG Account is in interop mode but LIME isn't available, creating a SIP simple conversation" + ) + params.backend = ChatRoom.Backend.Basic + params.isEncryptionEnabled = false + } + } else { + Log.e( + "$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]" + ) + operationInProgress.postValue(false) + showRedToastEvent.postValue( + Event( + Pair( + 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, localAddress, participants) + if (chatRoom != null) { + if (params.backend == ChatRoom.Backend.FlexisipChat) { + if (chatRoom.state == ChatRoom.State.Created) { + val id = LinphoneUtils.getChatRoomId(chatRoom) + Log.i("$TAG 1-1 conversation [$id] has been created") + operationInProgress.postValue(false) + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + } else { + Log.i("$TAG Conversation isn't in Created state yet, wait for it") + chatRoom.addListener(chatRoomListener) + } + } else { + val id = LinphoneUtils.getChatRoomId(chatRoom) + Log.i("$TAG Conversation successfully created [$id]") + operationInProgress.postValue(false) + chatRoomCreatedEvent.postValue( + Event( + Pair( + chatRoom.localAddress.asStringUriOnly(), + chatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + } + } else { + Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!") + operationInProgress.postValue(false) + showRedToastEvent.postValue( + Event( + Pair( + 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!" + ) + operationInProgress.postValue(false) + chatRoomCreatedEvent.postValue( + Event( + Pair( + existingChatRoom.localAddress.asStringUriOnly(), + existingChatRoom.peerAddress.asStringUriOnly() + ) + ) + ) + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt index e03d0f4be..956620968 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt @@ -394,6 +394,10 @@ class SendMessageInConversationViewModel @UiThread constructor() : GenericViewMo val forwardedMessage = chatRoom.createForwardMessage(messageToForward) Log.i("$TAG Sending forwarded message") forwardedMessage.send() + + showGreenToastEvent.postValue( + Event(Pair(R.string.conversation_message_forwarded_toast, R.drawable.forward)) + ) } } } diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt index c2bf653ef..939c79ed1 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt @@ -85,7 +85,7 @@ abstract class AddressSelectionViewModel @UiThread constructor() : DefaultAccoun multipleSelectionMode.value = false coreContext.postOnCoreThread { core -> - limitSearchToLinphoneAccounts = isEndToEndEncryptionMandatory() ?: false + limitSearchToLinphoneAccounts = isEndToEndEncryptionMandatory() coreContext.contactsManager.addListener(contactsListener) magicSearch = core.createMagicSearch() diff --git a/app/src/main/res/layout/chat_bubble_content_grid_cell.xml b/app/src/main/res/layout/chat_bubble_content_grid_cell.xml index 923445249..ff46732d8 100644 --- a/app/src/main/res/layout/chat_bubble_content_grid_cell.xml +++ b/app/src/main/res/layout/chat_bubble_content_grid_cell.xml @@ -91,7 +91,7 @@ android:layout_height="wrap_content" android:layout_marginStart="5dp" android:text="@{model.audioVideoDuration, default=`00:42`}" - android:textColor="@color/white" + android:textColor="@color/main2_600" android:textSize="12sp" android:visibility="@{model.isAudio && model.audioVideoDuration.length() > 0 ? View.VISIBLE : View.GONE, default=gone}" app:layout_constraintBottom_toBottomOf="@id/file_icon" diff --git a/app/src/main/res/layout/chat_message_forward_contact_list_cell.xml b/app/src/main/res/layout/chat_message_forward_contact_list_cell.xml new file mode 100644 index 000000000..7ebdc0022 --- /dev/null +++ b/app/src/main/res/layout/chat_message_forward_contact_list_cell.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_message_forward_conversation_list_cell.xml b/app/src/main/res/layout/chat_message_forward_conversation_list_cell.xml new file mode 100644 index 000000000..6ca96de31 --- /dev/null +++ b/app/src/main/res/layout/chat_message_forward_conversation_list_cell.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_message_forward_fragment.xml b/app/src/main/res/layout/chat_message_forward_fragment.xml new file mode 100644 index 000000000..f47fc0124 --- /dev/null +++ b/app/src/main/res/layout/chat_message_forward_fragment.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_message_forward_suggestion_list_cell.xml b/app/src/main/res/layout/chat_message_forward_suggestion_list_cell.xml new file mode 100644 index 000000000..96ed2e62c --- /dev/null +++ b/app/src/main/res/layout/chat_message_forward_suggestion_list_cell.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/chat_nav_graph.xml b/app/src/main/res/navigation/chat_nav_graph.xml index 00390cdb3..dcf50e0ae 100644 --- a/app/src/main/res/navigation/chat_nav_graph.xml +++ b/app/src/main/res/navigation/chat_nav_graph.xml @@ -59,6 +59,14 @@ app:exitAnim="@anim/slide_out_left" app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e80db35fa..66d56747a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -492,6 +492,11 @@ Médias partagés Documents partagés + Transférer à… + Le message a été transféré + Transfert du message abandonné + Conversations + Lu %s Reçu %s Envoyé %s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6ebb9af4..ec3502469 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -529,6 +529,11 @@ Shared media Shared documents + Forward message to… + Message was forwarded + Message forward was cancelled + Conversations + Read %s Received %s Sent %s