From 8a62a68d6e846d0705b338a88061b1ef85c1e48d Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Wed, 11 Oct 2023 15:53:57 +0200 Subject: [PATCH] Added group avatar for group chat rooms --- .../chat/adapter/ConversationEventAdapter.kt | 114 +++++++----------- .../ui/main/chat/model/ConversationModel.kt | 15 ++- .../chat/viewmodel/ConversationViewModel.kt | 18 ++- .../main/contacts/model/GroupAvatarModel.kt | 81 +++++++++++++ .../org/linphone/utils/DataBindingUtils.kt | 39 ++++++ .../res/layout/chat_conversation_fragment.xml | 33 ++++- app/src/main/res/layout/chat_list_cell.xml | 35 +++++- 7 files changed, 259 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/main/contacts/model/GroupAvatarModel.kt 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 8c83d07e4..2a3307c9b 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 @@ -112,45 +112,52 @@ class ConversationEventAdapter( selectedAdapterPosition = -1 } + fun groupPreviousItem(item: ChatMessageModel, position: Int): Boolean { + return if (position == 0) { + false + } else { + val previous = position - 1 + if (getItemViewType(position) == getItemViewType(previous)) { + val previousItem = getItem(previous).data as ChatMessageModel + if (kotlin.math.abs(item.timestamp - previousItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { + previousItem.fromSipUri == item.fromSipUri + } else { + false + } + } else { + false + } + } + } + + fun isLastItemOfGroup(item: ChatMessageModel, position: Int): Boolean { + return if (position == itemCount - 1) { + true + } else { + val next = position + 1 + if (getItemViewType(next) == getItemViewType(position)) { + val nextItem = getItem(next).data as ChatMessageModel + if (kotlin.math.abs(item.timestamp - nextItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { + nextItem.fromSipUri != item.fromSipUri + } else { + true + } + } else { + true + } + } + } + inner class IncomingBubbleViewHolder( val binding: ChatBubbleIncomingBinding ) : RecyclerView.ViewHolder(binding.root) { - fun bind(chatMessageData: ChatMessageModel) { + fun bind(message: ChatMessageModel) { with(binding) { - model = chatMessageData + model = message val position = bindingAdapterPosition - isGroupedWithPreviousOne = if (position == 0) { - false - } else { - val previous = position - 1 - if (getItemViewType(previous) == INCOMING_CHAT_MESSAGE) { - val previousItem = getItem(previous).data as ChatMessageModel - if (kotlin.math.abs(chatMessageData.timestamp - previousItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { - previousItem.fromSipUri == chatMessageData.fromSipUri - } else { - false - } - } else { - false - } - } - - isLastOneOfGroup = if (position == itemCount - 1) { - true - } else { - val next = position + 1 - if (getItemViewType(next) == INCOMING_CHAT_MESSAGE) { - val nextItem = getItem(next).data as ChatMessageModel - if (kotlin.math.abs(chatMessageData.timestamp - nextItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { - nextItem.fromSipUri != chatMessageData.fromSipUri - } else { - true - } - } else { - true - } - } + isGroupedWithPreviousOne = groupPreviousItem(message, position) + isLastOneOfGroup = isLastItemOfGroup(message, position) lifecycleOwner = viewLifecycleOwner executePendingBindings() @@ -161,42 +168,13 @@ class ConversationEventAdapter( inner class OutgoingBubbleViewHolder( val binding: ChatBubbleOutgoingBinding ) : RecyclerView.ViewHolder(binding.root) { - fun bind(chatMessageData: ChatMessageModel) { + fun bind(message: ChatMessageModel) { with(binding) { - model = chatMessageData + model = message val position = bindingAdapterPosition - isGroupedWithPreviousOne = if (position == 0) { - false - } else { - val previous = position - 1 - if (getItemViewType(previous) == OUTGOING_CHAT_MESSAGE) { - val previousItem = getItem(previous).data as ChatMessageModel - if (kotlin.math.abs(chatMessageData.timestamp - previousItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { - previousItem.fromSipUri == chatMessageData.fromSipUri - } else { - false - } - } else { - false - } - } - - isLastOneOfGroup = if (position == itemCount - 1) { - true - } else { - val next = position + 1 - if (getItemViewType(next) == INCOMING_CHAT_MESSAGE) { - val nextItem = getItem(next).data as ChatMessageModel - if (kotlin.math.abs(chatMessageData.timestamp - nextItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { - nextItem.fromSipUri != chatMessageData.fromSipUri - } else { - true - } - } else { - true - } - } + isGroupedWithPreviousOne = groupPreviousItem(message, position) + isLastOneOfGroup = isLastItemOfGroup(message, position) lifecycleOwner = viewLifecycleOwner executePendingBindings() @@ -206,9 +184,9 @@ class ConversationEventAdapter( inner class EventViewHolder( val binding: ChatEventBinding ) : RecyclerView.ViewHolder(binding.root) { - fun bind(eventData: EventModel) { + fun bind(event: EventModel) { with(binding) { - model = eventData + model = event lifecycleOwner = viewLifecycleOwner executePendingBindings() 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 075c474e7..039efa696 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 @@ -27,8 +27,10 @@ import org.linphone.R import org.linphone.core.ChatMessage import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom.Capabilities +import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.contacts.model.GroupAvatarModel import org.linphone.utils.AppUtils import org.linphone.utils.LinphoneUtils import org.linphone.utils.TimestampUtils @@ -72,10 +74,13 @@ class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom val avatarModel: ContactAvatarModel + val groupAvatarModel: GroupAvatarModel + init { subject.postValue(chatRoom.subject) lastUpdateTime.postValue(chatRoom.lastUpdateTime) + val friends = arrayListOf() val address = if (chatRoom.hasCapability(Capabilities.Basic.toInt())) { Log.i("$TAG Chat room [$id] is 'Basic'") chatRoom.peerAddress @@ -83,6 +88,14 @@ class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom val firstParticipant = chatRoom.participants.firstOrNull() if (isGroup) { Log.i("$TAG Group chat room [$id] has [${chatRoom.nbParticipants}] participant(s)") + for (participant in chatRoom.participants) { + val friend = coreContext.contactsManager.findContactByAddress( + participant.address + ) + if (friend != null) { + friends.add(friend) + } + } } else { Log.i( "$TAG Chat room [$id] is with participant [${firstParticipant?.address?.asStringUriOnly()}]" @@ -90,7 +103,6 @@ class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom } firstParticipant?.address ?: chatRoom.peerAddress } - val friend = coreContext.contactsManager.findContactByAddress(address) if (friend != null) { avatarModel = ContactAvatarModel(friend) @@ -99,6 +111,7 @@ class ConversationModel @WorkerThread constructor(private val chatRoom: ChatRoom fakeFriend.address = address avatarModel = ContactAvatarModel(fakeFriend) } + groupAvatarModel = GroupAvatarModel(friends) isMuted.postValue(chatRoom.muted) isEphemeral.postValue(chatRoom.isEphemeralEnabled) 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 4182cf29b..366234de4 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 @@ -27,9 +27,11 @@ import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.Address import org.linphone.core.ChatRoom import org.linphone.core.Factory +import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.EventLogModel import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.contacts.model.GroupAvatarModel import org.linphone.utils.Event class ConversationViewModel @UiThread constructor() : ViewModel() { @@ -41,6 +43,8 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { val avatarModel = MutableLiveData() + val groupAvatarModel = MutableLiveData() + val events = MutableLiveData>() val isGroup = MutableLiveData() @@ -106,14 +110,24 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { ) subject.postValue(chatRoom.subject) + val friends = arrayListOf() val address = if (chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())) { chatRoom.peerAddress } else { + for (participant in chatRoom.participants) { + val friend = coreContext.contactsManager.findContactByAddress(participant.address) + if (friend != null) { + friends.add(friend) + } + } + val firstParticipant = chatRoom.participants.firstOrNull() firstParticipant?.address ?: chatRoom.peerAddress } - - avatarModel.postValue(getAvatarModelForAddress(address)) + val avatar = getAvatarModelForAddress(address) + avatarModel.postValue(avatar) + val groupAvatar = GroupAvatarModel(friends) + groupAvatarModel.postValue(groupAvatar) val eventsList = arrayListOf() diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/GroupAvatarModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/GroupAvatarModel.kt new file mode 100644 index 000000000..61d95a3db --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/GroupAvatarModel.kt @@ -0,0 +1,81 @@ +/* + * 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.contacts.model + +import android.content.ContentUris +import android.net.Uri +import android.provider.ContactsContract +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.core.ChatRoom.SecurityLevel +import org.linphone.core.Friend +import org.linphone.core.tools.Log + +class GroupAvatarModel @WorkerThread constructor(friends: ArrayList) { + companion object { + private const val TAG = "[Group Avatar Model]" + } + + val trust = MutableLiveData() + + val uris = MutableLiveData>() + + init { + trust.postValue(SecurityLevel.Safe) // TODO FIXME: use API + + val list = arrayListOf() + Log.d("$TAG [${friends.size}] friends to use") + for (friend in friends) { + val uri = getAvatarUri(friend) + if (uri != null) { + if (!list.contains(uri)) { + list.add(uri) + } + } + } + uris.postValue(list.toList()) + } + + @WorkerThread + private fun getAvatarUri(friend: Friend): Uri? { + val picturePath = friend.photo + if (!picturePath.isNullOrEmpty()) { + return Uri.parse(picturePath) + } + + val refKey = friend.refKey + if (refKey != null) { + try { + val lookupUri = ContentUris.withAppendedId( + ContactsContract.Contacts.CONTENT_URI, + refKey.toLong() + ) + return Uri.withAppendedPath( + lookupUri, + ContactsContract.Contacts.Photo.CONTENT_DIRECTORY + ) + } catch (numberFormatException: NumberFormatException) { + // Expected for contacts created by Linphone + } + } + + return null + } +} diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index bcb82b3d3..9be41448a 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -52,6 +52,7 @@ import org.linphone.core.ChatRoom import org.linphone.core.ConsolidatedPresence import org.linphone.core.tools.Log import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.contacts.model.GroupAvatarModel import org.linphone.ui.main.model.AccountModel /** @@ -311,6 +312,44 @@ fun AvatarView.loadContactAvatar(contact: ContactAvatarModel?) { onSuccess = { _, _ -> // If loading is successful, remove initials otherwise image won't be visible avatarInitials = "" + }, + onError = { _, result -> + Log.e("[Contact Avatar Model] Can't load data: ${result.throwable}") + } + ) + } +} + +@UiThread +@BindingAdapter("groupAvatar") +fun AvatarView.loadGroupAvatar(contact: GroupAvatarModel?) { + if (contact == null) { + loadImage(R.drawable.user_circle) + } else { + val uris = contact.uris.value.orEmpty() + loadImage( + data = uris, + onStart = { + when (contact.trust.value) { + ChatRoom.SecurityLevel.Unsafe -> { + avatarBorderColor = + resources.getColor(R.color.red_danger_500, context.theme) + avatarBorderWidth = + AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() + } + ChatRoom.SecurityLevel.Encrypted -> { + avatarBorderColor = + resources.getColor(R.color.blue_info_500, context.theme) + avatarBorderWidth = + AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() + } + else -> { + avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() + } + } + }, + onError = { _, result -> + Log.e("[Group Avatar Model] Can't load data: ${result.throwable}") } ) } diff --git a/app/src/main/res/layout/chat_conversation_fragment.xml b/app/src/main/res/layout/chat_conversation_fragment.xml index 71b12c898..065ccba3a 100644 --- a/app/src/main/res/layout/chat_conversation_fragment.xml +++ b/app/src/main/res/layout/chat_conversation_fragment.xml @@ -50,6 +50,7 @@ android:layout_marginStart="5dp" android:adjustViewBounds="true" android:background="@drawable/shape_circle_light_blue_background" + android:visibility="@{viewModel.isGroup ? View.GONE : View.VISIBLE}" contactAvatar="@{viewModel.avatarModel}" app:avatarViewPlaceholder="@drawable/user_circle" app:avatarViewInitialsBackgroundColor="@color/gray_main2_200" @@ -67,10 +68,38 @@ android:layout_width="@dimen/avatar_presence_badge_size" android:layout_height="@dimen/avatar_presence_badge_size" android:src="@{viewModel.avatarModel.trust == SecurityLevel.Encrypted ? @drawable/trusted : @drawable/not_trusted, default=@drawable/trusted}" - android:visibility="@{viewModel.avatarModel.trust == SecurityLevel.Encrypted || viewModel.avatarModel.trust == SecurityLevel.Unsafe ? View.VISIBLE : View.GONE}" + android:visibility="@{!viewModel.isGroup && viewModel.avatarModel.trust == SecurityLevel.Encrypted || viewModel.avatarModel.trust == SecurityLevel.Unsafe ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="@id/avatar" app:layout_constraintBottom_toBottomOf="@id/avatar"/> + + + + @@ -66,10 +67,38 @@ android:layout_width="@dimen/avatar_presence_badge_size" android:layout_height="@dimen/avatar_presence_badge_size" android:src="@{model.avatarModel.trust == SecurityLevel.Encrypted ? @drawable/trusted : @drawable/not_trusted, default=@drawable/trusted}" - android:visibility="@{model.avatarModel.trust == SecurityLevel.Encrypted || model.avatarModel.trust == SecurityLevel.Unsafe ? View.VISIBLE : View.GONE}" + android:visibility="@{!model.isGroup && model.avatarModel.trust == SecurityLevel.Encrypted || model.avatarModel.trust == SecurityLevel.Unsafe ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="@id/avatar" app:layout_constraintBottom_toBottomOf="@id/avatar"/> + + + +