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