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 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 */ updateRecodeParameters(final int currentSize)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 */ scaleCenterCrop(final Bitmap source, final int newWidth, final int newHeight)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 */ getTintedDrawable(final Context context, final Drawable drawable, final int color)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 */ decodeImageBounds(final Context context, final Uri imageUri)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