1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.messaging.ui;
18 
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.graphics.Canvas;
22 import android.graphics.Color;
23 import android.graphics.Path;
24 import android.graphics.RectF;
25 import android.graphics.drawable.ColorDrawable;
26 import android.graphics.drawable.Drawable;
27 import androidx.annotation.Nullable;
28 import android.support.rastermill.FrameSequenceDrawable;
29 import android.text.TextUtils;
30 import android.util.AttributeSet;
31 import android.widget.ImageView;
32 
33 import com.android.messaging.R;
34 import com.android.messaging.datamodel.binding.Binding;
35 import com.android.messaging.datamodel.binding.BindingBase;
36 import com.android.messaging.datamodel.media.BindableMediaRequest;
37 import com.android.messaging.datamodel.media.GifImageResource;
38 import com.android.messaging.datamodel.media.ImageRequest;
39 import com.android.messaging.datamodel.media.ImageRequestDescriptor;
40 import com.android.messaging.datamodel.media.ImageResource;
41 import com.android.messaging.datamodel.media.MediaRequest;
42 import com.android.messaging.datamodel.media.MediaResourceManager;
43 import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
44 import com.android.messaging.util.Assert;
45 import com.android.messaging.util.LogUtil;
46 import com.android.messaging.util.ThreadUtil;
47 import com.android.messaging.util.UiUtils;
48 import com.google.common.annotations.VisibleForTesting;
49 
50 import java.util.HashSet;
51 
52 /**
53  * An ImageView used to asynchronously request an image from MediaResourceManager and render it.
54  */
55 public class AsyncImageView extends ImageView implements MediaResourceLoadListener<ImageResource> {
56     private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
57     // 100ms delay before disposing the image in case the AsyncImageView is re-added to the UI
58     private static final int DISPOSE_IMAGE_DELAY = 100;
59 
60     // AsyncImageView has a 1-1 binding relationship with an ImageRequest instance that requests
61     // the image from the MediaResourceManager. Since the request is done asynchronously, we
62     // want to make sure the image view is always bound to the latest image request that it
63     // issues, so that when the image is loaded, the ImageRequest (which extends BindableData)
64     // will be able to figure out whether the binding is still valid and whether the loaded image
65     // should be delivered to the AsyncImageView via onMediaResourceLoaded() callback.
66     @VisibleForTesting
67     public final Binding<BindableMediaRequest<ImageResource>> mImageRequestBinding;
68 
69     /** True if we want the image to fade in when it loads */
70     private boolean mFadeIn;
71 
72     /** True if we want the image to reveal (scale) when it loads. When set to true, this
73      * will take precedence over {@link #mFadeIn} */
74     private final boolean mReveal;
75 
76     // The corner radius for drawing rounded corners around bitmap. The default value is zero
77     // (no rounded corners)
78     private final int mCornerRadius;
79     private final Path mRoundedCornerClipPath;
80     private int mClipPathWidth;
81     private int mClipPathHeight;
82 
83     // A placeholder drawable that takes the spot of the image when it's loading. The default
84     // setting is null (no placeholder).
85     private final Drawable mPlaceholderDrawable;
86     protected ImageResource mImageResource;
87     private final Runnable mDisposeRunnable = new Runnable() {
88         @Override
89         public void run() {
90             if (mImageRequestBinding.isBound()) {
91                 mDetachedRequestDescriptor = (ImageRequestDescriptor)
92                         mImageRequestBinding.getData().getDescriptor();
93             }
94             unbindView();
95             releaseImageResource();
96         }
97     };
98 
99     private AsyncImageViewDelayLoader mDelayLoader;
100     private ImageRequestDescriptor mDetachedRequestDescriptor;
101 
AsyncImageView(final Context context, final AttributeSet attrs)102     public AsyncImageView(final Context context, final AttributeSet attrs) {
103         super(context, attrs);
104         mImageRequestBinding = BindingBase.createBinding(this);
105         final TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView,
106                 0, 0);
107         mFadeIn = attr.getBoolean(R.styleable.AsyncImageView_fadeIn, true);
108         mReveal = attr.getBoolean(R.styleable.AsyncImageView_reveal, false);
109         mPlaceholderDrawable = attr.getDrawable(R.styleable.AsyncImageView_placeholderDrawable);
110         mCornerRadius = attr.getDimensionPixelSize(R.styleable.AsyncImageView_cornerRadius, 0);
111         mRoundedCornerClipPath = new Path();
112 
113         attr.recycle();
114     }
115 
116     /**
117      * The main entrypoint for AsyncImageView to load image resource given an ImageRequestDescriptor
118      * @param descriptor the request descriptor, or null if no image should be displayed
119      */
setImageResourceId(@ullable final ImageRequestDescriptor descriptor)120     public void setImageResourceId(@Nullable final ImageRequestDescriptor descriptor) {
121         final String requestKey = (descriptor == null) ? null : descriptor.getKey();
122         if (mImageRequestBinding.isBound()) {
123             if (TextUtils.equals(mImageRequestBinding.getData().getKey(), requestKey)) {
124                 // Don't re-request the bitmap if the new request is for the same resource.
125                 return;
126             }
127             unbindView();
128         } else {
129             mDetachedRequestDescriptor = null;
130         }
131         setImage(null);
132         resetTransientViewStates();
133         if (!TextUtils.isEmpty(requestKey)) {
134             maybeSetupPlaceholderDrawable(descriptor);
135             final BindableMediaRequest<ImageResource> imageRequest =
136                     descriptor.buildAsyncMediaRequest(getContext(), this);
137             requestImage(imageRequest);
138         }
139     }
140 
141     /**
142      * Sets a delay loader that centrally manages image request delay loading logic.
143      */
setDelayLoader(final AsyncImageViewDelayLoader delayLoader)144     public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
145         Assert.isTrue(mDelayLoader == null);
146         mDelayLoader = delayLoader;
147     }
148 
149     /**
150      * Called by the delay loader when we can resume image loading.
151      */
resumeLoading()152     public void resumeLoading() {
153         Assert.notNull(mDelayLoader);
154         Assert.isTrue(mImageRequestBinding.isBound());
155         MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData());
156     }
157 
158     /**
159      * Setup the placeholder drawable if:
160      * 1. There's an image to be loaded AND
161      * 2. We are given a placeholder drawable AND
162      * 3. The descriptor provided us with source width and height.
163      */
maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor)164     private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) {
165         if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) {
166             if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE &&
167                 descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) {
168                 // Set a transparent inset drawable to the foreground so it will mimick the final
169                 // size of the image, and use the background to show the actual placeholder
170                 // drawable.
171                 setImageDrawable(PlaceholderInsetDrawable.fromDrawable(
172                         new ColorDrawable(Color.TRANSPARENT),
173                         descriptor.sourceWidth, descriptor.sourceHeight));
174             }
175             setBackground(mPlaceholderDrawable);
176         }
177     }
178 
setImage(final ImageResource resource)179     protected void setImage(final ImageResource resource) {
180         setImage(resource, false /* isCached */);
181     }
182 
setImage(final ImageResource resource, final boolean isCached)183     protected void setImage(final ImageResource resource, final boolean isCached) {
184         // Switch reference to the new ImageResource. Make sure we release the current
185         // resource and addRef() on the new resource so that the underlying bitmaps don't
186         // get leaked or get recycled by the bitmap cache.
187         releaseImageResource();
188         // Ensure that any pending dispose runnables get removed.
189         ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
190         // The drawable may require work to get if its a static object so try to only make this call
191         // once.
192         final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null;
193         if (drawable != null) {
194             mImageResource = resource;
195             mImageResource.addRef();
196             setImageDrawable(drawable);
197             if (drawable instanceof FrameSequenceDrawable) {
198                 ((FrameSequenceDrawable) drawable).start();
199             }
200 
201             if (getVisibility() == VISIBLE) {
202                 if (mReveal) {
203                     setVisibility(INVISIBLE);
204                     UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null);
205                 } else if (mFadeIn && !isCached) {
206                     // Hide initially to avoid flash.
207                     setAlpha(0F);
208                     animate().alpha(1F).start();
209                 }
210             }
211 
212             if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
213                 if (mImageResource instanceof GifImageResource) {
214                     LogUtil.v(TAG, "setImage size unknown -- it's a GIF");
215                 } else {
216                     LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() +
217                             " width: " + mImageResource.getBitmap().getWidth() +
218                             " heigh: " + mImageResource.getBitmap().getHeight());
219                 }
220             }
221         }
222         invalidate();
223     }
224 
requestImage(final BindableMediaRequest<ImageResource> request)225     private void requestImage(final BindableMediaRequest<ImageResource> request) {
226         mImageRequestBinding.bind(request);
227         if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) {
228             MediaResourceManager.get().requestMediaResourceAsync(request);
229         } else {
230             mDelayLoader.registerView(this);
231         }
232     }
233 
234     @Override
onMediaResourceLoaded(final MediaRequest<ImageResource> request, final ImageResource resource, final boolean isCached)235     public void onMediaResourceLoaded(final MediaRequest<ImageResource> request,
236             final ImageResource resource, final boolean isCached) {
237         if (mImageResource != resource) {
238             setImage(resource, isCached);
239         }
240     }
241 
242     @Override
onMediaResourceLoadError( final MediaRequest<ImageResource> request, final Exception exception)243     public void onMediaResourceLoadError(
244             final MediaRequest<ImageResource> request, final Exception exception) {
245         // Media load failed, unbind and reset bitmap to default.
246         unbindView();
247         setImage(null);
248     }
249 
releaseImageResource()250     private void releaseImageResource() {
251         final Drawable drawable = getDrawable();
252         if (drawable instanceof FrameSequenceDrawable) {
253             ((FrameSequenceDrawable) drawable).stop();
254             ((FrameSequenceDrawable) drawable).destroy();
255         }
256         if (mImageResource != null) {
257             mImageResource.release();
258             mImageResource = null;
259         }
260         setImageDrawable(null);
261         setBackground(null);
262     }
263 
264     /**
265      * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view.
266      */
resetTransientViewStates()267     private void resetTransientViewStates() {
268         clearAnimation();
269         setAlpha(1F);
270     }
271 
272     @Override
onAttachedToWindow()273     protected void onAttachedToWindow() {
274         super.onAttachedToWindow();
275         // If it was recently removed, then cancel disposing, we're still using it.
276         ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
277 
278         // When the image view gets detached and immediately re-attached, any fade-in animation
279         // will be terminated, leaving the view in a semi-transparent state. Make sure we restore
280         // alpha when the view is re-attached.
281         if (mFadeIn) {
282             setAlpha(1F);
283         }
284 
285         // Check whether we are in a simple reuse scenario: detached from window, and reattached
286         // later without rebinding. This may be done by containers such as the RecyclerView to
287         // reuse the views. In this case, we would like to rebind the original image request.
288         if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) {
289             setImageResourceId(mDetachedRequestDescriptor);
290         }
291         mDetachedRequestDescriptor = null;
292     }
293 
294     @Override
onDetachedFromWindow()295     protected void onDetachedFromWindow() {
296         super.onDetachedFromWindow();
297         // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly
298         // re-added, we shouldn't dispose, so wait a short time before disposing
299         ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY);
300     }
301 
302     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)303     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
304         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
305 
306         // The base implementation does not honor the minimum sizes. We try to to honor it here.
307 
308         final int measuredWidth = getMeasuredWidth();
309         final int measuredHeight = getMeasuredHeight();
310         if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) {
311             // We are ok if either of the minimum sizes is honored. Note that satisfying both the
312             // sizes may not be possible, depending on the aspect ratio of the image and whether
313             // a maximum size has been specified. This implementation only tries to handle the case
314             // where both the minimum sizes are not being satisfied.
315             return;
316         }
317 
318         if (!getAdjustViewBounds()) {
319             // The base implementation is reasonable in this case. If the view bounds cannot be
320             // changed, it is not possible to satisfy the minimum sizes anyway.
321             return;
322         }
323 
324         final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
325         final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
326         if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) {
327             // The base implementation is reasonable in this case.
328             return;
329         }
330 
331         int width = measuredWidth;
332         int height = measuredHeight;
333         // Get the minimum sizes that will honor other constraints as well.
334         final int minimumWidth = resolveSize(
335                 getMinimumWidth(), getMaxWidth(), widthMeasureSpec);
336         final int minimumHeight = resolveSize(
337                 getMinimumHeight(), getMaxHeight(), heightMeasureSpec);
338         final float aspectRatio = measuredWidth / (float) measuredHeight;
339         if (aspectRatio == 0) {
340             // If the image is (close to) infinitely high, there is not much we can do.
341             return;
342         }
343 
344         if (width < minimumWidth) {
345             height = resolveSize((int) (minimumWidth / aspectRatio),
346                     getMaxHeight(), heightMeasureSpec);
347             width = (int) (height * aspectRatio);
348         }
349 
350         if (height < minimumHeight) {
351             width = resolveSize((int) (minimumHeight * aspectRatio),
352                     getMaxWidth(), widthMeasureSpec);
353             height = (int) (width / aspectRatio);
354         }
355 
356         setMeasuredDimension(width, height);
357     }
358 
resolveSize(int desiredSize, int maxSize, int measureSpec)359     private static int resolveSize(int desiredSize, int maxSize, int measureSpec) {
360         final int specMode = MeasureSpec.getMode(measureSpec);
361         final int specSize =  MeasureSpec.getSize(measureSpec);
362         switch(specMode) {
363             case MeasureSpec.UNSPECIFIED:
364                 return Math.min(desiredSize, maxSize);
365 
366             case MeasureSpec.AT_MOST:
367                 return Math.min(Math.min(desiredSize, specSize), maxSize);
368 
369             default:
370                 Assert.fail("Unreachable");
371                 return specSize;
372         }
373     }
374 
375     @Override
onDraw(final Canvas canvas)376     protected void onDraw(final Canvas canvas) {
377         if (mCornerRadius > 0) {
378             final int currentWidth = this.getWidth();
379             final int currentHeight = this.getHeight();
380             if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) {
381                 final RectF rect = new RectF(0, 0, currentWidth, currentHeight);
382                 mRoundedCornerClipPath.reset();
383                 mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius,
384                         Path.Direction.CW);
385                 mClipPathWidth = currentWidth;
386                 mClipPathHeight = currentHeight;
387             }
388 
389             final int saveCount = canvas.getSaveCount();
390             canvas.save();
391             canvas.clipPath(mRoundedCornerClipPath);
392             super.onDraw(canvas);
393             canvas.restoreToCount(saveCount);
394         } else {
395             super.onDraw(canvas);
396         }
397     }
398 
unbindView()399     private void unbindView() {
400         if (mImageRequestBinding.isBound()) {
401             mImageRequestBinding.unbind();
402             if (mDelayLoader != null) {
403                 mDelayLoader.unregisterView(this);
404             }
405         }
406     }
407 
408     /**
409      * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading
410      * the image when it's busy doing other things (such as when a list view is scrolling). In
411      * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be
412      * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call
413      * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively.
414      */
415     public static class AsyncImageViewDelayLoader {
416         private boolean mShouldDelayLoad;
417         private final HashSet<AsyncImageView> mAttachedViews;
418 
AsyncImageViewDelayLoader()419         public AsyncImageViewDelayLoader() {
420             mAttachedViews = new HashSet<AsyncImageView>();
421         }
422 
registerView(final AsyncImageView view)423         private void registerView(final AsyncImageView view) {
424             mAttachedViews.add(view);
425         }
426 
unregisterView(final AsyncImageView view)427         private void unregisterView(final AsyncImageView view) {
428             mAttachedViews.remove(view);
429         }
430 
isDelayLoadingImage()431         public boolean isDelayLoadingImage() {
432             return mShouldDelayLoad;
433         }
434 
435         /**
436          * Called by the consumer of this view to delay loading images
437          */
onDelayLoading()438         public void onDelayLoading() {
439             // Don't need to explicitly tell the AsyncImageView to stop loading since
440             // ImageRequests are not cancellable.
441             mShouldDelayLoad = true;
442         }
443 
444         /**
445          * Called by the consumer of this view to resume loading images
446          */
onResumeLoading()447         public void onResumeLoading() {
448             if (mShouldDelayLoad) {
449                 mShouldDelayLoad = false;
450 
451                 // Notify all attached views to resume loading.
452                 for (final AsyncImageView view : mAttachedViews) {
453                     view.resumeLoading();
454                 }
455                 mAttachedViews.clear();
456             }
457         }
458     }
459 }
460