diff options
| author | Mike Dodd <mdodd@google.com> | 2015-08-11 11:16:59 -0700 |
|---|---|---|
| committer | Mike Dodd <mdodd@google.com> | 2015-08-12 08:58:28 -0700 |
| commit | 461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch) | |
| tree | bc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/util/ImageUtils.java | |
| parent | 8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (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.java | 908 |
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); + } +} |
