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