Commit 961fdc8e authored by Torsten Grote's avatar Torsten Grote

[android] Show multiple images in message bubble

parent c3d44663
......@@ -23,6 +23,7 @@ class ConversationAdapter
private ConversationListener listener;
private final RecycledViewPool imageViewPool;
private final ImageItemDecoration imageItemDecoration;
ConversationAdapter(Context ctx,
ConversationListener conversationListener) {
......@@ -30,6 +31,8 @@ class ConversationAdapter
listener = conversationListener;
// This shares the same pool for view recycling between all image lists
imageViewPool = new RecycledViewPool();
// Share the item decoration as well
imageItemDecoration = new ImageItemDecoration(ctx);
}
@LayoutRes
......@@ -47,10 +50,10 @@ class ConversationAdapter
switch (type) {
case R.layout.list_item_conversation_msg_in:
return new ConversationMessageViewHolder(v, listener, true,
imageViewPool);
imageViewPool, imageItemDecoration);
case R.layout.list_item_conversation_msg_out:
return new ConversationMessageViewHolder(v, listener, false,
imageViewPool);
imageViewPool, imageItemDecoration);
case R.layout.list_item_conversation_notice_in:
return new ConversationNoticeViewHolder(v, listener, true);
case R.layout.list_item_conversation_notice_out:
......
......@@ -2,8 +2,6 @@ package org.briarproject.briar.android.conversation;
import android.support.annotation.UiThread;
import android.support.constraint.ConstraintSet;
import android.support.v4.content.ContextCompat;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.RecycledViewPool;
import android.view.View;
......@@ -13,6 +11,7 @@ import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R;
import static android.support.constraint.ConstraintSet.WRAP_CONTENT;
import static android.support.v4.content.ContextCompat.getColor;
@UiThread
@NotNullByDefault
......@@ -26,21 +25,22 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder {
private final ConstraintSet imageTextConstraints = new ConstraintSet();
ConversationMessageViewHolder(View v, ConversationListener listener,
boolean isIncoming, RecycledViewPool imageViewPool) {
boolean isIncoming, RecycledViewPool imageViewPool,
ImageItemDecoration imageItemDecoration) {
super(v, listener, isIncoming);
statusLayout = v.findViewById(R.id.statusLayout);
// image list
RecyclerView list = v.findViewById(R.id.imageView);
RecyclerView list = v.findViewById(R.id.imageList);
list.setRecycledViewPool(imageViewPool);
list.setLayoutManager(new GridLayoutManager(v.getContext(), 2));
adapter = new ImageAdapter(listener);
adapter =
new ImageAdapter(v.getContext(), imageItemDecoration, listener);
list.setAdapter(adapter);
list.addItemDecoration(imageItemDecoration);
// remember original status text color
timeColor = time.getCurrentTextColor();
timeColorBubble =
ContextCompat.getColor(v.getContext(), R.color.briar_white);
timeColorBubble = getColor(v.getContext(), R.color.briar_white);
// clone constraint sets from layout files
textConstraints
......@@ -80,8 +80,7 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder {
private void bindImageItem(ConversationMessageItem item) {
ConstraintSet constraintSet;
if (item.getText() == null) {
statusLayout
.setBackgroundResource(R.drawable.msg_status_bubble);
statusLayout.setBackgroundResource(R.drawable.msg_status_bubble);
time.setTextColor(timeColorBubble);
constraintSet = imageConstraints;
} else {
......@@ -94,11 +93,12 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder {
AttachmentItem attachment = item.getAttachments().get(0);
int width = attachment.getThumbnailWidth();
int height = attachment.getThumbnailHeight();
constraintSet.constrainWidth(R.id.imageView, width);
constraintSet.constrainHeight(R.id.imageView, height);
constraintSet.constrainWidth(R.id.imageList, width);
constraintSet.constrainHeight(R.id.imageList, height);
} else {
constraintSet.constrainWidth(R.id.imageView, WRAP_CONTENT);
constraintSet.constrainHeight(R.id.imageView, WRAP_CONTENT);
// bubble adapts to size of image list
constraintSet.constrainWidth(R.id.imageList, WRAP_CONTENT);
constraintSet.constrainHeight(R.id.imageList, WRAP_CONTENT);
}
constraintSet.applyTo(layout);
adapter.setConversationItem(item);
......
package org.briarproject.briar.android.conversation;
import android.content.Context;
import android.content.res.Resources;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView.Adapter;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.conversation.glide.Radii;
import java.util.ArrayList;
import java.util.List;
import static android.content.Context.WINDOW_SERVICE;
import static java.util.Objects.requireNonNull;
@NotNullByDefault
class ImageAdapter extends Adapter<ImageViewHolder> {
private final static int TYPE_SINGLE = 0;
private final static int TYPE_MULTIPLE = 1;
private final List<AttachmentItem> items = new ArrayList<>();
private final ConversationListener listener;
private final int imageSize, borderSize;
private final int radiusBig, radiusSmall;
private final boolean isRtl;
@Nullable
private ConversationMessageItem conversationItem;
public ImageAdapter(ConversationListener listener) {
super();
public ImageAdapter(Context ctx, ImageItemDecoration imageItemDecoration,
ConversationListener listener) {
this.listener = listener;
}
@Override
public int getItemViewType(int position) {
return items.size() == 1 ? TYPE_SINGLE : TYPE_MULTIPLE;
borderSize = imageItemDecoration.getBorderSize();
imageSize = getImageSize(ctx);
Resources res = ctx.getResources();
radiusBig =
res.getDimensionPixelSize(R.dimen.message_bubble_radius_big);
radiusSmall =
res.getDimensionPixelSize(R.dimen.message_bubble_radius_small);
isRtl = imageItemDecoration.isRtl();
}
@Override
public ImageViewHolder onCreateViewHolder(ViewGroup viewGroup, int type) {
View v = LayoutInflater.from(viewGroup.getContext()).inflate(
R.layout.list_item_image, viewGroup, false);
return type == TYPE_SINGLE ? new SingleImageViewHolder(v) :
new ImageViewHolder(v);
return new ImageViewHolder(v, imageSize, borderSize);
}
@Override
public void onBindViewHolder(ImageViewHolder imageViewHolder,
int position) {
// get item
requireNonNull(conversationItem);
AttachmentItem item = items.get(position);
// set onClick listener
imageViewHolder.itemView.setOnClickListener(v ->
listener.onAttachmentClicked(v, conversationItem, item)
);
if (imageViewHolder instanceof SingleImageViewHolder) {
boolean isIncoming = conversationItem.isIncoming();
boolean hasText = conversationItem.getText() != null;
((SingleImageViewHolder) imageViewHolder)
.bind(item, isIncoming, hasText);
} else {
imageViewHolder.bind(item);
}
// bind view holder
int size = items.size();
boolean isIncoming = conversationItem.isIncoming();
boolean hasText = conversationItem.getText() != null;
Radii r = getRadii(position, size, isIncoming, hasText);
imageViewHolder.bind(item, r, size == 1, singleInRow(position, size));
}
@Override
......@@ -73,9 +81,76 @@ class ImageAdapter extends Adapter<ImageViewHolder> {
notifyDataSetChanged();
}
private int getImageSize(Context ctx) {
Resources res = ctx.getResources();
WindowManager windowManager =
(WindowManager) ctx.getSystemService(WINDOW_SERVICE);
DisplayMetrics displayMetrics = new DisplayMetrics();
if (windowManager == null) {
return res.getDimensionPixelSize(
R.dimen.message_bubble_image_default);
}
windowManager.getDefaultDisplay().getMetrics(displayMetrics);
int imageSize = displayMetrics.widthPixels / 3;
int maxSize = res.getDimensionPixelSize(
R.dimen.message_bubble_image_max_width);
return Math.min(imageSize, maxSize);
}
private Radii getRadii(int pos, int num, boolean isIncoming,
boolean hasText) {
boolean left = isLeft(pos);
boolean single = num == 1;
// Top Row
int topLeft;
int topRight;
if (single) {
topLeft = isIncoming ? radiusSmall : radiusBig;
topRight = !isIncoming ? radiusSmall : radiusBig;
} else if (isTopRow(pos)) {
topLeft = left ? (isIncoming ? radiusSmall : radiusBig) : 0;
topRight = !left ? (!isIncoming ? radiusSmall : radiusBig) : 0;
} else {
topLeft = 0;
topRight = 0;
}
// Bottom Row
boolean singleInRow = singleInRow(pos, num);
int bottomLeft;
int bottomRight;
if (!hasText && isBottomRow(pos, num)) {
bottomLeft = singleInRow || left ? radiusBig : 0;
bottomRight = singleInRow || !left ? radiusBig : 0;
} else {
bottomLeft = 0;
bottomRight = 0;
}
if (isRtl) return new Radii(topRight, topLeft, bottomRight, bottomLeft);
return new Radii(topLeft, topRight, bottomLeft, bottomRight);
}
void clear() {
items.clear();
notifyDataSetChanged();
}
static boolean isTopRow(int pos) {
return pos < 2;
}
static boolean isLeft(int pos) {
return pos % 2 == 0;
}
static boolean isBottomRow(int pos, int num) {
return num % 2 == 0 ?
pos >= num - 2 : // last two, if even
pos > num - 2; // last one, if odd
}
static boolean singleInRow(int pos, int num) {
// last item of an odd number
return num % 2 != 0 && pos == num -1;
}
}
package org.briarproject.briar.android.conversation;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.ItemDecoration;
import android.support.v7.widget.RecyclerView.State;
import android.view.View;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R;
import static android.os.Build.VERSION.SDK_INT;
import static android.support.v4.view.ViewCompat.LAYOUT_DIRECTION_RTL;
import static org.briarproject.briar.android.conversation.ImageAdapter.isBottomRow;
import static org.briarproject.briar.android.conversation.ImageAdapter.isLeft;
import static org.briarproject.briar.android.conversation.ImageAdapter.isTopRow;
import static org.briarproject.briar.android.conversation.ImageAdapter.singleInRow;
@NotNullByDefault
class ImageItemDecoration extends ItemDecoration {
private final int realBorderSize, border;
private final boolean isRtl;
public ImageItemDecoration(Context ctx) {
Resources res = ctx.getResources();
// for pixel perfection, add a pixel to the border if it has an odd size
int b = res.getDimensionPixelSize(R.dimen.message_bubble_border);
realBorderSize = b % 2 == 0 ? b : b + 1;;
// we are applying half the border around the insides of each image
// to prevent differently sized images looking slightly broken
border = realBorderSize / 2;
// find out if we are showing a RTL language
Configuration config = res.getConfiguration();
isRtl = SDK_INT >= 17 &&
config.getLayoutDirection() == LAYOUT_DIRECTION_RTL;
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
State state) {
if (state.getItemCount() == 1) return;
int pos = parent.getChildAdapterPosition(view);
int num = state.getItemCount();
boolean left = isLeft(pos) ^ isRtl;
outRect.top = isTopRow(pos) ? 0 : border;
outRect.left = left ? 0 : border;
outRect.right = left && !singleInRow(pos, num) ? border : 0;
outRect.bottom = isBottomRow(pos, num) ? 0 : border;
}
public int getBorderSize() {
return realBorderSize;
}
public boolean isRtl() {
return isRtl;
}
}
......@@ -3,15 +3,17 @@ package org.briarproject.briar.android.conversation;
import android.graphics.Bitmap;
import android.support.annotation.DrawableRes;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.support.v7.widget.StaggeredGridLayoutManager.LayoutParams;
import android.view.View;
import android.widget.ImageView;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.conversation.glide.BriarImageTransformation;
import org.briarproject.briar.android.conversation.glide.GlideApp;
import org.briarproject.briar.android.conversation.glide.Radii;
import static com.bumptech.glide.load.engine.DiskCacheStrategy.NONE;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
......@@ -23,24 +25,41 @@ class ImageViewHolder extends ViewHolder {
private static final int ERROR_RES = R.drawable.ic_image_broken;
protected final ImageView imageView;
protected Transformation<Bitmap> transformation = new CenterCrop();
private final int imageSize, borderSize;
public ImageViewHolder(View v) {
public ImageViewHolder(View v, int imageSize, int borderSize) {
super(v);
imageView = v.findViewById(R.id.imageView);
this.imageSize = imageSize;
this.borderSize = borderSize;
}
void bind(AttachmentItem attachment) {
void bind(AttachmentItem attachment, Radii r, boolean single,
boolean needsStretch) {
if (attachment.hasError()) {
GlideApp.with(imageView)
.clear(imageView);
imageView.setImageResource(ERROR_RES);
} else {
loadImage(attachment);
setImageViewDimensions(attachment, single, needsStretch);
loadImage(attachment, r);
}
}
private void loadImage(AttachmentItem a) {
private void setImageViewDimensions(AttachmentItem a, boolean single,
boolean needsStretch) {
LayoutParams params = (LayoutParams) imageView.getLayoutParams();
// actual image size will shrink half the border
int stretchSize = (imageSize - borderSize / 2) * 2 + borderSize;
int width = needsStretch ? stretchSize : imageSize;
params.width = single ? a.getThumbnailWidth() : width;
params.height = single ? a.getThumbnailHeight() : imageSize;
params.setFullSpan(!single && needsStretch);
imageView.setLayoutParams(params);
}
private void loadImage(AttachmentItem a, Radii r) {
Transformation<Bitmap> transformation = new BriarImageTransformation(r);
GlideApp.with(imageView)
.load(a)
.diskCacheStrategy(NONE)
......
package org.briarproject.briar.android.conversation;
import android.content.res.Configuration;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.conversation.glide.BriarImageTransformation;
import static android.os.Build.VERSION.SDK_INT;
import static android.support.v4.view.ViewCompat.LAYOUT_DIRECTION_RTL;
@NotNullByDefault
class SingleImageViewHolder extends ImageViewHolder {
private final int radiusBig, radiusSmall;
private final boolean isRtl;
public SingleImageViewHolder(View v) {
super(v);
radiusBig = v.getContext().getResources()
.getDimensionPixelSize(R.dimen.message_bubble_radius_big);
radiusSmall = v.getContext().getResources()
.getDimensionPixelSize(R.dimen.message_bubble_radius_small);
// find out if we are showing a RTL language, Use the configuration,
// because getting the layout direction of views is not reliable
Configuration config = v.getContext().getResources().getConfiguration();
isRtl = SDK_INT >= 17 &&
config.getLayoutDirection() == LAYOUT_DIRECTION_RTL;
}
void bind(AttachmentItem a, boolean isIncoming, boolean hasText) {
if (!a.hasError()) beforeLoadingImage(a, isIncoming, hasText);
super.bind(a);
}
private void beforeLoadingImage(AttachmentItem a, boolean isIncoming,
boolean hasText) {
// apply image size constraints, so glides picks them up for scaling
LayoutParams layoutParams =
new LayoutParams(a.getThumbnailWidth(), a.getThumbnailHeight());
imageView.setLayoutParams(layoutParams);
boolean leftCornerSmall =
(isIncoming && !isRtl) || (!isIncoming && isRtl);
boolean bottomRound = !hasText;
transformation = new BriarImageTransformation(radiusSmall, radiusBig,
leftCornerSmall, bottomRound);
}
}
......@@ -7,10 +7,8 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
public class BriarImageTransformation extends MultiTransformation<Bitmap> {
public BriarImageTransformation(int smallRadius, int radius,
boolean leftCornerSmall, boolean bottomRound) {
super(new CenterCrop(), new ImageCornerTransformation(
smallRadius, radius, leftCornerSmall, bottomRound));
public BriarImageTransformation(Radii r) {
super(new CenterCrop(), new CustomCornersTransformation(r));
}
}
......@@ -21,19 +21,14 @@ import static android.graphics.Shader.TileMode.CLAMP;
@Immutable
@NotNullByDefault
class ImageCornerTransformation extends BitmapTransformation {
public class CustomCornersTransformation extends BitmapTransformation {
private static final String ID = ImageCornerTransformation.class.getName();
private static final String ID = CustomCornersTransformation.class.getName();
private final int smallRadius, radius;
private final boolean leftCornerSmall, bottomRound;
private final Radii radii;
ImageCornerTransformation(int smallRadius, int radius,
boolean leftCornerSmall, boolean bottomRound) {
this.smallRadius = smallRadius;
this.radius = radius;
this.leftCornerSmall = leftCornerSmall;
this.bottomRound = bottomRound;
public CustomCornersTransformation(Radii radii) {
this.radii = radii;
}
@Override
......@@ -55,57 +50,79 @@ class ImageCornerTransformation extends BitmapTransformation {
private void drawRect(Canvas canvas, Paint paint, float width,
float height) {
drawSmallCorner(canvas, paint, width);
drawBigCorners(canvas, paint, width, height);
drawTopLeft(canvas, paint, radii.topLeft, width, height);
drawTopRight(canvas, paint, radii.topRight, width, height);
drawBottomLeft(canvas, paint, radii.bottomLeft, width, height);
drawBottomRight(canvas, paint, radii.bottomRight, width, height);
}
private void drawSmallCorner(Canvas canvas, Paint paint, float width) {
float left = leftCornerSmall ? 0 : width - radius;
float right = leftCornerSmall ? radius : width;
canvas.drawRoundRect(new RectF(left, 0, right, radius),
smallRadius, smallRadius, paint);
private void drawTopLeft(Canvas canvas, Paint paint, int radius,
float width, float height) {
RectF rect = new RectF(
0,
0,
width / 2 + radius + 1,
height / 2 + radius + 1
);
if (radius == 0) canvas.drawRect(rect, paint);
else canvas.drawRoundRect(rect, radius, radius, paint);
}
private void drawBigCorners(Canvas canvas, Paint paint, float width,
float height) {
float top = bottomRound ? 0 : radius;
RectF rect = new RectF(0, top, width, height);
if (bottomRound) {
canvas.drawRoundRect(rect, radius, radius, paint);
} else {
canvas.drawRect(rect, paint);
canvas.drawRoundRect(new RectF(0, 0, width, radius * 2),
radius, radius, paint);
}
private void drawTopRight(Canvas canvas, Paint paint, int radius,
float width, float height) {
RectF rect = new RectF(
width / 2 - radius,
0,
width,
height / 2 + radius + 1
);
if (radius == 0) canvas.drawRect(rect, paint);
else canvas.drawRoundRect(rect, radius, radius, paint);
}
private void drawBottomLeft(Canvas canvas, Paint paint, int radius,
float width, float height) {
RectF rect = new RectF(
0,
height / 2 - radius,
width / 2 + radius + 1,
height
);
if (radius == 0) canvas.drawRect(rect, paint);
else canvas.drawRoundRect(rect, radius, radius, paint);
}
private void drawBottomRight(Canvas canvas, Paint paint, int radius,
float width, float height) {
RectF rect = new RectF(
width / 2 - radius,
height / 2 - radius,
width,
height
);
if (radius == 0) canvas.drawRect(rect, paint);
else canvas.drawRoundRect(rect, radius, radius, paint);
}
@Override
public String toString() {
return "ImageCornerTransformation(smallRadius=" + smallRadius +
", radius=" + radius + ", leftCornerSmall=" + leftCornerSmall +
", bottomRound=" + bottomRound + ")";
return "ImageCornerTransformation(" + radii + ")";
}
@Override
public boolean equals(Object o) {
return o instanceof ImageCornerTransformation &&
((ImageCornerTransformation) o).smallRadius == smallRadius &&
((ImageCornerTransformation) o).radius == radius &&
((ImageCornerTransformation) o).leftCornerSmall ==
leftCornerSmall &&
((ImageCornerTransformation) o).bottomRound == bottomRound;
return o instanceof CustomCornersTransformation &&
radii.equals(((CustomCornersTransformation) o).radii);
}
@Override
public int hashCode() {
return ID.hashCode() + (smallRadius << 16) ^ (radius << 2) ^
(leftCornerSmall ? 2 : 0) ^ (bottomRound ? 1 : 0);
return ID.hashCode() + radii.hashCode();
}
@Override
public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
messageDigest.update((ID + '|' + smallRadius + '|' + radius + '|' +
leftCornerSmall + '|' + bottomRound).getBytes(CHARSET));
messageDigest.update((ID + radii).getBytes(CHARSET));
}
}
package org.briarproject.briar.android.conversation.glide;
import android.support.annotation.Nullable;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
@NotNullByDefault
public class Radii {
public final int topLeft, topRight, bottomLeft, bottomRight;
public Radii(int topLeft, int topRight, int bottomLeft, int bottomRight) {
this.topLeft = topLeft;
this.topRight = topRight;
this.bottomLeft = bottomLeft;
this.bottomRight = bottomRight;
}
@Override
public boolean equals(@Nullable Object o) {
return o instanceof Radii &&
topLeft == ((Radii) o).topLeft &&
topRight == ((Radii) o).topRight &&
bottomLeft == ((Radii) o).bottomLeft &&
bottomRight == ((Radii) o).bottomRight;
}
@Override
public int hashCode() {
return topLeft << 24 ^ topRight << 16 ^ bottomLeft << 8 ^ bottomRight;
}
@Override
public String toString() {
return "Radii(topLeft=" + topLeft +
",topRight=" + topRight +
",bottomLeft=" + bottomLeft +
",bottomRight=" + bottomRight;
}
}