1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.messaging.util;
17 
18 import android.app.ActivityManager;
19 import android.content.ContentResolver;
20 import android.content.Context;
21 import android.database.Cursor;
22 import android.graphics.Bitmap;
23 import android.graphics.BitmapFactory;
24 import android.graphics.BitmapShader;
25 import android.graphics.Canvas;
26 import android.graphics.Matrix;
27 import android.graphics.Paint;
28 import android.graphics.PorterDuff;
29 import android.graphics.Rect;
30 import android.graphics.RectF;
31 import android.graphics.Shader.TileMode;
32 import android.graphics.drawable.Drawable;
33 import android.net.Uri;
34 import android.provider.MediaStore;
35 import androidx.annotation.Nullable;
36 import android.text.TextUtils;
37 import android.view.View;
38 
39 import com.android.messaging.Factory;
40 import com.android.messaging.datamodel.MediaScratchFileProvider;
41 import com.android.messaging.datamodel.MessagingContentProvider;
42 import com.android.messaging.datamodel.media.ImageRequest;
43 import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
44 import com.android.messaging.util.exif.ExifInterface;
45 import com.google.common.annotations.VisibleForTesting;
46 import com.google.common.io.Files;
47 
48 import java.io.ByteArrayOutputStream;
49 import java.io.File;
50 import java.io.FileNotFoundException;
51 import java.io.IOException;
52 import java.io.InputStream;
53 import java.nio.charset.Charset;
54 import java.util.Arrays;
55 
56 public class ImageUtils {
57     private static final String TAG = LogUtil.BUGLE_TAG;
58     private static final int MAX_OOM_COUNT = 1;
59     private static final byte[] GIF87_HEADER = "GIF87a".getBytes(Charset.forName("US-ASCII"));
60     private static final byte[] GIF89_HEADER = "GIF89a".getBytes(Charset.forName("US-ASCII"));
61 
62     // Used for drawBitmapWithCircleOnCanvas.
63     // Default color is transparent for both circle background and stroke.
64     public static final int DEFAULT_CIRCLE_BACKGROUND_COLOR = 0;
65     public static final int DEFAULT_CIRCLE_STROKE_COLOR = 0;
66 
67     private static volatile ImageUtils sInstance;
68 
get()69     public static ImageUtils get() {
70         if (sInstance == null) {
71             synchronized (ImageUtils.class) {
72                 if (sInstance == null) {
73                     sInstance = new ImageUtils();
74                 }
75             }
76         }
77         return sInstance;
78     }
79 
80     @VisibleForTesting
set(final ImageUtils imageUtils)81     public static void set(final ImageUtils imageUtils) {
82         sInstance = imageUtils;
83     }
84 
85     /**
86      * Transforms a bitmap into a byte array.
87      *
88      * @param quality Value between 0 and 100 that the compressor uses to discern what quality the
89      *                resulting bytes should be
90      * @param bitmap Bitmap to convert into bytes
91      * @return byte array of bitmap
92      */
bitmapToBytes(final Bitmap bitmap, final int quality)93     public static byte[] bitmapToBytes(final Bitmap bitmap, final int quality)
94             throws OutOfMemoryError {
95         boolean done = false;
96         int oomCount = 0;
97         byte[] imageBytes = null;
98         while (!done) {
99             try {
100                 final ByteArrayOutputStream os = new ByteArrayOutputStream();
101                 bitmap.compress(Bitmap.CompressFormat.JPEG, quality, os);
102                 imageBytes = os.toByteArray();
103                 done = true;
104             } catch (final OutOfMemoryError e) {
105                 LogUtil.w(TAG, "OutOfMemory converting bitmap to bytes.");
106                 oomCount++;
107                 if (oomCount <= MAX_OOM_COUNT) {
108                     Factory.get().reclaimMemory();
109                 } else {
110                     done = true;
111                     LogUtil.w(TAG, "Failed to convert bitmap to bytes. Out of Memory.");
112                 }
113                 throw e;
114             }
115         }
116         return imageBytes;
117     }
118 
119     /**
120      * Given the source bitmap and a canvas, draws the bitmap through a circular
121      * mask. Only draws a circle with diameter equal to the destination width.
122      *
123      * @param bitmap The source bitmap to draw.
124      * @param canvas The canvas to draw it on.
125      * @param source The source bound of the bitmap.
126      * @param dest The destination bound on the canvas.
127      * @param bitmapPaint Optional Paint object for the bitmap
128      * @param fillBackground when set, fill the circle with backgroundColor
129      * @param strokeColor draw a border outside the circle with strokeColor
130      */
drawBitmapWithCircleOnCanvas(final Bitmap bitmap, final Canvas canvas, final RectF source, final RectF dest, @Nullable Paint bitmapPaint, final boolean fillBackground, final int backgroundColor, int strokeColor)131     public static void drawBitmapWithCircleOnCanvas(final Bitmap bitmap, final Canvas canvas,
132             final RectF source, final RectF dest, @Nullable Paint bitmapPaint,
133             final boolean fillBackground, final int backgroundColor, int strokeColor) {
134         // Draw bitmap through shader first.
135         final BitmapShader shader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP);
136         final Matrix matrix = new Matrix();
137 
138         // Fit bitmap to bounds.
139         matrix.setRectToRect(source, dest, Matrix.ScaleToFit.CENTER);
140 
141         shader.setLocalMatrix(matrix);
142 
143         if (bitmapPaint == null) {
144             bitmapPaint = new Paint();
145         }
146 
147         bitmapPaint.setAntiAlias(true);
148         if (fillBackground) {
149             bitmapPaint.setColor(backgroundColor);
150             canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
151         }
152 
153         bitmapPaint.setShader(shader);
154         canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint);
155         bitmapPaint.setShader(null);
156 
157         if (strokeColor != 0) {
158             final Paint stroke = new Paint();
159             stroke.setAntiAlias(true);
160             stroke.setColor(strokeColor);
161             stroke.setStyle(Paint.Style.STROKE);
162             final float strokeWidth = 6f;
163             stroke.setStrokeWidth(strokeWidth);
164             canvas.drawCircle(dest.centerX(),
165                     dest.centerX(),
166                     dest.width() / 2f - stroke.getStrokeWidth() / 2f,
167                     stroke);
168         }
169     }
170 
171     /**
172      * Sets a drawable to the background of a view. setBackgroundDrawable() is deprecated since
173      * JB and replaced by setBackground().
174      */
175     @SuppressWarnings("deprecation")
setBackgroundDrawableOnView(final View view, final Drawable drawable)176     public static void setBackgroundDrawableOnView(final View view, final Drawable drawable) {
177         if (OsUtil.isAtLeastJB()) {
178             view.setBackground(drawable);
179         } else {
180             view.setBackgroundDrawable(drawable);
181         }
182     }
183 
184     /**
185      * Based on the input bitmap bounds given by BitmapFactory.Options, compute the required
186      * sub-sampling size for loading a scaled down version of the bitmap to the required size
187      * @param options a BitmapFactory.Options instance containing the bounds info of the bitmap
188      * @param reqWidth the desired width of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE.
189      * @param reqHeight the desired height of the bitmap.  Can be ImageRequest.UNSPECIFIED_SIZE.
190      * @return
191      */
calculateInSampleSize( final BitmapFactory.Options options, final int reqWidth, final int reqHeight)192     public int calculateInSampleSize(
193             final BitmapFactory.Options options, final int reqWidth, final int reqHeight) {
194         // Raw height and width of image
195         final int height = options.outHeight;
196         final int width = options.outWidth;
197         int inSampleSize = 1;
198 
199         final boolean checkHeight = reqHeight != ImageRequest.UNSPECIFIED_SIZE;
200         final boolean checkWidth = reqWidth != ImageRequest.UNSPECIFIED_SIZE;
201         if ((checkHeight && height > reqHeight) ||
202                 (checkWidth && width > reqWidth)) {
203 
204             final int halfHeight = height / 2;
205             final int halfWidth = width / 2;
206 
207             // Calculate the largest inSampleSize value that is a power of 2 and keeps both
208             // height and width larger than the requested height and width.
209             while ((!checkHeight || (halfHeight / inSampleSize) > reqHeight)
210                     && (!checkWidth || (halfWidth / inSampleSize) > reqWidth)) {
211                 inSampleSize *= 2;
212             }
213         }
214 
215         return inSampleSize;
216     }
217 
218     private static final String[] MEDIA_CONTENT_PROJECTION = new String[] {
219         MediaStore.MediaColumns.MIME_TYPE
220     };
221 
222     private static final int INDEX_CONTENT_TYPE = 0;
223 
224     @DoesNotRunOnMainThread
getContentType(final ContentResolver cr, final Uri uri)225     public static String getContentType(final ContentResolver cr, final Uri uri) {
226         // Figure out the content type of media.
227         String contentType = null;
228         Cursor cursor = null;
229         if (UriUtil.isMediaStoreUri(uri)) {
230             try {
231                 cursor = cr.query(uri, MEDIA_CONTENT_PROJECTION, null, null, null);
232 
233                 if (cursor != null && cursor.moveToFirst()) {
234                     contentType = cursor.getString(INDEX_CONTENT_TYPE);
235                 }
236             } finally {
237                 if (cursor != null) {
238                     cursor.close();
239                 }
240             }
241         }
242         if (contentType == null) {
243             // Last ditch effort to get the content type. Look at the file extension.
244             contentType = ContentType.getContentTypeFromExtension(uri.toString(),
245                     ContentType.IMAGE_UNSPECIFIED);
246         }
247         return contentType;
248     }
249 
250     /**
251      * @param context Android context
252      * @param uri Uri to the image data
253      * @return The exif orientation value for the image in the specified uri
254      */
getOrientation(final Context context, final Uri uri)255     public static int getOrientation(final Context context, final Uri uri) {
256         try {
257             return getOrientation(context.getContentResolver().openInputStream(uri));
258         } catch (FileNotFoundException e) {
259             LogUtil.e(TAG, "getOrientation couldn't open: " + uri, e);
260         }
261         return android.media.ExifInterface.ORIENTATION_UNDEFINED;
262     }
263 
264     /**
265      * @param inputStream The stream to the image file.  Closed on completion
266      * @return The exif orientation value for the image in the specified stream
267      */
getOrientation(final InputStream inputStream)268     public static int getOrientation(final InputStream inputStream) {
269         int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED;
270         if (inputStream != null) {
271             try {
272                 final ExifInterface exifInterface = new ExifInterface();
273                 exifInterface.readExif(inputStream);
274                 final Integer orientationValue =
275                         exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION);
276                 if (orientationValue != null) {
277                     orientation = orientationValue.intValue();
278                 }
279             } catch (IOException e) {
280                 // If the image if GIF, PNG, or missing exif header, just use the defaults
281             } finally {
282                 try {
283                     if (inputStream != null) {
284                         inputStream.close();
285                     }
286                 } catch (IOException e) {
287                     LogUtil.e(TAG, "getOrientation error closing input stream", e);
288                 }
289             }
290         }
291         return orientation;
292     }
293 
294     /**
295      * Returns whether the resource is a GIF image.
296      */
isGif(String contentType, Uri contentUri)297     public static boolean isGif(String contentType, Uri contentUri) {
298         if (TextUtils.equals(contentType, ContentType.IMAGE_GIF)) {
299             return true;
300         }
301         if (ContentType.isImageType(contentType)) {
302             try {
303                 ContentResolver contentResolver = Factory.get().getApplicationContext()
304                         .getContentResolver();
305                 InputStream inputStream = contentResolver.openInputStream(contentUri);
306                 return ImageUtils.isGif(inputStream);
307             } catch (Exception e) {
308                 LogUtil.w(TAG, "Could not open GIF input stream", e);
309             }
310         }
311         // Assume anything with a non-image content type is not a GIF
312         return false;
313     }
314 
315     /**
316      * @param inputStream The stream to the image file. Closed on completion
317      * @return Whether the image stream represents a GIF
318      */
isGif(InputStream inputStream)319     public static boolean isGif(InputStream inputStream) {
320         if (inputStream != null) {
321             try {
322                 byte[] gifHeaderBytes = new byte[6];
323                 int value = inputStream.read(gifHeaderBytes, 0, 6);
324                 if (value == 6) {
325                     return Arrays.equals(gifHeaderBytes, GIF87_HEADER)
326                             || Arrays.equals(gifHeaderBytes, GIF89_HEADER);
327                 }
328             } catch (IOException e) {
329                 return false;
330             } finally {
331                 try {
332                     inputStream.close();
333                 } catch (IOException e) {
334                     // Ignore
335                 }
336             }
337         }
338         return false;
339     }
340 
341     /**
342      * Read an image and compress it to particular max dimensions and size.
343      * Used to ensure images can fit in an MMS.
344      * TODO: This uses memory very inefficiently as it processes the whole image as a unit
345      *  (rather than slice by slice) but system JPEG functions do not support slicing and dicing.
346      */
347     public static class ImageResizer {
348 
349         /**
350          * The quality parameter which is used to compress JPEG images.
351          */
352         private static final int IMAGE_COMPRESSION_QUALITY = 95;
353         /**
354          * The minimum quality parameter which is used to compress JPEG images.
355          */
356         private static final int MINIMUM_IMAGE_COMPRESSION_QUALITY = 50;
357 
358         /**
359          * Minimum factor to reduce quality value
360          */
361         private static final double QUALITY_SCALE_DOWN_RATIO = 0.85f;
362 
363         /**
364          * Maximum passes through the resize loop before failing permanently
365          */
366         private static final int NUMBER_OF_RESIZE_ATTEMPTS = 6;
367 
368         /**
369          * Amount to scale down the picture when it doesn't fit
370          */
371         private static final float MIN_SCALE_DOWN_RATIO = 0.75f;
372 
373         /**
374          * When computing sampleSize target scaling of no more than this ratio
375          */
376         private static final float MAX_TARGET_SCALE_FACTOR = 1.5f;
377 
378 
379         // Current sample size for subsampling image during initial decode
380         private int mSampleSize;
381         // Current bitmap holding initial decoded source image
382         private Bitmap mDecoded;
383         // If scaling is needed this holds the scaled bitmap (else should equal mDecoded)
384         private Bitmap mScaled;
385         // Current JPEG compression quality to use when compressing image
386         private int mQuality;
387         // Current factor to scale down decoded image before compressing
388         private float mScaleFactor;
389         // Flag keeping track of whether cache memory has been reclaimed
390         private boolean mHasReclaimedMemory;
391 
392         // Initial size of the image (typically provided but can be UNSPECIFIED_SIZE)
393         private int mWidth;
394         private int mHeight;
395         // Orientation params of image as read from EXIF data
396         private final ExifInterface.OrientationParams mOrientationParams;
397         // Matrix to undo orientation and scale at the same time
398         private final Matrix mMatrix;
399         // Size limit as provided by MMS library
400         private final int mWidthLimit;
401         private final int mHeightLimit;
402         private final int mByteLimit;
403         //  Uri from which to read source image
404         private final Uri mUri;
405         // Application context
406         private final Context mContext;
407         // Cached value of bitmap factory options
408         private final BitmapFactory.Options mOptions;
409         private final String mContentType;
410 
411         private final int mMemoryClass;
412 
413         /**
414          * Return resized (compressed) image (else null)
415          *
416          * @param width The width of the image (if known)
417          * @param height The height of the image (if known)
418          * @param orientation The orientation of the image as an ExifInterface constant
419          * @param widthLimit The width limit, in pixels
420          * @param heightLimit The height limit, in pixels
421          * @param byteLimit The binary size limit, in bytes
422          * @param uri Uri to the image data
423          * @param context Needed to open the image
424          * @param contentType of image
425          * @return encoded image meeting size requirements else null
426          */
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)427         public static byte[] getResizedImageData(final int width, final int height,
428                 final int orientation, final int widthLimit, final int heightLimit,
429                 final int byteLimit, final Uri uri, final Context context,
430                 final String contentType) {
431             final ImageResizer resizer = new ImageResizer(width, height, orientation,
432                     widthLimit, heightLimit, byteLimit, uri, context, contentType);
433             return resizer.resize();
434         }
435 
436         /**
437          * Create and initialize an image resizer
438          */
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)439         private ImageResizer(final int width, final int height, final int orientation,
440                 final int widthLimit, final int heightLimit, final int byteLimit, final Uri uri,
441                 final Context context, final String contentType) {
442             mWidth = width;
443             mHeight = height;
444             mOrientationParams = ExifInterface.getOrientationParams(orientation);
445             mMatrix = new Matrix();
446             mWidthLimit = widthLimit;
447             mHeightLimit = heightLimit;
448             mByteLimit = byteLimit;
449             mUri = uri;
450             mWidth = width;
451             mContext = context;
452             mQuality = IMAGE_COMPRESSION_QUALITY;
453             mScaleFactor = 1.0f;
454             mHasReclaimedMemory = false;
455             mOptions = new BitmapFactory.Options();
456             mOptions.inScaled = false;
457             mOptions.inDensity = 0;
458             mOptions.inTargetDensity = 0;
459             mOptions.inSampleSize = 1;
460             mOptions.inJustDecodeBounds = false;
461             mOptions.inMutable = false;
462             final ActivityManager am =
463                     (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
464             mMemoryClass = Math.max(16, am.getMemoryClass());
465             mContentType = contentType;
466         }
467 
468         /**
469          * Try to compress the image
470          *
471          * @return encoded image meeting size requirements else null
472          */
resize()473         private byte[] resize() {
474             return ImageUtils.isGif(mContentType, mUri) ? resizeGifImage() : resizeStaticImage();
475         }
476 
resizeGifImage()477         private byte[] resizeGifImage() {
478             byte[] bytesToReturn = null;
479             final String inputFilePath;
480             if (MediaScratchFileProvider.isMediaScratchSpaceUri(mUri)) {
481                 inputFilePath = MediaScratchFileProvider.getFileFromUri(mUri).getAbsolutePath();
482             } else {
483                 if (!TextUtils.equals(mUri.getScheme(), ContentResolver.SCHEME_FILE)) {
484                     Assert.fail("Expected a GIF file uri, but actual uri = " + mUri.toString());
485                 }
486                 inputFilePath = mUri.getPath();
487             }
488 
489             if (GifTranscoder.canBeTranscoded(mWidth, mHeight)) {
490                 // Needed to perform the transcoding so that the gif can continue to play in the
491                 // conversation while the sending is taking place
492                 final Uri tmpUri = MediaScratchFileProvider.buildMediaScratchSpaceUri("gif");
493                 final File outputFile = MediaScratchFileProvider.getFileFromUri(tmpUri);
494                 final String outputFilePath = outputFile.getAbsolutePath();
495 
496                 final boolean success =
497                         GifTranscoder.transcode(mContext, inputFilePath, outputFilePath);
498                 if (success) {
499                     try {
500                         bytesToReturn = Files.toByteArray(outputFile);
501                     } catch (IOException e) {
502                         LogUtil.e(TAG, "Could not create FileInputStream with path of "
503                                 + outputFilePath, e);
504                     }
505                 }
506 
507                 // Need to clean up the new file created to compress the gif
508                 mContext.getContentResolver().delete(tmpUri, null, null);
509             } else {
510                 // We don't want to transcode the gif because its image dimensions would be too
511                 // small so just return the bytes of the original gif
512                 try {
513                     bytesToReturn = Files.toByteArray(new File(inputFilePath));
514                 } catch (IOException e) {
515                     LogUtil.e(TAG,
516                             "Could not create FileInputStream with path of " + inputFilePath, e);
517                 }
518             }
519 
520             return bytesToReturn;
521         }
522 
resizeStaticImage()523         private byte[] resizeStaticImage() {
524             if (!ensureImageSizeSet()) {
525                 // Cannot read image size
526                 return null;
527             }
528             // Find incoming image size
529             if (!canBeCompressed()) {
530                 return null;
531             }
532 
533             //  Decode image - if out of memory - reclaim memory and retry
534             try {
535                 for (int attempts = 0; attempts < NUMBER_OF_RESIZE_ATTEMPTS; attempts++) {
536                     final byte[] encoded = recodeImage(attempts);
537 
538                     // Only return data within the limit
539                     if (encoded != null && encoded.length <= mByteLimit) {
540                         return encoded;
541                     } else {
542                         final int currentSize = (encoded == null ? 0 : encoded.length);
543                         updateRecodeParameters(currentSize);
544                     }
545                 }
546             } catch (final FileNotFoundException e) {
547                 LogUtil.e(TAG, "File disappeared during resizing");
548             } finally {
549                 // Release all bitmaps
550                 if (mScaled != null && mScaled != mDecoded) {
551                     mScaled.recycle();
552                 }
553                 if (mDecoded != null) {
554                     mDecoded.recycle();
555                 }
556             }
557             return null;
558         }
559 
560         /**
561          * Ensure that the width and height of the source image are known
562          * @return flag indicating whether size is known
563          */
ensureImageSizeSet()564         private boolean ensureImageSizeSet() {
565             if (mWidth == MessagingContentProvider.UNSPECIFIED_SIZE ||
566                     mHeight == MessagingContentProvider.UNSPECIFIED_SIZE) {
567                 // First get the image data (compressed)
568                 final ContentResolver cr = mContext.getContentResolver();
569                 InputStream inputStream = null;
570                 // Find incoming image size
571                 try {
572                     mOptions.inJustDecodeBounds = true;
573                     inputStream = cr.openInputStream(mUri);
574                     BitmapFactory.decodeStream(inputStream, null, mOptions);
575 
576                     mWidth = mOptions.outWidth;
577                     mHeight = mOptions.outHeight;
578                     mOptions.inJustDecodeBounds = false;
579 
580                     return true;
581                 } catch (final FileNotFoundException e) {
582                     LogUtil.e(TAG, "Could not open file corresponding to uri " + mUri, e);
583                 } catch (final NullPointerException e) {
584                     LogUtil.e(TAG, "NPE trying to open the uri " + mUri, e);
585                 } finally {
586                     if (inputStream != null) {
587                         try {
588                             inputStream.close();
589                         } catch (final IOException e) {
590                             // Nothing to do
591                         }
592                     }
593                 }
594 
595                 return false;
596             }
597             return true;
598         }
599 
600         /**
601          * Choose an initial subsamplesize that ensures the decoded image is no more than
602          * MAX_TARGET_SCALE_FACTOR bigger than largest supported image and that it is likely to
603          * compress to smaller than the target size (assuming compression down to 1 bit per pixel).
604          * @return whether the image can be down subsampled
605          */
canBeCompressed()606         private boolean canBeCompressed() {
607             final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
608 
609             int imageHeight = mHeight;
610             int imageWidth = mWidth;
611 
612             // Assume can use half working memory to decode the initial image (4 bytes per pixel)
613             final int workingMemoryPixelLimit = (mMemoryClass * 1024 * 1024 / 8);
614             // Target 1 bits per pixel in final compressed image
615             final int finalSizePixelLimit = mByteLimit * 8;
616             // When choosing to halve the resolution - only do so the image will still be too big
617             // after scaling by MAX_TARGET_SCALE_FACTOR
618             final int heightLimitWithSlop = (int) (mHeightLimit * MAX_TARGET_SCALE_FACTOR);
619             final int widthLimitWithSlop = (int) (mWidthLimit * MAX_TARGET_SCALE_FACTOR);
620             final int pixelLimitWithSlop = (int) (finalSizePixelLimit *
621                     MAX_TARGET_SCALE_FACTOR * MAX_TARGET_SCALE_FACTOR);
622             final int pixelLimit = Math.min(pixelLimitWithSlop, workingMemoryPixelLimit);
623 
624             int sampleSize = 1;
625             boolean fits = (imageHeight < heightLimitWithSlop &&
626                     imageWidth < widthLimitWithSlop &&
627                     imageHeight * imageWidth < pixelLimit);
628 
629             // Compare sizes to compute sub-sampling needed
630             while (!fits) {
631                 sampleSize = sampleSize * 2;
632                 // Note that recodeImage may try using mSampleSize * 2. Hence we use the factor of 4
633                 if (sampleSize >= (Integer.MAX_VALUE / 4)) {
634                     LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, String.format(
635                             "Cannot resize image: widthLimit=%d heightLimit=%d byteLimit=%d " +
636                             "imageWidth=%d imageHeight=%d", mWidthLimit, mHeightLimit, mByteLimit,
637                             mWidth, mHeight));
638                     Assert.fail("Image cannot be resized"); // http://b/18926934
639                     return false;
640                 }
641                 if (logv) {
642                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
643                             "computeInitialSampleSize: Increasing sampleSize to " + sampleSize
644                             + " as h=" + imageHeight + " vs " + heightLimitWithSlop
645                             + " w=" + imageWidth  + " vs " +  widthLimitWithSlop
646                             + " p=" + imageHeight * imageWidth + " vs " + pixelLimit);
647                 }
648                 imageHeight = mHeight / sampleSize;
649                 imageWidth = mWidth / sampleSize;
650                 fits = (imageHeight < heightLimitWithSlop &&
651                         imageWidth < widthLimitWithSlop &&
652                         imageHeight * imageWidth < pixelLimit);
653             }
654 
655             if (logv) {
656                 LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
657                         "computeInitialSampleSize: Initial sampleSize " + sampleSize
658                         + " for h=" + imageHeight + " vs " + heightLimitWithSlop
659                         + " w=" + imageWidth  + " vs " +  widthLimitWithSlop
660                         + " p=" + imageHeight * imageWidth + " vs " + pixelLimit);
661             }
662 
663             mSampleSize = sampleSize;
664             return true;
665         }
666 
667         /**
668          * Recode the image from initial Uri to encoded JPEG
669          * @param attempt Attempt number
670          * @return encoded image
671          */
recodeImage(final int attempt)672         private byte[] recodeImage(final int attempt) throws FileNotFoundException {
673             byte[] encoded = null;
674             try {
675                 final ContentResolver cr = mContext.getContentResolver();
676                 final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
677                 if (logv) {
678                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: attempt=" + attempt
679                             + " limit (w=" + mWidthLimit + " h=" + mHeightLimit + ") quality="
680                             + mQuality + " scale=" + mScaleFactor + " sampleSize=" + mSampleSize);
681                 }
682                 if (mScaled == null) {
683                     if (mDecoded == null) {
684                         mOptions.inSampleSize = mSampleSize;
685                         try (final InputStream inputStream = cr.openInputStream(mUri)) {
686                             mDecoded = BitmapFactory.decodeStream(inputStream, null, mOptions);
687                         } catch (IOException e) {
688                             // Ignore
689                         }
690                         if (mDecoded == null) {
691                             if (logv) {
692                                 LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
693                                         "getResizedImageData: got empty decoded bitmap");
694                             }
695                             return null;
696                         }
697                     }
698                     if (logv) {
699                         LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: decoded w,h="
700                                 + mDecoded.getWidth() + "," + mDecoded.getHeight());
701                     }
702                     // Make sure to scale the decoded image if dimension is not within limit
703                     final int decodedWidth = mDecoded.getWidth();
704                     final int decodedHeight = mDecoded.getHeight();
705                     if (decodedWidth > mWidthLimit || decodedHeight > mHeightLimit) {
706                         final float minScaleFactor = Math.max(
707                                 mWidthLimit == 0 ? 1.0f :
708                                     (float) decodedWidth / (float) mWidthLimit,
709                                     mHeightLimit == 0 ? 1.0f :
710                                         (float) decodedHeight / (float) mHeightLimit);
711                         if (mScaleFactor < minScaleFactor) {
712                             mScaleFactor = minScaleFactor;
713                         }
714                     }
715                     if (mScaleFactor > 1.0 || mOrientationParams.rotation != 0) {
716                         mMatrix.reset();
717                         mMatrix.postRotate(mOrientationParams.rotation);
718                         mMatrix.postScale(mOrientationParams.scaleX / mScaleFactor,
719                                 mOrientationParams.scaleY / mScaleFactor);
720                         mScaled = Bitmap.createBitmap(mDecoded, 0, 0, decodedWidth, decodedHeight,
721                                 mMatrix, false /* filter */);
722                         if (mScaled == null) {
723                             if (logv) {
724                                 LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
725                                         "getResizedImageData: got empty scaled bitmap");
726                             }
727                             return null;
728                         }
729                         if (logv) {
730                             LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: scaled w,h="
731                                     + mScaled.getWidth() + "," + mScaled.getHeight());
732                         }
733                     } else {
734                         mScaled = mDecoded;
735                     }
736                 }
737                 // Now encode it at current quality
738                 encoded = ImageUtils.bitmapToBytes(mScaled, mQuality);
739                 if (encoded != null && logv) {
740                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
741                             "getResizedImageData: Encoded down to " + encoded.length + "@"
742                                     + mScaled.getWidth() + "/" + mScaled.getHeight() + "~"
743                                     + mQuality);
744                 }
745             } catch (final OutOfMemoryError e) {
746                 LogUtil.w(LogUtil.BUGLE_IMAGE_TAG,
747                         "getResizedImageData - image too big (OutOfMemoryError), will try "
748                                 + " with smaller scale factor");
749                 // fall through and keep trying with more compression
750             }
751             return encoded;
752         }
753 
754         /**
755          * When image recode fails this method updates compression parameters for the next attempt
756          * @param currentSize encoded image size (will be 0 if OOM)
757          */
updateRecodeParameters(final int currentSize)758         private void updateRecodeParameters(final int currentSize) {
759             final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
760             // Only return data within the limit
761             if (currentSize > 0 &&
762                     mQuality > MINIMUM_IMAGE_COMPRESSION_QUALITY) {
763                 // First if everything succeeded but failed to hit target size
764                 // Try quality proportioned to sqrt of size over size limit
765                 mQuality = Math.max(MINIMUM_IMAGE_COMPRESSION_QUALITY,
766                         Math.min((int) (mQuality * Math.sqrt((1.0 * mByteLimit) / currentSize)),
767                                 (int) (mQuality * QUALITY_SCALE_DOWN_RATIO)));
768                 if (logv) {
769                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
770                             "getResizedImageData: Retrying at quality " + mQuality);
771                 }
772             } else if (currentSize > 0 &&
773                     mScaleFactor < 2.0 * MIN_SCALE_DOWN_RATIO * MIN_SCALE_DOWN_RATIO) {
774                 // JPEG compression failed to hit target size - need smaller image
775                 // First try scaling by a little (< factor of 2) just so long resulting scale down
776                 // ratio is still significantly bigger than next subsampling step
777                 // i.e. mScaleFactor/MIN_SCALE_DOWN_RATIO (new scaling factor) <
778                 //       2.0 / MIN_SCALE_DOWN_RATIO (arbitrary limit)
779                 mQuality = IMAGE_COMPRESSION_QUALITY;
780                 mScaleFactor = mScaleFactor / MIN_SCALE_DOWN_RATIO;
781                 if (logv) {
782                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
783                             "getResizedImageData: Retrying at scale " + mScaleFactor);
784                 }
785                 // Release scaled bitmap to trigger rescaling
786                 if (mScaled != null && mScaled != mDecoded) {
787                     mScaled.recycle();
788                 }
789                 mScaled = null;
790             } else if (currentSize <= 0 && !mHasReclaimedMemory) {
791                 // Then before we subsample try cleaning up our cached memory
792                 Factory.get().reclaimMemory();
793                 mHasReclaimedMemory = true;
794                 if (logv) {
795                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
796                             "getResizedImageData: Retrying after reclaiming memory ");
797                 }
798             } else {
799                 // Last resort - subsample image by another factor of 2 and try again
800                 mSampleSize = mSampleSize * 2;
801                 mQuality = IMAGE_COMPRESSION_QUALITY;
802                 mScaleFactor = 1.0f;
803                 if (logv) {
804                     LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
805                             "getResizedImageData: Retrying at sampleSize " + mSampleSize);
806                 }
807                 // Release all bitmaps to trigger subsampling
808                 if (mScaled != null && mScaled != mDecoded) {
809                     mScaled.recycle();
810                 }
811                 mScaled = null;
812                 if (mDecoded != null) {
813                     mDecoded.recycle();
814                     mDecoded = null;
815                 }
816             }
817         }
818     }
819 
820     /**
821      * Scales and center-crops a bitmap to the size passed in and returns the new bitmap.
822      *
823      * @param source Bitmap to scale and center-crop
824      * @param newWidth destination width
825      * @param newHeight destination height
826      * @return Bitmap scaled and center-cropped bitmap
827      */
scaleCenterCrop(final Bitmap source, final int newWidth, final int newHeight)828     public static Bitmap scaleCenterCrop(final Bitmap source, final int newWidth,
829             final int newHeight) {
830         final int sourceWidth = source.getWidth();
831         final int sourceHeight = source.getHeight();
832 
833         // Compute the scaling factors to fit the new height and width, respectively.
834         // To cover the final image, the final scaling will be the bigger
835         // of these two.
836         final float xScale = (float) newWidth / sourceWidth;
837         final float yScale = (float) newHeight / sourceHeight;
838         final float scale = Math.max(xScale, yScale);
839 
840         // Now get the size of the source bitmap when scaled
841         final float scaledWidth = scale * sourceWidth;
842         final float scaledHeight = scale * sourceHeight;
843 
844         // Let's find out the upper left coordinates if the scaled bitmap
845         // should be centered in the new size give by the parameters
846         final float left = (newWidth - scaledWidth) / 2;
847         final float top = (newHeight - scaledHeight) / 2;
848 
849         // The target rectangle for the new, scaled version of the source bitmap will now
850         // be
851         final RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
852 
853         // Finally, we create a new bitmap of the specified size and draw our new,
854         // scaled bitmap onto it.
855         final Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, source.getConfig());
856         final Canvas canvas = new Canvas(dest);
857         canvas.drawBitmap(source, null, targetRect, null);
858 
859         return dest;
860     }
861 
862     /**
863      *  The drawable can be a Nine-Patch. If we directly use the same drawable instance for each
864      *  drawable of different sizes, then the drawable sizes would interfere with each other. The
865      *  solution here is to create a new drawable instance for every time with the SAME
866      *  ConstantState (i.e. sharing the same common state such as the bitmap, so that we don't have
867      *  to recreate the bitmap resource), and apply the different properties on top (nine-patch
868      *  size and color tint).
869      *
870      *  TODO: we are creating new drawable instances here, but there are optimizations that
871      *  can be made. For example, message bubbles shouldn't need the mutate() call and the
872      *  play/pause buttons shouldn't need to create new drawable from the constant state.
873      */
getTintedDrawable(final Context context, final Drawable drawable, final int color)874     public static Drawable getTintedDrawable(final Context context, final Drawable drawable,
875             final int color) {
876         // For some reason occassionally drawables on JB has a null constant state
877         final Drawable.ConstantState constantStateDrawable = drawable.getConstantState();
878         final Drawable retDrawable = (constantStateDrawable != null)
879                 ? constantStateDrawable.newDrawable(context.getResources()).mutate()
880                 : drawable;
881         retDrawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
882         return retDrawable;
883     }
884 
885     /**
886      * Decodes image resource header and returns the image size.
887      */
decodeImageBounds(final Context context, final Uri imageUri)888     public static Rect decodeImageBounds(final Context context, final Uri imageUri) {
889         final ContentResolver cr = context.getContentResolver();
890         try {
891             final InputStream inputStream = cr.openInputStream(imageUri);
892             if (inputStream != null) {
893                 try {
894                     BitmapFactory.Options options = new BitmapFactory.Options();
895                     options.inJustDecodeBounds = true;
896                     BitmapFactory.decodeStream(inputStream, null, options);
897                     return new Rect(0, 0, options.outWidth, options.outHeight);
898                 } finally {
899                     try {
900                         inputStream.close();
901                     } catch (IOException e) {
902                         // Do nothing.
903                     }
904                 }
905             }
906         } catch (FileNotFoundException e) {
907             LogUtil.e(TAG, "Couldn't open input stream for uri = " + imageUri);
908         }
909         return new Rect(0, 0, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE);
910     }
911 }
912