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
17package com.android.messaging.ui;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.graphics.Canvas;
22import android.graphics.Color;
23import android.graphics.Path;
24import android.graphics.RectF;
25import android.graphics.drawable.ColorDrawable;
26import android.graphics.drawable.Drawable;
27import android.support.annotation.Nullable;
28import android.support.rastermill.FrameSequenceDrawable;
29import android.text.TextUtils;
30import android.util.AttributeSet;
31import android.widget.ImageView;
32
33import com.android.messaging.R;
34import com.android.messaging.datamodel.binding.Binding;
35import com.android.messaging.datamodel.binding.BindingBase;
36import com.android.messaging.datamodel.media.BindableMediaRequest;
37import com.android.messaging.datamodel.media.GifImageResource;
38import com.android.messaging.datamodel.media.ImageRequest;
39import com.android.messaging.datamodel.media.ImageRequestDescriptor;
40import com.android.messaging.datamodel.media.ImageResource;
41import com.android.messaging.datamodel.media.MediaRequest;
42import com.android.messaging.datamodel.media.MediaResourceManager;
43import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener;
44import com.android.messaging.util.Assert;
45import com.android.messaging.util.LogUtil;
46import com.android.messaging.util.ThreadUtil;
47import com.android.messaging.util.UiUtils;
48import com.google.common.annotations.VisibleForTesting;
49
50import java.util.HashSet;
51
52/**
53 * An ImageView used to asynchronously request an image from MediaResourceManager and render it.
54 */
55public 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
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     */
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        }
129        setImage(null);
130        resetTransientViewStates();
131        if (!TextUtils.isEmpty(requestKey)) {
132            maybeSetupPlaceholderDrawable(descriptor);
133            final BindableMediaRequest<ImageResource> imageRequest =
134                    descriptor.buildAsyncMediaRequest(getContext(), this);
135            requestImage(imageRequest);
136        }
137    }
138
139    /**
140     * Sets a delay loader that centrally manages image request delay loading logic.
141     */
142    public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
143        Assert.isTrue(mDelayLoader == null);
144        mDelayLoader = delayLoader;
145    }
146
147    /**
148     * Called by the delay loader when we can resume image loading.
149     */
150    public void resumeLoading() {
151        Assert.notNull(mDelayLoader);
152        Assert.isTrue(mImageRequestBinding.isBound());
153        MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData());
154    }
155
156    /**
157     * Setup the placeholder drawable if:
158     * 1. There's an image to be loaded AND
159     * 2. We are given a placeholder drawable AND
160     * 3. The descriptor provided us with source width and height.
161     */
162    private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) {
163        if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) {
164            if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE &&
165                descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) {
166                // Set a transparent inset drawable to the foreground so it will mimick the final
167                // size of the image, and use the background to show the actual placeholder
168                // drawable.
169                setImageDrawable(PlaceholderInsetDrawable.fromDrawable(
170                        new ColorDrawable(Color.TRANSPARENT),
171                        descriptor.sourceWidth, descriptor.sourceHeight));
172            }
173            setBackground(mPlaceholderDrawable);
174        }
175    }
176
177    protected void setImage(final ImageResource resource) {
178        setImage(resource, false /* isCached */);
179    }
180
181    protected void setImage(final ImageResource resource, final boolean isCached) {
182        // Switch reference to the new ImageResource. Make sure we release the current
183        // resource and addRef() on the new resource so that the underlying bitmaps don't
184        // get leaked or get recycled by the bitmap cache.
185        releaseImageResource();
186        // Ensure that any pending dispose runnables get removed.
187        ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
188        // The drawable may require work to get if its a static object so try to only make this call
189        // once.
190        final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null;
191        if (drawable != null) {
192            mImageResource = resource;
193            mImageResource.addRef();
194            setImageDrawable(drawable);
195            if (drawable instanceof FrameSequenceDrawable) {
196                ((FrameSequenceDrawable) drawable).start();
197            }
198
199            if (getVisibility() == VISIBLE) {
200                if (mReveal) {
201                    setVisibility(INVISIBLE);
202                    UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null);
203                } else if (mFadeIn && !isCached) {
204                    // Hide initially to avoid flash.
205                    setAlpha(0F);
206                    animate().alpha(1F).start();
207                }
208            }
209
210            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
211                if (mImageResource instanceof GifImageResource) {
212                    LogUtil.v(TAG, "setImage size unknown -- it's a GIF");
213                } else {
214                    LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() +
215                            " width: " + mImageResource.getBitmap().getWidth() +
216                            " heigh: " + mImageResource.getBitmap().getHeight());
217                }
218            }
219        }
220        invalidate();
221    }
222
223    private void requestImage(final BindableMediaRequest<ImageResource> request) {
224        mImageRequestBinding.bind(request);
225        if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) {
226            MediaResourceManager.get().requestMediaResourceAsync(request);
227        } else {
228            mDelayLoader.registerView(this);
229        }
230    }
231
232    @Override
233    public void onMediaResourceLoaded(final MediaRequest<ImageResource> request,
234            final ImageResource resource, final boolean isCached) {
235        if (mImageResource != resource) {
236            setImage(resource, isCached);
237        }
238    }
239
240    @Override
241    public void onMediaResourceLoadError(
242            final MediaRequest<ImageResource> request, final Exception exception) {
243        // Media load failed, unbind and reset bitmap to default.
244        unbindView();
245        setImage(null);
246    }
247
248    private void releaseImageResource() {
249        final Drawable drawable = getDrawable();
250        if (drawable instanceof FrameSequenceDrawable) {
251            ((FrameSequenceDrawable) drawable).stop();
252            ((FrameSequenceDrawable) drawable).destroy();
253        }
254        if (mImageResource != null) {
255            mImageResource.release();
256            mImageResource = null;
257        }
258        setImageDrawable(null);
259        setBackground(null);
260    }
261
262    /**
263     * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view.
264     */
265    private void resetTransientViewStates() {
266        clearAnimation();
267        setAlpha(1F);
268    }
269
270    @Override
271    protected void onAttachedToWindow() {
272        super.onAttachedToWindow();
273        // If it was recently removed, then cancel disposing, we're still using it.
274        ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
275
276        // When the image view gets detached and immediately re-attached, any fade-in animation
277        // will be terminated, leaving the view in a semi-transparent state. Make sure we restore
278        // alpha when the view is re-attached.
279        if (mFadeIn) {
280            setAlpha(1F);
281        }
282
283        // Check whether we are in a simple reuse scenario: detached from window, and reattached
284        // later without rebinding. This may be done by containers such as the RecyclerView to
285        // reuse the views. In this case, we would like to rebind the original image request.
286        if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) {
287            setImageResourceId(mDetachedRequestDescriptor);
288        }
289        mDetachedRequestDescriptor = null;
290    }
291
292    @Override
293    protected void onDetachedFromWindow() {
294        super.onDetachedFromWindow();
295        // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly
296        // re-added, we shouldn't dispose, so wait a short time before disposing
297        ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY);
298    }
299
300    @Override
301    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
302        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
303
304        // The base implementation does not honor the minimum sizes. We try to to honor it here.
305
306        final int measuredWidth = getMeasuredWidth();
307        final int measuredHeight = getMeasuredHeight();
308        if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) {
309            // We are ok if either of the minimum sizes is honored. Note that satisfying both the
310            // sizes may not be possible, depending on the aspect ratio of the image and whether
311            // a maximum size has been specified. This implementation only tries to handle the case
312            // where both the minimum sizes are not being satisfied.
313            return;
314        }
315
316        if (!getAdjustViewBounds()) {
317            // The base implementation is reasonable in this case. If the view bounds cannot be
318            // changed, it is not possible to satisfy the minimum sizes anyway.
319            return;
320        }
321
322        final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
323        final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
324        if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) {
325            // The base implementation is reasonable in this case.
326            return;
327        }
328
329        int width = measuredWidth;
330        int height = measuredHeight;
331        // Get the minimum sizes that will honor other constraints as well.
332        final int minimumWidth = resolveSize(
333                getMinimumWidth(), getMaxWidth(), widthMeasureSpec);
334        final int minimumHeight = resolveSize(
335                getMinimumHeight(), getMaxHeight(), heightMeasureSpec);
336        final float aspectRatio = measuredWidth / (float) measuredHeight;
337        if (aspectRatio == 0) {
338            // If the image is (close to) infinitely high, there is not much we can do.
339            return;
340        }
341
342        if (width < minimumWidth) {
343            height = resolveSize((int) (minimumWidth / aspectRatio),
344                    getMaxHeight(), heightMeasureSpec);
345            width = (int) (height * aspectRatio);
346        }
347
348        if (height < minimumHeight) {
349            width = resolveSize((int) (minimumHeight * aspectRatio),
350                    getMaxWidth(), widthMeasureSpec);
351            height = (int) (width / aspectRatio);
352        }
353
354        setMeasuredDimension(width, height);
355    }
356
357    private static int resolveSize(int desiredSize, int maxSize, int measureSpec) {
358        final int specMode = MeasureSpec.getMode(measureSpec);
359        final int specSize =  MeasureSpec.getSize(measureSpec);
360        switch(specMode) {
361            case MeasureSpec.UNSPECIFIED:
362                return Math.min(desiredSize, maxSize);
363
364            case MeasureSpec.AT_MOST:
365                return Math.min(Math.min(desiredSize, specSize), maxSize);
366
367            default:
368                Assert.fail("Unreachable");
369                return specSize;
370        }
371    }
372
373    @Override
374    protected void onDraw(final Canvas canvas) {
375        if (mCornerRadius > 0) {
376            final int currentWidth = this.getWidth();
377            final int currentHeight = this.getHeight();
378            if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) {
379                final RectF rect = new RectF(0, 0, currentWidth, currentHeight);
380                mRoundedCornerClipPath.reset();
381                mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius,
382                        Path.Direction.CW);
383                mClipPathWidth = currentWidth;
384                mClipPathHeight = currentHeight;
385            }
386
387            final int saveCount = canvas.getSaveCount();
388            canvas.save();
389            canvas.clipPath(mRoundedCornerClipPath);
390            super.onDraw(canvas);
391            canvas.restoreToCount(saveCount);
392        } else {
393            super.onDraw(canvas);
394        }
395    }
396
397    private void unbindView() {
398        if (mImageRequestBinding.isBound()) {
399            mImageRequestBinding.unbind();
400            if (mDelayLoader != null) {
401                mDelayLoader.unregisterView(this);
402            }
403        }
404    }
405
406    /**
407     * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading
408     * the image when it's busy doing other things (such as when a list view is scrolling). In
409     * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be
410     * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call
411     * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively.
412     */
413    public static class AsyncImageViewDelayLoader {
414        private boolean mShouldDelayLoad;
415        private final HashSet<AsyncImageView> mAttachedViews;
416
417        public AsyncImageViewDelayLoader() {
418            mAttachedViews = new HashSet<AsyncImageView>();
419        }
420
421        private void registerView(final AsyncImageView view) {
422            mAttachedViews.add(view);
423        }
424
425        private void unregisterView(final AsyncImageView view) {
426            mAttachedViews.remove(view);
427        }
428
429        public boolean isDelayLoadingImage() {
430            return mShouldDelayLoad;
431        }
432
433        /**
434         * Called by the consumer of this view to delay loading images
435         */
436        public void onDelayLoading() {
437            // Don't need to explicitly tell the AsyncImageView to stop loading since
438            // ImageRequests are not cancellable.
439            mShouldDelayLoad = true;
440        }
441
442        /**
443         * Called by the consumer of this view to resume loading images
444         */
445        public void onResumeLoading() {
446            if (mShouldDelayLoad) {
447                mShouldDelayLoad = false;
448
449                // Notify all attached views to resume loading.
450                for (final AsyncImageView view : mAttachedViews) {
451                    view.resumeLoading();
452                }
453                mAttachedViews.clear();
454            }
455        }
456    }
457}
458