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