Reworked chat message forward UI/UX

This commit is contained in:
Sylvain Berfini 2024-06-14 10:09:54 +02:00
parent a858cffc82
commit 5b80833f30
17 changed files with 1373 additions and 17 deletions

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<ConversationContactOrSuggestionModel, RecyclerView.ViewHolder>(
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<Event<ConversationContactOrSuggestionModel>> by lazy {
MutableLiveData<Event<ConversationContactOrSuggestionModel>>()
}
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<ConversationContactOrSuggestionModel>() {
override fun areItemsTheSame(
oldItem: ConversationContactOrSuggestionModel,
newItem: ConversationContactOrSuggestionModel
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: ConversationContactOrSuggestionModel,
newItem: ConversationContactOrSuggestionModel
): Boolean {
return false
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<ContactNumberOrAddressModel>) {
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
val dialog =
DialogUtils.getNumberOrAddressPickerDialog(
requireActivity(),
numberOrAddressModel
)
numberOrAddressPickerDialog = dialog
numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event ->
event.consume {
dialog.dismiss()
}
}
dialog.show()
}
}

View file

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

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<ContactAvatarModel>()
@UiThread
fun onClicked() {
onClicked?.invoke(address)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<String>()
val conversationsContactsAndSuggestionsList = MutableLiveData<ArrayList<ConversationContactOrSuggestionModel>>()
private var limitSearchToLinphoneAccounts = true
private lateinit var magicSearch: MagicSearch
val operationInProgress = MutableLiveData<Boolean>()
val chatRoomCreatedEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
}
val showNumberOrAddressPickerDialogEvent: MutableLiveData<Event<ArrayList<ContactNumberOrAddressModel>>> by lazy {
MutableLiveData<Event<ArrayList<ContactNumberOrAddressModel>>>()
}
val hideNumberOrAddressPickerDialogEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
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<SearchResult>) {
Log.i("$TAG Processing [${results.size}] results")
val conversationsList = arrayListOf<ConversationContactOrSuggestionModel>()
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<ConversationContactOrSuggestionModel>()
val suggestionsList = arrayListOf<ConversationContactOrSuggestionModel>()
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<ConversationContactOrSuggestionModel>()
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()
)
)
)
}
}
}

View file

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

View file

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

View file

@ -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 &amp;&amp; model.audioVideoDuration.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/file_icon"

View file

