add avatar image to chat overview item

This commit is contained in:
Daniel Gultsch 2023-03-07 16:04:32 +01:00
parent 260654f171
commit 0d134a919e
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
11 changed files with 379 additions and 9 deletions

View file

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

View file

@ -0,0 +1,6 @@
package im.conversations.android.database.model;
public enum AvatarType {
VCARD,
PEP
}

View file

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

View file

@ -100,10 +100,33 @@ public class ChatOverviewItem {
return fallbackName(); 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() { private Jid getJidAddress() {
return address == null ? null : JidCreate.fromOrNull(address); 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) { private static boolean notNullNotEmpty(final String value) {
return value != null && !value.trim().isEmpty(); return value != null && !value.trim().isEmpty();
} }

View file

@ -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<Bitmap> future;
private AvatarFetcher(
AvatarWithAccount avatar, ListenableFuture<Bitmap> 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<Bitmap> {
private final WeakReference<ImageView> 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<Bitmap> 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<AvatarManager> getAvatarManager(
final Context context, final long account) {
return Futures.transform(
ConnectionPool.getInstance(context).get(account),
xc -> xc.getManager(AvatarManager.class),
MoreExecutors.directExecutor());
}
}

View file

@ -1,6 +1,7 @@
package im.conversations.android.ui.adapter; package im.conversations.android.ui.adapter;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil; import androidx.databinding.DataBindingUtil;
@ -10,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView;
import im.conversations.android.R; import im.conversations.android.R;
import im.conversations.android.database.model.ChatOverviewItem; import im.conversations.android.database.model.ChatOverviewItem;
import im.conversations.android.databinding.ItemChatoverviewBinding; import im.conversations.android.databinding.ItemChatoverviewBinding;
import im.conversations.android.ui.AvatarFetcher;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -37,6 +39,17 @@ public class ChatOverviewAdapter
public void onBindViewHolder(@NonNull ChatOverviewViewHolder holder, int position) { public void onBindViewHolder(@NonNull ChatOverviewViewHolder holder, int position) {
final var chatOverviewItem = getItem(position); final var chatOverviewItem = getItem(position);
holder.binding.setChatOverviewItem(chatOverviewItem); 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 { public static class ChatOverviewViewHolder extends RecyclerView.ViewHolder {

View file

@ -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;
}
}

View file

@ -22,7 +22,7 @@ import com.google.android.material.color.MaterialColors;
import com.google.common.base.Charsets; import com.google.common.base.Charsets;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import org.hsluv.HUSLColorConverter; import org.hsluv.HUSLColorConverter;
import org.jxmpp.jid.BareJid; import org.jxmpp.jid.Jid;
public final class ConsistentColorGeneration { public final class ConsistentColorGeneration {
@ -40,7 +40,7 @@ public final class ConsistentColorGeneration {
@ColorInt @ColorInt
public static int rgb(final String input) { public static int rgb(final String input) {
final double[] rgb = final double[] rgb =
HUSLColorConverter.hsluvToRgb(new double[] {angle(input) * 360, 100, 50}); HUSLColorConverter.hsluvToRgb(new double[] {angle(input) * 360, 85, 58});
return rgb( return rgb(
(int) Math.round(rgb[0] * 255), (int) Math.round(rgb[0] * 255),
(int) Math.round(rgb[1] * 255), (int) Math.round(rgb[1] * 255),
@ -52,7 +52,7 @@ public final class ConsistentColorGeneration {
return MaterialColors.harmonizeWithPrimary(context, rgb(input)); 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()); return harmonized(context, jid.toString());
} }

View file

@ -1,6 +1,8 @@
package im.conversations.android.xmpp.manager; package im.conversations.android.xmpp.manager;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.hash.Hashing; 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.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture; 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.XmppConnection;
import im.conversations.android.xmpp.model.avatar.Data; import im.conversations.android.xmpp.model.avatar.Data;
import im.conversations.android.xmpp.model.avatar.Info; import im.conversations.android.xmpp.model.avatar.Info;
@ -89,6 +92,23 @@ public class AvatarManager extends AbstractManager {
return future; return future;
} }
public ListenableFuture<Bitmap> getAvatarBitmap(
final Jid address, final AvatarType type, final String id) {
final ListenableFuture<byte[]> 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 { private byte[] getCachedAvatar(final Jid address, final String id) throws IOException {
final var cache = getCacheFile(address, id); final var cache = getCacheFile(address, id);
final byte[] avatar = Files.toByteArray(cache); final byte[] avatar = Files.toByteArray(cache);

View file

@ -8,20 +8,28 @@
app:layout_constraintBottom_toBottomOf="@+id/name" app:layout_constraintBottom_toBottomOf="@+id/name"
app:layout_constraintTop_toBottomOf="@+id/name"> app:layout_constraintTop_toBottomOf="@+id/name">
<ImageView
android:id="@+id/avatar"
android:layout_width="@dimen/avatar_drawable_size"
android:layout_height="@dimen/avatar_drawable_size"
android:layout_margin="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/name" android:id="@+id/name"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="16dp"
android:maxLines="1" android:maxLines="1"
android:text="@{chatOverviewItem.name}" android:text="@{chatOverviewItem.name}"
android:textAppearance="?textAppearanceTitleMedium" android:textAppearance="?textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@+id/message" app:layout_constraintBottom_toTopOf="@+id/message"
app:layout_constraintEnd_toStartOf="@+id/sentAt" app:layout_constraintEnd_toStartOf="@+id/sentAt"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toEndOf="@+id/avatar"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.45" app:layout_constraintVertical_chainStyle="packed"
tools:text="The City of Verona" /> tools:text="The City of Verona" />
<TextView <TextView
@ -29,6 +37,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="4sp" android:layout_marginEnd="4sp"
android:maxLines="1"
android:text="@{chatOverviewItem.sender}" android:text="@{chatOverviewItem.sender}"
android:textAppearance="?textAppearanceBodyMedium" android:textAppearance="?textAppearanceBodyMedium"
android:textColor="?colorTertiary" android:textColor="?colorTertiary"
@ -36,10 +45,10 @@
app:layout_constraintBaseline_toBaselineOf="@+id/message" app:layout_constraintBaseline_toBaselineOf="@+id/message"
app:layout_constraintEnd_toStartOf="@+id/message" app:layout_constraintEnd_toStartOf="@+id/message"
app:layout_constraintStart_toStartOf="@+id/name" app:layout_constraintStart_toStartOf="@+id/name"
android:maxLines="1"
tools:text="You:" /> tools:text="You:" />
<TextView <TextView
android:layout_marginTop="4sp"
android:id="@+id/message" android:id="@+id/message"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -57,7 +66,7 @@
android:id="@+id/sentAt" android:id="@+id/sentAt"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="16dp"
android:textAppearance="?textAppearanceLabelMedium" android:textAppearance="?textAppearanceLabelMedium"
app:instant="@{chatOverviewItem.sentAt}" app:instant="@{chatOverviewItem.sentAt}"
app:layout_constraintBaseline_toBaselineOf="@+id/name" app:layout_constraintBaseline_toBaselineOf="@+id/name"

View file

@ -8,4 +8,5 @@
<dimen name="in_call_fab_margin">8dp</dimen> <dimen name="in_call_fab_margin">8dp</dimen>
<dimen name="in_call_fab_margin_center">12dp</dimen> <dimen name="in_call_fab_margin_center">12dp</dimen>
<dimen name="publish_avatar_size">96dp</dimen> <dimen name="publish_avatar_size">96dp</dimen>
<dimen name="avatar_drawable_size">40dp</dimen>
</resources> </resources>