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 image 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 || mimeType.equals("image/avif")) { 277 try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) { 278 retriever.setDataSource(file.getAbsolutePath()); 279 bitmap = retriever.getThumbnailImageAtIndex(-1, 280 new MediaMetadataRetriever.BitmapParams(), size.getWidth(), 281 size.getWidth() * size.getHeight()); 282 } catch (RuntimeException e) { 283 throw new IOException("Failed to create thumbnail", e); 284 } 285 } 286 287 if (bitmap == null && exif != null) { 288 final byte[] raw = exif.getThumbnailBytes(); 289 if (raw != null) { 290 try { 291 bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer); 292 } catch (ImageDecoder.DecodeException e) { 293 Log.w(TAG, e); 294 } 295 } 296 } 297 298 // Checkpoint before going deeper 299 if (signal != null) signal.throwIfCanceled(); 300 301 if (bitmap == null) { 302 bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), resizer); 303 // Use ImageDecoder to do full file decoding, we don't need to handle the orientation 304 return bitmap; 305 } 306 307 // Transform the bitmap if the orientation of the image is not 0. 308 if (orientation != 0 && bitmap != null) { 309 final int width = bitmap.getWidth(); 310 final int height = bitmap.getHeight(); 311 312 final Matrix m = new Matrix(); 313 m.setRotate(orientation, width / 2, height / 2); 314 bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false); 315 } 316 317 return bitmap; 318 } 319 320 /** 321 * Create a thumbnail for given video file. 322 * 323 * @param filePath The video file. 324 * @param kind The desired thumbnail kind, such as 325 * {@link android.provider.MediaStore.Images.Thumbnails#MINI_KIND}. 326 * @deprecated Callers should migrate to using 327 * {@link #createVideoThumbnail(File, Size, CancellationSignal)}, 328 * as it offers more control over resizing and cancellation. 329 */ 330 @Deprecated createVideoThumbnail(@onNull String filePath, int kind)331 public static @Nullable Bitmap createVideoThumbnail(@NonNull String filePath, int kind) { 332 try { 333 return createVideoThumbnail(new File(filePath), convertKind(kind), null); 334 } catch (IOException e) { 335 Log.w(TAG, e); 336 return null; 337 } 338 } 339 340 /** 341 * Create a thumbnail for given video file. 342 * <p> 343 * This method should only be used for files that you have direct access to; 344 * if you'd like to work with media hosted outside your app, consider using 345 * {@link ContentResolver#loadThumbnail(Uri, Size, CancellationSignal)} 346 * which enables remote providers to efficiently cache and invalidate 347 * thumbnails. 348 * 349 * @param file The video file. 350 * @param size The desired thumbnail size. 351 * @throws IOException If any trouble was encountered while generating or 352 * loading the thumbnail, or if 353 * {@link CancellationSignal#cancel()} was invoked. 354 */ createVideoThumbnail(@onNull File file, @NonNull Size size, @Nullable CancellationSignal signal)355 public static @NonNull Bitmap createVideoThumbnail(@NonNull File file, @NonNull Size size, 356 @Nullable CancellationSignal signal) throws IOException { 357 // Checkpoint before going deeper 358 if (signal != null) signal.throwIfCanceled(); 359 360 final Resizer resizer = new Resizer(size, signal); 361 try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) { 362 mmr.setDataSource(file.getAbsolutePath()); 363 364 // Try to retrieve thumbnail from metadata 365 final byte[] raw = mmr.getEmbeddedPicture(); 366 if (raw != null) { 367 return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw), resizer); 368 } 369 370 final BitmapParams params = new BitmapParams(); 371 params.setPreferredConfig(Bitmap.Config.ARGB_8888); 372 373 final int width = Integer.parseInt(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)); 374 final int height = Integer.parseInt(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)); 375 // Fall back to middle of video 376 // Note: METADATA_KEY_DURATION unit is in ms, not us. 377 final long thumbnailTimeUs = 378 Long.parseLong(mmr.extractMetadata(METADATA_KEY_DURATION)) * 1000 / 2; 379 380 // If we're okay with something larger than native format, just 381 // return a frame without up-scaling it 382 if (size.getWidth() > width && size.getHeight() > height) { 383 return Objects.requireNonNull( 384 mmr.getFrameAtTime(thumbnailTimeUs, OPTION_CLOSEST_SYNC, params)); 385 } else { 386 return Objects.requireNonNull( 387 mmr.getScaledFrameAtTime(thumbnailTimeUs, OPTION_CLOSEST_SYNC, 388 size.getWidth(), size.getHeight(), params)); 389 } 390 } catch (RuntimeException e) { 391 throw new IOException("Failed to create thumbnail", e); 392 } 393 } 394 395 /** 396 * Creates a centered bitmap of the desired size. 397 * 398 * @param source original bitmap source 399 * @param width targeted width 400 * @param height targeted height 401 */ extractThumbnail( Bitmap source, int width, int height)402 public static Bitmap extractThumbnail( 403 Bitmap source, int width, int height) { 404 return extractThumbnail(source, width, height, OPTIONS_NONE); 405 } 406 407 /** 408 * Creates a centered bitmap of the desired size. 409 * 410 * @param source original bitmap source 411 * @param width targeted width 412 * @param height targeted height 413 * @param options options used during thumbnail extraction 414 */ extractThumbnail( Bitmap source, int width, int height, int options)415 public static Bitmap extractThumbnail( 416 Bitmap source, int width, int height, int options) { 417 if (source == null) { 418 return null; 419 } 420 421 float scale; 422 if (source.getWidth() < source.getHeight()) { 423 scale = width / (float) source.getWidth(); 424 } else { 425 scale = height / (float) source.getHeight(); 426 } 427 Matrix matrix = new Matrix(); 428 matrix.setScale(scale, scale); 429 Bitmap thumbnail = transform(matrix, source, width, height, 430 OPTIONS_SCALE_UP | options); 431 return thumbnail; 432 } 433 434 @Deprecated 435 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) computeSampleSize(BitmapFactory.Options options, int minSideLength, int maxNumOfPixels)436 private static int computeSampleSize(BitmapFactory.Options options, 437 int minSideLength, int maxNumOfPixels) { 438 return 1; 439 } 440 441 @Deprecated 442 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) computeInitialSampleSize(BitmapFactory.Options options, int minSideLength, int maxNumOfPixels)443 private static int computeInitialSampleSize(BitmapFactory.Options options, 444 int minSideLength, int maxNumOfPixels) { 445 return 1; 446 } 447 448 @Deprecated 449 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) closeSilently(ParcelFileDescriptor c)450 private static void closeSilently(ParcelFileDescriptor c) { 451 IoUtils.closeQuietly(c); 452 } 453 454 @Deprecated 455 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) makeInputStream( Uri uri, ContentResolver cr)456 private static ParcelFileDescriptor makeInputStream( 457 Uri uri, ContentResolver cr) { 458 try { 459 return cr.openFileDescriptor(uri, "r"); 460 } catch (IOException ex) { 461 return null; 462 } 463 } 464 465 /** 466 * Transform source Bitmap to targeted width and height. 467 */ 468 @Deprecated 469 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) transform(Matrix scaler, Bitmap source, int targetWidth, int targetHeight, int options)470 private static Bitmap transform(Matrix scaler, 471 Bitmap source, 472 int targetWidth, 473 int targetHeight, 474 int options) { 475 boolean scaleUp = (options & OPTIONS_SCALE_UP) != 0; 476 boolean recycle = (options & OPTIONS_RECYCLE_INPUT) != 0; 477 478 int deltaX = source.getWidth() - targetWidth; 479 int deltaY = source.getHeight() - targetHeight; 480 if (!scaleUp && (deltaX < 0 || deltaY < 0)) { 481 /* 482 * In this case the bitmap is smaller, at least in one dimension, 483 * than the target. Transform it by placing as much of the image 484 * as possible into the target and leaving the top/bottom or 485 * left/right (or both) black. 486 */ 487 Bitmap b2 = Bitmap.createBitmap(targetWidth, targetHeight, 488 Bitmap.Config.ARGB_8888); 489 Canvas c = new Canvas(b2); 490 491 int deltaXHalf = Math.max(0, deltaX / 2); 492 int deltaYHalf = Math.max(0, deltaY / 2); 493 Rect src = new Rect( 494 deltaXHalf, 495 deltaYHalf, 496 deltaXHalf + Math.min(targetWidth, source.getWidth()), 497 deltaYHalf + Math.min(targetHeight, source.getHeight())); 498 int dstX = (targetWidth - src.width()) / 2; 499 int dstY = (targetHeight - src.height()) / 2; 500 Rect dst = new Rect( 501 dstX, 502 dstY, 503 targetWidth - dstX, 504 targetHeight - dstY); 505 c.drawBitmap(source, src, dst, null); 506 if (recycle) { 507 source.recycle(); 508 } 509 c.setBitmap(null); 510 return b2; 511 } 512 float bitmapWidthF = source.getWidth(); 513 float bitmapHeightF = source.getHeight(); 514 515 float bitmapAspect = bitmapWidthF / bitmapHeightF; 516 float viewAspect = (float) targetWidth / targetHeight; 517 518 if (bitmapAspect > viewAspect) { 519 float scale = targetHeight / bitmapHeightF; 520 if (scale < .9F || scale > 1F) { 521 scaler.setScale(scale, scale); 522 } else { 523 scaler = null; 524 } 525 } else { 526 float scale = targetWidth / bitmapWidthF; 527 if (scale < .9F || scale > 1F) { 528 scaler.setScale(scale, scale); 529 } else { 530 scaler = null; 531 } 532 } 533 534 Bitmap b1; 535 if (scaler != null) { 536 // this is used for minithumb and crop, so we want to filter here. 537 b1 = Bitmap.createBitmap(source, 0, 0, 538 source.getWidth(), source.getHeight(), scaler, true); 539 } else { 540 b1 = source; 541 } 542 543 if (recycle && b1 != source) { 544 source.recycle(); 545 } 546 547 int dx1 = Math.max(0, b1.getWidth() - targetWidth); 548 int dy1 = Math.max(0, b1.getHeight() - targetHeight); 549 550 Bitmap b2 = Bitmap.createBitmap( 551 b1, 552 dx1 / 2, 553 dy1 / 2, 554 targetWidth, 555 targetHeight); 556 557 if (b2 != b1) { 558 if (recycle || b1 != source) { 559 b1.recycle(); 560 } 561 } 562 563 return b2; 564 } 565 566 @Deprecated 567 private static class SizedThumbnailBitmap { 568 public byte[] mThumbnailData; 569 public Bitmap mBitmap; 570 public int mThumbnailWidth; 571 public int mThumbnailHeight; 572 } 573 574 @Deprecated 575 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) createThumbnailFromEXIF(String filePath, int targetSize, int maxPixels, SizedThumbnailBitmap sizedThumbBitmap)576 private static void createThumbnailFromEXIF(String filePath, int targetSize, 577 int maxPixels, SizedThumbnailBitmap sizedThumbBitmap) { 578 } 579 } 580