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