blob: e541d6908d382e149c0613ea9a416433429bcf2a [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.util;
import android.app.ActivityManager;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Shader.TileMode;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.provider.MediaStore;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.view.View;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.MediaScratchFileProvider;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.media.ImageRequest;
import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
import com.android.messaging.util.exif.ExifInterface;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.io.Files;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Arrays;
public class ImageUtils {
private static final String TAG = LogUtil.BUGLE_TAG;
private static final int MAX_OOM_COUNT = 1;
private static final byte[] GIF87_HEADER = "GIF87a".getBytes(Charset.forName("US-ASCII"));
private static final byte[] GIF89_HEADER = "GIF89a".getBytes(Charset.forName("US-ASCII"));
// Used for drawBitmapWithCircleOnCanvas.
// Default color is transparent for both circle background and stroke.
public static final int DEFAULT_CIRCLE_BACKGROUND_COLOR = 0;
public static final int DEFAULT_CIRCLE_STROKE_COLOR = 0;
private static volatile ImageUtils sInstance;
public static ImageUtils get() {
if (sInstance == null) {
synchronized (ImageUtils.class) {
if (sInstance == null) {
sInstance = new ImageUtils();
}
}
}
return sInstance;
}
@VisibleForTesting
public static void set(final ImageUtils imageUtils) {
sInstance = imageUtils;
}
/**
* Transforms a bitmap into a byte array.
*
* @param quality Value between 0 and 100 that the compressor uses to discern what quality the
* resulting bytes should be
* @param bitmap Bitmap to convert into bytes
* @return byte array of bitmap
*/
public static byte[] bitmapToBytes(final Bitmap bitmap, final int quality)
throws OutOfMemoryError {
boolean done = false;
int oomCount = 0;
byte[] imageBytes = null;
while (!done) {
try {
final ByteArrayOutputStream os = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, os);
imageBytes = os.toByteArray();
done = true;
} catch (final OutOfMemoryError e) {
LogUtil.w(TAG, "OutOfMemory converting bitmap to bytes.");
oomCount++;
if (oomCount <= MAX_OOM_COUNT) {
Factory.get().reclaimMemory();
} else {
done = true;
LogUtil.w(TAG, "Failed to convert bitmap to bytes. Out of Memory.");
}
throw e;
}
}
return imageBytes;
}
/**
* Given the source bitmap and a canvas, draws the bitmap through a circular
* mask. Only draws a circle with diameter equal to the destination width.
*
* @param bitmap The source bitmap to draw.
* @param canvas The canvas to draw it on.
* @param source The source bound of the bitmap.
* @param dest The destination bound on the canvas.
* @param bitmapPaint Optional Paint object for the bitmap
* @param fillBackground when set, fill the circle with backgroundColor
* @param strokeColor draw a border outside the circle with strokeColor
*/
public static void drawBitmapWithCircleOnCanvas(final Bitmap bitmap, final Canvas canvas,
final RectF source, final RectF dest, @Nullable Paint bitmapPaint,
final boolean fillBackground, final int backgroundColor, int strokeColor) {
// Draw bitmap through shader first.
final BitmapShader shader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP);
final Matrix matrix = new Matrix();
// Fit bitmap to bounds.
matrix.setRectToRect(source, dest, Matrix.ScaleToFit.CENTER);
shader.setLocalMatrix(matrix);
if (bitmapPaint == null) {
bitmapPaint = new Paint();
}
bitmapPaint.setAntiAlias(true);
if (fillBackground) {
bitmapPaint.setColor(backgroundColor);
canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
}
bitmapPaint.setShader(shader);
canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
bitmapPaint.setShader(null);
if (strokeColor != 0) {
final Paint stroke = new Paint();
stroke.setAntiAlias(true);
stroke.setColor(strokeColor);
stroke.setStyle(Paint.Style.STROKE);
final float strokeWidth = 6f;
stroke.setStrokeWidth(strokeWidth);
canvas.drawCircle(dest.centerX(),
dest.centerX(),
dest.width() / 2f - stroke.getStrokeWidth() / 2f,
stroke);
}
}
/**
* Sets a drawable to the background of a view. setBackgroundDrawable() is deprecated since
* JB and replaced by setBackground().
*/
@SuppressWarnings("deprecation")
public static void setBackgroundDrawableOnView(final View view, final Drawable drawable) {
if (OsUtil.isAtLeastJB()) {
view.setBackground(drawable);
} else {
view.setBackgroundDrawable(drawable);
}
}
/**
* Based on the input bitmap bounds given by BitmapFactory.Options, compute the required
* sub-sampling size for loading a scaled down version of the bitmap to the required size
* @param options a BitmapFactory.Options instance containing the bounds info of the bitmap
* @param reqWidth the desired width of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE.
* @param reqHeight the desired height of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE.
* @return
*/
public int calculateInSampleSize(
final BitmapFactory.Options options, final int reqWidth, final int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
final boolean checkHeight = reqHeight != ImageRequest.UNSPECIFIED_SIZE;
final boolean checkWidth = reqWidth != ImageRequest.UNSPECIFIED_SIZE;
if ((checkHeight && height > reqHeight) ||
(checkWidth && width > reqWidth)) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while ((!checkHeight || (halfHeight / inSampleSize) > reqHeight)
&& (!checkWidth || (halfWidth / inSampleSize) > reqWidth)) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
private static final String[] MEDIA_CONTENT_PROJECTION = new String[] {
MediaStore.MediaColumns.MIME_TYPE
};
private static final int INDEX_CONTENT_TYPE = 0;
@DoesNotRunOnMainThread
public static String getContentType(final ContentResolver cr, final Uri uri) {
// Figure out the content type of media.
String contentType = null;
Cursor cursor = null;
if (UriUtil.isMediaStoreUri(uri)) {
try {
cursor = cr.query(uri, MEDIA_CONTENT_PROJECTION, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
contentType = cursor.getString(INDEX_CONTENT_TYPE);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
}
if (contentType == null) {
// Last ditch effort to get the content type. Look at the file extension.
contentType = ContentType.getContentTypeFromExtension(uri.toString(),
ContentType.IMAGE_UNSPECIFIED);
}
return contentType;
}
/**
* @param context Android context
* @param uri Uri to the image data
* @return The exif orientation value for the image in the specified uri
*/
public static int getOrientation(final Context context, final Uri uri) {
try {
return getOrientation(context.getContentResolver().openInputStream(uri));
} catch (FileNotFoundException e) {
LogUtil.e(TAG, "getOrientation couldn't open: " + uri, e);
}
return android.media.ExifInterface.ORIENTATION_UNDEFINED;
}
/**
* @param inputStream The stream to the image file. Closed on completion
* @return The exif orientation value for the image in the specified stream
*/
public static int getOrientation(final InputStream inputStream) {
int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED;
if (inputStream != null) {
try {
final ExifInterface exifInterface = new ExifInterface();
exifInterface.readExif(inputStream);
final Integer orientationValue =
exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION);
if (orientationValue != null) {
orientation = orientationValue.intValue();
}
} catch (IOException e) {
// If the image if GIF, PNG, or missing exif header, just use the defaults
} finally {
try {
if (inputStream != null) {
inputStream.close();
}
} catch (IOException e) {
LogUtil.e(TAG, "getOrientation error closing input stream", e);
}
}
}
return orientation;
}
/**
* Returns whether the resource is a GIF image.
*/
public static boolean isGif(String contentType, Uri contentUri) {
if (TextUtils.equals(contentType, ContentType.IMAGE_GIF)) {
return true;
}
if (ContentType.isImageType(contentType)) {
try {
ContentResolver contentResolver = Factory.get().getApplicationContext()
.getContentResolver();
InputStream inputStream = contentResolver.openInputStream(contentUri);
return ImageUtils.isGif(inputStream);
} catch (Exception e) {
LogUtil.w(TAG, "Could not open GIF input stream", e);
}
}
// Assume anything with a non-image content type is not a GIF
return false;
}
/**
* @param inputStream The stream to the image file. Closed on completion
* @return Whether the image stream represents a GIF
*/
public static boolean isGif(InputStream inputStream) {
if (inputStream != null) {
try {
byte[] gifHeaderBytes = new byte[6];
int value = inputStream.read(gifHeaderBytes, 0, 6);
if (value == 6) {
return Arrays.equals(gifHeaderBytes, GIF87_HEADER)
|| Arrays.equals(gifHeaderBytes, GIF89_HEADER);
}
} catch (IOException e) {
return false;
} finally {
try {
inputStream.close();
} catch (IOException e) {
// Ignore
}
}
}
return false;
}
/**
* Read an image and compress it to particular max dimensions and size.
* Used to ensure images can fit in an MMS.
* TODO: This uses memory very inefficiently as it processes the whole image as a unit
* (rather than slice by slice) but system JPEG functions do not support slicing and dicing.
*/
public static class ImageResizer {
/**
* The quality parameter which is used to compress JPEG images.
*/
private static final int IMAGE_COMPRESSION_QUALITY = 95;
/**
* The minimum quality parameter which is used to compress JPEG images.
*/
private static final int MINIMUM_IMAGE_COMPRESSION_QUALITY = 50;
/**
* Minimum factor to reduce quality value
*/
private static final double QUALITY_SCALE_DOWN_RATIO = 0.85f;
/**
* Maximum passes through the resize loop before failing permanently
*/
private static final int NUMBER_OF_RESIZE_ATTEMPTS = 6;
/**
* Amount to scale down the picture when it doesn't fit
*/
private static final float MIN_SCALE_DOWN_RATIO = 0.75f;
/**
* When computing sampleSize target scaling of no more than this ratio
*/
private static final float MAX_TARGET_SCALE_FACTOR = 1.5f;
// Current sample size for subsampling image during initial decode
private int mSampleSize;
// Current bitmap holding initial decoded source image
private Bitmap mDecoded;
// If scaling is needed this holds the scaled bitmap (else should equal mDecoded)
private Bitmap mScaled;
// Current JPEG compression quality to use when compressing image
private int mQuality;
// Current factor to scale down decoded image before compressing
private float mScaleFactor;
// Flag keeping track of whether cache memory has been reclaimed
private boolean mHasReclaimedMemory;
// Initial size of the image (typically provided but can be UNSPECIFIED_SIZE)
private int mWidth;
private int mHeight;
// Orientation params of image as read from EXIF data
private final ExifInterface.OrientationParams mOrientationParams;
// Matrix to undo orientation and scale at the same time
private final Matrix mMatrix;
// Size limit as provided by MMS library
private final int mWidthLimit;
private final int mHeightLimit;
private final int mByteLimit;
// Uri from which to read source image
private final Uri mUri;
// Application context
private final Context mContext;
// Cached value of bitmap factory options
private final BitmapFactory.Options mOptions;
private final String mContentType;
private final int mMemoryClass;
/**
* Return resized (compressed) image (else null)
*
* @param width The width of the image (if known)
* @param height The height of the image (if known)
* @param orientation The orientation of the image as an ExifInterface constant
* @param widthLimit The width limit, in pixels
* @param heightLimit The height limit, in pixels
* @param byteLimit The binary size limit, in bytes
* @param uri Uri to the image data
* @param context Needed to open the image
* @param contentType of image
* @return encoded image meeting size requirements else null
*/
public static byte[] getResizedImageData(final int width, final int height,
final int orientation, final int widthLimit, final int heightLimit,
final int byteLimit, final Uri uri, final Context context,
final String contentType) {
final ImageResizer resizer = new ImageResizer(width, height, orientation,
widthLimit, heightLimit, byteLimit, uri, context, contentType);
return resizer.resize();
}
/**
* Create and initialize an image resizer
*/
private ImageResizer(final int width, final int height, final int orientation,
final int widthLimit, final int heightLimit, final int byteLimit, final Uri uri,
final Context context, final String contentType) {
mWidth = width;
mHeight = height;
mOrientationParams = ExifInterface.getOrientationParams(orientation);
mMatrix = new Matrix();
mWidthLimit = widthLimit;
mHeightLimit = heightLimit;
mByteLimit = byteLimit;
mUri = uri;
mWidth = width;
mContext = context;
mQuality = IMAGE_COMPRESSION_QUALITY;
mScaleFactor = 1.0f;
mHasReclaimedMemory = false;
mOptions = new BitmapFactory.Options();
mOptions.inScaled = false;
mOptions.inDensity = 0;
mOptions.inTargetDensity = 0;
mOptions.inSampleSize = 1;
mOptions.inJustDecodeBounds = false;
mOptions.inMutable = false;
final ActivityManager am =
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
mMemoryClass = Math.max(16, am.getMemoryClass());
mContentType = contentType;
}
/**
* Try to compress the image
*
* @return encoded image meeting size requirements else null
*/
private byte[] resize() {
return ImageUtils.isGif(mContentType, mUri) ? resizeGifImage() : resizeStaticImage();
}
private byte[] resizeGifImage() {
byte[] bytesToReturn = null;
final String inputFilePath;
if (MediaScratchFileProvider.isMediaScratchSpaceUri(mUri)) {
inputFilePath = MediaScratchFileProvider.getFileFromUri(mUri).getAbsolutePath();
} else {
if (!UriUtil.isFileUri(mUri)) {
Assert.fail("Expected a GIF file uri, but actual uri = " + mUri.toString());
}
inputFilePath = mUri.getPath();
}
if (GifTranscoder.canBeTranscoded(mWidth, mHeight)) {
// Needed to perform the transcoding so that the gif can continue to play in the
// conversation while the sending is taking place
final Uri tmpUri = MediaScratchFileProvider.buildMediaScratchSpaceUri("gif");
final File outputFile = MediaScratchFileProvider.getFileFromUri(tmpUri);
final String outputFilePath = outputFile.getAbsolutePath();
final boolean success =
GifTranscoder.transcode(mContext, inputFilePath, outputFilePath);
if (success) {
try {
bytesToReturn = Files.toByteArray(outputFile);
} catch (IOException e) {
LogUtil.e(TAG, "Could not create FileInputStream with path of "
+ outputFilePath, e);
}
}
// Need to clean up the new file created to compress the gif
mContext.getContentResolver().delete(tmpUri, null, null);
} else {
// We don't want to transcode the gif because its image dimensions would be too
// small so just return the bytes of the original gif
try {
bytesToReturn = Files.toByteArray(new File(inputFilePath));
} catch (IOException e) {
LogUtil.e(TAG,
"Could not create FileInputStream with path of " + inputFilePath, e);
}
}
return bytesToReturn;
}
private byte[] resizeStaticImage() {
if (!ensureImageSizeSet()) {
// Cannot read image size
return null;
}
// Find incoming image size
if (!canBeCompressed()) {
return null;
}
// Decode image - if out of memory - reclaim memory and retry
try {
for (int attempts = 0; attempts < NUMBER_OF_RESIZE_ATTEMPTS; attempts++) {
final byte[] encoded = recodeImage(attempts);
// Only return data within the limit
if (encoded != null && encoded.length <= mByteLimit) {
return encoded;
} else {
final int currentSize = (encoded == null ? 0 : encoded.length);
updateRecodeParameters(currentSize);
}
}
} catch (final FileNotFoundException e) {
LogUtil.e(TAG, "File disappeared during resizing");
} finally {
// Release all bitmaps
if (mScaled != null && mScaled != mDecoded) {
mScaled.recycle();
}
if (mDecoded != null) {
mDecoded.recycle();
}
}
return null;
}
/**
* Ensure that the width and height of the source image are known
* @return flag indicating whether size is known
*/
private boolean ensureImageSizeSet() {
if (mWidth == MessagingContentProvider.UNSPECIFIED_SIZE ||
mHeight == MessagingContentProvider.UNSPECIFIED_SIZE) {
// First get the image data (compressed)
final ContentResolver cr = mContext.getContentResolver();
InputStream inputStream = null;
// Find incoming image size
try {
mOptions.inJustDecodeBounds = true;
inputStream = cr.openInputStream(mUri);
BitmapFactory.decodeStream(inputStream, null, mOptions);
mWidth = mOptions.outWidth;
mHeight = mOptions.outHeight;
mOptions.inJustDecodeBounds = false;
return true;
} catch (final FileNotFoundException e) {
LogUtil.e(TAG, "Could not open file corresponding to uri " + mUri, e);
} catch (final NullPointerException e) {
LogUtil.e(TAG, "NPE trying to open the uri " + mUri, e);
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (final IOException e) {
// Nothing to do
}
}
}
return false;
}
return true;
}
/**
* Choose an initial subsamplesize that ensures the decoded image is no more than
* MAX_TARGET_SCALE_FACTOR bigger than largest supported image and that it is likely to
* compress to smaller than the target size (assuming compression down to 1 bit per pixel).
* @return whether the image can be down subsampled
*/
private boolean canBeCompressed() {
final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
int imageHeight = mHeight;
int imageWidth = mWidth;
// Assume can use half working memory to decode the initial image (4 bytes per pixel)
final int workingMemoryPixelLimit = (mMemoryClass * 1024 * 1024 / 8);
// Target 1 bits per pixel in final compressed image
final int finalSizePixelLimit = mByteLimit * 8;
// When choosing to halve the resolution - only do so the image will still be too big
// after scaling by MAX_TARGET_SCALE_FACTOR
final int heightLimitWithSlop = (int) (mHeightLimit * MAX_TARGET_SCALE_FACTOR);
final int widthLimitWithSlop = (int) (mWidthLimit * MAX_TARGET_SCALE_FACTOR);
final int pixelLimitWithSlop = (int) (finalSizePixelLimit *
MAX_TARGET_SCALE_FACTOR * MAX_TARGET_SCALE_FACTOR);
final int pixelLimit = Math.min(pixelLimitWithSlop, workingMemoryPixelLimit);
int sampleSize = 1;
boolean fits = (imageHeight < heightLimitWithSlop &&
imageWidth < widthLimitWithSlop &&
imageHeight * imageWidth < pixelLimit);
// Compare sizes to compute sub-sampling needed
while (!fits) {
sampleSize = sampleSize * 2;
// Note that recodeImage may try using mSampleSize * 2. Hence we use the factor of 4
if (sampleSize >= (Integer.MAX_VALUE / 4)) {
LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, String.format(
"Cannot resize image: widthLimit=%d heightLimit=%d byteLimit=%d " +
"imageWidth=%d imageHeight=%d", mWidthLimit, mHeightLimit, mByteLimit,
mWidth, mHeight));
Assert.fail("Image cannot be resized"); // http://b/18926934
return false;
}
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"computeInitialSampleSize: Increasing sampleSize to " + sampleSize
+ " as h=" + imageHeight + " vs " + heightLimitWithSlop
+ " w=" + imageWidth + " vs " + widthLimitWithSlop
+ " p=" + imageHeight * imageWidth + " vs " + pixelLimit);
}
imageHeight = mHeight / sampleSize;
imageWidth = mWidth / sampleSize;
fits = (imageHeight < heightLimitWithSlop &&
imageWidth < widthLimitWithSlop &&
imageHeight * imageWidth < pixelLimit);
}
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"computeInitialSampleSize: Initial sampleSize " + sampleSize
+ " for h=" + imageHeight + " vs " + heightLimitWithSlop
+ " w=" + imageWidth + " vs " + widthLimitWithSlop
+ " p=" + imageHeight * imageWidth + " vs " + pixelLimit);
}
mSampleSize = sampleSize;
return true;
}
/**
* Recode the image from initial Uri to encoded JPEG
* @param attempt Attempt number
* @return encoded image
*/
private byte[] recodeImage(final int attempt) throws FileNotFoundException {
byte[] encoded = null;
try {
final ContentResolver cr = mContext.getContentResolver();
final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: attempt=" + attempt
+ " limit (w=" + mWidthLimit + " h=" + mHeightLimit + ") quality="
+ mQuality + " scale=" + mScaleFactor + " sampleSize=" + mSampleSize);
}
if (mScaled == null) {
if (mDecoded == null) {
mOptions.inSampleSize = mSampleSize;
try (final InputStream inputStream = cr.openInputStream(mUri)) {
mDecoded = BitmapFactory.decodeStream(inputStream, null, mOptions);
} catch (IOException e) {
// Ignore
}
if (mDecoded == null) {
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: got empty decoded bitmap");
}
return null;
}
}
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: decoded w,h="
+ mDecoded.getWidth() + "," + mDecoded.getHeight());
}
// Make sure to scale the decoded image if dimension is not within limit
final int decodedWidth = mDecoded.getWidth();
final int decodedHeight = mDecoded.getHeight();
if (decodedWidth > mWidthLimit || decodedHeight > mHeightLimit) {
final float minScaleFactor = Math.max(
mWidthLimit == 0 ? 1.0f :
(float) decodedWidth / (float) mWidthLimit,
mHeightLimit == 0 ? 1.0f :
(float) decodedHeight / (float) mHeightLimit);
if (mScaleFactor < minScaleFactor) {
mScaleFactor = minScaleFactor;
}
}
if (mScaleFactor > 1.0 || mOrientationParams.rotation != 0) {
mMatrix.reset();
mMatrix.postRotate(mOrientationParams.rotation);
mMatrix.postScale(mOrientationParams.scaleX / mScaleFactor,
mOrientationParams.scaleY / mScaleFactor);
mScaled = Bitmap.createBitmap(mDecoded, 0, 0, decodedWidth, decodedHeight,
mMatrix, false /* filter */);
if (mScaled == null) {
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: got empty scaled bitmap");
}
return null;
}
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: scaled w,h="
+ mScaled.getWidth() + "," + mScaled.getHeight());
}
} else {
mScaled = mDecoded;
}
}
// Now encode it at current quality
encoded = ImageUtils.bitmapToBytes(mScaled, mQuality);
if (encoded != null && logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: Encoded down to " + encoded.length + "@"
+ mScaled.getWidth() + "/" + mScaled.getHeight() + "~"
+ mQuality);
}
} catch (final OutOfMemoryError e) {
LogUtil.w(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData - image too big (OutOfMemoryError), will try "
+ " with smaller scale factor");
// fall through and keep trying with more compression
}
return encoded;
}
/**
* When image recode fails this method updates compression parameters for the next attempt
* @param currentSize encoded image size (will be 0 if OOM)
*/
private void updateRecodeParameters(final int currentSize) {
final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
// Only return data within the limit
if (currentSize > 0 &&
mQuality > MINIMUM_IMAGE_COMPRESSION_QUALITY) {
// First if everything succeeded but failed to hit target size
// Try quality proportioned to sqrt of size over size limit
mQuality = Math.max(MINIMUM_IMAGE_COMPRESSION_QUALITY,
Math.min((int) (mQuality * Math.sqrt((1.0 * mByteLimit) / currentSize)),
(int) (mQuality * QUALITY_SCALE_DOWN_RATIO)));
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: Retrying at quality " + mQuality);
}
} else if (currentSize > 0 &&
mScaleFactor < 2.0 * MIN_SCALE_DOWN_RATIO * MIN_SCALE_DOWN_RATIO) {
// JPEG compression failed to hit target size - need smaller image
// First try scaling by a little (< factor of 2) just so long resulting scale down
// ratio is still significantly bigger than next subsampling step
// i.e. mScaleFactor/MIN_SCALE_DOWN_RATIO (new scaling factor) <
// 2.0 / MIN_SCALE_DOWN_RATIO (arbitrary limit)
mQuality = IMAGE_COMPRESSION_QUALITY;
mScaleFactor = mScaleFactor / MIN_SCALE_DOWN_RATIO;
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: Retrying at scale " + mScaleFactor);
}
// Release scaled bitmap to trigger rescaling
if (mScaled != null && mScaled != mDecoded) {
mScaled.recycle();
}
mScaled = null;
} else if (currentSize <= 0 && !mHasReclaimedMemory) {
// Then before we subsample try cleaning up our cached memory
Factory.get().reclaimMemory();
mHasReclaimedMemory = true;
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: Retrying after reclaiming memory ");
}
} else {
// Last resort - subsample image by another factor of 2 and try again
mSampleSize = mSampleSize * 2;
mQuality = IMAGE_COMPRESSION_QUALITY;
mScaleFactor = 1.0f;
if (logv) {
LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
"getResizedImageData: Retrying at sampleSize " + mSampleSize);
}
// Release all bitmaps to trigger subsampling
if (mScaled != null && mScaled != mDecoded) {
mScaled.recycle();
}
mScaled = null;
if (mDecoded != null) {
mDecoded.recycle();
mDecoded = null;
}
}
}
}
/**
* Scales and center-crops a bitmap to the size passed in and returns the new bitmap.
*
* @param source Bitmap to scale and center-crop
* @param newWidth destination width
* @param newHeight destination height
* @return Bitmap scaled and center-cropped bitmap
*/
public static Bitmap scaleCenterCrop(final Bitmap source, final int newWidth,
final int newHeight) {
final int sourceWidth = source.getWidth();
final int sourceHeight = source.getHeight();
// Compute the scaling factors to fit the new height and width, respectively.
// To cover the final image, the final scaling will be the bigger
// of these two.
final float xScale = (float) newWidth / sourceWidth;
final float yScale = (float) newHeight / sourceHeight;
final float scale = Math.max(xScale, yScale);
// Now get the size of the source bitmap when scaled
final float scaledWidth = scale * sourceWidth;
final float scaledHeight = scale * sourceHeight;
// Let's find out the upper left coordinates if the scaled bitmap
// should be centered in the new size give by the parameters
final float left = (newWidth - scaledWidth) / 2;
final float top = (newHeight - scaledHeight) / 2;
// The target rectangle for the new, scaled version of the source bitmap will now
// be
final RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
// Finally, we create a new bitmap of the specified size and draw our new,
// scaled bitmap onto it.
final Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, source.getConfig());
final Canvas canvas = new Canvas(dest);
canvas.drawBitmap(source, null, targetRect, null);
return dest;
}
/**
* The drawable can be a Nine-Patch. If we directly use the same drawable instance for each
* drawable of different sizes, then the drawable sizes would interfere with each other. The
* solution here is to create a new drawable instance for every time with the SAME
* ConstantState (i.e. sharing the same common state such as the bitmap, so that we don't have
* to recreate the bitmap resource), and apply the different properties on top (nine-patch
* size and color tint).
*
* TODO: we are creating new drawable instances here, but there are optimizations that
* can be made. For example, message bubbles shouldn't need the mutate() call and the
* play/pause buttons shouldn't need to create new drawable from the constant state.
*/
public static Drawable getTintedDrawable(final Context context, final Drawable drawable,
final int color) {
// For some reason occassionally drawables on JB has a null constant state
final Drawable.ConstantState constantStateDrawable = drawable.getConstantState();
final Drawable retDrawable = (constantStateDrawable != null)
? constantStateDrawable.newDrawable(context.getResources()).mutate()
: drawable;
retDrawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
return retDrawable;
}
/**
* Decodes image resource header and returns the image size.
*/
public static Rect decodeImageBounds(final Context context, final Uri imageUri) {
final ContentResolver cr = context.getContentResolver();
try {
final InputStream inputStream = cr.openInputStream(imageUri);
if (inputStream != null) {
try {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(inputStream, null, options);
return new Rect(0, 0, options.outWidth, options.outHeight);
} finally {
try {
inputStream.close();
} catch (IOException e) {
// Do nothing.
}
}
}
} catch (FileNotFoundException e) {
LogUtil.e(TAG, "Couldn't open input stream for uri = " + imageUri);
}
return new Rect(0, 0, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE);
}
}