Added group avatar for group chat rooms

This commit is contained in:
Sylvain Berfini 2023-10-11 15:53:57 +02:00
parent 3dc23d906e
commit 8a62a68d6e
7 changed files with 259 additions and 76 deletions

View file

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

View file

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

View file

@ -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<ContactAvatarModel>()
val groupAvatarModel = MutableLiveData<GroupAvatarModel>()
val events = MutableLiveData<ArrayList<EventLogModel>>()
val isGroup = MutableLiveData<Boolean>()
@ -106,14 +110,24 @@ class ConversationViewModel @UiThread constructor() : ViewModel() {
)
subject.postValue(chatRoom.subject)
val friends = arrayListOf<Friend>()
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<EventLogModel>()

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<Friend>) {
companion object {
private const val TAG = "[Group Avatar Model]"
}
val trust = MutableLiveData<SecurityLevel>()
val uris = MutableLiveData<List<Uri>>()
init {
trust.postValue(SecurityLevel.Safe) // TODO FIXME: use API
val list = arrayListOf<Uri>()
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
}
}

View file

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

View file

@ -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 &amp;&amp; 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"/>
<io.getstream.avatarview.AvatarView
android:id="@+id/group_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:layout_marginStart="5dp"
android:adjustViewBounds="true"
android:background="@drawable/shape_circle_light_blue_background"
android:visibility="@{viewModel.isGroup ? View.VISIBLE : View.GONE, default=gone}"
groupAvatar="@{viewModel.groupAvatarModel}"
app:avatarViewPlaceholder="@drawable/users_three"
app:avatarViewMaxSectionSize="four"
app:avatarViewShape="circle"
app:avatarViewBorderWidth="0dp"
app:layout_constraintBottom_toBottomOf="@id/back"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="@id/back" />
<ImageView
android:id="@+id/group_trust_badge"
android:layout_width="@dimen/avatar_presence_badge_size"
android:layout_height="@dimen/avatar_presence_badge_size"
android:src="@{viewModel.groupAvatarModel.trust == SecurityLevel.Encrypted ? @drawable/trusted : @drawable/not_trusted, default=@drawable/trusted}"
android:visibility="@{viewModel.isGroup &amp;&amp; viewModel.groupAvatarModel.trust == SecurityLevel.Encrypted || viewModel.avatarModel.trust == SecurityLevel.Unsafe ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintStart_toStartOf="@id/group_avatar"
app:layout_constraintBottom_toBottomOf="@id/group_avatar"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/title"
@ -85,7 +114,7 @@
android:textColor="@color/gray_main2_600"
android:gravity="center_vertical"
app:layout_constraintEnd_toStartOf="@id/call"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintStart_toEndOf="@id/group_avatar"
app:layout_constraintTop_toTopOf="parent"/>
<ImageView

View file

@ -38,6 +38,7 @@
android:adjustViewBounds="true"
android:background="@drawable/shape_circle_light_blue_background"
contactAvatar="@{model.avatarModel}"
android:visibility="@{model.isGroup ? View.GONE : View.VISIBLE}"
app:avatarViewPlaceholder="@drawable/user_circle"
app:avatarViewInitialsBackgroundColor="@color/gray_main2_200"
app:avatarViewInitialsTextColor="@color/gray_main2_600"
@ -57,7 +58,7 @@
android:background="@drawable/led_background"
android:padding="@dimen/avatar_presence_badge_padding"
app:presenceIcon="@{model.avatarModel.presenceStatus}"
android:visibility="@{model.avatarModel.presenceStatus == ConsolidatedPresence.Offline ? View.GONE : View.VISIBLE}"
android:visibility="@{model.isGroup || model.avatarModel.presenceStatus == ConsolidatedPresence.Offline ? View.GONE : View.VISIBLE}"
app:layout_constraintEnd_toEndOf="@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 &amp;&amp; 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"/>
<io.getstream.avatarview.AvatarView
android:id="@+id/group_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:layout_marginStart="5dp"
android:adjustViewBounds="true"
android:background="@drawable/shape_circle_light_blue_background"
android:visibility="@{model.isGroup ? View.VISIBLE : View.GONE, default=gone}"
groupAvatar="@{model.groupAvatarModel}"
app:avatarViewPlaceholder="@drawable/users_three"
app:avatarViewMaxSectionSize="four"
app:avatarViewShape="circle"
app:avatarViewBorderWidth="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/group_trust_badge"
android:layout_width="@dimen/avatar_presence_badge_size"
android:layout_height="@dimen/avatar_presence_badge_size"
android:src="@{model.groupAvatarModel.trust == SecurityLevel.Encrypted ? @drawable/trusted : @drawable/not_trusted, default=@drawable/trusted}"
android:visibility="@{model.isGroup &amp;&amp; model.groupAvatarModel.trust == SecurityLevel.Encrypted || model.avatarModel.trust == SecurityLevel.Unsafe ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintStart_toStartOf="@id/group_avatar"
app:layout_constraintBottom_toBottomOf="@id/group_avatar"/>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/right_border"
android:layout_width="wrap_content"
@ -91,7 +120,7 @@
android:textSize="14sp"
android:textColor="@color/gray_main2_800"
android:textStyle="@{model.unreadMessageCount > 0 ? Typeface.BOLD : Typeface.NORMAL}"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintStart_toEndOf="@id/group_avatar"
app:layout_constraintEnd_toStartOf="@id/right_border"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/last_message_or_composing"/>