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 */
16package com.android.messaging.util;
17
18import android.app.ActivityManager;
19import android.content.ContentResolver;
20import android.content.Context;
21import android.database.Cursor;
22import android.graphics.Bitmap;
23import android.graphics.BitmapFactory;
24import android.graphics.BitmapShader;
25import android.graphics.Canvas;
26import android.graphics.Matrix;
27import android.graphics.Paint;
28import android.graphics.PorterDuff;
29import android.graphics.Rect;
30import android.graphics.RectF;
31import android.graphics.Shader.TileMode;
32import android.graphics.drawable.Drawable;
33import android.net.Uri;
34import android.provider.MediaStore;
35import android.support.annotation.Nullable;
36import android.text.TextUtils;
37import android.view.View;
38
39import com.android.messaging.Factory;
40import com.android.messaging.datamodel.MediaScratchFileProvider;
41import com.android.messaging.datamodel.MessagingContentProvider;
42import com.android.messaging.datamodel.media.ImageRequest;
43import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
44import com.android.messaging.util.exif.ExifInterface;
45import com.google.common.annotations.VisibleForTesting;
46import com.google.common.io.Files;
47
48import java.io.ByteArrayOutputStream;
49import java.io.File;
50import java.io.FileNotFoundException;
51import java.io.IOException;
52import java.io.InputStream;
53import java.nio.charset.Charset;
54import java.util.Arrays;
55
56public 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
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
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     */
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     */
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")
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     */
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
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     */
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     */
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     */
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     */
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         */
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         */
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         */
473        private byte[] resize() {
474            return ImageUtils.isGif(mContentType, mUri) ? resizeGifImage() : resizeStaticImage();
475        }
476
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
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         */
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         */
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         */
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                        final InputStream inputStream = cr.openInputStream(mUri);
686                        mDecoded = BitmapFactory.decodeStream(inputStream, null, mOptions);
687                        if (mDecoded == null) {
688                            if (logv) {
689                                LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
690                                        "getResizedImageData: got empty decoded bitmap");
691                            }
692                            return null;
693                        }
694                    }
695                    if (logv) {
696                        LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: decoded w,h="
697                                + mDecoded.getWidth() + "," + mDecoded.getHeight());
698                    }
699                    // Make sure to scale the decoded image if dimension is not within limit
700                    final int decodedWidth = mDecoded.getWidth();
701                    final int decodedHeight = mDecoded.getHeight();
702                    if (decodedWidth > mWidthLimit || decodedHeight > mHeightLimit) {
703                        final float minScaleFactor = Math.max(
704                                mWidthLimit == 0 ? 1.0f :
705                                    (float) decodedWidth / (float) mWidthLimit,
706                                    mHeightLimit == 0 ? 1.0f :
707                                        (float) decodedHeight / (float) mHeightLimit);
708                        if (mScaleFactor < minScaleFactor) {
709                            mScaleFactor = minScaleFactor;
710                        }
711                    }
712                    if (mScaleFactor > 1.0 || mOrientationParams.rotation != 0) {
713                        mMatrix.reset();
714                        mMatrix.postRotate(mOrientationParams.rotation);
715                        mMatrix.postScale(mOrientationParams.scaleX / mScaleFactor,
716                                mOrientationParams.scaleY / mScaleFactor);
717                        mScaled = Bitmap.createBitmap(mDecoded, 0, 0, decodedWidth, decodedHeight,
718                                mMatrix, false /* filter */);
719                        if (mScaled == null) {
720                            if (logv) {
721                                LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
722                                        "getResizedImageData: got empty scaled bitmap");
723                            }
724                            return null;
725                        }
726                        if (logv) {
727                            LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: scaled w,h="
728                                    + mScaled.getWidth() + "," + mScaled.getHeight());
729                        }
730                    } else {
731                        mScaled = mDecoded;
732                    }
733                }
734                // Now encode it at current quality
735                encoded = ImageUtils.bitmapToBytes(mScaled, mQuality);
736                if (encoded != null && logv) {
737                    LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
738                            "getResizedImageData: Encoded down to " + encoded.length + "@"
739                                    + mScaled.getWidth() + "/" + mScaled.getHeight() + "~"
740                                    + mQuality);
741                }
742            } catch (final OutOfMemoryError e) {
743                LogUtil.w(LogUtil.BUGLE_IMAGE_TAG,
744                        "getResizedImageData - image too big (OutOfMemoryError), will try "
745                                + " with smaller scale factor");
746                // fall through and keep trying with more compression
747            }
748            return encoded;
749        }
750
751        /**
752         * When image recode fails this method updates compression parameters for the next attempt
753         * @param currentSize encoded image size (will be 0 if OOM)
754         */
755        private void updateRecodeParameters(final int currentSize) {
756            final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE);
757            // Only return data within the limit
758            if (currentSize > 0 &&
759                    mQuality > MINIMUM_IMAGE_COMPRESSION_QUALITY) {
760                // First if everything succeeded but failed to hit target size
761                // Try quality proportioned to sqrt of size over size limit
762                mQuality = Math.max(MINIMUM_IMAGE_COMPRESSION_QUALITY,
763                        Math.min((int) (mQuality * Math.sqrt((1.0 * mByteLimit) / currentSize)),
764                                (int) (mQuality * QUALITY_SCALE_DOWN_RATIO)));
765                if (logv) {
766                    LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
767                            "getResizedImageData: Retrying at quality " + mQuality);
768                }
769            } else if (currentSize > 0 &&
770                    mScaleFactor < 2.0 * MIN_SCALE_DOWN_RATIO * MIN_SCALE_DOWN_RATIO) {
771                // JPEG compression failed to hit target size - need smaller image
772                // First try scaling by a little (< factor of 2) just so long resulting scale down
773                // ratio is still significantly bigger than next subsampling step
774                // i.e. mScaleFactor/MIN_SCALE_DOWN_RATIO (new scaling factor) <
775                //       2.0 / MIN_SCALE_DOWN_RATIO (arbitrary limit)
776                mQuality = IMAGE_COMPRESSION_QUALITY;
777                mScaleFactor = mScaleFactor / MIN_SCALE_DOWN_RATIO;
778                if (logv) {
779                    LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
780                            "getResizedImageData: Retrying at scale " + mScaleFactor);
781                }
782                // Release scaled bitmap to trigger rescaling
783                if (mScaled != null && mScaled != mDecoded) {
784                    mScaled.recycle();
785                }
786                mScaled = null;
787            } else if (currentSize <= 0 && !mHasReclaimedMemory) {
788                // Then before we subsample try cleaning up our cached memory
789                Factory.get().reclaimMemory();
790                mHasReclaimedMemory = true;
791                if (logv) {
792                    LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
793                            "getResizedImageData: Retrying after reclaiming memory ");
794                }
795            } else {
796                // Last resort - subsample image by another factor of 2 and try again
797                mSampleSize = mSampleSize * 2;
798                mQuality = IMAGE_COMPRESSION_QUALITY;
799                mScaleFactor = 1.0f;
800                if (logv) {
801                    LogUtil.v(LogUtil.BUGLE_IMAGE_TAG,
802                            "getResizedImageData: Retrying at sampleSize " + mSampleSize);
803                }
804                // Release all bitmaps to trigger subsampling
805                if (mScaled != null && mScaled != mDecoded) {
806                    mScaled.recycle();
807                }
808                mScaled = null;
809                if (mDecoded != null) {
810                    mDecoded.recycle();
811                    mDecoded = null;
812                }
813            }
814        }
815    }
816
817    /**
818     * Scales and center-crops a bitmap to the size passed in and returns the new bitmap.
819     *
820     * @param source Bitmap to scale and center-crop
821     * @param newWidth destination width
822     * @param newHeight destination height
823     * @return Bitmap scaled and center-cropped bitmap
824     */
825    public static Bitmap scaleCenterCrop(final Bitmap source, final int newWidth,
826            final int newHeight) {
827        final int sourceWidth = source.getWidth();
828        final int sourceHeight = source.getHeight();
829
830        // Compute the scaling factors to fit the new height and width, respectively.
831        // To cover the final image, the final scaling will be the bigger
832        // of these two.
833        final float xScale = (float) newWidth / sourceWidth;
834        final float yScale = (float) newHeight / sourceHeight;
835        final float scale = Math.max(xScale, yScale);
836
837        // Now get the size of the source bitmap when scaled
838        final float scaledWidth = scale * sourceWidth;
839        final float scaledHeight = scale * sourceHeight;
840
841        // Let's find out the upper left coordinates if the scaled bitmap
842        // should be centered in the new size give by the parameters
843        final float left = (newWidth - scaledWidth) / 2;
844        final float top = (newHeight - scaledHeight) / 2;
845
846        // The target rectangle for the new, scaled version of the source bitmap will now
847        // be
848        final RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
849
850        // Finally, we create a new bitmap of the specified size and draw our new,
851        // scaled bitmap onto it.
852        final Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, source.getConfig());
853        final Canvas canvas = new Canvas(dest);
854        canvas.drawBitmap(source, null, targetRect, null);
855
856        return dest;
857    }
858
859    /**
860     *  The drawable can be a Nine-Patch. If we directly use the same drawable instance for each
861     *  drawable of different sizes, then the drawable sizes would interfere with each other. The
862     *  solution here is to create a new drawable instance for every time with the SAME
863     *  ConstantState (i.e. sharing the same common state such as the bitmap, so that we don't have
864     *  to recreate the bitmap resource), and apply the different properties on top (nine-patch
865     *  size and color tint).
866     *
867     *  TODO: we are creating new drawable instances here, but there are optimizations that
868     *  can be made. For example, message bubbles shouldn't need the mutate() call and the
869     *  play/pause buttons shouldn't need to create new drawable from the constant state.
870     */
871    public static Drawable getTintedDrawable(final Context context, final Drawable drawable,
872            final int color) {
873        // For some reason occassionally drawables on JB has a null constant state
874        final Drawable.ConstantState constantStateDrawable = drawable.getConstantState();
875        final Drawable retDrawable = (constantStateDrawable != null)
876                ? constantStateDrawable.newDrawable(context.getResources()).mutate()
877                : drawable;
878        retDrawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
879        return retDrawable;
880    }
881
882    /**
883     * Decodes image resource header and returns the image size.
884     */
885    public static Rect decodeImageBounds(final Context context, final Uri imageUri) {
886        final ContentResolver cr = context.getContentResolver();
887        try {
888            final InputStream inputStream = cr.openInputStream(imageUri);
889            if (inputStream != null) {
890                try {
891                    BitmapFactory.Options options = new BitmapFactory.Options();
892                    options.inJustDecodeBounds = true;
893                    BitmapFactory.decodeStream(inputStream, null, options);
894                    return new Rect(0, 0, options.outWidth, options.outHeight);
895                } finally {
896                    try {
897                        inputStream.close();
898                    } catch (IOException e) {
899                        // Do nothing.
900                    }
901                }
902            }
903        } catch (FileNotFoundException e) {
904            LogUtil.e(TAG, "Couldn't open input stream for uri = " + imageUri);
905        }
906        return new Rect(0, 0, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE);
907    }
908}
909