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