blob: 1f858664c5346dd093aa9670c153c3360aecf682 [file] [log] [blame]
package org.wordpress.android.widgets;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.AsyncTask;
import android.support.annotation.ColorRes;
import android.support.annotation.DrawableRes;
import android.support.v4.content.ContextCompat;
import android.support.v7.widget.AppCompatImageView;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.ImageLoader;
import org.wordpress.android.R;
import org.wordpress.android.WordPress;
import org.wordpress.android.datasets.ReaderThumbnailTable;
import org.wordpress.android.ui.reader.utils.ReaderVideoUtils;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.DisplayUtils;
import org.wordpress.android.util.ImageUtils;
import org.wordpress.android.util.MediaUtils;
import org.wordpress.android.util.VolleyUtils;
import java.util.HashSet;
/**
* most of the code below is from Volley's NetworkImageView, but it's modified to support:
* (1) fading in downloaded images
* (2) manipulating images before display
* (3) automatically retrieving the thumbnail for YouTube & Vimeo videos
*/
public class WPNetworkImageView extends AppCompatImageView {
public enum ImageType {
NONE,
PHOTO,
PHOTO_ROUNDED,
VIDEO,
AVATAR,
BLAVATAR,
GONE_UNTIL_AVAILABLE,
}
public interface ImageLoadListener {
void onLoaded();
void onError();
}
private ImageType mImageType = ImageType.NONE;
private String mUrl;
private ImageLoader.ImageContainer mImageContainer;
private int mDefaultImageResId;
private int mErrorImageResId;
private static final HashSet<String> mUrlSkipList = new HashSet<>();
public WPNetworkImageView(Context context) {
super(context);
}
public WPNetworkImageView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public WPNetworkImageView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public void setImageUrl(String url, ImageType imageType) {
setImageUrl(url, imageType, null);
}
public void setImageUrl(String url, ImageType imageType, ImageLoadListener imageLoadListener) {
mUrl = url;
mImageType = imageType;
// The URL has potentially changed. See if we need to load it.
loadImageIfNecessary(false, imageLoadListener);
}
/*
* determine whether we can show a thumbnail image for the passed video - currently
* we support YouTube, Vimeo & standard images
*/
public static boolean canShowVideoThumbnail(String videoUrl) {
return ReaderVideoUtils.isVimeoLink(videoUrl)
|| ReaderVideoUtils.isYouTubeVideoLink(videoUrl)
|| MediaUtils.isValidImage(videoUrl);
}
/*
* retrieves and displays the thumbnail for the passed video
*/
public void setVideoUrl(final long postId, final String videoUrl) {
mImageType = ImageType.VIDEO;
if (TextUtils.isEmpty(videoUrl)) {
showErrorImage();
return;
}
// if this is a YouTube video we can determine the thumbnail url from the passed url,
// otherwise check if we've already cached the thumbnail url for this video
String thumbnailUrl;
if (ReaderVideoUtils.isYouTubeVideoLink(videoUrl)) {
thumbnailUrl = ReaderVideoUtils.getYouTubeThumbnailUrl(videoUrl);
} else {
thumbnailUrl = ReaderThumbnailTable.getThumbnailUrl(videoUrl);
}
if (!TextUtils.isEmpty(thumbnailUrl)) {
setImageUrl(thumbnailUrl, ImageType.VIDEO);
return;
}
if (MediaUtils.isValidImage(videoUrl)) {
setImageUrl(videoUrl, ImageType.VIDEO);
} else if (ReaderVideoUtils.isVimeoLink(videoUrl)) {
// vimeo videos require network request to get thumbnail
showDefaultImage();
ReaderVideoUtils.requestVimeoThumbnail(videoUrl, new ReaderVideoUtils.VideoThumbnailListener() {
@Override
public void onResponse(boolean successful, String thumbnailUrl) {
if (successful) {
ReaderThumbnailTable.addThumbnail(postId, videoUrl, thumbnailUrl);
setImageUrl(thumbnailUrl, ImageType.VIDEO);
}
}
});
} else {
AppLog.d(AppLog.T.UTILS, "no video thumbnail for " + videoUrl);
showErrorImage();
}
}
/**
* Loads the image for the view if it isn't already loaded.
* @param isInLayoutPass True if this was invoked from a layout pass, false otherwise.
*/
private void loadImageIfNecessary(final boolean isInLayoutPass, final ImageLoadListener imageLoadListener) {
// do nothing if image type hasn't been set yet
if (mImageType == ImageType.NONE) {
return;
}
int width = getWidth();
int height = getHeight();
ScaleType scaleType = getScaleType();
boolean wrapWidth = false, wrapHeight = false;
if (getLayoutParams() != null) {
wrapWidth = getLayoutParams().width == LayoutParams.WRAP_CONTENT;
wrapHeight = getLayoutParams().height == LayoutParams.WRAP_CONTENT;
}
// if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content
// view, hold off on loading the image.
boolean isFullyWrapContent = wrapWidth && wrapHeight;
if (width == 0 && height == 0 && !isFullyWrapContent && mImageType != ImageType.GONE_UNTIL_AVAILABLE) {
return;
}
// if the URL to be loaded in this view is empty, cancel any old requests and clear the
// currently loaded image.
if (TextUtils.isEmpty(mUrl)) {
if (mImageContainer != null) {
mImageContainer.cancelRequest();
mImageContainer = null;
}
showErrorImage();
return;
}
// if there was an old request in this view, check if it needs to be canceled.
if (mImageContainer != null && mImageContainer.getRequestUrl() != null) {
if (mImageContainer.getRequestUrl().equals(mUrl)) {
// if the request is from the same URL and it's not GONE_UNTIL_AVAILABLE, return.
if (mImageType != ImageType.GONE_UNTIL_AVAILABLE) {
// GONE_UNTIL_AVAILABLE image type will make a new request if the previous response wasn't a 404 response,
// Volley usually returns it from cache.
return;
}
} else {
// if there is a pre-existing request, cancel it if it's fetching a different URL.
mImageContainer.cancelRequest();
showDefaultImage();
}
}
// skip this URL if a previous request for it returned a 404
if (mUrlSkipList.contains(mUrl)) {
AppLog.d(AppLog.T.UTILS, "skipping image request " + mUrl);
showErrorImage();
return;
}
// Calculate the max image width / height to use while ignoring WRAP_CONTENT dimens.
int maxWidth = wrapWidth ? 0 : width;
int maxHeight = wrapHeight ? 0 : height;
// The pre-existing content of this view didn't match the current URL. Load the new image
// from the network.
ImageLoader.ImageContainer newContainer = WordPress.imageLoader.get(mUrl,
new ImageLoader.ImageListener() {
@Override
public void onErrorResponse(VolleyError error) {
showErrorImage();
// keep track of URLs that 404 so we can skip them the next time
int statusCode = VolleyUtils.statusCodeFromVolleyError(error);
if (statusCode == 404) {
mUrlSkipList.add(mUrl);
}
if (imageLoadListener != null) {
imageLoadListener.onError();
}
}
@Override
public void onResponse(final ImageLoader.ImageContainer response, boolean isImmediate) {
// If this was an immediate response that was delivered inside of a layout
// pass do not set the image immediately as it will trigger a requestLayout
// inside of a layout. Instead, defer setting the image by posting back to
// the main thread.
if (isImmediate && isInLayoutPass) {
post(new Runnable() {
@Override
public void run() {
handleResponse(response, true, imageLoadListener);
}
});
} else {
handleResponse(response, isImmediate, imageLoadListener);
}
}
}, maxWidth, maxHeight, scaleType);
// update the ImageContainer to be the new bitmap container.
mImageContainer = newContainer;
}
private static boolean canFadeInImageType(ImageType imageType) {
return imageType == ImageType.PHOTO
|| imageType == ImageType.VIDEO;
}
private void handleResponse(ImageLoader.ImageContainer response, boolean isCached, ImageLoadListener
imageLoadListener) {
if (response.getBitmap() != null) {
Bitmap bitmap = response.getBitmap();
if (mImageType == ImageType.GONE_UNTIL_AVAILABLE) {
setVisibility(View.VISIBLE);
}
// Apply circular rounding to avatars in a background task
if (mImageType == ImageType.AVATAR) {
new ShapeBitmapTask(ShapeType.CIRCLE, imageLoadListener).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, bitmap);
return;
} else if (mImageType == ImageType.PHOTO_ROUNDED) {
new ShapeBitmapTask(ShapeType.ROUNDED, imageLoadListener).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, bitmap);
return;
}
setImageBitmap(bitmap);
// fade in photos/videos if not cached (not used for other image types since animation can be expensive)
if (!isCached && canFadeInImageType(mImageType)) {
fadeIn();
}
} else {
showDefaultImage();
}
}
public void invalidateImage() {
mUrlSkipList.clear();
if (mImageContainer != null) {
// If the view was bound to an image request, cancel it and clear
// out the image from the view.
mImageContainer.cancelRequest();
setImageBitmap(null);
// also clear out the container so we can reload the image if necessary.
mImageContainer = null;
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (!isInEditMode()) {
loadImageIfNecessary(true, null);
}
}
@Override
protected void onDetachedFromWindow() {
invalidateImage();
super.onDetachedFromWindow();
}
@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
invalidate();
}
private int getColorRes(@ColorRes int resId) {
return ContextCompat.getColor(getContext(), resId);
}
public void setDefaultImageResId(@DrawableRes int resourceId) {
mDefaultImageResId = resourceId;
}
public void setErrorImageResId(@DrawableRes int resourceId) {
mErrorImageResId = resourceId;
}
public void showDefaultImage() {
// use default image resource if one was supplied...
if (mDefaultImageResId != 0) {
setImageResource(mDefaultImageResId);
return;
}
// ... otherwise use built-in default
switch (mImageType) {
case GONE_UNTIL_AVAILABLE:
this.setVisibility(View.GONE);
break;
case NONE:
// do nothing
break;
case AVATAR:
// Grey circle for avatars
setImageResource(R.drawable.shape_oval_grey_light);
break;
default :
// light grey box for all others
setImageDrawable(new ColorDrawable(getColorRes(R.color.grey_light)));
break;
}
}
private void showErrorImage() {
if (mErrorImageResId != 0) {
setImageResource(mErrorImageResId);
return;
}
switch (mImageType) {
case GONE_UNTIL_AVAILABLE:
this.setVisibility(View.GONE);
break;
case NONE:
// do nothing
break;
case AVATAR:
// circular "mystery man" for failed avatars
showDefaultGravatarImage();
break;
case BLAVATAR:
showDefaultBlavatarImage();
break;
default :
// grey box for all others
setImageDrawable(new ColorDrawable(getColorRes(R.color.grey_lighten_30)));
break;
}
}
public void showDefaultGravatarImage() {
if (getContext() == null) return;
new ShapeBitmapTask(ShapeType.CIRCLE, null).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, BitmapFactory.decodeResource(
getContext().getResources(),
R.drawable.gravatar_placeholder
));
}
public void showDefaultBlavatarImage() {
setImageResource(R.drawable.blavatar_placeholder);
}
// --------------------------------------------------------------------------------------------------
private static final int FADE_TRANSITION = 250;
private void fadeIn() {
ObjectAnimator alpha = ObjectAnimator.ofFloat(this, View.ALPHA, 0.25f, 1f);
alpha.setDuration(FADE_TRANSITION);
alpha.start();
}
// Circularizes or rounds the corners of a bitmap in a background thread
private enum ShapeType { CIRCLE, ROUNDED }
private class ShapeBitmapTask extends AsyncTask<Bitmap, Void, Bitmap> {
private final ImageLoadListener mImageLoadListener;
private final ShapeType mShapeType;
private int mRoundedCornerRadiusPx;
private static final int ROUNDED_CORNER_RADIUS_DP = 2;
public ShapeBitmapTask(ShapeType shapeType, ImageLoadListener imageLoadListener) {
mImageLoadListener = imageLoadListener;
mShapeType = shapeType;
if (mShapeType == ShapeType.ROUNDED) {
mRoundedCornerRadiusPx = DisplayUtils.dpToPx(getContext(), ROUNDED_CORNER_RADIUS_DP);
}
}
@Override
protected Bitmap doInBackground(Bitmap... params) {
if (params == null || params.length == 0) return null;
Bitmap bitmap = params[0];
switch (mShapeType) {
case CIRCLE:
return ImageUtils.getCircularBitmap(bitmap);
case ROUNDED:
return ImageUtils.getRoundedEdgeBitmap(bitmap, mRoundedCornerRadiusPx, Color.TRANSPARENT);
default:
return bitmap;
}
}
@Override
protected void onPostExecute(Bitmap bitmap) {
if (bitmap != null) {
setImageBitmap(bitmap);
if (mImageLoadListener != null) {
mImageLoadListener.onLoaded();
fadeIn();
}
} else {
if (mImageLoadListener != null) {
mImageLoadListener.onError();
}
}
}
}
}