summaryrefslogtreecommitdiff
path: root/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java')
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java412
1 files changed, 412 insertions, 0 deletions
diff --git a/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java b/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java
new file mode 100644
index 0000000..fbd9818
--- /dev/null
+++ b/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java
@@ -0,0 +1,412 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * 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 com.android.car.messenger.impl.datamodels.util;
+
+import static java.lang.Math.min;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.Shader.TileMode;
+import android.text.TextUtils;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.car.messenger.R;
+import com.android.car.messenger.core.ui.shared.LetterTileDrawable;
+
+import java.util.List;
+
+/**
+ * Avatar Utils for generating conversation and contact avatars
+ *
+ * <p>For historical context, AvatarUtil is derived from Android Messages implementation of group
+ * avatars particularly from these sources:
+ *
+ * <p>AvatarGroupRequestDescriptor#generateDestRectArray:
+ * packages/apps/Messaging/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor
+ *
+ * <p>CompositeImageRequest#loadMediaInternal:
+ * packages/apps/Messaging/src/com/android/messaging/datamodel/media/CompositeImageRequest
+ *
+ * <p>ImageUtils#drawBitmapWithCircleOnCanvas:
+ * packages/apps/Messaging/src/com/android/messaging/util/ImageUtils.java
+ *
+ * <p>Current implementation is close to reference. However, future iterations can diverge.
+ */
+public final class AvatarUtil {
+
+ private AvatarUtil() {}
+
+ private static final class GroupAvatarConfigs {
+ int mWidth;
+ int mHeight;
+ int mMaximumGroupSize;
+ int mBackgroundColor;
+ int mStrokeColor;
+ boolean mFillBackground;
+ }
+
+ /**
+ * Supports creating a group avatar: a minimum of 1 avatar and a maximum of four avatars are
+ * supported. Any avatars beyond the 4th index is ignored.
+ */
+ @Nullable
+ public static Bitmap createGroupAvatar(
+ @NonNull Context context, @Nullable List<Bitmap> participantsIcon) {
+ if (participantsIcon == null || participantsIcon.isEmpty()) {
+ return null;
+ }
+
+ GroupAvatarConfigs groupAvatarConfigs = new GroupAvatarConfigs();
+ Resources resources = context.getResources();
+ int size = resources.getDimensionPixelSize(R.dimen.conversation_avatar_width);
+ groupAvatarConfigs.mWidth = size;
+ groupAvatarConfigs.mHeight = size;
+ groupAvatarConfigs.mMaximumGroupSize =
+ resources.getInteger(R.integer.group_avatar_max_group_size);
+ groupAvatarConfigs.mBackgroundColor =
+ resources.getColor(R.color.group_avatar_background_color, context.getTheme());
+ groupAvatarConfigs.mStrokeColor =
+ resources.getColor(R.color.group_avatar_stroke_color, context.getTheme());
+ groupAvatarConfigs.mFillBackground =
+ context.getResources().getBoolean(R.bool.group_avatar_fill_background);
+
+ if (participantsIcon.size() == 1 || groupAvatarConfigs.mMaximumGroupSize == 1) {
+ return participantsIcon.get(0);
+ }
+
+ return createGroupAvatarBitmap(participantsIcon, groupAvatarConfigs);
+ }
+
+ /**
+ * Resolves person avatar to either the provided bitmap clipped into a circle or a letter
+ * drawable
+ */
+ @Nullable
+ public static Bitmap resolvePersonAvatar(
+ @NonNull Context context, @Nullable Bitmap bitmap, @Nullable CharSequence name) {
+ if (bitmap != null) {
+ return AvatarUtil.createClippedCircle(bitmap);
+ } else {
+ return createLetterTile(context, name);
+ }
+ }
+
+ /**
+ * Create a {@link Bitmap} for the given name
+ *
+ * @param name will decide the color for the drawable. If null, a default color will be used.
+ */
+ @Nullable
+ private static Bitmap createLetterTile(@NonNull Context context, @Nullable CharSequence name) {
+ if (TextUtils.isEmpty(name)) {
+ return null;
+ }
+ char firstInitial = name.charAt(0);
+ String letters = Character.isLetter(firstInitial) ? Character.toString(firstInitial) : null;
+ LetterTileDrawable drawable =
+ new LetterTileDrawable(context.getResources(), letters, name.toString());
+ int size = context.getResources().getDimensionPixelSize(R.dimen.conversation_avatar_width);
+ return drawable.toBitmap(size);
+ }
+
+ /** Returns a circle-clipped bitmap */
+ @NonNull
+ private static Bitmap createClippedCircle(Bitmap bitmap) {
+ final int width = bitmap.getWidth();
+ final int height = bitmap.getHeight();
+ final Bitmap outputBitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888);
+
+ final Path path = new Path();
+ path.addCircle(
+ (float) (width / 2),
+ (float) (height / 2),
+ (float) min(width, (height / 2)),
+ Path.Direction.CCW);
+
+ final Canvas canvas = new Canvas(outputBitmap);
+ canvas.clipPath(path);
+ canvas.drawBitmap(bitmap, 0, 0, null);
+ return outputBitmap;
+ }
+
+ /** Creates a group avatar bitmap */
+ @NonNull
+ private static Bitmap createGroupAvatarBitmap(
+ @NonNull List<Bitmap> participantsIcon, GroupAvatarConfigs groupAvatarConfigs) {
+ int width = groupAvatarConfigs.mWidth;
+ int height = groupAvatarConfigs.mHeight;
+ Bitmap bitmap = createOrReuseBitmap(width, height, Color.TRANSPARENT);
+ Canvas canvas = new Canvas(bitmap);
+ RectF[] rect =
+ generateDestRectArray(
+ width,
+ height,
+ /* cropToCircle= */ true,
+ min(participantsIcon.size(), groupAvatarConfigs.mMaximumGroupSize));
+
+ for (int i = 0; i < rect.length; i++) {
+ RectF avatarDestOnGroup = rect[i];
+ // Draw the bitmap into a smaller size with a circle mask.
+ Bitmap resourceBitmap = participantsIcon.get(i);
+ RectF resourceRect =
+ new RectF(
+ /* left= */ 0,
+ /* top= */ 0,
+ resourceBitmap.getWidth(),
+ resourceBitmap.getHeight());
+ Bitmap smallCircleBitmap =
+ createOrReuseBitmap(
+ Math.round(avatarDestOnGroup.width()),
+ Math.round(avatarDestOnGroup.height()),
+ Color.TRANSPARENT);
+ RectF smallCircleRect =
+ new RectF(
+ /* left= */ 0,
+ /* top= */ 0,
+ smallCircleBitmap.getWidth(),
+ smallCircleBitmap.getHeight());
+ Canvas smallCircleCanvas = new Canvas(smallCircleBitmap);
+ drawBitmapWithCircleOnCanvas(
+ resourceBitmap,
+ smallCircleCanvas,
+ resourceRect,
+ smallCircleRect,
+ groupAvatarConfigs.mFillBackground,
+ groupAvatarConfigs.mBackgroundColor,
+ groupAvatarConfigs.mStrokeColor);
+ Matrix matrix = new Matrix();
+ matrix.setRectToRect(smallCircleRect, avatarDestOnGroup, Matrix.ScaleToFit.FILL);
+ canvas.drawBitmap(smallCircleBitmap, matrix, new Paint(Paint.ANTI_ALIAS_FLAG));
+ }
+
+ return bitmap;
+ }
+
+ /**
+ * Given the source bitmap and a canvas, draws the bitmap through a circular mask. Only draws a
+ * circle with diameter equal to the destination width.
+ *
+ * @param bitmap The source bitmap to draw.
+ * @param canvas The canvas to draw it on.
+ * @param source The source bound of the bitmap.
+ * @param dest The destination bound on the canvas.
+ * @param fillBackground when set, fill the circle with backgroundColor
+ * @param strokeColor draw a border outside the circle with strokeColor
+ */
+ private static void drawBitmapWithCircleOnCanvas(
+ @NonNull Bitmap bitmap,
+ @NonNull Canvas canvas,
+ @NonNull RectF source,
+ @NonNull RectF dest,
+ boolean fillBackground,
+ int backgroundColor,
+ int strokeColor) {
+ // Draw bitmap through shader first.
+ final BitmapShader shader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP);
+ final Matrix matrix = new Matrix();
+
+ // Fit bitmap to bounds.
+ matrix.setRectToRect(source, dest, Matrix.ScaleToFit.CENTER);
+
+ shader.setLocalMatrix(matrix);
+ Paint bitmapPaint = new Paint();
+
+ bitmapPaint.setAntiAlias(true);
+ if (fillBackground) {
+ bitmapPaint.setColor(backgroundColor);
+ canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
+ }
+
+ bitmapPaint.setShader(shader);
+ canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
+ bitmapPaint.setShader(null);
+
+ if (strokeColor != Color.TRANSPARENT) {
+ final Paint stroke = new Paint();
+ stroke.setAntiAlias(true);
+ stroke.setColor(strokeColor);
+ stroke.setStyle(Paint.Style.STROKE);
+ final float strokeWidth = 6f;
+ stroke.setStrokeWidth(strokeWidth);
+ canvas.drawCircle(
+ dest.centerX(),
+ dest.centerX(),
+ /* radius= */ dest.width() / 2f - stroke.getStrokeWidth() / 2f,
+ stroke);
+ }
+ }
+
+ @NonNull
+ private static Bitmap createOrReuseBitmap(int width, int height, @ColorInt int background) {
+ Bitmap bitmap =
+ Bitmap.createBitmap(width, height, /* Bitmap.Config= */ Bitmap.Config.ARGB_8888);
+ bitmap.eraseColor(background);
+ return bitmap;
+ }
+
+ /**
+ * Generates an array of {@link RectF} which represents where each of the individual avatar
+ * should be located in the final group avatar image. The location of each avatar depends on the
+ * size of the group and the size of the overall group avatar size. If we're cropping to a
+ * circle, inset the rects so the circle surrounds all the mini-avatars.
+ */
+ public static RectF[] generateDestRectArray(
+ int desiredWidth, int desiredHeight, boolean cropToCircle, int groupSize) {
+ float halfWidth = desiredWidth / 2F;
+ float halfHeight = desiredHeight / 2F;
+
+ // If we're cropping to a circle, calculate an inset so that all the mini-avatars will fit
+ // inside the circle.
+ float inset =
+ cropToCircle ? (float) ((Math.hypot(halfWidth, halfHeight) - halfWidth) / 2f) : 0F;
+ RectF[] destArray = new RectF[groupSize];
+ switch (groupSize) {
+ case 2:
+ /*
+ * +-------+
+ * | 0 | |
+ * +-------+
+ * | | 1 |
+ * +-------+ *
+ * We want two circles which touches in the center. To get this we know that
+ * the diagonal
+ * of the overall group avatar is squareRoot(2) * w We also know that the two
+ * circles
+ * touches the at the center of the overall group avatar and the distance from
+ * the center of
+ * the circle to the corner of the group avatar is radius * squareRoot(2).
+ * Therefore, the
+ * following emerges.
+ *
+ * w * squareRoot(2) = 2 (radius + radius * squareRoot(2)) Solving for radius
+ * we get: d =
+ * 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w d = (2 - squareRoot(2)
+ * ) * w
+ */
+ float diameter = (float) ((2 - Math.sqrt(2)) * ((float) desiredWidth - inset));
+ destArray[0] = new RectF(inset, inset, diameter, diameter);
+ destArray[1] =
+ new RectF(
+ /* left= */ (float) desiredWidth - diameter,
+ /* top= */ (float) desiredHeight - diameter,
+ /* right= */ (float) desiredWidth - inset,
+ /* bottom= */ (float) desiredHeight - inset);
+ break;
+ case 3:
+ /*
+ * +-------+
+ * | | 0 | |
+ * +-------+
+ * | 1 | 2 |
+ * +-------+
+ * i0
+ * |\
+ * a | \ c
+ * --- i2
+ * b
+ *
+ * a = radius * squareRoot(3) due to the triangle being a 30-60-90 right
+ * triangle. b =
+ * radius of circle c = 2 * radius of circle
+ *
+ * All three of the images are circles and therefore image zero will not touch
+ * image one
+ * or image two. Move image zero down so it touches image one and image two. This
+ * can be
+ * done by keeping image zero in the center and moving it down slightly. The
+ * amount to move
+ * down can be calculated by solving a right triangle. We know that the center x
+ * of image
+ * two to the center x of image zero is the radius of the circle, this is the
+ * length of edge
+ * b. Also we know that the distance from image zero to image two's center is 2 *
+ * radius,
+ * edge c. From this we know that the distance from center y of image two to
+ * center y of
+ * image one, edge a, is equal to radius * squareRoot(3) due to this triangle
+ * being a
+ * 30-60-90 right triangle.
+ */
+ float quarterWidth = (float) desiredWidth / 4F;
+ float threeQuarterWidth = 3 * quarterWidth;
+ float radius = cropToCircle ? (halfHeight - inset) / 2 : (float) desiredHeight / 4F;
+ float imageTwoCenterY = (float) desiredHeight - radius;
+ float lengthOfEdgeA = (float) (radius * Math.sqrt(3));
+ float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA;
+ float imageZeroTop = imageZeroCenterY - radius - 2 * inset;
+ float imageZeroBottom = imageZeroCenterY + radius - 2 * inset;
+ destArray[0] =
+ new RectF(
+ quarterWidth, imageZeroTop,
+ threeQuarterWidth, imageZeroBottom);
+ destArray[1] =
+ new RectF(
+ inset,
+ /* top= */ halfHeight - inset,
+ halfWidth,
+ /* bottom= */ (float) desiredHeight - 2 * inset);
+ destArray[2] =
+ new RectF(
+ halfWidth,
+ /* top= */ halfHeight - inset,
+ /* right= */ (float) desiredWidth - inset,
+ /* bottom= */ (float) desiredHeight - 2 * inset);
+ break;
+ default:
+ /*
+ * +-------+
+ * | 0 | 1 |
+ * +-------+
+ * | 2 | 3 |
+ * +-------+
+ */
+ destArray[0] = new RectF(inset, inset, halfWidth, halfHeight);
+ destArray[1] =
+ new RectF(
+ halfWidth,
+ inset,
+ /* right= */ (float) desiredWidth - inset,
+ halfHeight);
+ destArray[2] =
+ new RectF(
+ inset,
+ halfHeight,
+ halfWidth,
+ /* bottom= */ (float) desiredHeight - inset);
+ destArray[3] =
+ new RectF(
+ halfWidth,
+ halfHeight,
+ /* right= */ (float) desiredWidth - inset,
+ /* bottom= */ (float) desiredHeight - inset);
+ break;
+ }
+ return destArray;
+ }
+}