From 0d880dda501502eccf3d491d5cb6fd9dda5c4e40 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Thu, 5 Oct 2023 12:32:08 +0200 Subject: [PATCH] Started to display conversations list with long press menu --- .../notifications/NotificationsManager.kt | 28 +-- .../chat/adapter/ConversationsListAdapter.kt | 86 ++++++++ .../fragment/ConversationDialogFragment.kt | 92 ++++++++ .../chat/fragment/ConversationsFragment.kt | 125 +++++++++++ .../fragment/ConversationsListFragment.kt | 158 ++++++++++++++ .../ui/main/chat/model/ConversationModel.kt | 206 ++++++++++++++++++ .../viewmodel/ConversationsListViewModel.kt | 132 +++++++++++ .../contacts/fragment/ContactsFragment.kt | 12 + .../contacts/fragment/ContactsListFragment.kt | 2 +- .../ui/main/fragment/BottomNavBarFragment.kt | 5 +- .../main/history/fragment/HistoryFragment.kt | 14 +- .../history/fragment/HistoryListFragment.kt | 2 +- .../ui/main/viewmodel/SharedMainViewModel.kt | 4 + .../java/org/linphone/utils/LinphoneUtils.kt | 25 +++ app/src/main/res/drawable/bell_simple.xml | 9 + .../main/res/drawable/bell_simple_slash.xml | 9 + app/src/main/res/drawable/checks.xml | 9 + app/src/main/res/drawable/clock_countdown.xml | 9 + app/src/main/res/drawable/in_progress.xml | 9 + app/src/main/res/drawable/sent.xml | 15 ++ app/src/main/res/drawable/sign_out.xml | 9 + .../main/res/layout-land/bottom_nav_bar.xml | 1 - .../main/res/layout-land/chat_fragment.xml | 42 ++++ .../res/layout-land/chat_list_fragment.xml | 122 +++++++++++ app/src/main/res/layout/bottom_nav_bar.xml | 1 - app/src/main/res/layout/chat_fragment.xml | 32 +++ app/src/main/res/layout/chat_list_cell.xml | 195 +++++++++++++++++ .../main/res/layout/chat_list_fragment.xml | 122 +++++++++++ .../main/res/layout/chat_long_press_menu.xml | 109 +++++++++ .../main/res/navigation/chat_nav_graph.xml | 19 ++ .../main/res/navigation/main_nav_graph.xml | 30 +++ app/src/main/res/values/dimen.xml | 1 + app/src/main/res/values/strings.xml | 9 + 33 files changed, 1611 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationDialogFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt create mode 100644 app/src/main/res/drawable/bell_simple.xml create mode 100644 app/src/main/res/drawable/bell_simple_slash.xml create mode 100644 app/src/main/res/drawable/checks.xml create mode 100644 app/src/main/res/drawable/clock_countdown.xml create mode 100644 app/src/main/res/drawable/in_progress.xml create mode 100644 app/src/main/res/drawable/sent.xml create mode 100644 app/src/main/res/drawable/sign_out.xml create mode 100644 app/src/main/res/layout-land/chat_fragment.xml create mode 100644 app/src/main/res/layout-land/chat_list_fragment.xml create mode 100644 app/src/main/res/layout/chat_fragment.xml create mode 100644 app/src/main/res/layout/chat_list_cell.xml create mode 100644 app/src/main/res/layout/chat_list_fragment.xml create mode 100644 app/src/main/res/layout/chat_long_press_menu.xml create mode 100644 app/src/main/res/navigation/chat_nav_graph.xml diff --git a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt index 63c8edfbd..830daa074 100644 --- a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt +++ b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt @@ -488,7 +488,7 @@ class NotificationsManager @MainThread constructor(private val context: Context) coreContext.contactsManager.findContactByAddress(address) val displayName = contact?.name ?: LinphoneUtils.getDisplayName(address) - val originalMessage = getTextDescribingMessage(message) + val originalMessage = LinphoneUtils.getTextDescribingMessage(message) val text = AppUtils.getString(R.string.notification_chat_message_reaction_received).format( displayName, reaction, @@ -583,7 +583,7 @@ class NotificationsManager @MainThread constructor(private val context: Context) coreContext.contactsManager.findContactByAddress(message.fromAddress) val displayName = contact?.name ?: LinphoneUtils.getDisplayName(message.fromAddress) - val text = getTextDescribingMessage(message) + val text = LinphoneUtils.getTextDescribingMessage(message) val notifiableMessage = NotifiableMessage( text, contact, @@ -1003,30 +1003,6 @@ class NotificationsManager @MainThread constructor(private val context: Context) notificationManager.createNotificationChannel(channel) } - @WorkerThread - private fun getTextDescribingMessage(message: ChatMessage): String { - // If message contains text, then use that - var text = message.contents.find { content -> content.isText }?.utf8Text ?: "" - - if (text.isEmpty()) { - val firstContent = message.contents.firstOrNull() - if (firstContent?.isIcalendar == true) { - text = "meeting invite" // TODO: use translated string - } else if (firstContent?.isVoiceRecording == true) { - text = "voice message" // TODO: use translated string - } else { - for (content in message.contents) { - if (text.isNotEmpty()) { - text += ", " - } - text += content.name - } - } - } - - return text - } - class Notifiable(val notificationId: Int) { var myself: String? = null var callId: String? = null diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt new file mode 100644 index 000000000..edd4dc87e --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationsListAdapter.kt @@ -0,0 +1,86 @@ +package org.linphone.ui.main.chat.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.linphone.R +import org.linphone.databinding.ChatListCellBinding +import org.linphone.ui.main.chat.model.ConversationModel +import org.linphone.utils.Event + +class ConversationsListAdapter( + private val viewLifecycleOwner: LifecycleOwner +) : ListAdapter(ChatRoomDiffCallback()) { + var selectedAdapterPosition = -1 + + val conversationClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val conversationLongClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding: ChatListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_list_cell, + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as ViewHolder).bind(getItem(position)) + } + + fun resetSelection() { + notifyItemChanged(selectedAdapterPosition) + selectedAdapterPosition = -1 + } + + inner class ViewHolder( + val binding: ChatListCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationModel: ConversationModel) { + with(binding) { + model = conversationModel + + lifecycleOwner = viewLifecycleOwner + + binding.root.isSelected = bindingAdapterPosition == selectedAdapterPosition + + binding.setOnClickListener { + conversationClickedEvent.value = Event(conversationModel) + } + + binding.setOnLongClickListener { + selectedAdapterPosition = bindingAdapterPosition + binding.root.isSelected = true + conversationLongClickedEvent.value = Event(conversationModel) + true + } + + executePendingBindings() + } + } + } + + private class ChatRoomDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { + return oldItem.id == newItem.id && oldItem.lastUpdateTime == newItem.lastUpdateTime + } + + override fun areContentsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { + return oldItem.avatarModel.id == newItem.avatarModel.id + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationDialogFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationDialogFragment.kt new file mode 100644 index 000000000..79d6d59ea --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationDialogFragment.kt @@ -0,0 +1,92 @@ +/* + * 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.fragment + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.linphone.databinding.ChatLongPressMenuBinding + +@UiThread +class ConversationDialogFragment( + private val isMuted: Boolean, + private val isGroup: Boolean, + private val onDismiss: (() -> Unit)? = null, + private val onMarkConversationAsRead: (() -> Unit)? = null, + private val onToggleMute: (() -> Unit)? = null, + private val onCall: (() -> Unit)? = null, + private val onDeleteConversation: (() -> Unit)? = null, + private val onLeaveGroup: (() -> Unit)? = null +) : BottomSheetDialogFragment() { + companion object { + const val TAG = "ConversationDialogFragment" + } + + override fun onCancel(dialog: DialogInterface) { + onDismiss?.invoke() + super.onCancel(dialog) + } + + override fun onDismiss(dialog: DialogInterface) { + onDismiss?.invoke() + super.onDismiss(dialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = ChatLongPressMenuBinding.inflate(layoutInflater) + view.isMuted = isMuted + view.isGroup = isGroup + + view.setMarkAsReadClickListener { + onMarkConversationAsRead?.invoke() + dismiss() + } + + view.setToggleMuteClickListener { + onToggleMute?.invoke() + dismiss() + } + + view.setCallClickListener { + onCall?.invoke() + dismiss() + } + + view.setDeleteClickListener { + onDeleteConversation?.invoke() + dismiss() + } + + view.setLeaveClickListener { + onLeaveGroup?.invoke() + dismiss() + } + + return view.root + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt new file mode 100644 index 000000000..e88dd0cb2 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt @@ -0,0 +1,125 @@ +/* + * 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.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import androidx.core.view.doOnPreDraw +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import androidx.slidingpanelayout.widget.SlidingPaneLayout +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatFragmentBinding +import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.utils.SlidingPaneBackPressedCallback + +class ConversationsFragment : GenericFragment() { + companion object { + private const val TAG = "[Conversations Fragment]" + } + + private lateinit var binding: ChatFragmentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { + /*if (findNavController().currentDestination?.id == R.id.newConversationFragment) { + // Holds fragment in place while new contact fragment slides over it + return AnimationUtils.loadAnimation(activity, R.anim.hold) + }*/ + return super.onCreateAnimation(transit, enter, nextAnim) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + binding.root.doOnPreDraw { + val slidingPane = binding.slidingPaneLayout + slidingPane.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED + + sharedViewModel.isSlidingPaneSlideable.value = slidingPane.isSlideable + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + SlidingPaneBackPressedCallback(slidingPane) + ) + } + + sharedViewModel.closeSlidingPaneEvent.observe( + viewLifecycleOwner + ) { + it.consume { + Log.i("$TAG Closing sliding pane") + binding.slidingPaneLayout.closePane() + } + } + + sharedViewModel.openSlidingPaneEvent.observe( + viewLifecycleOwner + ) { + it.consume { + Log.i("$TAG Opening sliding pane") + binding.slidingPaneLayout.openPane() + } + } + + sharedViewModel.navigateToContactsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.conversationsFragment) { + // To prevent any previously seen conversation to show up when navigating back to here later + binding.chatNavContainer.findNavController().popBackStack() + + val action = ConversationsFragmentDirections.actionConversationsFragmentToContactsFragment() + findNavController().navigate(action) + } + } + } + + sharedViewModel.navigateToCallsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.conversationsFragment) { + // To prevent any previously seen conversation to show up when navigating back to here later + binding.chatNavContainer.findNavController().popBackStack() + + val action = ConversationsFragmentDirections.actionConversationsFragmentToHistoryFragment() + findNavController().navigate(action) + } + } + } + } + + override fun onResume() { + super.onResume() + sharedViewModel.currentlyDisplayedFragment.value = R.id.conversationsFragment + } +} 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 new file mode 100644 index 000000000..ace1bfa21 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt @@ -0,0 +1,158 @@ +/* + * 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.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatListFragmentBinding +import org.linphone.ui.main.chat.adapter.ConversationsListAdapter +import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel +import org.linphone.ui.main.fragment.AbstractTopBarFragment +import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment +import org.linphone.utils.hideKeyboard +import org.linphone.utils.showKeyboard + +class ConversationsListFragment : AbstractTopBarFragment() { + companion object { + private const val TAG = "[Conversations List Fragment]" + } + + private lateinit var binding: ChatListFragmentBinding + + private lateinit var listViewModel: ConversationsListViewModel + + private lateinit var adapter: ConversationsListAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatListFragmentBinding.inflate(layoutInflater) + return binding.root + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + listViewModel = requireActivity().run { + ViewModelProvider(this)[ConversationsListViewModel::class.java] + } + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = listViewModel + + binding.setOnNewConversationClicked { + // TODO: open start conversation fragment + } + + adapter = ConversationsListAdapter(viewLifecycleOwner) + binding.conversationsList.setHasFixedSize(true) + binding.conversationsList.adapter = adapter + + val layoutManager = LinearLayoutManager(requireContext()) + binding.conversationsList.layoutManager = layoutManager + + adapter.conversationLongClickedEvent.observe(viewLifecycleOwner) { + it.consume { model -> + val modalBottomSheet = ConversationDialogFragment( + model.isMuted.value == true, + model.isGroup, + { // onDismiss + adapter.resetSelection() + }, + { // onMarkConversationAsRead + Log.i("$TAG Marking conversation [${model.id}] as read") + model.markAsRead() + }, + { // onToggleMute + Log.i("$TAG Changing mute status of conversation [${model.id}]") + model.toggleMute() + }, + { // onCall + Log.i("$TAG Calling conversation [${model.id}]") + model.call() + }, + { // onDeleteConversation + Log.i("$TAG Deleting conversation [${model.id}]") + model.delete() + listViewModel.applyFilter() + }, + { // onLeaveGroup + Log.i("$TAG Leaving group conversation [${model.id}]") + model.leaveGroup() + } + ) + modalBottomSheet.show(parentFragmentManager, HistoryMenuDialogFragment.TAG) + } + } + + adapter.conversationClickedEvent.observe(viewLifecycleOwner) { + it.consume { model -> + Log.i("$TAG Show conversation with ID [${model.id}]") + } + } + + listViewModel.conversations.observe(viewLifecycleOwner) { + val currentCount = adapter.itemCount + adapter.submitList(it) + Log.i("$TAG Conversations list ready with [${it.size}] items") + + if (currentCount < it.size) { + binding.conversationsList.scrollToPosition(0) + } + } + + sharedViewModel.defaultAccountChangedEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i( + "$TAG Default account changed, updating avatar in top bar & re-computing conversations" + ) + } + } + + // TopBarFragment related + + setViewModelAndTitle( + listViewModel, + getString(R.string.bottom_navigation_conversations_label) + ) + + listViewModel.searchFilter.observe(viewLifecycleOwner) { filter -> + listViewModel.applyFilter(filter.trim()) + } + + listViewModel.focusSearchBarEvent.observe(viewLifecycleOwner) { + it.consume { show -> + if (show) { + // To automatically open keyboard + binding.topBar.search.showKeyboard() + } else { + binding.topBar.search.hideKeyboard() + } + } + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt new file mode 100644 index 000000000..6bf27cbd3 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt @@ -0,0 +1,206 @@ +/* + * 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.LinphoneApplication.Companion.coreContext +import org.linphone.R +import org.linphone.core.ChatMessage +import org.linphone.core.ChatRoom +import org.linphone.core.ChatRoom.Capabilities +import org.linphone.core.tools.Log +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.utils.AppUtils +import org.linphone.utils.LinphoneUtils +import org.linphone.utils.TimestampUtils + +class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom) { + companion object { + private const val TAG = "[Conversation Model]" + } + + val id = LinphoneUtils.getChatRoomId(chatRoom) + + val isGroup = !chatRoom.hasCapability(Capabilities.OneToOne.toInt()) + + val lastUpdateTime = MutableLiveData() + + val isComposing = MutableLiveData() + + val isMuted = MutableLiveData() + + val isEphemeral = MutableLiveData() + + val composingLabel = MutableLiveData() + + val lastMessage = MutableLiveData() + + val lastMessageIcon = MutableLiveData() + + val isLastMessageOutgoing = MutableLiveData() + + val dateTime = MutableLiveData() + + val unreadMessageCount = MutableLiveData() + + val avatarModel: ContactAvatarModel + + init { + lastUpdateTime.postValue(chatRoom.lastUpdateTime) + + val address = if (chatRoom.hasCapability(Capabilities.Basic.toInt())) { + Log.i("$TAG Chat room [$id] is 'Basic'") + chatRoom.peerAddress + } else { + val firstParticipant = chatRoom.participants.firstOrNull() + if (isGroup) { + Log.i("$TAG Group chat room [$id] has [${chatRoom.nbParticipants}] participant(s)") + } else { + Log.i( + "$TAG Chat room [$id] is with participant [${firstParticipant?.address?.asStringUriOnly()}]" + ) + } + firstParticipant?.address ?: chatRoom.peerAddress + } + + val friend = coreContext.contactsManager.findContactByAddress(address) + if (friend != null) { + avatarModel = ContactAvatarModel(friend) + } else { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.address = address + avatarModel = ContactAvatarModel(fakeFriend) + } + + isMuted.postValue(chatRoom.muted) + isEphemeral.postValue(chatRoom.isEphemeralEnabled) + + updateLastMessage() + + updateLastUpdatedTime() + + unreadMessageCount.postValue(chatRoom.unreadMessagesCount) + } + + @UiThread + fun markAsRead() { + coreContext.postOnCoreThread { + chatRoom.markAsRead() + unreadMessageCount.postValue(chatRoom.unreadMessagesCount) + Log.i("$TAG Conversation [$id] has been marked as read") + } + } + + @UiThread + fun toggleMute() { + coreContext.postOnCoreThread { + chatRoom.muted = !chatRoom.muted + val muted = chatRoom.muted + if (muted) { + Log.i("$TAG Conversation [$id] is now muted") + } else { + Log.i("$TAG Conversation [$id] is no longer muted") + } + isMuted.postValue(muted) + } + } + + @UiThread + fun call() { + coreContext.postOnCoreThread { + val address = chatRoom.participants.firstOrNull()?.address ?: chatRoom.peerAddress + Log.i("$TAG Calling [${address.asStringUriOnly()}]") + coreContext.startCall(address) + } + } + + @UiThread + fun delete() { + coreContext.postOnCoreThread { core -> + core.deleteChatRoom(chatRoom) + Log.i("$TAG Conversation [$id] has been deleted") + } + } + + @UiThread + fun leaveGroup() { + coreContext.postOnCoreThread { + chatRoom.leave() + Log.i("$TAG Group conversation [$id] has been leaved") + } + } + + @WorkerThread + private fun updateLastMessage() { + val message = chatRoom.lastMessageInHistory + if (message != null) { + val text = LinphoneUtils.getTextDescribingMessage(message) + lastMessage.postValue(text) + + val isOutgoing = message.isOutgoing + isLastMessageOutgoing.postValue(isOutgoing) + if (isOutgoing) { + val icon = when (message.state) { + ChatMessage.State.Displayed -> { + R.drawable.checks + } + ChatMessage.State.DeliveredToUser -> { + R.drawable.check + } + ChatMessage.State.Delivered -> { + R.drawable.sent + } + ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress -> { + R.drawable.in_progress + } + ChatMessage.State.NotDelivered, ChatMessage.State.FileTransferError -> { + R.drawable.warning_circle + } + else -> { + R.drawable.info + } + } + lastMessageIcon.postValue(icon) + } + } else { + Log.w("$TAG No last message to display for chat room [$id]") + } + } + + @WorkerThread + private fun updateLastUpdatedTime() { + val timestamp = chatRoom.lastUpdateTime + val humanReadableTimestamp = when { + TimestampUtils.isToday(timestamp) -> { + TimestampUtils.timeToString(chatRoom.lastUpdateTime) + } + TimestampUtils.isYesterday(timestamp) -> { + val time = TimestampUtils.timeToString(chatRoom.lastUpdateTime) + AppUtils.getFormattedString(R.string.conversation_yesterday_timestamp, time) + } + else -> { + TimestampUtils.toString(chatRoom.lastUpdateTime, onlyDate = true) + } + } + dateTime.postValue(humanReadableTimestamp) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt new file mode 100644 index 000000000..faf19ef50 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt @@ -0,0 +1,132 @@ +/* + * 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.viewmodel + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.contacts.ContactsManager +import org.linphone.core.CallLog +import org.linphone.core.ChatRoom.Capabilities +import org.linphone.core.Core +import org.linphone.core.CoreListenerStub +import org.linphone.core.tools.Log +import org.linphone.ui.main.chat.model.ConversationModel +import org.linphone.ui.main.model.isInSecureMode +import org.linphone.ui.main.viewmodel.AbstractTopBarViewModel +import org.linphone.utils.LinphoneUtils + +class ConversationsListViewModel @UiThread constructor() : AbstractTopBarViewModel() { + companion object { + private const val TAG = "[Conversations List ViewModel]" + } + + val conversations = MutableLiveData>() + + val fetchInProgress = MutableLiveData() + + private var currentFilter = "" + + private val coreListener = object : CoreListenerStub() { + override fun onCallLogUpdated(core: Core, callLog: CallLog) { + computeChatRoomsList(currentFilter) + } + } + + private val contactsListener = object : ContactsManager.ContactsListener { + @WorkerThread + override fun onContactsLoaded() { + Log.i("$TAG Contacts have been (re)loaded, updating list") + computeChatRoomsList(currentFilter) + } + } + + init { + coreContext.postOnCoreThread { core -> + coreContext.contactsManager.addListener(contactsListener) + core.addListener(coreListener) + + computeChatRoomsList(currentFilter) + } + } + + @UiThread + override fun onCleared() { + super.onCleared() + + coreContext.postOnCoreThread { core -> + coreContext.contactsManager.removeListener(contactsListener) + core.removeListener(coreListener) + } + } + + @UiThread + fun applyFilter(filter: String = currentFilter) { + currentFilter = filter + + coreContext.postOnCoreThread { + computeChatRoomsList(currentFilter) + } + } + + @WorkerThread + private fun computeChatRoomsList(filter: String) { + if (conversations.value.orEmpty().isEmpty()) { + fetchInProgress.postValue(true) + } + + val list = arrayListOf() + var count = 0 + + // TODO? : Add support for chat rooms in magic search + val account = LinphoneUtils.getDefaultAccount() + val chatRooms = account?.chatRooms ?: coreContext.core.chatRooms + for (chatRoom in chatRooms) { + // TODO: remove when SDK will do it automatically + if (account?.isInSecureMode() == true) { + if (!chatRoom.hasCapability(Capabilities.Encrypted.toInt())) { + Log.w( + "$TAG Skipping chat room [${LinphoneUtils.getChatRoomId(chatRoom)}] as it is not E2E encrypted and default account requires it" + ) + continue + } + } + + val participants = chatRoom.participants + val found = participants.find { + it.address.asStringUriOnly().contains(filter) + } + if (found != null || chatRoom.peerAddress.asStringUriOnly().contains(filter)) { + val model = ConversationModel(chatRoom) + list.add(model) + count += 1 + } + + if (count == 20) { + conversations.postValue(list) + fetchInProgress.postValue(false) + } + } + + conversations.postValue(list) + fetchInProgress.postValue(false) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt index ee7453424..8235ddb7a 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt @@ -130,6 +130,18 @@ class ContactsFragment : GenericFragment() { } } } + + sharedViewModel.navigateToConversationsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.contactsFragment) { + // To prevent any previously seen contact to show up when navigating back to here later + binding.contactsNavContainer.findNavController().popBackStack() + + val action = ContactsFragmentDirections.actionContactsFragmentToConversationsFragment() + findNavController().navigate(action) + } + } + } } override fun onResume() { diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt index d96979693..a8c75ea18 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt @@ -145,7 +145,7 @@ class ContactsListFragment : AbstractTopBarFragment() { // TopBarFragment related - setViewModelAndTitle(listViewModel, "Contacts") + setViewModelAndTitle(listViewModel, getString(R.string.bottom_navigation_contacts_label)) listViewModel.searchFilter.observe(viewLifecycleOwner) { filter -> listViewModel.applyFilter(filter.trim()) diff --git a/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt index 74a4e681e..cb13b4b20 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt @@ -83,7 +83,9 @@ class BottomNavBarFragment : Fragment() { } binding.setOnConversationsClicked { - // TODO: chat feature + if (sharedViewModel.currentlyDisplayedFragment.value != R.id.conversationsFragment) { + sharedViewModel.navigateToConversationsEvent.value = Event(true) + } } binding.setOnMeetingsClicked { @@ -93,6 +95,7 @@ class BottomNavBarFragment : Fragment() { sharedViewModel.currentlyDisplayedFragment.observe(viewLifecycleOwner) { viewModel.contactsSelected.value = it == R.id.contactsFragment viewModel.callsSelected.value = it == R.id.historyFragment + viewModel.conversationsSelected.value = it == R.id.conversationsFragment } sharedViewModel.resetMissedCallsCountEvent.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt index ec43fecec..3bcfb5340 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt @@ -119,7 +119,7 @@ class HistoryFragment : GenericFragment() { sharedViewModel.navigateToContactsEvent.observe(viewLifecycleOwner) { it.consume { if (findNavController().currentDestination?.id == R.id.historyFragment) { - // To prevent any previously seen contact to show up when navigating back to here later + // To prevent any previously seen call log to show up when navigating back to here later binding.historyNavContainer.findNavController().popBackStack() val action = HistoryFragmentDirections.actionHistoryFragmentToContactsFragment() @@ -127,6 +127,18 @@ class HistoryFragment : GenericFragment() { } } } + + sharedViewModel.navigateToConversationsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.historyFragment) { + // To prevent any previously seen call log to show up when navigating back to here later + binding.historyNavContainer.findNavController().popBackStack() + + val action = HistoryFragmentDirections.actionHistoryFragmentToConversationsFragment() + findNavController().navigate(action) + } + } + } } override fun onResume() { diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt index 26ad8a6c0..eef9372a6 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt @@ -191,7 +191,7 @@ class HistoryListFragment : AbstractTopBarFragment() { // TopBarFragment related - setViewModelAndTitle(listViewModel, "Calls") + setViewModelAndTitle(listViewModel, getString(R.string.bottom_navigation_calls_label)) listViewModel.searchFilter.observe(viewLifecycleOwner) { filter -> listViewModel.applyFilter(filter.trim()) diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt index f789d5e64..7839f8f03 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt @@ -45,6 +45,10 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { MutableLiveData>() } + val navigateToConversationsEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + var currentlyDisplayedFragment = MutableLiveData() /* Top bar related */ diff --git a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt index 99e23204c..914ee3d69 100644 --- a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt +++ b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt @@ -33,6 +33,7 @@ import org.linphone.core.Address import org.linphone.core.Call import org.linphone.core.Call.Dir import org.linphone.core.Call.Status +import org.linphone.core.ChatMessage import org.linphone.core.ChatRoom import org.linphone.core.Core import org.linphone.core.tools.Log @@ -239,5 +240,29 @@ class LinphoneUtils { } } } + + @WorkerThread + fun getTextDescribingMessage(message: ChatMessage): String { + // If message contains text, then use that + var text = message.contents.find { content -> content.isText }?.utf8Text ?: "" + + if (text.isEmpty()) { + val firstContent = message.contents.firstOrNull() + if (firstContent?.isIcalendar == true) { + text = "meeting invite" // TODO: use translated string + } else if (firstContent?.isVoiceRecording == true) { + text = "voice message" // TODO: use translated string + } else { + for (content in message.contents) { + if (text.isNotEmpty()) { + text += ", " + } + text += content.name + } + } + } + + return text + } } } diff --git a/app/src/main/res/drawable/bell_simple.xml b/app/src/main/res/drawable/bell_simple.xml new file mode 100644 index 000000000..99803a93f --- /dev/null +++ b/app/src/main/res/drawable/bell_simple.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bell_simple_slash.xml b/app/src/main/res/drawable/bell_simple_slash.xml new file mode 100644 index 000000000..70b3b7728 --- /dev/null +++ b/app/src/main/res/drawable/bell_simple_slash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/checks.xml b/app/src/main/res/drawable/checks.xml new file mode 100644 index 000000000..c7b8956c3 --- /dev/null +++ b/app/src/main/res/drawable/checks.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/clock_countdown.xml b/app/src/main/res/drawable/clock_countdown.xml new file mode 100644 index 000000000..7df865794 --- /dev/null +++ b/app/src/main/res/drawable/clock_countdown.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/in_progress.xml b/app/src/main/res/drawable/in_progress.xml new file mode 100644 index 000000000..fdd29401e --- /dev/null +++ b/app/src/main/res/drawable/in_progress.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/sent.xml b/app/src/main/res/drawable/sent.xml new file mode 100644 index 000000000..a21c10136 --- /dev/null +++ b/app/src/main/res/drawable/sent.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/sign_out.xml b/app/src/main/res/drawable/sign_out.xml new file mode 100644 index 000000000..21853f598 --- /dev/null +++ b/app/src/main/res/drawable/sign_out.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout-land/bottom_nav_bar.xml b/app/src/main/res/layout-land/bottom_nav_bar.xml index cd7ae7d76..07a905307 100644 --- a/app/src/main/res/layout-land/bottom_nav_bar.xml +++ b/app/src/main/res/layout-land/bottom_nav_bar.xml @@ -79,7 +79,6 @@ style="@style/bottom_nav_bar_label_style" android:id="@+id/conversations" android:visibility="@{viewModel.hideConversations ? View.GONE : View.VISIBLE}" - android:enabled="false" android:layout_width="0dp" android:layout_height="wrap_content" android:drawableTop="@drawable/chat_text" diff --git a/app/src/main/res/layout-land/chat_fragment.xml b/app/src/main/res/layout-land/chat_fragment.xml new file mode 100644 index 000000000..a9a66f7e9 --- /dev/null +++ b/app/src/main/res/layout-land/chat_fragment.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/chat_list_fragment.xml b/app/src/main/res/layout-land/chat_list_fragment.xml new file mode 100644 index 000000000..7d9c766b7 --- /dev/null +++ b/app/src/main/res/layout-land/chat_list_fragment.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_nav_bar.xml b/app/src/main/res/layout/bottom_nav_bar.xml index 80786cf8a..02da73fae 100644 --- a/app/src/main/res/layout/bottom_nav_bar.xml +++ b/app/src/main/res/layout/bottom_nav_bar.xml @@ -87,7 +87,6 @@ style="@style/bottom_nav_bar_label_style" android:onClick="@{onConversationsClicked}" android:visibility="@{viewModel.hideConversations ? View.GONE : View.VISIBLE}" - android:enabled="false" android:id="@+id/conversations" android:layout_width="0dp" android:layout_height="wrap_content" diff --git a/app/src/main/res/layout/chat_fragment.xml b/app/src/main/res/layout/chat_fragment.xml new file mode 100644 index 000000000..c842ffcc0 --- /dev/null +++ b/app/src/main/res/layout/chat_fragment.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_list_cell.xml b/app/src/main/res/layout/chat_list_cell.xml new file mode 100644 index 000000000..d3cd23f7b --- /dev/null +++ b/app/src/main/res/layout/chat_list_cell.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_list_fragment.xml b/app/src/main/res/layout/chat_list_fragment.xml new file mode 100644 index 000000000..0aa141aed --- /dev/null +++ b/app/src/main/res/layout/chat_list_fragment.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_long_press_menu.xml b/app/src/main/res/layout/chat_long_press_menu.xml new file mode 100644 index 000000000..c1c44f18c --- /dev/null +++ b/app/src/main/res/layout/chat_long_press_menu.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 000000000..d0fda79b8 --- /dev/null +++ b/app/src/main/res/navigation/chat_nav_graph.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 960cab03f..e8cc973a8 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -17,6 +17,12 @@ app:launchSingleTop="true" app:popUpTo="@id/contactsFragment" app:popUpToInclusive="true" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index 4d22c47d1..f2251c70e 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -10,6 +10,7 @@ 355dp 300dp + 14dp 24dp 100dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5cd9c2d91..d9a67caf0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,6 +317,15 @@ Call ended Incoming call for %s + No conversation for the moment… + Mark as read + Mute + Un-mute + Call + Delete conversation + Leave the group + Yesterday at %s + Operation in progress, please wait Transfer