summaryrefslogtreecommitdiff
path: root/src/com/android/messaging/util/ImageUtils.java
diff options
context:
space:
mode:
authorMike Dodd <mdodd@google.com>2015-08-11 11:16:59 -0700
committerMike Dodd <mdodd@google.com>2015-08-12 08:58:28 -0700
commit461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch)
treebc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/util/ImageUtils.java
parent8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (diff)
Initial checkin of AOSP Messaging app.
b/23110861 Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
Diffstat (limited to 'src/com/android/messaging/util/ImageUtils.java')
-rw-r--r--src/com/android/messaging/util/ImageUtils.java908
1 files changed, 908 insertions, 0 deletions
diff --git a/src/com/android/messaging/util/ImageUtils.java b/src/com/android/messaging/util/ImageUtils.java
new file mode 100644
index 0000000..05d3678
--- /dev/null
+++ b/src/com/android/messaging/util/ImageUtils.java
@@ -0,0 +1,908 @@
+/*
+ * 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 android.support.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 (!TextUtils.equals(mUri.getScheme(), ContentResolver.SCHEME_FILE)) {
+ 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;
+ final InputStream inputStream = cr.openInputStream(mUri);
+ mDecoded = BitmapFactory.decodeStream(inputStream, null, mOptions);
+ 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);
+ }
+}