1 /* 2 * Copyright (C) 2009 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 17 package android.media; 18 19 import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION; 20 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT; 21 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH; 22 import static android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC; 23 import static android.os.Environment.MEDIA_UNKNOWN; 24 25 import android.annotation.NonNull; 26 import android.annotation.Nullable; 27 import android.compat.annotation.UnsupportedAppUsage; 28 import android.content.ContentResolver; 29 import android.graphics.Bitmap; 30 import android.graphics.BitmapFactory; 31 import android.graphics.Canvas; 32 import android.graphics.ImageDecoder; 33 import android.graphics.ImageDecoder.ImageInfo; 34 import android.graphics.ImageDecoder.Source; 35 import android.graphics.Matrix; 36 import android.graphics.Point; 37 import android.graphics.Rect; 38 import android.net.Uri; 39 import android.os.Build; 40 import android.os.CancellationSignal; 41 import android.os.Environment; 42 import android.os.ParcelFileDescriptor; 43 import android.provider.MediaStore.ThumbnailConstants; 44 import android.util.Log; 45 import android.util.Size; 46 47 import com.android.internal.util.ArrayUtils; 48 49 import libcore.io.IoUtils; 50 51 import java.io.File; 52 import java.io.IOException; 53 import java.util.Arrays; 54 import java.util.Comparator; 55 import java.util.Objects; 56 import java.util.function.ToIntFunction; 57 58 /** 59 * Utilities for generating visual thumbnails from files. 60 */ 61 public class ThumbnailUtils { 62 private static final String TAG = "ThumbnailUtils"; 63 64 /** @hide */ 65 @Deprecated 66 @UnsupportedAppUsage 67 public static final int TARGET_SIZE_MICRO_THUMBNAIL = 96; 68 69 /* Options used internally. */ 70 private static final int OPTIONS_NONE = 0x0; 71 private static final int OPTIONS_SCALE_UP = 0x1; 72 73 /** 74 * Constant used to indicate we should recycle the input in 75 * {@link #extractThumbnail(Bitmap, int, int, int)} unless the output is the input. 76 */ 77 public static final int OPTIONS_RECYCLE_INPUT = 0x2; 78 convertKind(int kind)79 private static Size convertKind(int kind) { 80 if (kind == ThumbnailConstants.MICRO_KIND) { 81 return Point.convert(ThumbnailConstants.MICRO_SIZE); 82 } else if (kind == ThumbnailConstants.FULL_SCREEN_KIND) { 83 return Point.convert(ThumbnailConstants.FULL_SCREEN_SIZE); 84 } else if (kind == ThumbnailConstants.MINI_KIND) { 85 return Point.convert(ThumbnailConstants.MINI_SIZE); 86 } else { 87 throw new IllegalArgumentException("Unsupported kind: " + kind); 88 } 89 } 90 91 private static class Resizer implements ImageDecoder.OnHeaderDecodedListener { 92 private final Size size; 93 private final CancellationSignal signal; 94 Resizer(Size size, CancellationSignal signal)95 public Resizer(Size size, CancellationSignal signal) { 96 this.size = size; 97 this.signal = signal; 98 } 99 100 @Override onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source)101 public void onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source) { 102 // One last-ditch check to see if we've been canceled. 103 if (signal != null) signal.throwIfCanceled(); 104 105 // We don't know how clients will use the decoded data, so we have 106 // to default to the more flexible "software" option. 107 decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE); 108 109 // We requested a rough thumbnail size, but the remote size may have 110 // returned something giant, so defensively scale down as needed. 111 final int widthSample = info.getSize().getWidth() / size.getWidth(); 112 final int heightSample = info.getSize().getHeight() / size.getHeight(); 113 final int sample = Math.max(widthSample, heightSample); 114 if (sample > 1) { 115 decoder.setTargetSampleSize(sample); 116 } 117 } 118 } 119 120 /** 121 * Create a thumbnail for given audio file. 122 * 123 * @param filePath The audio file. 124 * @param kind The desired thumbnail kind, such as 125 * {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}. 126 * @deprecated Callers should migrate to using 127 * {@link #createAudioThumbnail(File, Size, CancellationSignal)}, 128 * as it offers more control over resizing and cancellation. 129 */ 130 @Deprecated createAudioThumbnail(@onNull String filePath, int kind)131 public static @Nullable Bitmap createAudioThumbnail(@NonNull String filePath, int kind) { 132 try { 133 return createAudioThumbnail(new File(filePath), convertKind(kind), null); 134 } catch (IOException e) { 135 Log.w(TAG, e); 136 return null; 137 } 138 } 139 140 /** 141 * Create a thumbnail for given audio file. 142 * 143 * @param file The audio file. 144 * @param size The desired thumbnail size. 145 * @throws IOException If any trouble was encountered while generating or 146 * loading the thumbnail, or if 147 * {@link CancellationSignal#cancel()} was invoked. 148 */ createAudioThumbnail(@onNull File file, @NonNull Size size, @Nullable CancellationSignal signal)149 public static @NonNull Bitmap createAudioThumbnail(@NonNull File file, @NonNull Size size, 150 @Nullable CancellationSignal signal) throws IOException { 151 // Checkpoint before going deeper 152 if (signal != null) signal.throwIfCanceled(); 153 154 final Resizer resizer = new Resizer(size, signal); 155 try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) { 156 retriever.setDataSource(file.getAbsolutePath()); 157 final byte[] raw = retriever.getEmbeddedPicture(); 158 if (raw != null) { 159 return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer); 160 } 161 } catch (RuntimeException e) { 162 throw new IOException("Failed to create thumbnail", e); 163 } 164 165 // Only poke around for files on external storage 166 if (MEDIA_UNKNOWN.equals(Environment.getExternalStorageState(file))) { 167 throw new IOException("No embedded album art found"); 168 } 169 170 // Ignore "Downloads" or top-level directories 171 final File parent = file.getParentFile(); 172 final File grandParent = parent != null ? parent.getParentFile() : null; 173 if (parent != null 174 && parent.getName().equals(Environment.DIRECTORY_DOWNLOADS)) { 175 throw new IOException("No thumbnails in Downloads directories"); 176 } 177 if (grandParent != null 178 && MEDIA_UNKNOWN.equals(Environment.getExternalStorageState(grandParent))) { 179 throw new IOException("No thumbnails in top-level directories"); 180 } 181 182 // If no embedded image found, look around for best standalone file 183 final File[] found = ArrayUtils 184 .defeatNullable(file.getParentFile().listFiles((dir, name) -> { 185 final String lower = name.toLowerCase(); 186 return (lower.endsWith(".jpg") || lower.endsWith(".png")); 187 })); 188 189 final ToIntFunction<File> score = (f) -> { 190 final String lower = f.getName().toLowerCase(); 191 if (lower.equals("albumart.jpg")) return 4; 192 if (lower.startsWith("albumart") && lower.endsWith(".jpg")) return 3; 193 if (lower.contains("albumart") && lower.endsWith(".jpg")) return 2; 194 if (lower.endsWith(".jpg")) return 1; 195 return 0; 196 }; 197 final Comparator<File> bestScore = (a, b) -> { 198 return score.applyAsInt(a) - score.applyAsInt(b); 199 }; 200 201 final File bestFile = Arrays.asList(found).stream().max(bestScore).orElse(null); 202 if (bestFile == null) { 203 throw new IOException("No album art found"); 204 } 205 206 // Checkpoint before going deeper 207 if (signal != null) signal.throwIfCanceled(); 208 209 return ImageDecoder.decodeBitmap(ImageDecoder.createSource(bestFile), resizer); 210 } 211 212 /** 213 * Create a thumbnail for given image file. 214 * 215 * @param filePath The image file. 216 * @param kind The desired thumbnail kind, such as 217 * {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}. 218 * @deprecated Callers should migrate to using 219 * {@link #createImageThumbnail(File, Size, CancellationSignal)}, 220 * as it offers more control over resizing and cancellation. 221 */ 222 @Deprecated createImageThumbnail(@onNull String filePath, int kind)223 public static @Nullable Bitmap createImageThumbnail(@NonNull String filePath, int kind) { 224 try { 225 return createImageThumbnail(new File(filePath), convertKind(kind), null); 226 } catch (IOException e) { 227 Log.w(TAG, e); 228 return null; 229 } 230 } 231 232 /** 233 * Create a thumbnail for given image file. 234 * 235 * @param file The audio file. 236 * @param size The desired thumbnail size. 237 * @throws IOException If any trouble was encountered while generating or 238 * loading the thumbnail, or if 239 * {@link CancellationSignal#cancel()} was invoked. 240 */ createImageThumbnail(@onNull File file, @NonNull Size size, @Nullable CancellationSignal signal)241 public static @NonNull Bitmap createImageThumbnail(@NonNull File file, @NonNull Size size, 242 @Nullable CancellationSignal signal) throws IOException { 243 // Checkpoint before going deeper 244 if (signal != null) signal.throwIfCanceled(); 245 246 final Resizer resizer = new Resizer(size, signal); 247 final String mimeType = MediaFile.getMimeTypeForFile(file.getName()); 248 Bitmap bitmap = null; 249 ExifInterface exif = null; 250 int orientation = 0; 251 252 // get orientation 253 if (MediaFile.isExifMimeType(mimeType)) { 254 exif = new ExifInterface(file); 255 switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)) { 256 case ExifInterface.ORIENTATION_ROTATE_90: 257 orientation = 90; 258 break; 259 case ExifInterface.ORIENTATION_ROTATE_180: 260 orientation = 180; 261 break; 262 case ExifInterface.ORIENTATION_ROTATE_270: 263 orientation = 270; 264 break; 265 } 266 } 267 268 if (mimeType.equals("image/heif") 269 || mimeType.equals("image/heif-sequence") 270 || mimeType.equals("image/heic") 271 || mimeType.equals("image/heic-sequence")) { 272 try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) { 273 retriever.setDataSource(file.getAbsolutePath()); 274 bitmap = retriever.getThumbnailImageAtIndex(-1, 275 new MediaMetadataRetriever.BitmapParams(), size.getWidth(), 276 size.getWidth() * size.getHeight()); 277 } catch (RuntimeException e) { 278 throw new IOException("Failed to create thumbnail", e); 279 } 280 } 281 282 if (bitmap == null && exif != null) { 283 final byte[] raw = exif.getThumbnailBytes(); 284 if (raw != null) { 285 try { 286 bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer); 287 } catch (ImageDecoder.DecodeException e) { 288 Log.w(TAG, e); 289 } 290 } 291 } 292 293 // Checkpoint before going deeper 294 if (signal != null) signal.throwIfCanceled(); 295 296 if (bitmap == null) { 297 bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), resizer); 298 // Use ImageDecoder to do full file decoding, we don't need to handle the orientation 299 return bitmap; 300 } 301 302 // Transform the bitmap if the orientation of the image is not 0. 303 if (orientation != 0 && bitmap != null) { 304 final int width = bitmap.getWidth(); 305 final int height = bitmap.getHeight(); 306 307 final Matrix m = new Matrix(); 308 m.setRotate(orientation, width / 2, height / 2); 309 bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false); 310 } 311 312 return bitmap; 313 } 314 315 /** 316 * Create a thumbnail for given video file. 317 * 318 * @param filePath The video file. 319 * @param kind The desired thumbnail kind, such as 320 * {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}. 321 * @deprecated Callers should migrate to using 322 * {@link #createVideoThumbnail(File, Size, CancellationSignal)}, 323 * as it offers more control over resizing and cancellation. 324 */ 325 @Deprecated createVideoThumbnail(@onNull String filePath, int kind)326 public static @Nullable Bitmap createVideoThumbnail(@NonNull String filePath, int kind) { 327 try { 328 return createVideoThumbnail(new File(filePath), convertKind(kind), null); 329 } catch (IOException e) { 330 Log.w(TAG, e); 331 return null; 332 } 333 } 334 335 /** 336 * Create a thumbnail for given video file. 337 * 338 * @param file The video file. 339 * @param size The desired thumbnail size. 340 * @throws IOException If any trouble was encountered while generating or 341 * loading the thumbnail, or if 342 * {@link CancellationSignal#cancel()} was invoked. 343 */ createVideoThumbnail(@onNull File file, @NonNull Size size, @Nullable CancellationSignal signal)344 public static @NonNull Bitmap createVideoThumbnail(@NonNull File file, @NonNull Size size, 345 @Nullable CancellationSignal signal) throws IOException { 346 // Checkpoint before going deeper 347 if (signal != null) signal.throwIfCanceled(); 348 349 final Resizer resizer = new Resizer(size, signal); 350 try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) { 351 mmr.setDataSource(file.getAbsolutePath()); 352 353 // Try to retrieve thumbnail from metadata 354 final byte[] raw = mmr.getEmbeddedPicture(); 355 if (raw != null) { 356 return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer); 357 } 358 359 // Fall back to middle of video 360 final int width = Integer.parseInt(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)); 361 final int height = Integer.parseInt(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)); 362 final long duration = Long.parseLong(mmr.extractMetadata(METADATA_KEY_DURATION)); 363 364 // If we're okay with something larger than native format, just 365 // return a frame without up-scaling it 366 if (size.getWidth() > width && size.getHeight() > height) { 367 return Objects.requireNonNull( 368 mmr.getFrameAtTime(duration / 2, OPTION_CLOSEST_SYNC)); 369 } else { 370 return Objects.requireNonNull( 371 mmr.getScaledFrameAtTime(duration / 2, OPTION_CLOSEST_SYNC, 372 size.getWidth(), size.getHeight())); 373 } 374 } catch (RuntimeException e) { 375 throw new IOException("Failed to create thumbnail", e); 376 } 377 } 378 379 /** 380 * Creates a centered bitmap of the desired size. 381 * 382 * @param source original bitmap source 383 * @param width targeted width 384 * @param height targeted height 385 */ extractThumbnail( Bitmap source, int width, int height)386 public static Bitmap extractThumbnail( 387 Bitmap source, int width, int height) { 388 return extractThumbnail(source, width, height, OPTIONS_NONE); 389 } 390 391 /** 392 * Creates a centered bitmap of the desired size. 393 * 394 * @param source original bitmap source 395 * @param width targeted width 396 * @param height targeted height 397 * @param options options used during thumbnail extraction 398 */ extractThumbnail( Bitmap source, int width, int height, int options)399 public static Bitmap extractThumbnail( 400 Bitmap source, int width, int height, int options) { 401 if (source == null) { 402 return null; 403 } 404 405 float scale; 406 if (source.getWidth() < source.getHeight()) { 407 scale = width / (float) source.getWidth(); 408 } else { 409 scale = height / (float) source.getHeight(); 410 } 411 Matrix matrix = new Matrix(); 412 matrix.setScale(scale, scale); 413 Bitmap thumbnail = transform(matrix, source, width, height, 414 OPTIONS_SCALE_UP | options); 415 return thumbnail; 416 } 417 418 @Deprecated 419 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) computeSampleSize(BitmapFactory.Options options, int minSideLength, int maxNumOfPixels)420 private static int computeSampleSize(BitmapFactory.Options options, 421 int minSideLength, int maxNumOfPixels) { 422 return 1; 423 } 424 425 @Deprecated 426 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) computeInitialSampleSize(BitmapFactory.Options options, int minSideLength, int maxNumOfPixels)427 private static int computeInitialSampleSize(BitmapFactory.Options options, 428 int minSideLength, int maxNumOfPixels) { 429 return 1; 430 } 431 432 @Deprecated 433 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) closeSilently(ParcelFileDescriptor c)434 private static void closeSilently(ParcelFileDescriptor c) { 435 IoUtils.closeQuietly(c); 436 } 437 438 @Deprecated 439 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) makeInputStream( Uri uri, ContentResolver cr)440 private static ParcelFileDescriptor makeInputStream( 441 Uri uri, ContentResolver cr) { 442 try { 443 return cr.openFileDescriptor(uri, "r"); 444 } catch (IOException ex) { 445 return null; 446 } 447 } 448 449 /** 450 * Transform source Bitmap to targeted width and height. 451 */ 452 @Deprecated 453 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) transform(Matrix scaler, Bitmap source, int targetWidth, int targetHeight, int options)454 private static Bitmap transform(Matrix scaler, 455 Bitmap source, 456 int targetWidth, 457 int targetHeight, 458 int options) { 459 boolean scaleUp = (options & OPTIONS_SCALE_UP) != 0; 460 boolean recycle = (options & OPTIONS_RECYCLE_INPUT) != 0; 461 462 int deltaX = source.getWidth() - targetWidth; 463 int deltaY = source.getHeight() - targetHeight; 464 if (!scaleUp && (deltaX < 0 || deltaY < 0)) { 465 /* 466 * In this case the bitmap is smaller, at least in one dimension, 467 * than the target. Transform it by placing as much of the image 468 * as possible into the target and leaving the top/bottom or 469 * left/right (or both) black. 470 */ 471 Bitmap b2 = Bitmap.createBitmap(targetWidth, targetHeight, 472 Bitmap.Config.ARGB_8888); 473 Canvas c = new Canvas(b2); 474 475 int deltaXHalf = Math.max(0, deltaX / 2); 476 int deltaYHalf = Math.max(0, deltaY / 2); 477 Rect src = new Rect( 478 deltaXHalf, 479 deltaYHalf, 480 deltaXHalf + Math.min(targetWidth, source.getWidth()), 481 deltaYHalf + Math.min(targetHeight, source.getHeight())); 482 int dstX = (targetWidth - src.width()) / 2; 483 int dstY = (targetHeight - src.height()) / 2; 484 Rect dst = new Rect( 485 dstX, 486 dstY, 487 targetWidth - dstX, 488 targetHeight - dstY); 489 c.drawBitmap(source, src, dst, null); 490 if (recycle) { 491 source.recycle(); 492 } 493 c.setBitmap(null); 494 return b2; 495 } 496 float bitmapWidthF = source.getWidth(); 497 float bitmapHeightF = source.getHeight(); 498 499 float bitmapAspect = bitmapWidthF / bitmapHeightF; 500 float viewAspect = (float) targetWidth / targetHeight; 501 502 if (bitmapAspect > viewAspect) { 503 float scale = targetHeight / bitmapHeightF; 504 if (scale < .9F || scale > 1F) { 505 scaler.setScale(scale, scale); 506 } else { 507 scaler = null; 508 } 509 } else { 510 float scale = targetWidth / bitmapWidthF; 511 if (scale < .9F || scale > 1F) { 512 scaler.setScale(scale, scale); 513 } else { 514 scaler = null; 515 } 516 } 517 518 Bitmap b1; 519 if (scaler != null) { 520 // this is used for minithumb and crop, so we want to filter here. 521 b1 = Bitmap.createBitmap(source, 0, 0, 522 source.getWidth(), source.getHeight(), scaler, true); 523 } else { 524 b1 = source; 525 } 526 527 if (recycle && b1 != source) { 528 source.recycle(); 529 } 530 531 int dx1 = Math.max(0, b1.getWidth() - targetWidth); 532 int dy1 = Math.max(0, b1.getHeight() - targetHeight); 533 534 Bitmap b2 = Bitmap.createBitmap( 535 b1, 536 dx1 / 2, 537 dy1 / 2, 538 targetWidth, 539 targetHeight); 540 541 if (b2 != b1) { 542 if (recycle || b1 != source) { 543 b1.recycle(); 544 } 545 } 546 547 return b2; 548 } 549 550 @Deprecated 551 private static class SizedThumbnailBitmap { 552 public byte[] mThumbnailData; 553 public Bitmap mBitmap; 554 public int mThumbnailWidth; 555 public int mThumbnailHeight; 556 } 557 558 @Deprecated 559 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) createThumbnailFromEXIF(String filePath, int targetSize, int maxPixels, SizedThumbnailBitmap sizedThumbBitmap)560 private static void createThumbnailFromEXIF(String filePath, int targetSize, 561 int maxPixels, SizedThumbnailBitmap sizedThumbBitmap) { 562 } 563 } 564