From 8a839fb5e4dfde3b14a96df1d53e48c67e844a7a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 5 Nov 2018 18:51:52 -0300 Subject: [PATCH] [android] display image attachments for conversation messages --- briar-android/build.gradle | 10 + briar-android/proguard-rules.txt | 3 + .../briar/android/AndroidComponent.java | 3 + .../conversation/AttachmentController.java | 198 ++++++++++++++++++ .../android/conversation/AttachmentItem.java | 51 +++++ .../conversation/ConversationActivity.java | 93 ++++++-- .../conversation/ConversationAdapter.java | 14 +- .../ConversationItemViewHolder.java | 6 +- .../conversation/ConversationMessageItem.java | 17 +- .../ConversationMessageViewHolder.java | 149 ++++++++++++- .../conversation/ConversationViewModel.java | 10 +- .../conversation/ConversationVisitor.java | 25 ++- .../conversation/glide/BriarDataFetcher.java | 96 +++++++++ .../conversation/glide/BriarGlideModule.java | 43 ++++ .../conversation/glide/BriarModelLoader.java | 44 ++++ .../glide/BriarModelLoaderFactory.java | 34 +++ .../src/main/res/drawable/ic_image_broken.xml | 9 + .../src/main/res/drawable/msg_in.xml | 8 +- .../src/main/res/drawable/msg_out.xml | 8 +- .../main/res/drawable/msg_status_bubble.xml | 14 ++ .../list_item_conversation_msg_image.xml | 70 +++++++ .../list_item_conversation_msg_image_text.xml | 69 ++++++ .../layout/list_item_conversation_msg_in.xml | 44 +++- .../layout/list_item_conversation_msg_out.xml | 67 ++++-- briar-android/src/main/res/values/color.xml | 1 + briar-android/src/main/res/values/dimens.xml | 9 + briar-android/witness.gradle | 6 + 27 files changed, 1027 insertions(+), 74 deletions(-) create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarDataFetcher.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarGlideModule.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoader.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoaderFactory.java create mode 100644 briar-android/src/main/res/drawable/ic_image_broken.xml create mode 100644 briar-android/src/main/res/drawable/msg_status_bubble.xml create mode 100644 briar-android/src/main/res/layout/list_item_conversation_msg_image.xml create mode 100644 briar-android/src/main/res/layout/list_item_conversation_msg_image_text.xml diff --git a/briar-android/build.gradle b/briar-android/build.gradle index fa356238c..7a16fe6cc 100644 --- a/briar-android/build.gradle +++ b/briar-android/build.gradle @@ -104,6 +104,7 @@ dependencies { } implementation "com.android.support:cardview-v7:$supportVersion" implementation "com.android.support:support-annotations:$supportVersion" + implementation "com.android.support:exifinterface:$supportVersion" implementation 'com.android.support.constraint:constraint-layout:1.1.3' implementation "android.arch.lifecycle:extensions:1.1.1" @@ -117,8 +118,17 @@ dependencies { implementation 'com.google.zxing:core:3.3.3' implementation 'uk.co.samuelwall:material-tap-target-prompt:2.12.4' implementation 'com.vanniktech:emoji-google:0.5.1' + def glideVersion = '4.8.0' + implementation("com.github.bumptech.glide:glide:$glideVersion") { + exclude group: 'com.android.support' + exclude module: 'disklrucache' // when there's no disk cache, we can't accidentally use it + } + implementation('jp.wasabeef:glide-transformations:3.3.0') { + exclude module: 'disklrucache' // this gets pulled in here otherwise + } annotationProcessor 'com.google.dagger:dagger-compiler:2.19' + annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" compileOnly 'javax.annotation:jsr250-api:1.0' diff --git a/briar-android/proguard-rules.txt b/briar-android/proguard-rules.txt index feb5984a4..b0148dae4 100644 --- a/briar-android/proguard-rules.txt +++ b/briar-android/proguard-rules.txt @@ -30,3 +30,6 @@ # Emoji -keep class com.vanniktech.emoji.** + +# Glide +-dontwarn com.bumptech.glide.load.engine.cache.DiskLruCacheWrapper diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java index 2b1216288..eb3016781 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java @@ -29,6 +29,7 @@ import org.briarproject.bramble.api.system.LocationUtils; import org.briarproject.bramble.plugin.tor.CircumventionProvider; import org.briarproject.briar.BriarCoreEagerSingletons; import org.briarproject.briar.BriarCoreModule; +import org.briarproject.briar.android.conversation.glide.BriarModelLoader; import org.briarproject.briar.android.login.SignInReminderReceiver; import org.briarproject.briar.android.reporting.BriarReportSender; import org.briarproject.briar.android.view.TextInputView; @@ -170,6 +171,8 @@ public interface AndroidComponent void inject(TextInputView textInputView); + void inject(BriarModelLoader briarModelLoader); + // Eager singleton load void inject(AppModule.EagerSingletons init); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java new file mode 100644 index 000000000..7fa5f3aba --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java @@ -0,0 +1,198 @@ +package org.briarproject.briar.android.conversation; + +import android.content.res.Resources; +import android.graphics.BitmapFactory; +import android.support.annotation.Nullable; +import android.support.media.ExifInterface; + +import org.briarproject.bramble.api.Pair; +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.MessageId; +import org.briarproject.briar.R; +import org.briarproject.briar.api.messaging.Attachment; +import org.briarproject.briar.api.messaging.AttachmentHeader; +import org.briarproject.briar.api.messaging.MessagingManager; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +import static android.support.media.ExifInterface.ORIENTATION_ROTATE_270; +import static android.support.media.ExifInterface.ORIENTATION_ROTATE_90; +import static android.support.media.ExifInterface.ORIENTATION_TRANSPOSE; +import static android.support.media.ExifInterface.ORIENTATION_TRANSVERSE; +import static android.support.media.ExifInterface.TAG_IMAGE_LENGTH; +import static android.support.media.ExifInterface.TAG_IMAGE_WIDTH; +import static android.support.media.ExifInterface.TAG_ORIENTATION; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.LogUtils.logDuration; +import static org.briarproject.bramble.util.LogUtils.logException; +import static org.briarproject.bramble.util.LogUtils.now; + +@NotNullByDefault +class AttachmentController { + + private static final Logger LOG = + getLogger(AttachmentController.class.getName()); + + private final MessagingManager messagingManager; + private final int defaultSize; + private final int minWidth, maxWidth; + private final int minHeight, maxHeight; + + private final Map> attachmentCache = + new ConcurrentHashMap<>(); + + AttachmentController(MessagingManager messagingManager, Resources res) { + this.messagingManager = messagingManager; + defaultSize = + res.getDimensionPixelSize(R.dimen.message_bubble_image_default); + minWidth = res.getDimensionPixelSize( + R.dimen.message_bubble_image_min_width); + maxWidth = res.getDimensionPixelSize( + R.dimen.message_bubble_image_max_width); + minHeight = res.getDimensionPixelSize( + R.dimen.message_bubble_image_min_height); + maxHeight = res.getDimensionPixelSize( + R.dimen.message_bubble_image_max_height); + } + + void put(MessageId messageId, List attachments) { + attachmentCache.put(messageId, attachments); + } + + @Nullable + List get(MessageId messageId) { + return attachmentCache.get(messageId); + } + + @DatabaseExecutor + List> getMessageAttachments( + List headers) throws DbException { + long start = now(); + List> attachments = + new ArrayList<>(headers.size()); + for (AttachmentHeader h : headers) { + Attachment a = + messagingManager.getAttachment(h.getMessageId()); + attachments.add(new Pair<>(h, a)); + } + logDuration(LOG, "Loading attachment", start); + return attachments; + } + + List getAttachmentItems( + List> attachments) { + List items = new ArrayList<>(attachments.size()); + for (Pair a : attachments) { + AttachmentItem item = + getAttachmentItem(a.getFirst(), a.getSecond()); + items.add(item); + } + return items; + } + + private AttachmentItem getAttachmentItem(AttachmentHeader h, Attachment a) { + MessageId messageId = h.getMessageId(); + Size size = new Size(); + + InputStream is = a.getStream(); + is.mark(Integer.MAX_VALUE); + try { + // use exif to get size + if (h.getContentType().equals("image/jpeg")) { + size = getSizeFromExif(is); + } + } catch (IOException e) { + logException(LOG, WARNING, e); + } + try { + // use BitmapFactory to get size + if (size.error) { + is.reset(); + size = getSizeFromBitmap(is); + } + is.close(); + } catch (IOException e) { + logException(LOG, WARNING, e); + } + + // calculate thumbnail size + Size thumbnailSize = new Size(defaultSize, defaultSize); + if (!size.error) { + thumbnailSize = getThumbnailSize(size.width, size.height); + } + return new AttachmentItem(messageId, size.width, size.height, + thumbnailSize.width, thumbnailSize.height, size.error); + } + + /** + * Gets the size of a JPEG {@link InputStream} if EXIF info is available. + */ + private static Size getSizeFromExif(InputStream is) + throws IOException { + ExifInterface exif = new ExifInterface(is); + // these can return 0 independent of default value + int width = exif.getAttributeInt(TAG_IMAGE_WIDTH, 0); + int height = exif.getAttributeInt(TAG_IMAGE_LENGTH, 0); + if (width == 0 || height == 0) return new Size(); + int orientation = exif.getAttributeInt(TAG_ORIENTATION, 0); + if (orientation == ORIENTATION_ROTATE_90 || + orientation == ORIENTATION_ROTATE_270 || + orientation == ORIENTATION_TRANSVERSE || + orientation == ORIENTATION_TRANSPOSE) { + //noinspection SuspiciousNameCombination + return new Size(height, width); + } + return new Size(width, height); + } + + /** + * Gets the size of any image {@link InputStream}. + */ + private static Size getSizeFromBitmap(InputStream is) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(is, null, options); + if (options.outWidth == -1 || options.outHeight == -1) + return new Size(); + return new Size(options.outWidth, options.outHeight); + } + + private Size getThumbnailSize(int width, int height) { + float widthPercentage = maxWidth / (float) width; + float heightPercentage = maxHeight / (float) height; + float scaleFactor = Math.min(widthPercentage, heightPercentage); + int thumbnailWidth = (int) (width * scaleFactor); + int thumbnailHeight = (int) (height * scaleFactor); + if (thumbnailWidth < minWidth) thumbnailWidth = minWidth; + if (thumbnailHeight < minHeight) thumbnailHeight = minHeight; + return new Size(thumbnailWidth, thumbnailHeight); + } + + private static class Size { + private int width; + private int height; + private boolean error; + + private Size(int width, int height) { + this.width = width; + this.height = height; + this.error = false; + } + + private Size() { + this.width = 0; + this.height = 0; + this.error = true; + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java new file mode 100644 index 000000000..5a5efb657 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java @@ -0,0 +1,51 @@ +package org.briarproject.briar.android.conversation; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.MessageId; + +import javax.annotation.concurrent.Immutable; + +@Immutable +@NotNullByDefault +public class AttachmentItem { + + private final MessageId messageId; + private final int width, height; + private final int thumbnailWidth, thumbnailHeight; + private final boolean hasError; + + AttachmentItem(MessageId messageId, int width, int height, + int thumbnailWidth, int thumbnailHeight, boolean hasError) { + this.messageId = messageId; + this.width = width; + this.height = height; + this.thumbnailWidth = thumbnailWidth; + this.thumbnailHeight = thumbnailHeight; + this.hasError = hasError; + } + + public MessageId getMessageId() { + return messageId; + } + + int getWidth() { + return width; + } + + int getHeight() { + return height; + } + + int getThumbnailWidth() { + return thumbnailWidth; + } + + int getThumbnailHeight() { + return thumbnailHeight; + } + + boolean hasError() { + return hasError; + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java index ae0d3ddad..5050bc3ec 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java @@ -24,6 +24,7 @@ import android.widget.TextView; import android.widget.Toast; import org.briarproject.bramble.api.FormatException; +import org.briarproject.bramble.api.Pair; import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.contact.ContactManager; import org.briarproject.bramble.api.contact.event.ContactRemovedEvent; @@ -51,6 +52,7 @@ import org.briarproject.briar.R; import org.briarproject.briar.android.activity.ActivityComponent; import org.briarproject.briar.android.activity.BriarActivity; import org.briarproject.briar.android.blog.BlogActivity; +import org.briarproject.briar.android.conversation.ConversationVisitor.AttachmentCache; import org.briarproject.briar.android.conversation.ConversationVisitor.TextCache; import org.briarproject.briar.android.forum.ForumActivity; import org.briarproject.briar.android.introduction.IntroductionActivity; @@ -69,6 +71,8 @@ import org.briarproject.briar.api.conversation.ConversationResponse; import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent; import org.briarproject.briar.api.forum.ForumSharingManager; import org.briarproject.briar.api.introduction.IntroductionManager; +import org.briarproject.briar.api.messaging.Attachment; +import org.briarproject.briar.api.messaging.AttachmentHeader; import org.briarproject.briar.api.messaging.MessagingManager; import org.briarproject.briar.api.messaging.PrivateMessage; import org.briarproject.briar.api.messaging.PrivateMessageFactory; @@ -116,7 +120,7 @@ import static uk.co.samuelwall.materialtaptargetprompt.MaterialTapTargetPrompt.S @ParametersNotNullByDefault public class ConversationActivity extends BriarActivity implements EventListener, ConversationListener, TextInputListener, - TextCache { + TextCache, AttachmentCache { public static final String CONTACT_ID = "briar.CONTACT_ID"; @@ -134,6 +138,7 @@ public class ConversationActivity extends BriarActivity Executor cryptoExecutor; private final Map textCache = new ConcurrentHashMap<>(); + private AttachmentController attachmentController; private ConversationViewModel viewModel; private ConversationVisitor visitor; @@ -191,6 +196,7 @@ public class ConversationActivity extends BriarActivity viewModel = ViewModelProviders.of(this, viewModelFactory) .get(ConversationViewModel.class); viewModel.setContactId(contactId); + attachmentController = viewModel.getAttachmentController(); setContentView(R.layout.activity_conversation); @@ -217,7 +223,7 @@ public class ConversationActivity extends BriarActivity setTransitionName(toolbarAvatar, getAvatarTransitionName(contactId)); setTransitionName(toolbarStatus, getBulbTransitionName(contactId)); - visitor = new ConversationVisitor(this, this, + visitor = new ConversationVisitor(this, this, this, viewModel.getContactDisplayName()); adapter = new ConversationAdapter(this, this); list = findViewById(R.id.conversationView); @@ -340,14 +346,30 @@ public class ConversationActivity extends BriarActivity // If the latest header is a private message, eagerly load // its text so we can set the scroll position correctly ConversationMessageHeader latest = sorted.get(0); - if (latest instanceof PrivateMessageHeader && - ((PrivateMessageHeader) latest).hasText()) { + if (latest instanceof PrivateMessageHeader) { MessageId id = latest.getId(); - String text = textCache.get(id); - if (text == null) { - LOG.info("Eagerly loading text of latest message"); - text = messagingManager.getMessageText(id); - textCache.put(id, text); + PrivateMessageHeader h = (PrivateMessageHeader) latest; + if (h.hasText()) { + String text = textCache.get(id); + if (text == null) { + LOG.info( + "Eagerly loading text of latest message"); + text = messagingManager.getMessageText(id); + textCache.put(id, text); + } + } + if (!h.getAttachmentHeaders().isEmpty()) { + List items = + attachmentController.get(id); + if (items == null) { + LOG.info( + "Eagerly loading image size for latest message"); + items = attachmentController.getAttachmentItems( + attachmentController + .getMessageAttachments( + h.getAttachmentHeaders())); + attachmentController.put(id, items); + } } } } @@ -408,16 +430,40 @@ public class ConversationActivity extends BriarActivity private void displayMessageText(MessageId m, String text) { runOnUiThreadUnlessDestroyed(() -> { textCache.put(m, text); - SparseArray messages = - adapter.getMessageItems(); - for (int i = 0; i < messages.size(); i++) { - ConversationItem item = messages.valueAt(i); - if (item.getId().equals(m)) { - item.setText(text); - adapter.notifyItemChanged(messages.keyAt(i)); - list.scrollToPosition(adapter.getItemCount() - 1); - return; - } + Pair pair = + adapter.getMessageItem(m); + if (pair != null) { + pair.getSecond().setText(text); + adapter.notifyItemChanged(pair.getFirst()); + list.scrollToPosition(adapter.getItemCount() - 1); + } + }); + } + + private void loadMessageAttachments(MessageId messageId, + List headers) { + runOnDbThread(() -> { + try { + displayMessageAttachments(messageId, + attachmentController.getMessageAttachments(headers)); + } catch (DbException e) { + logException(LOG, WARNING, e); + } + }); + } + + private void displayMessageAttachments(MessageId m, + List> attachments) { + runOnUiThreadUnlessDestroyed(() -> { + List items = + attachmentController.getAttachmentItems(attachments); + attachmentController.put(m, items); + Pair pair = + adapter.getMessageItem(m); + if (pair != null) { + pair.getSecond().setAttachments(items); + adapter.notifyItemChanged(pair.getFirst()); + list.scrollToPosition(adapter.getItemCount() - 1); } }); } @@ -782,4 +828,13 @@ public class ConversationActivity extends BriarActivity if (text == null) loadMessageText(m); return text; } + + @Nullable + @Override + public List getAttachmentItems(MessageId m, + List headers) { + List attachments = attachmentController.get(m); + if (attachments == null) loadMessageAttachments(m, headers); + return attachments; + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java index 4b56c234a..5ddc1650a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java @@ -7,7 +7,9 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import org.briarproject.bramble.api.Pair; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.MessageId; import org.briarproject.briar.R; import org.briarproject.briar.android.util.BriarAdapter; @@ -98,16 +100,16 @@ class ConversationAdapter return messages; } - SparseArray getMessageItems() { - SparseArray messages = new SparseArray<>(); - + @Nullable + Pair getMessageItem(MessageId messageId) { for (int i = 0; i < items.size(); i++) { ConversationItem item = items.get(i); - if (item instanceof ConversationMessageItem) { - messages.put(i, (ConversationMessageItem) item); + if (item instanceof ConversationMessageItem && + item.getId().equals(messageId)) { + return new Pair<>(i, (ConversationMessageItem) item); } } - return messages; + return null; } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java index 9718d7e47..7d0e25d94 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java @@ -3,9 +3,9 @@ package org.briarproject.briar.android.conversation; import android.support.annotation.CallSuper; import android.support.annotation.Nullable; import android.support.annotation.UiThread; +import android.support.constraint.ConstraintLayout; import android.support.v7.widget.RecyclerView.ViewHolder; import android.view.View; -import android.view.ViewGroup; import android.widget.TextView; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; @@ -18,11 +18,11 @@ import static org.briarproject.briar.android.util.UiUtils.formatDate; @NotNullByDefault abstract class ConversationItemViewHolder extends ViewHolder { - protected final ViewGroup layout; + protected final ConstraintLayout layout; @Nullable private final OutItemViewHolder outViewHolder; private final TextView text; - private final TextView time; + protected final TextView time; ConversationItemViewHolder(View v, boolean isIncoming) { super(v); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java index 6aa92f51d..106b0aed2 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java @@ -1,9 +1,9 @@ package org.briarproject.briar.android.conversation; import android.support.annotation.LayoutRes; +import android.support.annotation.Nullable; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; -import org.briarproject.briar.api.messaging.AttachmentHeader; import org.briarproject.briar.api.messaging.PrivateMessageHeader; import java.util.List; @@ -14,15 +14,22 @@ import javax.annotation.concurrent.NotThreadSafe; @NotNullByDefault class ConversationMessageItem extends ConversationItem { - private final List attachments; + @Nullable + private List attachments; - ConversationMessageItem(@LayoutRes int layoutRes, PrivateMessageHeader h) { + ConversationMessageItem(@LayoutRes int layoutRes, PrivateMessageHeader h, + @Nullable List attachments) { super(layoutRes, h); - this.attachments = h.getAttachmentHeaders(); + this.attachments = attachments; } - List getAttachments() { + @Nullable + List getAttachments() { return attachments; } + void setAttachments(List attachments) { + this.attachments = attachments; + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java index a4a4360c8..3ced99cd9 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java @@ -1,18 +1,165 @@ package org.briarproject.briar.android.conversation; +import android.graphics.Bitmap; +import android.support.annotation.DrawableRes; import android.support.annotation.UiThread; +import android.support.constraint.ConstraintSet; +import android.support.v4.content.ContextCompat; import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.bumptech.glide.load.MultiTransformation; +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.GlideApp; + +import jp.wasabeef.glide.transformations.RoundedCornersTransformation; + +import static com.bumptech.glide.load.engine.DiskCacheStrategy.NONE; +import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; +import static jp.wasabeef.glide.transformations.RoundedCornersTransformation.CornerType.BOTTOM; +import static jp.wasabeef.glide.transformations.RoundedCornersTransformation.CornerType.TOP_LEFT; +import static jp.wasabeef.glide.transformations.RoundedCornersTransformation.CornerType.TOP_RIGHT; @UiThread @NotNullByDefault class ConversationMessageViewHolder extends ConversationItemViewHolder { - // image support will be added here (#1242) + @DrawableRes + private static final int errorRes = R.drawable.ic_image_broken; + + private final ImageView imageView; + private final ViewGroup statusLayout; + private final int timeColor, timeColorBubble; + private final int radiusBig, radiusSmall; + private final ConstraintSet textConstraints = new ConstraintSet(); + private final ConstraintSet imageConstraints = new ConstraintSet(); + private final ConstraintSet imageTextConstraints = new ConstraintSet(); ConversationMessageViewHolder(View v, boolean isIncoming) { super(v, isIncoming); + imageView = v.findViewById(R.id.imageView); + statusLayout = v.findViewById(R.id.statusLayout); + radiusBig = v.getContext().getResources() + .getDimensionPixelSize(R.dimen.message_bubble_radius_big); + radiusSmall = v.getContext().getResources() + .getDimensionPixelSize(R.dimen.message_bubble_radius_small); + + // remember original status text color + timeColor = time.getCurrentTextColor(); + timeColorBubble = + ContextCompat.getColor(v.getContext(), R.color.briar_white); + + // clone constraint sets from layout files + textConstraints + .clone(v.getContext(), R.layout.list_item_conversation_msg_in); + imageConstraints.clone(v.getContext(), + R.layout.list_item_conversation_msg_image); + imageTextConstraints.clone(v.getContext(), + R.layout.list_item_conversation_msg_image_text); + + // in/out are different layouts, so we need to do this only once + textConstraints + .setHorizontalBias(R.id.statusLayout, isIncoming() ? 1 : 0); + imageConstraints + .setHorizontalBias(R.id.statusLayout, isIncoming() ? 1 : 0); + imageTextConstraints + .setHorizontalBias(R.id.statusLayout, isIncoming() ? 1 : 0); + } + + @Override + void bind(ConversationItem conversationItem, + ConversationListener listener) { + super.bind(conversationItem, listener); + ConversationMessageItem item = + (ConversationMessageItem) conversationItem; + if (item.getAttachments() == null || item.getAttachments().isEmpty()) { + bindTextItem(); + } else { + bindImageItem(item); + } + } + + private void bindTextItem() { + clearImage(); + statusLayout.setBackgroundResource(0); + // also reset padding (the background drawable defines some) + statusLayout.setPadding(0, 0, 0, 0); + time.setTextColor(timeColor); + textConstraints.applyTo(layout); + } + + private void bindImageItem(ConversationMessageItem item) { + // TODO show more than just the first image + AttachmentItem attachment = item.getAttachments().get(0); + + ConstraintSet constraintSet; + if (item.getText() == null) { + statusLayout + .setBackgroundResource(R.drawable.msg_status_bubble); + time.setTextColor(timeColorBubble); + constraintSet = imageConstraints; + } else { + statusLayout.setBackgroundResource(0); + // also reset padding (the background drawable defines some) + statusLayout.setPadding(0, 0, 0, 0); + time.setTextColor(timeColor); + constraintSet = imageTextConstraints; + } + + // apply image size constraints, so glides picks them up for scaling + int width = attachment.getThumbnailWidth(); + int height = attachment.getThumbnailHeight(); + constraintSet.constrainWidth(R.id.imageView, width); + constraintSet.constrainHeight(R.id.imageView, height); + constraintSet.applyTo(layout); + + if (attachment.hasError()) { + clearImage(); + imageView.setImageResource(errorRes); + } else { + loadImage(item, attachment); + } + } + + private void clearImage() { + GlideApp.with(imageView) + .clear(imageView); + } + + private void loadImage(ConversationMessageItem item, + AttachmentItem attachment) { + // these transformations can be optimized by writing our own + Transformation transformation; + if (item.getText() == null) { + transformation = new MultiTransformation<>(new CenterCrop(), + new RoundedCornersTransformation(radiusSmall, 0, + isIncoming() ? TOP_LEFT : TOP_RIGHT), + new RoundedCornersTransformation(radiusBig, 0, + isIncoming() ? TOP_RIGHT : TOP_LEFT), + new RoundedCornersTransformation(radiusBig, 0, BOTTOM) + ); + } else { + transformation = new MultiTransformation<>(new CenterCrop(), + new RoundedCornersTransformation(radiusSmall, 0, + isIncoming() ? TOP_LEFT : TOP_RIGHT), + new RoundedCornersTransformation(radiusBig, 0, + isIncoming() ? TOP_RIGHT : TOP_LEFT) + ); + } + + GlideApp.with(imageView) + .load(attachment) + .diskCacheStrategy(NONE) + .error(errorRes) + .transform(transformation) + .transition(withCrossFade()) + .into(imageView) + .waitForLayout(); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java index 684fadf45..3a8e6e364 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java @@ -16,6 +16,7 @@ import org.briarproject.bramble.api.db.NoSuchContactException; import org.briarproject.bramble.api.identity.AuthorId; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.briar.android.util.UiUtils; +import org.briarproject.briar.api.messaging.MessagingManager; import java.util.concurrent.Executor; import java.util.logging.Logger; @@ -38,6 +39,7 @@ public class ConversationViewModel extends AndroidViewModel { @DatabaseExecutor private final Executor dbExecutor; private final ContactManager contactManager; + private final AttachmentController attachmentController; @Nullable private ContactId contactId = null; @@ -52,10 +54,12 @@ public class ConversationViewModel extends AndroidViewModel { @Inject ConversationViewModel(Application application, @DatabaseExecutor Executor dbExecutor, - ContactManager contactManager) { + ContactManager contactManager, MessagingManager messagingManager) { super(application); this.dbExecutor = dbExecutor; this.contactManager = contactManager; + this.attachmentController = new AttachmentController(messagingManager, + application.getResources()); contactDeleted.setValue(false); } @@ -96,6 +100,10 @@ public class ConversationViewModel extends AndroidViewModel { }); } + AttachmentController getAttachmentController() { + return attachmentController; + } + LiveData getContact() { return contact; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java index a7e680c9b..b8b57488e 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java @@ -14,12 +14,16 @@ import org.briarproject.briar.api.forum.ForumInvitationRequest; import org.briarproject.briar.api.forum.ForumInvitationResponse; import org.briarproject.briar.api.introduction.IntroductionRequest; import org.briarproject.briar.api.introduction.IntroductionResponse; +import org.briarproject.briar.api.messaging.AttachmentHeader; import org.briarproject.briar.api.messaging.PrivateMessageHeader; import org.briarproject.briar.api.privategroup.invitation.GroupInvitationRequest; import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse; +import java.util.List; + import javax.annotation.Nullable; +import static java.util.Collections.emptyList; import static org.briarproject.briar.android.conversation.ConversationRequestItem.RequestType.BLOG; import static org.briarproject.briar.android.conversation.ConversationRequestItem.RequestType.FORUM; import static org.briarproject.briar.android.conversation.ConversationRequestItem.RequestType.GROUP; @@ -33,24 +37,33 @@ class ConversationVisitor implements private final Context ctx; private final TextCache textCache; + private final AttachmentCache attachmentCache; private final LiveData contactName; ConversationVisitor(Context ctx, TextCache textCache, - LiveData contactName) { + AttachmentCache attachmentCache, LiveData contactName) { this.ctx = ctx; this.textCache = textCache; + this.attachmentCache = attachmentCache; this.contactName = contactName; } @Override public ConversationItem visitPrivateMessageHeader(PrivateMessageHeader h) { ConversationItem item; + List attachments; + if (h.getAttachmentHeaders().isEmpty()) { + attachments = emptyList(); + } else { + attachments = attachmentCache + .getAttachmentItems(h.getId(), h.getAttachmentHeaders()); + } if (h.isLocal()) { item = new ConversationMessageItem( - R.layout.list_item_conversation_msg_out, h); + R.layout.list_item_conversation_msg_out, h, attachments); } else { item = new ConversationMessageItem( - R.layout.list_item_conversation_msg_in, h); + R.layout.list_item_conversation_msg_in, h, attachments); } if (h.hasText()) { String text = textCache.getText(h.getId()); @@ -279,4 +292,10 @@ class ConversationVisitor implements @Nullable String getText(MessageId m); } + + interface AttachmentCache { + @Nullable + List getAttachmentItems(MessageId m, + List headers); + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarDataFetcher.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarDataFetcher.java new file mode 100644 index 000000000..5ef97d6ed --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarDataFetcher.java @@ -0,0 +1,96 @@ +package org.briarproject.briar.android.conversation.glide; + +import android.support.annotation.Nullable; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.MessageId; +import org.briarproject.briar.android.conversation.AttachmentItem; +import org.briarproject.briar.api.messaging.MessagingManager; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.inject.Inject; + +import static com.bumptech.glide.load.DataSource.LOCAL; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.LogUtils.logException; + +@NotNullByDefault +class BriarDataFetcher implements DataFetcher { + + private final static Logger LOG = + getLogger(BriarDataFetcher.class.getName()); + + private final MessagingManager messagingManager; + @DatabaseExecutor + private final Executor dbExecutor; + + @Nullable + private AttachmentItem attachment; + @Nullable + private volatile InputStream inputStream; + + @Inject + public BriarDataFetcher(MessagingManager messagingManager, + @DatabaseExecutor Executor dbExecutor) { + this.messagingManager = messagingManager; + this.dbExecutor = dbExecutor; + } + + @Override + public void loadData(Priority priority, + DataCallback callback) { + MessageId id = requireNonNull(attachment).getMessageId(); + dbExecutor.execute(() -> { + try { + inputStream = messagingManager.getAttachment(id).getStream(); + callback.onDataReady(inputStream); + } catch (DbException e) { + callback.onLoadFailed(e); + } + }); + } + + @Override + public void cleanup() { + final InputStream stream = inputStream; + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + logException(LOG, WARNING, e); + } + } + } + + @Override + public void cancel() { + // does it make sense to cancel a database load? + } + + @Override + public Class getDataClass() { + return InputStream.class; + } + + @Override + public DataSource getDataSource() { + return LOCAL; + } + + public void setAttachment(AttachmentItem attachment) { + this.attachment = attachment; + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarGlideModule.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarGlideModule.java new file mode 100644 index 000000000..f0776bae2 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarGlideModule.java @@ -0,0 +1,43 @@ +package org.briarproject.briar.android.conversation.glide; + +import android.content.Context; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.GlideBuilder; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.module.AppGlideModule; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.briar.android.BriarApplication; +import org.briarproject.briar.android.conversation.AttachmentItem; + +import java.io.InputStream; + +import static android.util.Log.DEBUG; +import static android.util.Log.WARN; +import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD; + +@GlideModule +@NotNullByDefault +public final class BriarGlideModule extends AppGlideModule { + + @Override + public void registerComponents(Context context, Glide glide, + Registry registry) { + BriarModelLoaderFactory factory = + new BriarModelLoaderFactory((BriarApplication) context); + registry.prepend(AttachmentItem.class, InputStream.class, factory); + } + + @Override + public void applyOptions(Context context, GlideBuilder builder) { + builder.setLogLevel(IS_DEBUG_BUILD ? DEBUG : WARN); + } + + @Override + public boolean isManifestParsingEnabled() { + return false; + } + +} \ No newline at end of file diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoader.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoader.java new file mode 100644 index 000000000..397910de9 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoader.java @@ -0,0 +1,44 @@ +package org.briarproject.briar.android.conversation.glide; + + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.signature.ObjectKey; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.android.BriarApplication; +import org.briarproject.briar.android.conversation.AttachmentItem; + +import java.io.InputStream; + +import javax.inject.Inject; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public final class BriarModelLoader + implements ModelLoader { + + private final BriarApplication app; + + @Inject + BriarDataFetcher dataFetcher; + + public BriarModelLoader(BriarApplication app) { + this.app = app; + } + + @Override + public LoadData buildLoadData(AttachmentItem model, int width, + int height, Options options) { + app.getApplicationComponent().inject(this); + ObjectKey key = new ObjectKey(model.getMessageId()); + dataFetcher.setAttachment(model); + return new LoadData<>(key, dataFetcher); + } + + @Override + public boolean handles(AttachmentItem model) { + return true; + } +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoaderFactory.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoaderFactory.java new file mode 100644 index 000000000..9d2a6d5d1 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/glide/BriarModelLoaderFactory.java @@ -0,0 +1,34 @@ +package org.briarproject.briar.android.conversation.glide; + +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.briar.android.BriarApplication; +import org.briarproject.briar.android.conversation.AttachmentItem; + +import java.io.InputStream; + +@NotNullByDefault +class BriarModelLoaderFactory + implements ModelLoaderFactory { + + private final BriarApplication app; + + public BriarModelLoaderFactory(BriarApplication app) { + this.app = app; + } + + @Override + public ModelLoader build( + MultiModelLoaderFactory multiFactory) { + return new BriarModelLoader(app); + } + + @Override + public void teardown() { + // noop + } + +} diff --git a/briar-android/src/main/res/drawable/ic_image_broken.xml b/briar-android/src/main/res/drawable/ic_image_broken.xml new file mode 100644 index 000000000..318d05b36 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_image_broken.xml @@ -0,0 +1,9 @@ + + + diff --git a/briar-android/src/main/res/drawable/msg_in.xml b/briar-android/src/main/res/drawable/msg_in.xml index b0a822b8c..d4e0d4da2 100644 --- a/briar-android/src/main/res/drawable/msg_in.xml +++ b/briar-android/src/main/res/drawable/msg_in.xml @@ -8,10 +8,10 @@ android:topLeftRadius="@dimen/message_bubble_radius_top_inner" android:topRightRadius="@dimen/message_bubble_radius_top_outer"/> + android:bottom="@dimen/message_bubble_border" + android:left="@dimen/message_bubble_border" + android:right="@dimen/message_bubble_border" + android:top="@dimen/message_bubble_border"/> + android:bottom="@dimen/message_bubble_border" + android:left="@dimen/message_bubble_border" + android:right="@dimen/message_bubble_border" + android:top="@dimen/message_bubble_border"/> + + + + + diff --git a/briar-android/src/main/res/layout/list_item_conversation_msg_image.xml b/briar-android/src/main/res/layout/list_item_conversation_msg_image.xml new file mode 100644 index 000000000..c36a3080c --- /dev/null +++ b/briar-android/src/main/res/layout/list_item_conversation_msg_image.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + diff --git a/briar-android/src/main/res/layout/list_item_conversation_msg_image_text.xml b/briar-android/src/main/res/layout/list_item_conversation_msg_image_text.xml new file mode 100644 index 000000000..5507d041d --- /dev/null +++ b/briar-android/src/main/res/layout/list_item_conversation_msg_image_text.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/briar-android/src/main/res/layout/list_item_conversation_msg_in.xml b/briar-android/src/main/res/layout/list_item_conversation_msg_in.xml index 2048bcacc..4e3950a87 100644 --- a/briar-android/src/main/res/layout/list_item_conversation_msg_in.xml +++ b/briar-android/src/main/res/layout/list_item_conversation_msg_in.xml @@ -15,29 +15,55 @@ android:background="@drawable/msg_in" android:elevation="@dimen/message_bubble_elevation"> + + + app:layout_constraintTop_toBottomOf="@+id/imageView" + tools:text="The text of a message which can sometimes be a bit longer as well"/> - + app:layout_constraintTop_toBottomOf="@+id/text"> + + + + diff --git a/briar-android/src/main/res/layout/list_item_conversation_msg_out.xml b/briar-android/src/main/res/layout/list_item_conversation_msg_out.xml index 0b346476c..d0dc1736e 100644 --- a/briar-android/src/main/res/layout/list_item_conversation_msg_out.xml +++ b/briar-android/src/main/res/layout/list_item_conversation_msg_out.xml @@ -21,39 +21,68 @@ android:background="@drawable/msg_out" android:elevation="@dimen/message_bubble_elevation"> + + - + tools:ignore="UseCompoundDrawables"> - + + + + + diff --git a/briar-android/src/main/res/values/color.xml b/briar-android/src/main/res/values/color.xml index 3d57fec4d..870fdae52 100644 --- a/briar-android/src/main/res/values/color.xml +++ b/briar-android/src/main/res/values/color.xml @@ -40,6 +40,7 @@ #cbcbcb #333333 @color/msg_stroke_light + #66000000 @color/briar_blue_light diff --git a/briar-android/src/main/res/values/dimens.xml b/briar-android/src/main/res/values/dimens.xml index 1b7f86cd8..81f7d4377 100644 --- a/briar-android/src/main/res/values/dimens.xml +++ b/briar-android/src/main/res/values/dimens.xml @@ -43,9 +43,18 @@ @dimen/message_bubble_radius_small @dimen/message_bubble_radius_big 6dp + 210dp + 150dp + 240dp + 100dp + 320dp + 2dp 12dp + 10dp 6dp + 4dp 4dp + 2dp 4dp 2dp 8dp diff --git a/briar-android/witness.gradle b/briar-android/witness.gradle index 2d092ffa5..a5213326c 100644 --- a/briar-android/witness.gradle +++ b/briar-android/witness.gradle @@ -36,6 +36,7 @@ dependencyVerification { 'com.android.support:design:28.0.0:design-28.0.0.aar:7874ad1904eedc74aa41cffffb7f759d8990056f3bbbc9264911651c67c42f5f', 'com.android.support:documentfile:28.0.0:documentfile-28.0.0.aar:47cdcd3e9302b7b064923f05487a5c03babbd9bbda4726b71e97791fab5d4779', 'com.android.support:drawerlayout:28.0.0:drawerlayout-28.0.0.aar:8f6809afae4793550c37461c9810e954ae6a23dbb4d23e5333bf18148df1150a', + 'com.android.support:exifinterface:28.0.0:exifinterface-28.0.0.aar:bbf44e519edd6333a24a3285aa21fd00181b920b81ca8aa89a8899f03ab4d6b0', 'com.android.support:interpolator:28.0.0:interpolator-28.0.0.aar:7bc7ee86a0db39a4b51956f3e89842d2bd962118d57d779eb6ed6b34ba0677ea', 'com.android.support:loader:28.0.0:loader-28.0.0.aar:920b85efd72dc33e915b0f88a883fe73b88483c6df8751a741e17611f2460341', 'com.android.support:localbroadcastmanager:28.0.0:localbroadcastmanager-28.0.0.aar:d287c823af5fdde72c099fcfc5f630efe9687af7a914343ae6fd92de32c8a806', @@ -84,6 +85,10 @@ dependencyVerification { 'com.android.tools:repository:26.2.1:repository-26.2.1.jar:fa74dae09103faef703df38550ad8fa244c5b6d1bf90d6198be932292b3d9cc1', 'com.android.tools:sdk-common:26.2.1:sdk-common-26.2.1.jar:759d4b292ca69a35cf961fca377b54158fc6c88108978006999442e80a011cf4', 'com.android.tools:sdklib:26.2.1:sdklib-26.2.1.jar:248df7ad5eac4aeb6f96c394c76760de4b7b89ac056e54d0c21a739368b91b45', + 'com.github.bumptech.glide:annotations:4.8.0:annotations-4.8.0.jar:4ea82e59874673105165820336c6ac268fc46149892486aad8e7a131a4449446', + 'com.github.bumptech.glide:compiler:4.8.0:compiler-4.8.0.jar:1fa93dd0cf7ef0b8b98a59a67a1ee84915416c2d677d83a771ea3e32ad15e6bf', + 'com.github.bumptech.glide:gifdecoder:4.8.0:gifdecoder-4.8.0.aar:b00c5454a023a9488ea49603930d9c25e09192e5ceaadf64977aa52946b3c1b4', + 'com.github.bumptech.glide:glide:4.8.0:glide-4.8.0.aar:5ddf08b12cc43332e812988f16c2c39e7fce49d1c4d94b7948dcde7f00bf49d6', 'com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:2.0:accessibility-test-framework-2.0.jar:cdf16ef8f5b8023d003ce3cc1b0d51bda737762e2dab2fedf43d1c4292353f7f', 'com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:2.1:accessibility-test-framework-2.1.jar:7b0aa6ed7553597ce0610684a9f7eca8021eee218f2e2f427c04a7fbf5f920bd', 'com.google.code.findbugs:jsr305:1.3.9:jsr305-1.3.9.jar:905721a0eea90a81534abb7ee6ef4ea2e5e645fa1def0a5cd88402df1b46c9ed', @@ -125,6 +130,7 @@ dependencyVerification { 'javax.annotation:jsr250-api:1.0:jsr250-api-1.0.jar:a1a922d0d9b6d183ed3800dfac01d1e1eb159f0e8c6f94736931c1def54a941f', 'javax.inject:javax.inject:1:javax.inject-1.jar:91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff', 'javax.xml.bind:jaxb-api:2.2.12-b140109.1041:jaxb-api-2.2.12-b140109.1041.jar:b5e60cd8b7b5ff01ce4a74c5dd008f4fbd14ced3495d0b47b85cfedc182211f2', + 'jp.wasabeef:glide-transformations:3.3.0:glide-transformations-3.3.0.aar:340c482364b84be768e7cb96975aa78b448b9f067913c8a23ae2339e0e908adf', 'junit:junit:4.12:junit-4.12.jar:59721f0805e223d84b90677887d9ff567dc534d7c502ca903c0c2b17f05c116a', 'nekohtml:nekohtml:1.9.6.2:nekohtml-1.9.6.2.jar:fdff6cfa9ed9cc911c842a5d2395f209ec621ef1239d46810e9e495809d3ae09', 'nekohtml:xercesMinimal:1.9.6.2:xercesMinimal-1.9.6.2.jar:95b8b357d19f63797dd7d67622fd3f18374d64acbc6584faba1c7759a31e8438',