From 69c48071763074b97d821a2906cfc907f6fd9170 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 1 Sep 2026 19:43:08 +0200 Subject: [PATCH] fix(avatars): draw guest and bot avatars on the client fix(avatars): draw guest and bot avatars on the client Guests, email participants and bots have no avatar on the server, yet the app requested one from /index.php/avatar/guest/{name} in six places and drew its own initial or ">" in others, each place with its own idea of when a display name counts as a real name. Resolve all of them in CharacterAvatarUtils, mirroring the web client's AvatarWrapper so both clients show the same avatar for the same actor: a named guest gets the first character of their name, a bot the shell prompt, an unnamed guest the generic person icon, and the changelog and sample bots keep the app icon. Two renderers consume that decision, CharacterAvatarDrawable for views and notifications and CharacterAvatar for Compose, both scaling the character to the bounds they are given instead of to a fixed bitmap, and both taking the theme-aware colours web uses. Removes ApiUtils.getUrlForGuestAvatar together with the requests for it, TextDrawable, and the already unused ChatMessageUtils. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger [skip ci] --- .../call/components/AvatarWithFallback.kt | 71 ++++----- .../talk/chat/MentionAutocompleteAdapter.kt | 7 +- .../talk/chat/ui/ShowReactionsSheet.kt | 60 ++++--- .../talk/chat/ui/model/ChatMessageUi.kt | 6 + .../talk/chat/viewmodels/ChatViewModel.kt | 10 +- .../ParticipantItemAdapter.kt | 10 +- .../talk/extensions/ImageViewExtensions.kt | 97 +++--------- .../nextcloud/talk/jobs/NotificationWorker.kt | 1 + .../adapters/PollResultVoterViewHolder.kt | 9 +- .../PollResultVotersOverviewViewHolder.kt | 13 +- .../talk/polls/adapters/PollResultsAdapter.kt | 2 +- .../com/nextcloud/talk/ui/CharacterAvatar.kt | 97 ++++++++++++ .../talk/ui/CharacterAvatarDrawable.kt | 130 ++++++++++++++++ .../talk/ui/chat/ChatMessageScaffold.kt | 48 ++++-- .../nextcloud/talk/ui/chat/MarkdownText.kt | 25 ++- .../com/nextcloud/talk/ui/chat/MentionChip.kt | 38 +++-- .../java/com/nextcloud/talk/utils/ApiUtils.kt | 6 - .../talk/utils/CharacterAvatarUtils.kt | 147 ++++++++++++++++++ .../nextcloud/talk/utils/ChatMessageUtils.kt | 38 ----- .../com/nextcloud/talk/utils/DisplayUtils.kt | 13 +- .../nextcloud/talk/utils/NotificationUtils.kt | 15 ++ .../com/nextcloud/talk/utils/TextDrawable.kt | 59 ------- app/src/main/res/values-night/colors.xml | 4 + app/src/main/res/values/colors.xml | 10 ++ .../talk/utils/CharacterAvatarUtilsTest.kt | 115 ++++++++++++++ 25 files changed, 719 insertions(+), 312 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt index e43a6a0f54..462f6e5b68 100644 --- a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt +++ b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt @@ -25,7 +25,9 @@ import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.activities.ParticipantUiState import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.ui.ActorAvatarImage import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DisplayUtils.isDarkModeOn @Composable @@ -35,21 +37,31 @@ fun AvatarWithFallback(participant: ParticipantUiState, displayName: String, mod .clip(CircleShape), contentAlignment = Alignment.Center ) { - val avatarUrl = getUrlForAvatar( - participant = participant, - displayName = displayName - ) - if (avatarUrl.isNotEmpty()) { - AsyncImage( - model = avatarUrl, - contentDescription = stringResource(R.string.avatar), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape) - ) + // Guests and email participants have no avatar on the server, so theirs is drawn here + val isGuest = Participant.ActorType.GUESTS == participant.actorType || + Participant.ActorType.EMAILS == participant.actorType + val guestAvatar = if (isGuest) { + CharacterAvatarUtils.guestAvatar(displayName, stringResource(R.string.nc_guest)) + } else { + null + } + + if (guestAvatar != null) { + ActorAvatarImage(avatar = guestAvatar, modifier = Modifier.fillMaxSize()) } else { - FallbackAvatar(participant = participant) + val avatarUrl = getUrlForAvatar(participant = participant) + if (avatarUrl.isNotEmpty()) { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + } else { + FallbackAvatar(participant = participant) + } } } } @@ -76,31 +88,20 @@ private fun FallbackAvatar(participant: ParticipantUiState) { } @Composable -fun getUrlForAvatar(participant: ParticipantUiState, displayName: String): String { - var url = ApiUtils.getUrlForAvatar( - participant.baseUrl, - participant.actorId, - true, - darkMode = isDarkModeOn(LocalContext.current) - ) - if (Participant.ActorType.GUESTS == participant.actorType || - Participant.ActorType.EMAILS == participant.actorType - ) { - url = ApiUtils.getUrlForGuestAvatar( - participant.baseUrl, - displayName, - true - ) - } +fun getUrlForAvatar(participant: ParticipantUiState): String = if (participant.actorType == Participant.ActorType.FEDERATED) { - val darkTheme = if (isDarkModeOn(LocalContext.current)) 1 else 0 - url = ApiUtils.getUrlForFederatedAvatar( + ApiUtils.getUrlForFederatedAvatar( participant.baseUrl, participant.roomToken, participant.actorId!!, - darkTheme, + if (isDarkModeOn(LocalContext.current)) 1 else 0, true ) + } else { + ApiUtils.getUrlForAvatar( + participant.baseUrl, + participant.actorId, + true, + darkMode = isDarkModeOn(LocalContext.current) + ) } - return url -} diff --git a/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt b/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt index 04b7ef8275..77ecb60b8f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt @@ -25,7 +25,6 @@ import com.nextcloud.talk.adapters.items.MentionAutocompleteItem.Companion.SOURC import com.nextcloud.talk.adapters.items.MentionAutocompleteItem.Companion.SOURCE_TEAMS import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.RvItemConversationInfoParticipantBinding -import com.nextcloud.talk.extensions.loadDefaultAvatar import com.nextcloud.talk.extensions.loadFederatedUserAvatar import com.nextcloud.talk.extensions.loadGuestAvatar import com.nextcloud.talk.extensions.loadUserAvatar @@ -138,11 +137,7 @@ class MentionAutocompleteAdapter( } SOURCE_GUESTS, SOURCE_EMAILS -> { - if (item.displayName.equals(context.resources.getString(R.string.nc_guest))) { - avatarView.loadDefaultAvatar(viewThemeUtils) - } else { - avatarView.loadGuestAvatar(currentUser, item.displayName!!, false) - } + avatarView.loadGuestAvatar(item.displayName, viewThemeUtils) } SOURCE_TEAMS -> diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt index bdc138b746..616e8b9fcb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt @@ -54,7 +54,10 @@ import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.reactions.ReactionVoter +import com.nextcloud.talk.ui.ActorAvatarImage +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import java.util.Collections private const val TAG = "ShowReactionsSheet" @@ -222,30 +225,24 @@ private fun ReactionVoterRow( val canDelete = hasReactPermission && reactionItem.reactionVoter.actorId == user.userId val guestLabel = stringResource(R.string.nc_guest) - val avatarUrl = remember(reactionItem, isDark) { - when (reactionItem.reactionVoter.actorType) { - ReactionVoter.ReactionActorType.GUESTS -> { - val displayName = reactionItem.reactionVoter.actorDisplayName - ?.takeIf { it.isNotEmpty() } - ?: guestLabel - ApiUtils.getUrlForGuestAvatar(user.baseUrl, displayName, false) - } - - ReactionVoter.ReactionActorType.USERS -> { - ApiUtils.getUrlForAvatar(user.baseUrl, reactionItem.reactionVoter.actorId, false, isDark) - } - - else -> null + // Guests have no avatar on the server, so theirs is drawn from their name here + val guestAvatar = remember(reactionItem, guestLabel) { + if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.GUESTS) { + CharacterAvatarUtils.guestAvatar(reactionItem.reactionVoter.actorDisplayName, guestLabel) + } else { + null } } - val avatarRequest = remember(avatarUrl, credentials) { - avatarUrl?.let { + val avatarRequest = remember(reactionItem, isDark, credentials) { + if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.USERS) { ImageRequest.Builder(context) - .data(it) + .data(ApiUtils.getUrlForAvatar(user.baseUrl, reactionItem.reactionVoter.actorId, false, isDark)) .transformations(CircleCropTransformation()) .addHeader("Authorization", credentials ?: "") .build() + } else { + null } } @@ -258,13 +255,7 @@ private fun ReactionVoterRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.Companion.CenterVertically ) { - AsyncImage( - model = avatarRequest ?: R.drawable.account_circle_96dp, - contentDescription = null, - placeholder = painterResource(R.drawable.account_circle_96dp), - error = painterResource(R.drawable.account_circle_96dp), - modifier = Modifier.Companion.size(40.dp) - ) + ReactionVoterAvatar(guestAvatar = guestAvatar, avatarRequest = avatarRequest) Spacer(modifier = Modifier.Companion.width(16.dp)) Text( text = reactionItem.reactionVoter.actorDisplayName ?: "", @@ -279,6 +270,27 @@ private fun ReactionVoterRow( } } +/** + * The voter's avatar: the one drawn for a guest, otherwise the avatar loaded from the server. + */ +@Composable +private fun ReactionVoterAvatar(guestAvatar: ActorAvatar?, avatarRequest: ImageRequest?) { + if (guestAvatar != null) { + ActorAvatarImage( + avatar = guestAvatar, + modifier = Modifier.Companion.size(40.dp) + ) + } else { + AsyncImage( + model = avatarRequest ?: R.drawable.account_circle_96dp, + contentDescription = null, + placeholder = painterResource(R.drawable.account_circle_96dp), + error = painterResource(R.drawable.account_circle_96dp), + modifier = Modifier.Companion.size(40.dp) + ) + } +} + private class ReactionComparator(private val activeUser: String?) : Comparator { @Suppress("ReturnCount") override fun compare(item1: ReactionItem?, item2: ReactionItem?): Int { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index 8a25f45962..dbd12514df 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -31,6 +31,8 @@ data class ChatMessageUi( val plainMessage: String = "", val renderMarkdown: Boolean, val actorDisplayName: String, + val actorType: String? = null, + val actorId: String? = null, val isThread: Boolean, val threadTitle: String, val threadTitleIconRes: Int = R.drawable.outline_forum_24, @@ -129,6 +131,8 @@ fun ChatMessage.toUiModel( plainMessage = message.orEmpty(), renderMarkdown = renderMarkdown == true, actorDisplayName = actorDisplayName.orEmpty(), + actorType = actorType, + actorId = actorId, threadTitle = threadTitle.orEmpty(), isThread = isThread, threadReplies = threadReplies ?: 0, @@ -180,6 +184,8 @@ fun ChatMessage.toScheduledMessageUiModel( plainMessage = message.orEmpty(), renderMarkdown = renderMarkdown != false, actorDisplayName = actorDisplayName.orEmpty(), + actorType = actorType, + actorId = actorId, isThread = isThread, threadTitle = threadTitle.orEmpty(), threadReplies = threadReplies ?: 0, diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 88d2fa78ad..9a40d599d5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -73,6 +73,7 @@ import com.nextcloud.talk.threadsoverview.data.ThreadsRepository import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils @@ -1691,8 +1692,12 @@ class ChatViewModel @AssistedInject constructor( // val timeString = DateUtils.getLocalTimeStringFromTimestamp(message.timestamp) + /** + * Avatar to request from the server for a message, empty for the actors the server has none for + * - those get their avatar drawn on the client instead, see [CharacterAvatarUtils]. + */ fun getAvatarUrl(message: ChatMessage): String = - if (this::currentUser.isInitialized) { + if (this::currentUser.isInitialized && !message.hasClientSideAvatar()) { ApiUtils.getUrlForAvatar( currentUser.baseUrl, message.actorId, @@ -1702,6 +1707,9 @@ class ChatViewModel @AssistedInject constructor( "" } + private fun ChatMessage.hasClientSideAvatar(): Boolean = + CharacterAvatarUtils.avatarFor(actorType, actorId, actorDisplayName, guestLabel = null) != null + fun initData(user: User, credentials: String, urlForChatting: String, threadId: Long?) { currentUser = user diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt index cc46c8a3d5..a83b607cef 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt @@ -23,10 +23,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.conversationinfo.model.ParticipantModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.RvItemConversationInfoParticipantBinding -import com.nextcloud.talk.extensions.loadDefaultAvatar import com.nextcloud.talk.extensions.loadDefaultGroupCallAvatar import com.nextcloud.talk.extensions.loadFederatedUserAvatar -import com.nextcloud.talk.extensions.loadFirstLetterAvatar +import com.nextcloud.talk.extensions.loadGuestAvatar import com.nextcloud.talk.extensions.loadPhoneAvatar import com.nextcloud.talk.extensions.loadTeamAvatar import com.nextcloud.talk.extensions.loadUserAvatar @@ -210,12 +209,7 @@ class ParticipantItemAdapter( } Participant.ActorType.GUESTS, Participant.ActorType.EMAILS -> { - val actorName = model.displayName - if (!actorName.isNullOrBlank()) { - binding.avatarView.loadFirstLetterAvatar(actorName) - } else { - binding.avatarView.loadDefaultAvatar(viewThemeUtils) - } + binding.avatarView.loadGuestAvatar(model.displayName, viewThemeUtils) } Participant.ActorType.FEDERATED -> { diff --git a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt index 5b784e3b3f..c984aeee67 100644 --- a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt @@ -10,11 +10,9 @@ package com.nextcloud.talk.extensions -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapShader import android.graphics.Canvas -import android.graphics.Color import android.graphics.ColorFilter import android.graphics.Matrix import android.graphics.Paint @@ -28,11 +26,9 @@ import android.graphics.drawable.LayerDrawable import android.util.Log import android.widget.ImageView import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.drawable.toDrawable import coil.annotation.ExperimentalCoilApi +import coil.dispose import coil.imageLoader import coil.load import coil.request.CachePolicy @@ -48,10 +44,11 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.toDrawable +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.TextDrawable -import java.util.Locale import kotlin.math.min private const val ROUNDING_PIXEL = 16f @@ -311,37 +308,6 @@ fun ImageView.loadNoteToSelfAvatar() { setImageDrawable(CircularDrawable(layerDrawable)) } -fun ImageView.loadFirstLetterAvatar(name: String): io.reactivex.disposables.Disposable { - val layers = arrayOfNulls(2) - layers[0] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_background) - layers[1] = createTextDrawable(context, name.trimStart().uppercase(Locale.ROOT)) - - val layerDrawable = LayerDrawable(layers) - val data: Any = layerDrawable - - return DisposableWrapper( - load(data) { - transformations(CircleCropTransformation()) - } - ) -} - -fun ImageView.loadChangelogBotAvatar(): io.reactivex.disposables.Disposable = loadSystemAvatar() - -fun ImageView.loadBotsAvatar(): io.reactivex.disposables.Disposable { - val layers = arrayOfNulls(2) - layers[0] = context.getColor(R.color.black).toDrawable() - layers[1] = TextDrawable(context, ">") - val layerDrawable = LayerDrawable(layers) - val data: Any = layerDrawable - - return DisposableWrapper( - load(data) { - transformations(CircleCropTransformation()) - } - ) -} - fun ImageView.loadDefaultGroupCallAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable { val data: Any = viewThemeUtils.talk.themePlaceholderAvatar(this, R.drawable.ic_avatar_group_small) as Any return loadUserAvatar(data) @@ -367,49 +333,24 @@ fun ImageView.loadMailAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.dispo return loadUserAvatar(data) } -fun ImageView.loadGuestAvatar(user: User, name: String, big: Boolean): io.reactivex.disposables.Disposable = - loadGuestAvatar(user.baseUrl!!, name, big) - -fun ImageView.loadGuestAvatar(baseUrl: String, name: String, big: Boolean): io.reactivex.disposables.Disposable { - val imageRequestUri = ApiUtils.getUrlForGuestAvatar( - baseUrl, - name, - big - ) - return DisposableWrapper( - load(imageRequestUri) { - transformations(CircleCropTransformation()) - listener(onError = { _, result -> - Log.w(TAG, "Can't load guest avatar with URL: $imageRequestUri", result.throwable) - }) +/** + * Client-side avatar for guest and email actors, which have no avatar on the server: a guest who + * gave us their name gets the first character of it drawn on a circle, an unnamed one the generic + * person icon. Nothing is requested from the server for either, matching the web client. + * + * Any avatar request still in flight for the view is cancelled first, so a recycled view cannot be + * overwritten by its predecessor's avatar. + */ +fun ImageView.loadGuestAvatar(displayName: String?, viewThemeUtils: ViewThemeUtils) { + when (val avatar = CharacterAvatarUtils.guestAvatar(displayName, context.getString(R.string.nc_guest))) { + is ActorAvatar.Character -> { + dispose() + setImageDrawable(avatar.toDrawable(context)) } - ) -} -@Suppress("MagicNumber") -private fun createTextDrawable(context: Context, letter: String): Drawable { - val size = 100 - val bitmap = createBitmap(size, size) - val canvas = Canvas(bitmap) - - val paint = Paint().apply { - color = ResourcesCompat.getColor(context.resources, R.color.grey_600, null) - style = Paint.Style.FILL - } - canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), paint) - - val textPaint = Paint().apply { - color = Color.WHITE - textSize = size / 2f - isAntiAlias = true - textAlign = Paint.Align.CENTER + ActorAvatar.AppIcon -> loadSystemAvatar() + ActorAvatar.PersonIcon -> loadDefaultAvatar(viewThemeUtils) } - - val xPos = size / 2f - val yPos = (canvas.height / 2 - (textPaint.descent() + textPaint.ascent()) / 2) - canvas.drawText(letter.take(1), xPos, yPos, textPaint) - - return bitmap.toDrawable(context.resources) } private class DisposableWrapper(private val disposable: coil.request.Disposable) : diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 0816301256..dc8a47c2c9 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -70,6 +70,7 @@ import com.nextcloud.talk.receivers.DismissRecordingAvailableReceiver import com.nextcloud.talk.receivers.MarkAsReadReceiver import com.nextcloud.talk.receivers.ShareRecordingToChatReceiver import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.ConversationUtils diff --git a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt index f0174eefe0..929ece4201 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt @@ -7,10 +7,7 @@ package com.nextcloud.talk.polls.adapters import android.annotation.SuppressLint -import android.text.TextUtils import android.widget.ImageView -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.PollResultVoterItemBinding import com.nextcloud.talk.extensions.loadFederatedUserAvatar @@ -42,11 +39,7 @@ class PollResultVoterViewHolder( private fun loadAvatar(pollDetail: PollDetails, avatar: ImageView) { when (pollDetail.actorType) { Participant.ActorType.GUESTS -> { - var displayName = NextcloudTalkApplication.sharedApplication?.resources?.getString(R.string.nc_guest) - if (!TextUtils.isEmpty(pollDetail.actorDisplayName)) { - displayName = pollDetail.actorDisplayName!! - } - avatar.loadGuestAvatar(user, displayName!!, false) + avatar.loadGuestAvatar(pollDetail.actorDisplayName, viewThemeUtils) } Participant.ActorType.FEDERATED -> { diff --git a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt index b23753cfcf..623e3fd507 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt @@ -8,12 +8,9 @@ package com.nextcloud.talk.polls.adapters import android.annotation.SuppressLint -import android.text.TextUtils import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.PollResultVotersOverviewItemBinding import com.nextcloud.talk.extensions.loadFederatedUserAvatar @@ -21,12 +18,14 @@ import com.nextcloud.talk.extensions.loadGuestAvatar import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.polls.model.PollDetails +import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.DisplayUtils class PollResultVotersOverviewViewHolder( private val user: User, private val roomToken: String, - override val binding: PollResultVotersOverviewItemBinding + override val binding: PollResultVotersOverviewItemBinding, + private val viewThemeUtils: ViewThemeUtils ) : PollResultViewHolder(binding) { @SuppressLint("SetTextI18n") @@ -72,11 +71,7 @@ class PollResultVotersOverviewViewHolder( private fun loadAvatar(pollDetail: PollDetails, avatar: ImageView) { when (pollDetail.actorType) { Participant.ActorType.GUESTS -> { - var displayName = NextcloudTalkApplication.sharedApplication?.resources?.getString(R.string.nc_guest) - if (!TextUtils.isEmpty(pollDetail.actorDisplayName)) { - displayName = pollDetail.actorDisplayName!! - } - avatar.loadGuestAvatar(user, displayName!!, false) + avatar.loadGuestAvatar(pollDetail.actorDisplayName, viewThemeUtils) } Participant.ActorType.FEDERATED -> { diff --git a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt index 4af97d1b2e..4f0cb46f23 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt @@ -49,7 +49,7 @@ class PollResultsAdapter( parent, false ) - viewHolder = PollResultVotersOverviewViewHolder(user, roomToken, itemBinding) + viewHolder = PollResultVotersOverviewViewHolder(user, roomToken, itemBinding, viewThemeUtils) } } return viewHolder!! diff --git a/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt new file mode 100644 index 0000000000..15819d32f8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import android.widget.ImageView +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.viewinterop.AndroidView +import com.nextcloud.talk.R +import com.nextcloud.talk.extensions.loadSystemAvatar +import com.nextcloud.talk.utils.ActorAvatar + +/** + * Circular avatar drawn from a character, the Compose counterpart of [CharacterAvatarDrawable]. + * + * Expects the single character resolved by [com.nextcloud.talk.utils.CharacterAvatarUtils]; the + * character scales with the space the avatar is given, so callers only size the modifier. + */ +@Composable +fun CharacterAvatar( + character: String, + backgroundColor: Color, + textColor: Color, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier, contentAlignment = Alignment.Center) { + // Sized to the shorter side and centered, so a circle stays a circle instead of being + // stretched into a pill when the space the avatar is given is not square + val diameter = minOf(maxWidth, maxHeight) + val fontSize = with(LocalDensity.current) { + (diameter * CharacterAvatarDrawable.TEXT_SIZE_RATIO).toSp() + } + + Box( + modifier = Modifier + .size(diameter) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = character, + color = textColor, + fontSize = fontSize, + maxLines = 1, + softWrap = false, + textAlign = TextAlign.Center + ) + } + } +} + +/** + * The avatar of an actor the server has no avatar for, whichever kind + * [com.nextcloud.talk.utils.CharacterAvatarUtils] resolved it to. + */ +@Composable +fun ActorAvatarImage(avatar: ActorAvatar, modifier: Modifier = Modifier) { + when (avatar) { + is ActorAvatar.Character -> CharacterAvatar( + character = avatar.character, + backgroundColor = colorResource(avatar.backgroundColor), + textColor = colorResource(avatar.textColor), + modifier = modifier + ) + + ActorAvatar.AppIcon -> AndroidView( + factory = { context -> ImageView(context).apply { loadSystemAvatar() } }, + modifier = modifier + ) + + ActorAvatar.PersonIcon -> Image( + painter = painterResource(R.drawable.account_circle_96dp), + contentDescription = stringResource(R.string.user_avatar), + modifier = modifier + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt new file mode 100644 index 0000000000..c0ff873f33 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt @@ -0,0 +1,130 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import com.nextcloud.talk.utils.ActorAvatar +import kotlin.math.min + +/** + * The character avatar as a drawable, for the views and notifications that cannot use the + * [CharacterAvatar] composable. + */ +fun ActorAvatar.Character.toDrawable(context: Context): CharacterAvatarDrawable = + CharacterAvatarDrawable( + character = character, + backgroundColor = ContextCompat.getColor(context, backgroundColor), + textColor = ContextCompat.getColor(context, textColor) + ) + +/** + * Circular avatar drawn from a character, for actors without an avatar on the server - see + * [com.nextcloud.talk.utils.CharacterAvatarUtils] for which actors those are. + * + * The character is scaled to the bounds it is handed rather than to a fixed bitmap, so the same + * avatar stays sharp in a mention chip and in a call tile. + */ +class CharacterAvatarDrawable( + private val character: String, + @ColorInt private val backgroundColor: Int, + @ColorInt private val textColor: Int +) : Drawable() { + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = backgroundColor + style = Paint.Style.FILL + } + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = textColor + textAlign = Paint.Align.CENTER + } + + private val textBounds = Rect() + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + fitTextTo(bounds) + } + + /** + * Sizes the character relative to the circle, then shrinks it if it is still too wide to sit + * inside the circle - a single letter always fits, the two-character bot prompt does not. + */ + private fun fitTextTo(bounds: Rect) { + val diameter = min(bounds.width(), bounds.height()) + if (diameter <= 0 || character.isEmpty()) { + return + } + + textPaint.textSize = diameter * TEXT_SIZE_RATIO + textPaint.getTextBounds(character, 0, character.length, textBounds) + + val maxTextWidth = diameter * MAX_TEXT_WIDTH_RATIO + if (textBounds.width() > maxTextWidth) { + textPaint.textSize *= maxTextWidth / textBounds.width() + } + } + + override fun draw(canvas: Canvas) { + val diameter = min(bounds.width(), bounds.height()) + canvas.drawCircle(bounds.exactCenterX(), bounds.exactCenterY(), diameter / 2f, backgroundPaint) + + if (character.isEmpty()) { + return + } + + val baseline = bounds.exactCenterY() - (textPaint.descent() + textPaint.ascent()) / 2 + canvas.drawText(character, bounds.exactCenterX(), baseline, textPaint) + } + + override fun setAlpha(alpha: Int) { + backgroundPaint.alpha = alpha + textPaint.alpha = alpha + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + backgroundPaint.colorFilter = colorFilter + textPaint.colorFilter = colorFilter + invalidateSelf() + } + + @Deprecated("Deprecated in Drawable", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat")) + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getIntrinsicWidth(): Int = INTRINSIC_SIZE + + override fun getIntrinsicHeight(): Int = INTRINSIC_SIZE + + companion object { + /** + * Character height relative to the circle, matching the web client's font-size of half the + * avatar size. + */ + internal const val TEXT_SIZE_RATIO = 0.5f + + /** + * Widest the character may get before it is scaled down, leaving the circle a margin. + */ + internal const val MAX_TEXT_WIDTH_RATIO = 0.6f + + /** + * Fallback size for views that size themselves to the drawable instead of the other way + * round. Large enough to stay sharp on high density screens. + */ + private const val INTRINSIC_SIZE = 128 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index a1c3bf8fe4..2f4b6199a2 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -69,7 +69,9 @@ import com.nextcloud.talk.chat.ui.model.MessageReactionUi import com.nextcloud.talk.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.ui.ActorAvatarImage import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.TextMatchers @@ -259,22 +261,38 @@ fun MessageScaffold( private fun RowScope.MessageLeadingDecoration(uiMessage: ChatMessageUi, isOneToOneConversation: Boolean) { val onAvatarClick = LocalAvatarClickHandler.current if (uiMessage.incoming && isOneToOneConversation && !uiMessage.isGrouped) { - val errorPlaceholderImage: Int = R.drawable.account_circle_96dp - val avatarContext = LocalContext.current - val loadedImage = remember(uiMessage.avatarUrl) { - loadImage(uiMessage.avatarUrl, avatarContext, errorPlaceholderImage) + val avatarModifier = Modifier + .size(48.dp) + .align(Alignment.Top) + .padding(end = 8.dp) + .combinedClickable( + onClick = { onAvatarClick(uiMessage.id) } + ) + val guestLabel = stringResource(R.string.nc_guest) + // Guests, email participants and bots have no avatar on the server, so theirs is drawn here + val actorAvatar = remember(uiMessage.actorType, uiMessage.actorId, uiMessage.actorDisplayName, guestLabel) { + CharacterAvatarUtils.avatarFor( + actorType = uiMessage.actorType, + actorId = uiMessage.actorId, + displayName = uiMessage.actorDisplayName, + guestLabel = guestLabel + ) + } + + if (actorAvatar != null) { + ActorAvatarImage(avatar = actorAvatar, modifier = avatarModifier) + } else { + val errorPlaceholderImage: Int = R.drawable.account_circle_96dp + val avatarContext = LocalContext.current + val loadedImage = remember(uiMessage.avatarUrl) { + loadImage(uiMessage.avatarUrl, avatarContext, errorPlaceholderImage) + } + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = avatarModifier + ) } - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.user_avatar), - modifier = Modifier - .size(48.dp) - .align(Alignment.Top) - .padding(end = 8.dp) - .combinedClickable( - onClick = { onAvatarClick(uiMessage.id) } - ) - ) } else if (uiMessage.incoming && isOneToOneConversation) { Spacer(Modifier.width(48.dp)) } else if (uiMessage.incoming) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt index 957b494608..2c782b133f 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt @@ -48,9 +48,13 @@ import androidx.core.content.ContextCompat import coil.imageLoader import coil.request.ImageRequest import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.ui.toDrawable +import com.nextcloud.talk.utils.ActorAvatar +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.message.MessageUtils import io.noties.markwon.core.spans.LinkSpan import org.greenrobot.eventbus.EventBus @@ -297,7 +301,6 @@ private fun applyMentionChips( val fgColor = if (isSelfMention) selfChipTextColor else chipTextColor val avatarUrl = resolveMentionAvatarUrl( rawId = rawId, - name = name, type = type, mentionId = mentionId, isFederated = isFederated, @@ -316,7 +319,7 @@ private fun applyMentionChips( avatarUrl = avatarUrl ) ) - val fallbackDrawable = ContextCompat.getDrawable(context, fallbackIconRes)?.mutate() ?: continue + val fallbackDrawable = mentionFallbackDrawable(context, type, rawId, name, fallbackIconRes) ?: continue val token = "{$key}" var searchFrom = 0 while (true) { @@ -354,6 +357,24 @@ private fun applyMentionChips( return hasClickableChips } +/** + * What the chip shows until - or instead of - an avatar arrives from the server: guests and email + * participants have none there, so theirs is drawn from their name rather than falling back to the + * generic person icon. + */ +@Suppress("LongParameterList") +private fun mentionFallbackDrawable( + context: Context, + type: String, + rawId: String, + name: String, + fallbackIconRes: Int +): Drawable? { + val character = CharacterAvatarUtils.avatarFor(type, rawId, name, context.getString(R.string.nc_guest)) + as? ActorAvatar.Character + return character?.toDrawable(context) ?: ContextCompat.getDrawable(context, fallbackIconRes)?.mutate() +} + private class MentionClickSpan(private val mentionId: String) : ClickableSpan() { override fun onClick(widget: View) { EventBus.getDefault().post(UserMentionClickEvent(mentionId)) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt index 93906c3978..c83c4be8f2 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt @@ -52,8 +52,11 @@ import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.events.UserMentionClickEvent +import com.nextcloud.talk.ui.ActorAvatarImage import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import org.greenrobot.eventbus.EventBus val mentionParameterTypes = setOf("user", "guest", "call", "user-group", "email", "circle") @@ -103,7 +106,6 @@ fun parseMentionChipModel( val isSelfMention = rawId == activeUserId val avatarUrl = resolveMentionAvatarUrl( rawId = rawId, - name = name, type = type, mentionId = mentionId, isFederated = isFederated, @@ -126,7 +128,6 @@ fun parseMentionChipModel( @Suppress("LongParameterList") fun resolveMentionAvatarUrl( rawId: String, - name: String, type: String, mentionId: String, isFederated: Boolean, @@ -142,11 +143,9 @@ fun resolveMentionAvatarUrl( darkTheme = 0, requestBigSize = false ) - type == "guest" || type == "email" -> ApiUtils.getUrlForGuestAvatar( - baseUrl = baseUrl, - name = name, - requestBigSize = true - ) + // Guests and email participants have no avatar on the server, so nothing is requested for + // them - their chip is drawn from resolveMentionCharacter instead, as on web + type == "guest" || type == "email" -> null type == "call" || type == "user-group" || type == "circle" -> null rawId.isNotEmpty() -> ApiUtils.getUrlForAvatar(baseUrl, rawId, false, false) else -> null @@ -301,12 +300,25 @@ fun MentionChip( @Composable fun MentionChipIcon(mention: MentionChipModel, fallbackIcon: Int) { - if (mention.avatarUrl != null) { - val context = LocalContext.current - val loadedImage = remember(mention.avatarUrl) { loadImage(mention.avatarUrl, context, fallbackIcon) } - AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) - } else { - Icon( + val guestLabel = stringResource(R.string.nc_guest) + // A mentioned guest has no avatar on the server, so their chip is drawn from their name + val character = remember(mention.type, mention.rawId, mention.name, guestLabel) { + CharacterAvatarUtils.avatarFor(mention.type, mention.rawId, mention.name, guestLabel) + as? ActorAvatar.Character + } + when { + character != null -> ActorAvatarImage( + avatar = character, + modifier = Modifier.size(mentionAvatarSize) + ) + + mention.avatarUrl != null -> { + val context = LocalContext.current + val loadedImage = remember(mention.avatarUrl) { loadImage(mention.avatarUrl, context, fallbackIcon) } + AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) + } + + else -> Icon( painter = painterResource(fallbackIcon), contentDescription = null, modifier = Modifier.size(mentionIconSize), diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 963351ec81..a214027525 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -376,12 +376,6 @@ object ApiUtils { return "$url?cloudId=$cloudId&darkTheme=$darkTheme" } - @JvmStatic - fun getUrlForGuestAvatar(baseUrl: String?, name: String?, requestBigSize: Boolean): String { - val avatarSize = if (requestBigSize) AVATAR_SIZE_BIG else AVATAR_SIZE_SMALL - return baseUrl + "/index.php/avatar/guest/" + Uri.encode(name) + "/" + avatarSize - } - fun getUrlForConversationAvatar(version: Int, baseUrl: String?, token: String?): String = getUrlForRoom(version, baseUrl, token) + "/avatar" diff --git a/app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt new file mode 100644 index 0000000000..06147ee049 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt @@ -0,0 +1,147 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import androidx.annotation.ColorRes +import com.nextcloud.talk.R +import java.util.Locale + +/** + * The avatar of an actor the server has no avatar for, drawn by the client instead of being + * requested. Resolved by [CharacterAvatarUtils.avatarFor], rendered by + * [com.nextcloud.talk.ui.ActorAvatarImage] in Compose and by + * [com.nextcloud.talk.ui.CharacterAvatarDrawable] everywhere else. + */ +sealed interface ActorAvatar { + + /** + * A character on a coloured circle: the first character of a guest's name, or the shell prompt + * of a bot. Both colours are theme-aware resources, as the web client's are. + */ + data class Character( + val character: String, + @ColorRes val backgroundColor: Int, + @ColorRes val textColor: Int + ) : ActorAvatar + + /** + * The app's own icon, for the bots that ship their avatar with the app. + */ + object AppIcon : ActorAvatar + + /** + * The generic person icon, for guests who did not tell us their name. + */ + object PersonIcon : ActorAvatar +} + +/** + * Decides what to draw for actors the server has no avatar for. + * + * Guests, email participants and bots have no avatar on the server, so theirs is rendered on the + * client instead of being requested: a guest who told us their name gets the first character of it, + * a bot gets a shell prompt. This mirrors the web client's AvatarWrapper, so both clients show the + * same avatar for the same actor. + * + * A guest without a name is not represented by a character at all - they get the generic person + * icon, because the placeholder name ("Guest") is the same for everyone in the conversation and its + * initial would tell the reader nothing. + */ +object CharacterAvatarUtils { + + /** + * Shell prompt drawn for bots, matching the web client. + */ + const val BOT_CHARACTER = ">_" + + /** + * Actor types without an avatar on the server. Both the plural spelling of the participant and + * chat APIs and the singular one of the message parameters are accepted, so every caller can + * pass the type it was given. + */ + private val GUEST_ACTOR_TYPES = setOf("guests", "guest", "emails", "email") + private val BOT_ACTOR_TYPES = setOf("bots", "bot") + + /** + * Bots that ship their own avatar with the app and therefore never draw a character. + */ + private val CHANGELOG_BOT_IDS = setOf("changelog", "sample") + + /** + * What to draw for the given actor, or null when the actor has an avatar on the server and it + * should be requested as usual. + * + * @param actorType the actor type as the API reports it, e.g. "guests" or "bots" + * @param actorId the actor's id, only used to spot the bots shipping their own avatar + * @param displayName the actor's display name, a guest's character is derived from it + * @param guestLabel the localized placeholder name for unnamed guests, null to accept any name + */ + fun avatarFor(actorType: String?, actorId: String?, displayName: String?, guestLabel: String?): ActorAvatar? = + when (actorType) { + in GUEST_ACTOR_TYPES -> guestAvatar(displayName, guestLabel) + in BOT_ACTOR_TYPES -> botAvatar(actorId) + else -> null + } + + /** + * The avatar of a guest or email participant, for callers whose actor type is already narrowed + * down to those - the name is all that is left to decide. + */ + fun guestAvatar(displayName: String?, guestLabel: String?): ActorAvatar = + guestCharacter(displayName, guestLabel) + ?.let { + ActorAvatar.Character( + character = it, + backgroundColor = R.color.character_avatar_background_guest, + textColor = R.color.character_avatar_text_guest + ) + } + ?: ActorAvatar.PersonIcon + + /** + * The avatar of a bot: the shell prompt, or the app's icon for the bots shipping their own. + */ + fun botAvatar(actorId: String?): ActorAvatar = + if (actorId in CHANGELOG_BOT_IDS) { + ActorAvatar.AppIcon + } else { + ActorAvatar.Character( + character = BOT_CHARACTER, + backgroundColor = R.color.character_avatar_background_bot, + textColor = R.color.character_avatar_text_bot + ) + } + + /** + * Whether the actor told us their name, as opposed to being labelled with the generic + * placeholder every unnamed guest shares. + * + * @param displayName the actor's display name, may be null or blank + * @param guestLabel the localized placeholder name for unnamed guests, null to accept any name + */ + fun hasCustomName(displayName: String?, guestLabel: String?): Boolean { + val name = displayName?.trim() + return !name.isNullOrEmpty() && name != guestLabel?.trim() + } + + /** + * The character to draw for a guest or email actor, or null when there is no name to derive it + * from and the generic person icon should be used instead. + */ + private fun guestCharacter(displayName: String?, guestLabel: String?): String? { + if (!hasCustomName(displayName, guestLabel)) { + return null + } + return firstCharacterOf(displayName!!.trim().uppercase(Locale.getDefault())) + } + + /** + * First character as a whole code point, so names starting with an emoji or any other + * character outside the basic plane do not get cut in half into an unrenderable fragment. + */ + private fun firstCharacterOf(name: String): String = String(Character.toChars(name.codePointAt(0))) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt deleted file mode 100644 index 9d271bf717..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Marcel Hibbe - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils - -import android.view.View -import android.widget.ImageView -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.extensions.loadBotsAvatar -import com.nextcloud.talk.extensions.loadChangelogBotAvatar -import com.nextcloud.talk.extensions.loadDefaultAvatar -import com.nextcloud.talk.extensions.loadFederatedUserAvatar -import com.nextcloud.talk.extensions.loadFirstLetterAvatar -import com.nextcloud.talk.ui.theme.ViewThemeUtils - -class ChatMessageUtils { - - fun setAvatarOnMessage(view: ImageView, message: ChatMessage, viewThemeUtils: ViewThemeUtils) { - view.visibility = View.VISIBLE - if (message.actorType == "guests" || message.actorType == "emails") { - val actorName = message.actorDisplayName - if (!actorName.isNullOrBlank()) { - view.loadFirstLetterAvatar(actorName) - } else { - view.loadDefaultAvatar(viewThemeUtils) - } - } else if (message.actorType == "bots" && (message.actorId == "changelog" || message.actorId == "sample")) { - view.loadChangelogBotAvatar() - } else if (message.actorType == "bots") { - view.loadBotsAvatar() - } else if (message.actorType == "federated_users") { - view.loadFederatedUserAvatar(message) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt index dcd778ff83..caf10e4895 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt @@ -62,7 +62,6 @@ import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar -import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar import com.nextcloud.talk.utils.preferences.AppPreferencesImpl import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -196,15 +195,11 @@ object DisplayUtils { chip.setChipIconResource(R.drawable.icon_circular_phone) } chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight) - if (!isGroup) { + // Guests and email participants have no avatar on the server, so their chip keeps the + // person icon set above instead of requesting one, matching the web client + val isGuest = "guests" == type || "guest" == type || "email" == type + if (!isGroup && !isGuest) { var url = getUrlForAvatar(conversationUser.baseUrl, id, false, isDarkModeOn(context)) - if ("guests" == type || "guest" == type || "email" == type) { - url = getUrlForGuestAvatar( - conversationUser.baseUrl, - label.toString(), - true - ) - } if (isFederated) { val darkTheme = if (isDarkModeOn(context)) 1 else 0 url = getUrlForFederatedAvatar( diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4023b8083e..6102b2d5c2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -18,6 +18,7 @@ import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import coil.executeBlocking import coil.imageLoader @@ -28,6 +29,7 @@ import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RingtoneSettings +import com.nextcloud.talk.ui.toDrawable import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences import java.io.IOException @@ -342,5 +344,18 @@ object NotificationUtils { return avatarIcon } + /** + * Notification avatar for actors without an avatar on the server, drawn by the client as + * resolved by [CharacterAvatarUtils] instead of being requested. + */ + fun characterAvatarBitmap(context: Context, avatar: ActorAvatar.Character): Bitmap = + avatar.toDrawable(context).toBitmap(CHARACTER_AVATAR_ICON_SIZE, CHARACTER_AVATAR_ICON_SIZE) + + /** + * Pixel size the character avatar is rasterized to for notifications, which cannot scale a + * drawable themselves. + */ + private const val CHARACTER_AVATAR_ICON_SIZE = 128 + private data class Channel(val id: String, val name: String, val description: String, val isImportant: Boolean) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt b/app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt deleted file mode 100644 index ce44f31b47..0000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Marcel Hibbe - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.utils - -import android.content.Context -import android.graphics.Canvas -import android.graphics.ColorFilter -import android.graphics.Paint -import android.graphics.PixelFormat -import android.graphics.Rect -import android.graphics.drawable.Drawable -import com.nextcloud.talk.R - -class TextDrawable(val context: Context, private var text: String) : Drawable() { - private val paint = Paint() - private val bounds: Rect - - init { - paint.color = context.getColor(R.color.textColorOnPrimaryBackground) - paint.isAntiAlias = true - paint.textSize = TEXT_SIZE - bounds = Rect() - } - - override fun draw(canvas: Canvas) { - if (text.isNotEmpty()) { - paint.getTextBounds( - text, - 0, - text.length, - bounds - ) - val x: Int = (getBounds().width() - bounds.width()) / 2 - val y: Int = ((getBounds().height() + bounds.height()) / 2) + Y_OFFSET - canvas.drawText(text, x.toFloat(), y.toFloat(), paint) - } - } - - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.setColorFilter(colorFilter) - } - - override fun setAlpha(alpha: Int) { - paint.alpha = alpha - } - - @Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.OPAQUE", "android.graphics.PixelFormat")) - override fun getOpacity(): Int = PixelFormat.OPAQUE - - companion object { - private const val Y_OFFSET = 5 - private const val TEXT_SIZE = 50f - } -} diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 4f1d5c8424..4dd49cc3dc 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -45,6 +45,10 @@ #313B75 #8c8c8c + + + #3B3B3B + #3B3B3B #29ffffff diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6981667983..3c80902ce0 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -56,6 +56,16 @@ #111111 #767676 + + #6B6B6B + #DBDBDB + @color/white + @color/high_emphasis_text + #666666 #FFFFFF diff --git a/app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt new file mode 100644 index 0000000000..0e43fe6bec --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import com.nextcloud.talk.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class CharacterAvatarUtilsTest { + + private val guestLabel = "Guest" + + private fun characterOf(actorType: String?, actorId: String?, displayName: String?): String? = + (CharacterAvatarUtils.avatarFor(actorType, actorId, displayName, guestLabel) as? ActorAvatar.Character) + ?.character + + @Test + fun `named guest is drawn from the first character of their name`() { + assertEquals("A", characterOf("guests", "guest-hash", "alice")) + } + + @Test + fun `surrounding whitespace is ignored`() { + assertEquals("B", characterOf("guests", "guest-hash", " bob ")) + } + + @Test + fun `guest without a name gets the person icon`() { + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(null, guestLabel)) + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar("", guestLabel)) + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(" ", guestLabel)) + } + + @Test + fun `guest labelled with the placeholder name gets the person icon`() { + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(guestLabel, guestLabel)) + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(" Guest ", guestLabel)) + } + + @Test + fun `any name counts as custom when no placeholder name is given`() { + val avatar = CharacterAvatarUtils.guestAvatar("Guest", null) + assertEquals("G", (avatar as ActorAvatar.Character).character) + } + + @Test + fun `first character outside the basic plane stays whole`() { + val name = "😀 party" + assertEquals(name.substring(0, 2), characterOf("guests", "guest-hash", name)) + } + + @Test + fun `both the plural and the singular spelling of an actor type are understood`() { + assertEquals("T", characterOf("guests", "guest-hash", "Test")) + assertEquals("T", characterOf("guest", "guest-hash", "Test")) + assertEquals("T", characterOf("emails", "email-hash", "Test")) + assertEquals("T", characterOf("email", "email-hash", "Test")) + assertEquals(">_", characterOf("bots", "weather-bot", "Weather")) + assertEquals(">_", characterOf("bot", "weather-bot", "Weather")) + } + + @Test + fun `bots are drawn as a shell prompt in the bot colours`() { + val avatar = CharacterAvatarUtils.botAvatar("weather-bot") as ActorAvatar.Character + assertEquals(CharacterAvatarUtils.BOT_CHARACTER, avatar.character) + assertEquals(R.color.character_avatar_background_bot, avatar.backgroundColor) + assertEquals(R.color.character_avatar_text_bot, avatar.textColor) + } + + @Test + fun `guests are drawn in the guest colours`() { + val avatar = CharacterAvatarUtils.guestAvatar("alice", guestLabel) as ActorAvatar.Character + assertEquals(R.color.character_avatar_background_guest, avatar.backgroundColor) + assertEquals(R.color.character_avatar_text_guest, avatar.textColor) + } + + @Test + fun `bots and guests do not share a colour pair`() { + val bot = CharacterAvatarUtils.botAvatar("weather-bot") as ActorAvatar.Character + val guest = CharacterAvatarUtils.guestAvatar("alice", guestLabel) as ActorAvatar.Character + assertNotEquals(bot.backgroundColor, guest.backgroundColor) + assertNotEquals(bot.textColor, guest.textColor) + } + + @Test + fun `bots shipping their own avatar are drawn with the app icon`() { + assertSame(ActorAvatar.AppIcon, CharacterAvatarUtils.botAvatar("changelog")) + assertSame(ActorAvatar.AppIcon, CharacterAvatarUtils.botAvatar("sample")) + assertSame(ActorAvatar.AppIcon, CharacterAvatarUtils.avatarFor("bots", "changelog", "Changelog", guestLabel)) + } + + @Test + fun `actors with an avatar on the server are left to the server`() { + assertNull(CharacterAvatarUtils.avatarFor("users", "alice", "Alice", guestLabel)) + assertNull(CharacterAvatarUtils.avatarFor("federated_users", "alice@cloud", "Alice", guestLabel)) + assertNull(CharacterAvatarUtils.avatarFor(null, null, "Test", guestLabel)) + } + + @Test + fun `custom name detection matches the placeholder name exactly`() { + assertTrue(CharacterAvatarUtils.hasCustomName("Alice", guestLabel)) + assertTrue(CharacterAvatarUtils.hasCustomName("Guest of honour", guestLabel)) + assertFalse(CharacterAvatarUtils.hasCustomName("Guest", guestLabel)) + assertFalse(CharacterAvatarUtils.hasCustomName(null, guestLabel)) + } +}