/* * 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 (!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; 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); } }