From 0d134a919e97de7690ff97485b02479438450cf9 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Tue, 7 Mar 2023 16:04:32 +0100 Subject: [PATCH] add avatar image to chat overview item --- .../database/model/AddressWithName.java | 28 +++++ .../android/database/model/AvatarType.java | 6 + .../database/model/AvatarWithAccount.java | 39 ++++++ .../database/model/ChatOverviewItem.java | 23 ++++ .../android/ui/AvatarFetcher.java | 112 +++++++++++++++++ .../ui/adapter/ChatOverviewAdapter.java | 13 ++ .../ui/graphics/drawable/AvatarDrawable.java | 119 ++++++++++++++++++ .../util/ConsistentColorGeneration.java | 6 +- .../android/xmpp/manager/AvatarManager.java | 20 +++ app/src/main/res/layout/item_chatoverview.xml | 21 +++- app/src/main/res/values/dimens.xml | 1 + 11 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/im/conversations/android/database/model/AddressWithName.java create mode 100644 app/src/main/java/im/conversations/android/database/model/AvatarType.java create mode 100644 app/src/main/java/im/conversations/android/database/model/AvatarWithAccount.java create mode 100644 app/src/main/java/im/conversations/android/ui/AvatarFetcher.java create mode 100644 app/src/main/java/im/conversations/android/ui/graphics/drawable/AvatarDrawable.java diff --git a/app/src/main/java/im/conversations/android/database/model/AddressWithName.java b/app/src/main/java/im/conversations/android/database/model/AddressWithName.java new file mode 100644 index 000000000..e8e0bbba4 --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/AddressWithName.java @@ -0,0 +1,28 @@ +package im.conversations.android.database.model; + +import com.google.common.base.Objects; +import org.jxmpp.jid.Jid; + +public class AddressWithName { + + public final Jid address; + public final String name; + + public AddressWithName(Jid address, String name) { + this.address = address; + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AddressWithName that = (AddressWithName) o; + return Objects.equal(address, that.address) && Objects.equal(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hashCode(address, name); + } +} diff --git a/app/src/main/java/im/conversations/android/database/model/AvatarType.java b/app/src/main/java/im/conversations/android/database/model/AvatarType.java new file mode 100644 index 000000000..cd58038fb --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/AvatarType.java @@ -0,0 +1,6 @@ +package im.conversations.android.database.model; + +public enum AvatarType { + VCARD, + PEP +} diff --git a/app/src/main/java/im/conversations/android/database/model/AvatarWithAccount.java b/app/src/main/java/im/conversations/android/database/model/AvatarWithAccount.java new file mode 100644 index 000000000..8b4d2025d --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/AvatarWithAccount.java @@ -0,0 +1,39 @@ +package im.conversations.android.database.model; + +import com.google.common.base.Objects; + +public class AvatarWithAccount { + + public final long account; + + public final AddressWithName addressWithName; + public final AvatarType avatarType; + public final String hash; + + public AvatarWithAccount( + long account, + final AddressWithName addressWithName, + AvatarType avatarType, + String hash) { + this.account = account; + this.addressWithName = addressWithName; + this.avatarType = avatarType; + this.hash = hash; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AvatarWithAccount that = (AvatarWithAccount) o; + return account == that.account + && Objects.equal(addressWithName, that.addressWithName) + && avatarType == that.avatarType + && Objects.equal(hash, that.hash); + } + + @Override + public int hashCode() { + return Objects.hashCode(account, addressWithName, avatarType, hash); + } +} diff --git a/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java b/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java index 61b02704a..02e86dff4 100644 --- a/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java +++ b/app/src/main/java/im/conversations/android/database/model/ChatOverviewItem.java @@ -100,10 +100,33 @@ public class ChatOverviewItem { return fallbackName(); } + public AddressWithName getAddressWithName() { + final Jid address = getJidAddress(); + final String name = name(); + if (address == null || name == null) { + return null; + } + return new AddressWithName(address, name); + } + private Jid getJidAddress() { return address == null ? null : JidCreate.fromOrNull(address); } + public AvatarWithAccount getAvatar() { + final var address = getAddressWithName(); + if (address == null) { + return null; + } + if (this.avatar != null) { + return new AvatarWithAccount(accountId, address, AvatarType.PEP, this.avatar); + } + if (this.vCardPhoto != null) { + return new AvatarWithAccount(accountId, address, AvatarType.VCARD, this.vCardPhoto); + } + return null; + } + private static boolean notNullNotEmpty(final String value) { return value != null && !value.trim().isEmpty(); } diff --git a/app/src/main/java/im/conversations/android/ui/AvatarFetcher.java b/app/src/main/java/im/conversations/android/ui/AvatarFetcher.java new file mode 100644 index 000000000..79e62dd2d --- /dev/null +++ b/app/src/main/java/im/conversations/android/ui/AvatarFetcher.java @@ -0,0 +1,112 @@ +package im.conversations.android.ui; + +import android.content.Context; +import android.graphics.Bitmap; +import android.widget.ImageView; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import im.conversations.android.database.model.AddressWithName; +import im.conversations.android.database.model.AvatarWithAccount; +import im.conversations.android.ui.graphics.drawable.AvatarDrawable; +import im.conversations.android.xmpp.ConnectionPool; +import im.conversations.android.xmpp.manager.AvatarManager; +import java.lang.ref.WeakReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AvatarFetcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(AvatarFetcher.class); + + public final AvatarWithAccount avatar; + public final ListenableFuture future; + + private AvatarFetcher( + AvatarWithAccount avatar, ListenableFuture future, ImageView imageView) { + this.avatar = avatar; + this.future = future; + Futures.addCallback( + this.future, + new Callback(imageView, avatar.addressWithName), + ContextCompat.getMainExecutor(imageView.getContext())); + } + + public static void setDefault( + final ImageView imageView, final AddressWithName addressWithName) { + imageView.setImageDrawable(new AvatarDrawable(imageView.getContext(), addressWithName)); + } + + private static class Callback implements FutureCallback { + + private final WeakReference imageViewWeakReference; + private final AddressWithName addressWithName; + + private Callback(final ImageView imageView, final AddressWithName addressWithName) { + this.imageViewWeakReference = new WeakReference<>(imageView); + this.addressWithName = addressWithName; + } + + @Override + public void onSuccess(final Bitmap result) { + final var imageView = imageViewWeakReference.get(); + if (imageView == null) { + LOGGER.info("ImageView reference was gone after fetching avatar"); + return; + } + final var roundedBitmapDrawable = + RoundedBitmapDrawableFactory.create(imageView.getResources(), result); + roundedBitmapDrawable.setCircular(true); + imageView.setImageDrawable(roundedBitmapDrawable); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + final var imageView = imageViewWeakReference.get(); + if (imageView == null) { + LOGGER.info("ImageView reference was gone after avatar fetch failed"); + return; + } + LOGGER.info("Could not load avatar", throwable); + setDefault(imageView, this.addressWithName); + } + } + + public static void fetchInto(final ImageView imageView, final AvatarWithAccount avatar) { + final var tag = imageView.getTag(); + if (tag instanceof AvatarFetcher avatarFetcher) { + if (avatar.equals(avatarFetcher.avatar)) { + return; + } + avatarFetcher.future.cancel(true); + } + final var future = getAvatar(imageView.getContext(), avatar); + // set default avatar until proper avatar is loaded + setDefault(imageView, avatar.addressWithName); + final var avatarFetcher = new AvatarFetcher(avatar, future, imageView); + imageView.setTag(avatarFetcher); + } + + private static ListenableFuture getAvatar( + final Context context, final AvatarWithAccount avatar) { + return Futures.transformAsync( + getAvatarManager(context, avatar.account), + am -> { + return am.getAvatarBitmap( + avatar.addressWithName.address, avatar.avatarType, avatar.hash); + }, + MoreExecutors.directExecutor()); + } + + private static ListenableFuture getAvatarManager( + final Context context, final long account) { + return Futures.transform( + ConnectionPool.getInstance(context).get(account), + xc -> xc.getManager(AvatarManager.class), + MoreExecutors.directExecutor()); + } +} diff --git a/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java b/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java index c69699dc2..537654a6e 100644 --- a/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java +++ b/app/src/main/java/im/conversations/android/ui/adapter/ChatOverviewAdapter.java @@ -1,6 +1,7 @@ package im.conversations.android.ui.adapter; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; @@ -10,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView; import im.conversations.android.R; import im.conversations.android.database.model.ChatOverviewItem; import im.conversations.android.databinding.ItemChatoverviewBinding; +import im.conversations.android.ui.AvatarFetcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +39,17 @@ public class ChatOverviewAdapter public void onBindViewHolder(@NonNull ChatOverviewViewHolder holder, int position) { final var chatOverviewItem = getItem(position); holder.binding.setChatOverviewItem(chatOverviewItem); + final var addressWithName = + chatOverviewItem == null ? null : chatOverviewItem.getAddressWithName(); + final var avatar = chatOverviewItem == null ? null : chatOverviewItem.getAvatar(); + if (avatar != null) { + AvatarFetcher.fetchInto(holder.binding.avatar, avatar); + } else if (addressWithName != null) { + holder.binding.avatar.setVisibility(View.VISIBLE); + AvatarFetcher.setDefault(holder.binding.avatar, addressWithName); + } else { + holder.binding.avatar.setVisibility(View.INVISIBLE); + } } public static class ChatOverviewViewHolder extends RecyclerView.ViewHolder { diff --git a/app/src/main/java/im/conversations/android/ui/graphics/drawable/AvatarDrawable.java b/app/src/main/java/im/conversations/android/ui/graphics/drawable/AvatarDrawable.java new file mode 100644 index 000000000..cf645cf3c --- /dev/null +++ b/app/src/main/java/im/conversations/android/ui/graphics/drawable/AvatarDrawable.java @@ -0,0 +1,119 @@ +/* + * Copyright 2019-2023 Daniel Gultsch + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.conversations.android.ui.graphics.drawable; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import com.google.android.material.elevation.SurfaceColors; +import com.google.common.base.Strings; +import im.conversations.android.R; +import im.conversations.android.database.model.AddressWithName; +import im.conversations.android.util.ConsistentColorGeneration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jxmpp.jid.Jid; + +public class AvatarDrawable extends ColorDrawable { + + // pattern from @cketti (K-9 Mail) + private static final Pattern LETTER_PATTERN = Pattern.compile("\\p{L}\\p{M}*"); + + private final Paint paint; + private final Paint textPaint; + private final String letter; + private final int intrinsicHeight; + private final int intrinsicWidth; + + private final Context context; + + public AvatarDrawable(final Context context, final AddressWithName addressWithName) { + this.context = context; + final String name = addressWithName.name; + final Jid key = addressWithName.address; + this.paint = getPaint(addressWithName.address); + this.textPaint = getTextPaint(); + final Matcher matcher = LETTER_PATTERN.matcher(Strings.nullToEmpty(name)); + this.letter = matcher.find() ? matcher.group().toUpperCase(Locale.ROOT) : null; + final int avatarDrawableSize = + context.getResources().getDimensionPixelSize(R.dimen.avatar_drawable_size); + this.intrinsicHeight = avatarDrawableSize; + this.intrinsicWidth = avatarDrawableSize; + } + + private Paint getPaint(final Jid key) { + final Paint paint = new Paint(); + paint.setColor( + key == null ? 0xff757575 : ConsistentColorGeneration.harmonized(context, key)); + paint.setAntiAlias(true); + return paint; + } + + private Paint getTextPaint() { + final Paint textPaint = new Paint(); + textPaint.setColor(SurfaceColors.SURFACE_0.getColor(context)); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setAntiAlias(true); + textPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)); + return textPaint; + } + + @Override + public void draw(final Canvas canvas) { + final float midX = getBounds().width() / 2.0f; + final float midY = getBounds().height() / 2.0f; + final float radius = Math.min(getBounds().width(), getBounds().height()) / 2.0f; + textPaint.setTextSize(radius); + final Rect r = new Rect(); + canvas.getClipBounds(r); + final int cHeight = r.height(); + final int cWidth = r.width(); + canvas.drawCircle(midX, midY, radius, paint); + if (letter == null) { + return; + } + textPaint.setTextAlign(Paint.Align.LEFT); + textPaint.getTextBounds(letter, 0, letter.length(), r); + float x = cWidth / 2f - r.width() / 2f - r.left; + float y = cHeight / 2f + r.height() / 2f - r.bottom; + canvas.drawText(letter, x, y, textPaint); + } + + @Override + public int getIntrinsicHeight() { + return intrinsicHeight; + } + + @Override + public int getIntrinsicWidth() { + return intrinsicWidth; + } + + public Bitmap toBitmap() { + final Bitmap bitmap = + Bitmap.createBitmap( + getIntrinsicWidth(), getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + this.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + this.draw(canvas); + return bitmap; + } +} diff --git a/app/src/main/java/im/conversations/android/util/ConsistentColorGeneration.java b/app/src/main/java/im/conversations/android/util/ConsistentColorGeneration.java index 16965bf05..ad9fc6454 100644 --- a/app/src/main/java/im/conversations/android/util/ConsistentColorGeneration.java +++ b/app/src/main/java/im/conversations/android/util/ConsistentColorGeneration.java @@ -22,7 +22,7 @@ import com.google.android.material.color.MaterialColors; import com.google.common.base.Charsets; import com.google.common.hash.Hashing; import org.hsluv.HUSLColorConverter; -import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.Jid; public final class ConsistentColorGeneration { @@ -40,7 +40,7 @@ public final class ConsistentColorGeneration { @ColorInt public static int rgb(final String input) { final double[] rgb = - HUSLColorConverter.hsluvToRgb(new double[] {angle(input) * 360, 100, 50}); + HUSLColorConverter.hsluvToRgb(new double[] {angle(input) * 360, 85, 58}); return rgb( (int) Math.round(rgb[0] * 255), (int) Math.round(rgb[1] * 255), @@ -52,7 +52,7 @@ public final class ConsistentColorGeneration { return MaterialColors.harmonizeWithPrimary(context, rgb(input)); } - public static int harmonized(final Context context, final BareJid jid) { + public static int harmonized(final Context context, final Jid jid) { return harmonized(context, jid.toString()); } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java index c8e50e5b0..9178e2c03 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/AvatarManager.java @@ -1,6 +1,8 @@ package im.conversations.android.xmpp.manager; import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import com.google.common.collect.Collections2; import com.google.common.collect.Iterables; import com.google.common.hash.Hashing; @@ -9,6 +11,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; +import im.conversations.android.database.model.AvatarType; import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.model.avatar.Data; import im.conversations.android.xmpp.model.avatar.Info; @@ -89,6 +92,23 @@ public class AvatarManager extends AbstractManager { return future; } + public ListenableFuture getAvatarBitmap( + final Jid address, final AvatarType type, final String id) { + final ListenableFuture avatar; + if (type == AvatarType.PEP) { + avatar = getAvatar(address, id); + } else { + return Futures.immediateFailedFuture( + new Exception(String.format("Can not load type %s avatar", type))); + } + return Futures.transform( + avatar, + bytes -> { + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + }, + CPU_EXECUTOR); + } + private byte[] getCachedAvatar(final Jid address, final String id) throws IOException { final var cache = getCacheFile(address, id); final byte[] avatar = Files.toByteArray(cache); diff --git a/app/src/main/res/layout/item_chatoverview.xml b/app/src/main/res/layout/item_chatoverview.xml index 0557910b0..d255e42bf 100644 --- a/app/src/main/res/layout/item_chatoverview.xml +++ b/app/src/main/res/layout/item_chatoverview.xml @@ -8,20 +8,28 @@ app:layout_constraintBottom_toBottomOf="@+id/name" app:layout_constraintTop_toBottomOf="@+id/name"> + + 8dp 12dp 96dp + 40dp