From a2355e322531ed926a48cc6d3a5d764b1daa121a Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Mon, 16 Oct 2023 15:11:16 +0200 Subject: [PATCH] Started conversation info fragment --- .../chat/adapter/ConversationEventAdapter.kt | 4 +- .../chat/fragment/ConversationFragment.kt | 8 + .../chat/fragment/ConversationInfoFragment.kt | 118 +++++++ .../ui/main/chat/model/ConversationModel.kt | 4 +- .../ui/main/chat/model/ParticipantModel.kt | 27 ++ .../viewmodel/ConversationInfoViewModel.kt | 263 ++++++++++++++ .../chat/viewmodel/ConversationViewModel.kt | 11 +- .../main/contacts/model/ContactAvatarModel.kt | 5 +- .../org/linphone/utils/DataBindingUtils.kt | 10 +- .../res/layout/chat_conversation_fragment.xml | 12 + .../main/res/layout/chat_info_fragment.xml | 326 ++++++++++++++++++ .../res/layout/chat_participant_list_cell.xml | 104 ++++++ .../main/res/navigation/chat_nav_graph.xml | 20 ++ app/src/main/res/values/strings.xml | 5 + 14 files changed, 905 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt create mode 100644 app/src/main/res/layout/chat_info_fragment.xml create mode 100644 app/src/main/res/layout/chat_participant_list_cell.xml diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt index 0e7d8b4c5..93bf866ba 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt @@ -161,10 +161,12 @@ class ConversationEventAdapter( override fun areContentsTheSame(oldItem: EventLogModel, newItem: EventLogModel): Boolean { return if (oldItem.isEvent && newItem.isEvent) { true - } else { + } else if (!oldItem.isEvent && !newItem.isEvent) { val oldModel = (newItem.model as ChatMessageModel) val newModel = newItem.model oldModel.statusIcon.value == newModel.statusIcon.value + } else { + false } } } 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 dd17d0b08..5df4f2060 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 @@ -168,6 +168,14 @@ class ConversationFragment : GenericFragment() { emojisBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED }*/ } + + binding.setGoToInfoClickListener { + val action = ConversationFragmentDirections.actionConversationFragmentToConversationInfoFragment( + localSipUri, + remoteSipUri + ) + findNavController().navigate(action) + } } private fun showChatMessageLongPressMenu(chatMessageModel: ChatMessageModel) { diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt new file mode 100644 index 000000000..4b5e8aeb2 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt @@ -0,0 +1,118 @@ +/* + * 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.annotation.UiThread +import androidx.core.view.doOnPreDraw +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatInfoFragmentBinding +import org.linphone.ui.main.chat.viewmodel.ConversationInfoViewModel +import org.linphone.ui.main.fragment.GenericFragment + +@UiThread +class ConversationInfoFragment : GenericFragment() { + companion object { + private const val TAG = "[Conversation Info Fragment]" + } + + private lateinit var binding: ChatInfoFragmentBinding + + private lateinit var viewModel: ConversationInfoViewModel + + private val args: ConversationInfoFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatInfoFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun goBack(): Boolean { + findNavController().popBackStack() + return true + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // This fragment is displayed in a SlidingPane "child" area + isSlidingPaneChild = true + + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + viewModel = ViewModelProvider(this)[ConversationInfoViewModel::class.java] + binding.viewModel = viewModel + + val localSipUri = args.localSipUri + val remoteSipUri = args.remoteSipUri + Log.i( + "$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" + ) + viewModel.findChatRoom(localSipUri, remoteSipUri) + + viewModel.chatRoomFoundEvent.observe(viewLifecycleOwner) { + it.consume { found -> + if (found) { + Log.i( + "$TAG Found matching chat room for local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" + ) + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } else { + (view.parent as? ViewGroup)?.doOnPreDraw { + Log.e("$TAG Failed to find chat room, going back") + goBack() + } + } + } + } + + viewModel.groupLeftEvent.observe(viewLifecycleOwner) { + it.consume { + // TODO: show toast ? + Log.i("$TAG Group has been left, leaving conversation info...") + goBack() + } + } + + viewModel.historyDeletedEvent.observe(viewLifecycleOwner) { + it.consume { + // TODO: show toast ? + Log.i("$TAG History has been deleted, leaving conversation info...") + goBack() + } + } + + binding.setBackClickListener { + goBack() + } + } +} 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 index 3416cb875..0b6157fd4 100644 --- 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 @@ -131,7 +131,7 @@ class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom val friend = coreContext.contactsManager.findContactByAddress( participant.address ) - if (friend != null) { + if (friend != null && !friends.contains(friend)) { friends.add(friend) } } @@ -146,7 +146,7 @@ class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom if (isGroup) { val fakeFriend = coreContext.core.createFriend() val model = ContactAvatarModel(fakeFriend) - model.addPicturesFromFriends(friends) + model.setPicturesFromFriends(friends) avatarModel.postValue(model) } else { val friend = coreContext.contactsManager.findContactByAddress(address) diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt new file mode 100644 index 000000000..4a0a37975 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt @@ -0,0 +1,27 @@ +/* + * 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 org.linphone.core.Friend +import org.linphone.ui.main.contacts.model.ContactAvatarModel + +class ParticipantModel(friend: Friend, val isMyselfAdmin: Boolean, val isParticipantAdmin: Boolean) : ContactAvatarModel( + friend +) diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt new file mode 100644 index 000000000..6274da003 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt @@ -0,0 +1,263 @@ +/* + * 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 androidx.lifecycle.ViewModel +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.Address +import org.linphone.core.ChatRoom +import org.linphone.core.ChatRoomListenerStub +import org.linphone.core.EventLog +import org.linphone.core.Factory +import org.linphone.core.Friend +import org.linphone.core.tools.Log +import org.linphone.ui.main.chat.model.ParticipantModel +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils + +class ConversationInfoViewModel @UiThread constructor() : ViewModel() { + companion object { + private const val TAG = "[Conversation Info ViewModel]" + } + + val avatarModel = MutableLiveData() + + val participants = MutableLiveData>() + + val isGroup = MutableLiveData() + + val subject = MutableLiveData() + + val isReadOnly = MutableLiveData() + + val isMyselfAdmin = MutableLiveData() + + val isMuted = MutableLiveData() + + val expandParticipants = MutableLiveData() + + val chatRoomFoundEvent = MutableLiveData>() + + val groupLeftEvent = MutableLiveData>() + + val historyDeletedEvent = MutableLiveData>() + + private lateinit var chatRoom: ChatRoom + + private val avatarsMap = hashMapOf() + + private val chatRoomListener = object : ChatRoomListenerStub() { + @WorkerThread + override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) { + computeParticipantsList(isGroup.value == true) + } + + @WorkerThread + override fun onParticipantRemoved(chatRoom: ChatRoom, eventLog: EventLog) { + computeParticipantsList(isGroup.value == true) + } + + @WorkerThread + override fun onParticipantAdminStatusChanged(chatRoom: ChatRoom, eventLog: EventLog) { + computeParticipantsList(isGroup.value == true) + } + + @WorkerThread + override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) { + subject.postValue(chatRoom.subject) + } + } + + init { + expandParticipants.value = true + } + + override fun onCleared() { + super.onCleared() + + coreContext.postOnCoreThread { + if (::chatRoom.isInitialized) { + chatRoom.removeListener(chatRoomListener) + } + + avatarModel.value?.destroy() + avatarsMap.values.forEach(ParticipantModel::destroy) + } + } + + @UiThread + fun findChatRoom(localSipUri: String, remoteSipUri: String) { + coreContext.postOnCoreThread { core -> + Log.i( + "$TAG Looking for chat room with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" + ) + + val localAddress = Factory.instance().createAddress(localSipUri) + val remoteAddress = Factory.instance().createAddress(remoteSipUri) + if (localAddress != null && remoteAddress != null) { + val found = core.searchChatRoom( + null, + localAddress, + remoteAddress, + arrayOfNulls( + 0 + ) + ) + if (found != null) { + chatRoom = found + chatRoom.addListener(chatRoomListener) + + configureChatRoom() + chatRoomFoundEvent.postValue(Event(true)) + } else { + Log.e("$TAG Failed to find chat room given local & remote addresses!") + chatRoomFoundEvent.postValue(Event(false)) + } + } else { + Log.e("$TAG Failed to parse local or remote SIP URI as Address!") + chatRoomFoundEvent.postValue(Event(false)) + } + } + } + + @UiThread + fun leaveGroup() { + coreContext.postOnCoreThread { + if (::chatRoom.isInitialized) { + Log.i("$TAG Leaving chat room [${LinphoneUtils.getChatRoomId(chatRoom)}]") + chatRoom.leave() + } + groupLeftEvent.postValue(Event(true)) + } + } + + @UiThread + fun deleteHistory() { + coreContext.postOnCoreThread { + // TODO: confirmation dialog ? + if (::chatRoom.isInitialized) { + Log.i("$TAG Cleaning chat room [${LinphoneUtils.getChatRoomId(chatRoom)}] history") + chatRoom.deleteHistory() + } + historyDeletedEvent.postValue(Event(true)) + } + } + + @UiThread + fun toggleMute() { + coreContext.postOnCoreThread { + chatRoom.muted = !chatRoom.muted + isMuted.postValue(chatRoom.muted) + } + } + + @UiThread + fun toggleParticipantsExpand() { + expandParticipants.value = expandParticipants.value == false + } + + @WorkerThread + private fun configureChatRoom() { + isMuted.postValue(chatRoom.muted) + + isMyselfAdmin.postValue(chatRoom.me?.isAdmin) + + val isGroupChatRoom = !chatRoom.hasCapability(ChatRoom.Capabilities.OneToOne.toInt()) && + chatRoom.hasCapability(ChatRoom.Capabilities.Conference.toInt()) + isGroup.postValue(isGroupChatRoom) + + val empty = chatRoom.hasCapability(ChatRoom.Capabilities.Conference.toInt()) && chatRoom.participants.isEmpty() + val readOnly = chatRoom.isReadOnly || empty + isReadOnly.postValue(readOnly) + if (readOnly) { + Log.w("$TAG Chat room with subject [${chatRoom.subject}] is read only!") + } + + subject.postValue(chatRoom.subject) + + computeParticipantsList(isGroupChatRoom) + } + + @WorkerThread + private fun computeParticipantsList(isGroupChatRoom: Boolean) { + avatarModel.value?.destroy() + avatarsMap.values.forEach(ParticipantModel::destroy) + + val friends = arrayListOf() + val participantsList = arrayListOf() + if (chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())) { + val model = getParticipantModelForAddress(chatRoom.peerAddress, false) + friends.add(model.friend) + participantsList.add(model) + } else { + for (participant in chatRoom.participants) { + val model = getParticipantModelForAddress( + participant.address, + if (isGroup.value == true) participant.isAdmin else false + ) + friends.add(model.friend) + participantsList.add(model) + } + } + + val avatar = if (isGroupChatRoom) { + val fakeFriend = coreContext.core.createFriend() + ContactAvatarModel(fakeFriend) + } else { + participantsList.first() + } + avatar.setPicturesFromFriends(friends) + avatarModel.postValue(avatar) + + participants.postValue(participantsList) + } + + @WorkerThread + private fun getParticipantModelForAddress(address: Address?, isAdmin: Boolean): ParticipantModel { + Log.i("$TAG Looking for participant model with address [${address?.asStringUriOnly()}]") + if (address == null) { + val fakeFriend = coreContext.core.createFriend() + return ParticipantModel(fakeFriend, isMyselfAdmin.value == true, false) + } + + val clone = address.clone() + clone.clean() + val key = clone.asStringUriOnly() + + val foundInMap = if (avatarsMap.keys.contains(key)) avatarsMap[key] else null + if (foundInMap != null) return foundInMap + + val friend = coreContext.contactsManager.findContactByAddress(clone) + val avatar = if (friend != null) { + ParticipantModel(friend, isMyselfAdmin.value == true, isAdmin) + } else { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.address = clone + ParticipantModel(fakeFriend, isMyselfAdmin.value == true, isAdmin) + } + + avatarsMap[key] = avatar + return avatar + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt index 688e77c7e..1a57c81c0 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt @@ -133,6 +133,7 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { coreContext.postOnCoreThread { chatRoom.removeListener(chatRoomListener) + avatarModel.value?.destroy() events.value.orEmpty().forEach(EventLogModel::destroy) avatarsMap.values.forEach(ContactAvatarModel::destroy) } @@ -163,11 +164,11 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { configureChatRoom() chatRoomFoundEvent.postValue(Event(true)) } else { - Log.e("Failed to find chat room given local & remote addresses!") + Log.e("$TAG Failed to find chat room given local & remote addresses!") chatRoomFoundEvent.postValue(Event(false)) } } else { - Log.e("Failed to parse local or remote SIP URI as Address!") + Log.e("$TAG Failed to parse local or remote SIP URI as Address!") chatRoomFoundEvent.postValue(Event(false)) } } @@ -233,7 +234,7 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { } else { for (participant in chatRoom.participants) { val friend = coreContext.contactsManager.findContactByAddress(participant.address) - if (friend != null) { + if (friend != null && !friends.contains(friend)) { friends.add(friend) } } @@ -248,7 +249,7 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { } else { getAvatarModelForAddress(address) } - avatar.addPicturesFromFriends(friends) + avatar.setPicturesFromFriends(friends) avatarModel.postValue(avatar) val history = chatRoom.getHistoryEvents(0) @@ -330,7 +331,7 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { @WorkerThread private fun getAvatarModelForAddress(address: Address?): ContactAvatarModel { - Log.i("Looking for avatar model with address [${address?.asStringUriOnly()}]") + Log.i("$TAG Looking for avatar model with address [${address?.asStringUriOnly()}]") if (address == null) { val fakeFriend = coreContext.core.createFriend() return ContactAvatarModel(fakeFriend) diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt index a4b91f266..469ebbac9 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt @@ -36,7 +36,7 @@ import org.linphone.ui.main.model.isInSecureMode import org.linphone.utils.AppUtils import org.linphone.utils.TimestampUtils -class ContactAvatarModel @WorkerThread constructor(val friend: Friend) : AbstractAvatarModel() { +open class ContactAvatarModel @WorkerThread constructor(val friend: Friend) : AbstractAvatarModel() { companion object { private const val TAG = "[Contact Avatar Model]" } @@ -83,10 +83,9 @@ class ContactAvatarModel @WorkerThread constructor(val friend: Friend) : Abstrac } @WorkerThread - fun addPicturesFromFriends(friends: List) { + fun setPicturesFromFriends(friends: List) { if (friends.isNotEmpty()) { val list = arrayListOf() - list.addAll(images.value.orEmpty()) for (friend in friends) { list.add(getAvatarUri(friend).toString()) } diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index d50d22209..436ccd5f8 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -350,7 +350,7 @@ private suspend fun loadContactPictureWithCoil( } ) } - } else if (count > 1) { + } else { val w = if (size > 0) { AppUtils.getDimension(size).toInt() } else { @@ -409,6 +409,14 @@ private suspend fun loadContactPictureWithCoil( imageView.load(bitmap) } + } else { + imageView.load( + ResourcesCompat.getDrawable( + context.resources, + R.drawable.user_circle, + context.theme + ) + ) } } } diff --git a/app/src/main/res/layout/chat_conversation_fragment.xml b/app/src/main/res/layout/chat_conversation_fragment.xml index 433e84186..203570ac0 100644 --- a/app/src/main/res/layout/chat_conversation_fragment.xml +++ b/app/src/main/res/layout/chat_conversation_fragment.xml @@ -9,6 +9,15 @@ + + + @@ -82,6 +91,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_participant_list_cell.xml b/app/src/main/res/layout/chat_participant_list_cell.xml new file mode 100644 index 000000000..9b8e37917 --- /dev/null +++ b/app/src/main/res/layout/chat_participant_list_cell.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 851aa32e4..e24a18af3 100644 --- a/app/src/main/res/navigation/chat_nav_graph.xml +++ b/app/src/main/res/navigation/chat_nav_graph.xml @@ -22,6 +22,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a22109e10..6a3283a38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -341,6 +341,11 @@ %s are composing… + Group members + Add participants + Admin + Delete history + No meeting for the moment… New meeting Meeting