Commit 87377666 authored by Torsten Grote's avatar Torsten Grote

Merge branch '1473-display-multiple-images' into 'master'

UX for displaying multiple image attachments

Closes #1473

See merge request !1010
parents 5c312b49 9d07b2e1
Pipeline #2897 passed with stage
in 15 minutes and 21 seconds
...@@ -98,10 +98,11 @@ class AttachmentController { ...@@ -98,10 +98,11 @@ class AttachmentController {
*/ */
List<AttachmentItem> getAttachmentItems( List<AttachmentItem> getAttachmentItems(
List<Pair<AttachmentHeader, Attachment>> attachments) { List<Pair<AttachmentHeader, Attachment>> attachments) {
boolean needsSize = attachments.size() == 1;
List<AttachmentItem> items = new ArrayList<>(attachments.size()); List<AttachmentItem> items = new ArrayList<>(attachments.size());
for (Pair<AttachmentHeader, Attachment> a : attachments) { for (Pair<AttachmentHeader, Attachment> a : attachments) {
AttachmentItem item = AttachmentItem item =
getAttachmentItem(a.getFirst(), a.getSecond()); getAttachmentItem(a.getFirst(), a.getSecond(), needsSize);
items.add(item); items.add(item);
} }
return items; return items;
...@@ -111,10 +112,22 @@ class AttachmentController { ...@@ -111,10 +112,22 @@ class AttachmentController {
* Creates an {@link AttachmentItem} from the {@link Attachment}'s * Creates an {@link AttachmentItem} from the {@link Attachment}'s
* {@link InputStream} which will be closed when this method returns. * {@link InputStream} which will be closed when this method returns.
*/ */
AttachmentItem getAttachmentItem(AttachmentHeader h, Attachment a) { AttachmentItem getAttachmentItem(AttachmentHeader h, Attachment a,
boolean needsSize) {
MessageId messageId = h.getMessageId(); MessageId messageId = h.getMessageId();
Size size = new Size(); if (!needsSize) {
String mimeType = h.getContentType();
String extension = getExtensionFromMimeType(mimeType);
boolean hasError = false;
if (extension == null) {
extension = "";
hasError = true;
}
return new AttachmentItem(messageId, 0, 0, mimeType, extension, 0,
0, hasError);
}
Size size = new Size();
InputStream is = new BufferedInputStream(a.getStream()); InputStream is = new BufferedInputStream(a.getStream());
is.mark(Integer.MAX_VALUE); is.mark(Integer.MAX_VALUE);
try { try {
...@@ -144,14 +157,19 @@ class AttachmentController { ...@@ -144,14 +157,19 @@ class AttachmentController {
getThumbnailSize(size.width, size.height, size.mimeType); getThumbnailSize(size.width, size.height, size.mimeType);
} }
// get file extension // get file extension
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); String extension = getExtensionFromMimeType(size.mimeType);
String extension = mimeTypeMap.getExtensionFromMimeType(size.mimeType);
if (extension == null) { if (extension == null) {
return new AttachmentItem(messageId, 0, 0, "", "", 0, 0, true); return new AttachmentItem(messageId, 0, 0, "", "", 0, 0, true);
} }
return new AttachmentItem(messageId, size.width, size.height, return new AttachmentItem(messageId, size.width, size.height,
size.mimeType, extension, thumbnailSize.width, thumbnailSize.height, size.mimeType, extension, thumbnailSize.width,
size.error); thumbnailSize.height, size.error);
}
@Nullable
private String getExtensionFromMimeType(String mimeType) {
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
return mimeTypeMap.getExtensionFromMimeType(mimeType);
} }
/** /**
......
...@@ -394,7 +394,7 @@ public class ConversationActivity extends BriarActivity ...@@ -394,7 +394,7 @@ public class ConversationActivity extends BriarActivity
textCache.put(id, text); textCache.put(id, text);
} }
} }
if (!h.getAttachmentHeaders().isEmpty()) { if (h.getAttachmentHeaders().size() == 1) {
List<AttachmentItem> items = List<AttachmentItem> items =
attachmentController.get(id); attachmentController.get(id);
if (items == null) { if (items == null) {
...@@ -483,7 +483,7 @@ public class ConversationActivity extends BriarActivity ...@@ -483,7 +483,7 @@ public class ConversationActivity extends BriarActivity
try { try {
List<Pair<AttachmentHeader, Attachment>> attachments = List<Pair<AttachmentHeader, Attachment>> attachments =
attachmentController.getMessageAttachments(headers); attachmentController.getMessageAttachments(headers);
// TODO move getting the items off to the IoExecutor // TODO move getting the items off to IoExecutor, if size == 1
List<AttachmentItem> items = List<AttachmentItem> items =
attachmentController.getAttachmentItems(attachments); attachmentController.getAttachmentItems(attachments);
displayMessageAttachments(messageId, items); displayMessageAttachments(messageId, items);
......
...@@ -3,6 +3,7 @@ package org.briarproject.briar.android.conversation; ...@@ -3,6 +3,7 @@ package org.briarproject.briar.android.conversation;
import android.content.Context; import android.content.Context;
import android.support.annotation.LayoutRes; import android.support.annotation.LayoutRes;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView.RecycledViewPool;
import android.util.SparseArray; import android.util.SparseArray;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
...@@ -21,11 +22,17 @@ class ConversationAdapter ...@@ -21,11 +22,17 @@ class ConversationAdapter
extends BriarAdapter<ConversationItem, ConversationItemViewHolder> { extends BriarAdapter<ConversationItem, ConversationItemViewHolder> {
private ConversationListener listener; private ConversationListener listener;
private final RecycledViewPool imageViewPool;
private final ImageItemDecoration imageItemDecoration;
ConversationAdapter(Context ctx, ConversationAdapter(Context ctx,
ConversationListener conversationListener) { ConversationListener conversationListener) {
super(ctx, ConversationItem.class); super(ctx, ConversationItem.class);
listener = conversationListener; 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 @LayoutRes
...@@ -42,15 +49,17 @@ class ConversationAdapter ...@@ -42,15 +49,17 @@ class ConversationAdapter
type, viewGroup, false); type, viewGroup, false);
switch (type) { switch (type) {
case R.layout.list_item_conversation_msg_in: case R.layout.list_item_conversation_msg_in:
return new ConversationMessageViewHolder(v, true); return new ConversationMessageViewHolder(v, listener, true,
imageViewPool, imageItemDecoration);
case R.layout.list_item_conversation_msg_out: case R.layout.list_item_conversation_msg_out:
return new ConversationMessageViewHolder(v, false); return new ConversationMessageViewHolder(v, listener, false,
imageViewPool, imageItemDecoration);
case R.layout.list_item_conversation_notice_in: case R.layout.list_item_conversation_notice_in:
return new ConversationNoticeViewHolder(v, true); return new ConversationNoticeViewHolder(v, listener, true);
case R.layout.list_item_conversation_notice_out: case R.layout.list_item_conversation_notice_out:
return new ConversationNoticeViewHolder(v, false); return new ConversationNoticeViewHolder(v, listener, false);
case R.layout.list_item_conversation_request: case R.layout.list_item_conversation_request:
return new ConversationRequestViewHolder(v, true); return new ConversationRequestViewHolder(v, listener, true);
default: default:
throw new IllegalArgumentException("Unknown ConversationItem"); throw new IllegalArgumentException("Unknown ConversationItem");
} }
...@@ -59,7 +68,7 @@ class ConversationAdapter ...@@ -59,7 +68,7 @@ class ConversationAdapter
@Override @Override
public void onBindViewHolder(ConversationItemViewHolder ui, int position) { public void onBindViewHolder(ConversationItemViewHolder ui, int position) {
ConversationItem item = items.get(position); ConversationItem item = items.get(position);
ui.bind(item, listener); ui.bind(item);
listener.onItemVisible(item); listener.onItemVisible(item);
} }
......
...@@ -18,14 +18,17 @@ import static org.briarproject.briar.android.util.UiUtils.formatDate; ...@@ -18,14 +18,17 @@ import static org.briarproject.briar.android.util.UiUtils.formatDate;
@NotNullByDefault @NotNullByDefault
abstract class ConversationItemViewHolder extends ViewHolder { abstract class ConversationItemViewHolder extends ViewHolder {
protected final ConversationListener listener;
protected final ConstraintLayout layout; protected final ConstraintLayout layout;
@Nullable @Nullable
private final OutItemViewHolder outViewHolder; private final OutItemViewHolder outViewHolder;
private final TextView text; private final TextView text;
protected final TextView time; protected final TextView time;
ConversationItemViewHolder(View v, boolean isIncoming) { ConversationItemViewHolder(View v, ConversationListener listener,
boolean isIncoming) {
super(v); super(v);
this.listener = listener;
this.outViewHolder = isIncoming ? null : new OutItemViewHolder(v); this.outViewHolder = isIncoming ? null : new OutItemViewHolder(v);
layout = v.findViewById(R.id.layout); layout = v.findViewById(R.id.layout);
text = v.findViewById(R.id.text); text = v.findViewById(R.id.text);
...@@ -33,7 +36,7 @@ abstract class ConversationItemViewHolder extends ViewHolder { ...@@ -33,7 +36,7 @@ abstract class ConversationItemViewHolder extends ViewHolder {
} }
@CallSuper @CallSuper
void bind(ConversationItem item, ConversationListener listener) { void bind(ConversationItem item) {
if (item.getText() != null) { if (item.getText() != null) {
text.setText(trim(item.getText())); text.setText(trim(item.getText()));
} }
......
package org.briarproject.briar.android.conversation; package org.briarproject.briar.android.conversation;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.support.annotation.DrawableRes;
import android.support.annotation.UiThread; import android.support.annotation.UiThread;
import android.support.constraint.ConstraintSet; import android.support.constraint.ConstraintSet;
import android.support.v4.content.ContextCompat; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.RecycledViewPool;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageView;
import com.bumptech.glide.load.Transformation;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R; import org.briarproject.briar.R;
import org.briarproject.briar.android.conversation.glide.BriarImageTransformation;
import org.briarproject.briar.android.conversation.glide.GlideApp;
import static android.os.Build.VERSION.SDK_INT; import static android.support.constraint.ConstraintSet.WRAP_CONTENT;
import static android.support.v4.view.ViewCompat.LAYOUT_DIRECTION_RTL; import static android.support.v4.content.ContextCompat.getColor;
import static com.bumptech.glide.load.engine.DiskCacheStrategy.NONE;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
@UiThread @UiThread
@NotNullByDefault @NotNullByDefault
class ConversationMessageViewHolder extends ConversationItemViewHolder { class ConversationMessageViewHolder extends ConversationItemViewHolder {
@DrawableRes private final ImageAdapter adapter;
private static final int ERROR_RES = R.drawable.ic_image_broken;
private final ImageView imageView;
private final ViewGroup statusLayout; private final ViewGroup statusLayout;
private final int timeColor, timeColorBubble; private final int timeColor, timeColorBubble;
private final int radiusBig, radiusSmall;
private final boolean isRtl;
private final ConstraintSet textConstraints = new ConstraintSet(); private final ConstraintSet textConstraints = new ConstraintSet();
private final ConstraintSet imageConstraints = new ConstraintSet(); private final ConstraintSet imageConstraints = new ConstraintSet();
private final ConstraintSet imageTextConstraints = new ConstraintSet(); private final ConstraintSet imageTextConstraints = new ConstraintSet();
ConversationMessageViewHolder(View v, boolean isIncoming) { ConversationMessageViewHolder(View v, ConversationListener listener,
super(v, isIncoming); boolean isIncoming, RecycledViewPool imageViewPool,
imageView = v.findViewById(R.id.imageView); ImageItemDecoration imageItemDecoration) {
super(v, listener, isIncoming);
statusLayout = v.findViewById(R.id.statusLayout); statusLayout = v.findViewById(R.id.statusLayout);
radiusBig = v.getContext().getResources()
.getDimensionPixelSize(R.dimen.message_bubble_radius_big); // image list
radiusSmall = v.getContext().getResources() RecyclerView list = v.findViewById(R.id.imageList);
.getDimensionPixelSize(R.dimen.message_bubble_radius_small); list.setRecycledViewPool(imageViewPool);
adapter = new ImageAdapter(v.getContext(), listener);
list.setAdapter(adapter);
list.addItemDecoration(imageItemDecoration);
// remember original status text color // remember original status text color
timeColor = time.getCurrentTextColor(); timeColor = time.getCurrentTextColor();
timeColorBubble = timeColorBubble = getColor(v.getContext(), R.color.briar_white);
ContextCompat.getColor(v.getContext(), R.color.briar_white);
// find out if we are showing a RTL language, Use the configuration,
// because getting the layout direction of views is not reliable
Configuration config =
imageView.getContext().getResources().getConfiguration();
isRtl = SDK_INT >= 17 &&
config.getLayoutDirection() == LAYOUT_DIRECTION_RTL;
// clone constraint sets from layout files // clone constraint sets from layout files
textConstraints textConstraints
...@@ -77,85 +59,55 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder { ...@@ -77,85 +59,55 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder {
} }
@Override @Override
void bind(ConversationItem conversationItem, void bind(ConversationItem conversationItem) {
ConversationListener listener) { super.bind(conversationItem);
super.bind(conversationItem, listener);
ConversationMessageItem item = ConversationMessageItem item =
(ConversationMessageItem) conversationItem; (ConversationMessageItem) conversationItem;
if (item.getAttachments().isEmpty()) { if (item.getAttachments().isEmpty()) {
bindTextItem(); bindTextItem();
} else { } else {
bindImageItem(item, listener); bindImageItem(item);
} }
} }
private void bindTextItem() { private void bindTextItem() {
clearImage(); resetStatusLayoutForText();
statusLayout.setBackgroundResource(0);
// also reset padding (the background drawable defines some)
statusLayout.setPadding(0, 0, 0, 0);
time.setTextColor(timeColor);
textConstraints.applyTo(layout); textConstraints.applyTo(layout);
adapter.clear();
} }
private void bindImageItem(ConversationMessageItem item, private void bindImageItem(ConversationMessageItem item) {
ConversationListener listener) {
// TODO show more than just the first image
AttachmentItem attachment = item.getAttachments().get(0);
ConstraintSet constraintSet; ConstraintSet constraintSet;
if (item.getText() == null) { if (item.getText() == null) {
statusLayout statusLayout.setBackgroundResource(R.drawable.msg_status_bubble);
.setBackgroundResource(R.drawable.msg_status_bubble);
time.setTextColor(timeColorBubble); time.setTextColor(timeColorBubble);
constraintSet = imageConstraints; constraintSet = imageConstraints;
} else { } else {
statusLayout.setBackgroundResource(0); resetStatusLayoutForText();
// also reset padding (the background drawable defines some)
statusLayout.setPadding(0, 0, 0, 0);
time.setTextColor(timeColor);
constraintSet = imageTextConstraints; constraintSet = imageTextConstraints;
} }
// apply image size constraints, so glides picks them up for scaling if (item.getAttachments().size() == 1) {
int width = attachment.getThumbnailWidth(); // apply image size constraints for a single image
int height = attachment.getThumbnailHeight(); AttachmentItem attachment = item.getAttachments().get(0);
constraintSet.constrainWidth(R.id.imageView, width); int width = attachment.getThumbnailWidth();
constraintSet.constrainHeight(R.id.imageView, height); int height = attachment.getThumbnailHeight();
constraintSet.applyTo(layout); constraintSet.constrainWidth(R.id.imageList, width);
constraintSet.constrainHeight(R.id.imageList, height);
if (attachment.hasError()) {
clearImage();
imageView.setImageResource(ERROR_RES);
} else { } else {
loadImage(item, attachment, listener); // 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);
} }
private void clearImage() { private void resetStatusLayoutForText() {
GlideApp.with(imageView) statusLayout.setBackgroundResource(0);
.clear(imageView); // also reset padding (the background drawable defines some)
imageView.setOnClickListener(null); statusLayout.setPadding(0, 0, 0, 0);
} time.setTextColor(timeColor);
private void loadImage(ConversationMessageItem item,
AttachmentItem attachment, ConversationListener listener) {
boolean leftCornerSmall =
(isIncoming() && !isRtl) || (!isIncoming() && isRtl);
boolean bottomRound = item.getText() == null;
Transformation<Bitmap> transformation = new BriarImageTransformation(
radiusSmall, radiusBig, leftCornerSmall, bottomRound);
GlideApp.with(imageView)
.load(attachment)
.diskCacheStrategy(NONE)
.error(ERROR_RES)
.transform(transformation)
.transition(withCrossFade())
.into(imageView)
.waitForLayout();
imageView.setOnClickListener(
view -> listener.onAttachmentClicked(view, item, attachment));
} }
} }
...@@ -19,16 +19,17 @@ class ConversationNoticeViewHolder extends ConversationItemViewHolder { ...@@ -19,16 +19,17 @@ class ConversationNoticeViewHolder extends ConversationItemViewHolder {
private final TextView msgText; private final TextView msgText;
ConversationNoticeViewHolder(View v, boolean isIncoming) { ConversationNoticeViewHolder(View v, ConversationListener listener,
super(v, isIncoming); boolean isIncoming) {
super(v, listener, isIncoming);
msgText = v.findViewById(R.id.msgText); msgText = v.findViewById(R.id.msgText);
} }
@Override @Override
@CallSuper @CallSuper
void bind(ConversationItem item, ConversationListener listener) { void bind(ConversationItem item) {
ConversationNoticeItem notice = (ConversationNoticeItem) item; ConversationNoticeItem notice = (ConversationNoticeItem) item;
super.bind(notice, listener); super.bind(notice);
String text = notice.getMsgText(); String text = notice.getMsgText();
if (isNullOrEmpty(text)) { if (isNullOrEmpty(text)) {
......
...@@ -17,16 +17,17 @@ class ConversationRequestViewHolder extends ConversationNoticeViewHolder { ...@@ -17,16 +17,17 @@ class ConversationRequestViewHolder extends ConversationNoticeViewHolder {
private final Button acceptButton; private final Button acceptButton;
private final Button declineButton; private final Button declineButton;
ConversationRequestViewHolder(View v, boolean isIncoming) { ConversationRequestViewHolder(View v, ConversationListener listener,
super(v, isIncoming); boolean isIncoming) {
super(v, listener, isIncoming);
acceptButton = v.findViewById(R.id.acceptButton); acceptButton = v.findViewById(R.id.acceptButton);
declineButton = v.findViewById(R.id.declineButton); declineButton = v.findViewById(R.id.declineButton);
} }
@Override @Override
void bind(ConversationItem item, ConversationListener listener) { void bind(ConversationItem item) {
ConversationRequestItem request = (ConversationRequestItem) item; ConversationRequestItem request = (ConversationRequestItem) item;
super.bind(request, listener); super.bind(request);
if (request.wasAnswered() && request.canBeOpened()) { if (request.wasAnswered() && request.canBeOpened()) {
acceptButton.setVisibility(VISIBLE); acceptButton.setVisibility(VISIBLE);
......
...@@ -160,9 +160,11 @@ public class ConversationViewModel extends AndroidViewModel { ...@@ -160,9 +160,11 @@ public class ConversationViewModel extends AndroidViewModel {
long start = now(); long start = now();
List<AttachmentHeader> attachments = new ArrayList<>(); List<AttachmentHeader> attachments = new ArrayList<>();
List<AttachmentItem> items = new ArrayList<>(); List<AttachmentItem> items = new ArrayList<>();
boolean needsSize = uris.size() == 1;
for (Uri uri : uris) { for (Uri uri : uris) {
Pair<AttachmentHeader, AttachmentItem> pair = Pair<AttachmentHeader, AttachmentItem> pair =
createAttachmentHeader(groupId, uri, timestamp); createAttachmentHeader(groupId, uri, timestamp,
needsSize);
if (pair == null) continue; if (pair == null) continue;
attachments.add(pair.getFirst()); attachments.add(pair.getFirst());
items.add(pair.getSecond()); items.add(pair.getSecond());
...@@ -175,7 +177,7 @@ public class ConversationViewModel extends AndroidViewModel { ...@@ -175,7 +177,7 @@ public class ConversationViewModel extends AndroidViewModel {
@Nullable @Nullable
@DatabaseExecutor @DatabaseExecutor
private Pair<AttachmentHeader, AttachmentItem> createAttachmentHeader( private Pair<AttachmentHeader, AttachmentItem> createAttachmentHeader(
GroupId groupId, Uri uri, long timestamp) { GroupId groupId, Uri uri, long timestamp, boolean needsSize) {
InputStream is = null; InputStream is = null;
try { try {
ContentResolver contentResolver = ContentResolver contentResolver =
...@@ -191,7 +193,7 @@ public class ConversationViewModel extends AndroidViewModel { ...@@ -191,7 +193,7 @@ public class ConversationViewModel extends AndroidViewModel {
is = contentResolver.openInputStream(uri); is = contentResolver.openInputStream(uri);
if (is == null) throw new IOException(); if (is == null) throw new IOException();
AttachmentItem item = attachmentController AttachmentItem item = attachmentController
.getAttachmentItem(h, new Attachment(is)); .getAttachmentItem(h, new Attachment(is), needsSize);
return new Pair<>(h, item); return new Pair<>(h, item);
} catch (DbException | IOException e) { } catch (DbException | IOException e) {
logException(LOG, WARNING, e); logException(LOG, WARNING, e);
......
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;
import static org.briarproject.briar.android.util.UiUtils.isRtl;
@NotNullByDefault
class ImageAdapter extends Adapter<ImageViewHolder> {
private final List<AttachmentItem> items = new ArrayList<>();
private final ConversationListener listener;
private final int imageSize;
private final int radiusBig, radiusSmall;
private final boolean isRtl;
@Nullable
private ConversationMessageItem conversationItem;
ImageAdapter(Context ctx, ConversationListener listener) {
this.listener = listener;
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 = isRtl(ctx);
}
@Override
public ImageViewHolder onCreateViewHolder(ViewGroup viewGroup, int type) {
View v = LayoutInflater.from(viewGroup.getContext()).inflate(
R.layout.list_item_image, viewGroup, false);
return new ImageViewHolder(v, imageSize);
}
@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)
);
// bind view holder
int size = items.size();