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