@ -0,0 +1,92 @@
<?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" />
<import type="android.graphics.Typeface" />
<variable
name="model"
type="org.linphone.ui.main.contacts.model.ContactAvatarModel" />
<variable
name="onClickListener"
type="View.OnClickListener" />
<variable
name="firstContactStartingByThatLetter"
type="Boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{onClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp">
<ImageView
android:id="@+id/background"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="5dp"
android:src="@drawable/primary_cell_background"
android:contentDescription="@null"
app:layout_constraintStart_toEndOf="@id/header"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_500"
android:id="@+id/header"
android:layout_width="25dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@{model.firstLetter, default=`A`}"
android:visibility="@{firstContactStartingByThatLetter ? View.VISIBLE : View.INVISIBLE}"
android:textColor="?attr/color_main2_400"
android:textSize="20sp"
android:gravity="center"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<include
android:id="@+id/avatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
layout="@layout/contact_avatar"
bind:model="@{model}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/header"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{model.name, default=`John Doe`}"
android:textSize="14sp"
android:layout_marginStart="10dp"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<View
android:id="@+id/separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginEnd="10dp"
android:background="?attr/color_main2_200"
android:visibility="gone"
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,74 @@
<?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" />
<import type="android.graphics.Typeface" />
<variable
name="onClickListener"
type="View.OnClickListener" />
<variable
name="model"
type="org.linphone.ui.main.chat.model.ConversationContactOrSuggestionModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{onClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp">
<ImageView
android:id="@+id/background"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="5dp"
android:src="@drawable/primary_cell_background"
android:contentDescription="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<include
android:id="@+id/avatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
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="wrap_content"
android:layout_height="wrap_content"
android:text="@{model.name, default=`Friends group`}"
android:textSize="14sp"
android:layout_marginStart="10dp"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<View
android:id="@+id/separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginEnd="10dp"
android:background="?attr/color_main2_200"
android:visibility="gone"
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,110 @@
<?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="backClickListener"
type="View.OnClickListener" />
<variable
name="viewModel"
type="org.linphone.ui.main.chat.viewmodel.ConversationForwardMessageViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/color_main2_000">
<ImageView
android:id="@+id/back"
android:layout_width="wrap_content"
android:layout_height="@dimen/top_bar_height"
android:padding="15dp"
android:adjustViewBounds="true"
android:onClick="@{backClickListener}"
android:src="@drawable/caret_left"
android:contentDescription="@string/content_description_go_back_icon"
app:tint="?attr/color_main1_500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/main_page_title_style"
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="@dimen/top_bar_height"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:text="@string/conversation_forward_message_title"
android:textColor="?attr/color_main1_500"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/back"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.appcompat.widget.AppCompatEditText
style="@style/default_text_style"
android:id="@+id/search_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="20dp"
android:background="@drawable/edit_text_background"
android:drawableStart="@drawable/magnifying_glass"
android:drawablePadding="10dp"
android:drawableTint="?attr/color_main2_600"
android:hint="@string/new_conversation_search_bar_filter_hint"
android:inputType="textPersonName|textNoSuggestions"
android:paddingStart="15dp"
android:paddingTop="10dp"
android:paddingEnd="15dp"
android:paddingBottom="10dp"
android:text="@={viewModel.searchFilter}"
android:textSize="14sp"
app:layout_constraintHeight_min="48dp"
app:layout_constraintWidth_max="@dimen/text_input_max_width"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<ImageView
android:id="@+id/clear_field"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:layout_marginEnd="15dp"
android:onClick="@{() -> viewModel.clearFilter()}"
android:src="@drawable/x"
android:contentDescription="@string/content_description_clear_filter"
android:visibility="@{viewModel.searchFilter.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/search_bar"
app:layout_constraintEnd_toEndOf="@id/search_bar"
app:layout_constraintTop_toTopOf="@id/search_bar"
app:tint="?attr/color_main2_600" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contacts_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:visibility="@{viewModel.conversationsContactsAndSuggestionsList.size() == 0 ? View.GONE : View.VISIBLE}"
app:layout_constraintTop_toBottomOf="@id/search_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<include
layout="@layout/operation_in_progress"
bind:visibility="@{viewModel.operationInProgress}" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -0,0 +1,62 @@
<?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">
<data>
<import type="android.view.View" />
<variable
name="onClickListener"
type="View.OnClickListener" />
<variable
name="model"
type="org.linphone.ui.main.chat.model.ConversationContactOrSuggestionModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{onClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/primary_cell_background">
<ImageView
style="@style/avatar_imageview"
android:id="@+id/avatar"
android:layout_width="@dimen/avatar_list_cell_size"
android:layout_height="@dimen/avatar_list_cell_size"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:contentDescription="@null"
coilInitials="@{model.initials, default=`JD`}"
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="wrap_content"
android:layout_height="wrap_content"
android:text="@{model.sipUri, default=`john.doe@sip.linphone.org`}"
android:textSize="14sp"
android:layout_marginStart="10dp"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<View
android:id="@+id/separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginEnd="10dp"
android:background="?attr/color_main2_200"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -59,6 +59,14 @@
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
<action
android:id="@+id/action_conversationFragment_to_conversationForwardMessageFragment"
app:destination="@id/conversationForwardMessageFragment"
app:launchSingleTop="true"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
</fragment>
<action
@ -143,4 +151,21 @@
app:argType="string" />
</fragment>
<fragment
android:id="@+id/conversationForwardMessageFragment"
android:name="org.linphone.ui.main.chat.fragment.ConversationForwardMessageFragment"
android:label="ConversationForwardMessageFragment"
tools:layout="@layout/chat_message_forward_fragment">
<action
android:id="@+id/action_conversationForwardMessageFragment_to_conversationFragment"
app:destination="@id/conversationFragment"
app:popUpTo="@id/conversationFragment"
app:popUpToInclusive="true"
app:launchSingleTop="true"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
</fragment>
</navigation>

View file

@ -492,6 +492,11 @@
<string name="conversation_media_list_title">Médias partagés</string>
<string name="conversation_document_list_title">Documents partagés</string>
<string name="conversation_forward_message_title">Transférer à…</string>
<string name="conversation_message_forwarded_toast">Le message a été transféré</string>
<string name="conversation_message_forward_cancelled_toast">Transfert du message abandonné</string>
<string name="conversation_message_forward_conversations_list_title">Conversations</string>
<string name="message_delivery_info_read_title">Lu %s</string>
<string name="message_delivery_info_received_title">Reçu %s</string>
<string name="message_delivery_info_sent_title">Envoyé %s</string>

View file

@ -529,6 +529,11 @@
<string name="conversation_media_list_title">Shared media</string>
<string name="conversation_document_list_title">Shared documents</string>
<string name="conversation_forward_message_title">Forward message to…</string>
<string name="conversation_message_forwarded_toast">Message was forwarded</string>
<string name="conversation_message_forward_cancelled_toast">Message forward was cancelled</string>
<string name="conversation_message_forward_conversations_list_title">Conversations</string>
<string name="message_delivery_info_read_title">Read %s</string>
<string name="message_delivery_info_received_title">Received %s</string>
<string name="message_delivery_info_sent_title">Sent %s</string>