1 /*
2  * Copyright (C) 2021 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 com.android.providers.media.util;
18 
19 import android.graphics.BitmapFactory;
20 import android.graphics.ImageDecoder;
21 import android.graphics.drawable.AnimatedImageDrawable;
22 import android.graphics.drawable.Drawable;
23 import android.media.ExifInterface;
24 import android.os.Trace;
25 import android.provider.MediaStore.Files.FileColumns;
26 import android.util.Log;
27 
28 import org.xmlpull.v1.XmlPullParser;
29 import org.xmlpull.v1.XmlPullParserException;
30 import org.xmlpull.v1.XmlPullParserFactory;
31 
32 import java.io.File;
33 import java.io.FileInputStream;
34 import java.io.IOException;
35 import java.io.StringReader;
36 import java.nio.charset.StandardCharsets;
37 
38 /**
39  * Class to detect and return special format for a media file.
40  */
41 public class SpecialFormatDetector {
42     private static final String TAG = "SpecialFormatDetector";
43     // These are the known MotionPhoto attribute names
44     private static final String[] MOTION_PHOTO_ATTRIBUTE_NAMES = {
45             "Camera:MotionPhoto", // Motion Photo V1
46             "GCamera:MotionPhoto", // Motion Photo V1 (legacy element naming)
47             "Camera:MicroVideo", // Micro Video V1b
48             "GCamera:MicroVideo", // Micro Video V1b (legacy element naming)
49     };
50 
51     private static final String[] DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES = {
52             "Camera:MicroVideoOffset", // Micro Video V1b
53             "GCamera:MicroVideoOffset", // Micro Video V1b (legacy element naming)
54     };
55 
56     private static final String XMP_META_TAG = "x:xmpmeta";
57     private static final String XMP_RDF_DESCRIPTION_TAG = "rdf:Description";
58 
59     private static final String XMP_CONTAINER_PREFIX = "Container";
60     private static final String XMP_GCONTAINER_PREFIX = "GContainer";
61 
62     private static final String XMP_ITEM_PREFIX = "Item";
63     private static final String XMP_GCONTAINER_ITEM_PREFIX =
64             XMP_GCONTAINER_PREFIX + XMP_ITEM_PREFIX;
65 
66     private static final String XMP_DIRECTORY_TAG = ":Directory";
67     private static final String XMP_CONTAINER_DIRECTORY_PREFIX =
68             XMP_CONTAINER_PREFIX + XMP_DIRECTORY_TAG;
69     private static final String XMP_GCONTAINER_DIRECTORY_PREFIX =
70             XMP_GCONTAINER_PREFIX + XMP_DIRECTORY_TAG;
71 
72     private static final String SEMANTIC_PRIMARY = "Primary";
73     private static final String SEMANTIC_MOTION_PHOTO = "MotionPhoto";
74 
75     /**
76      * {@return} special format for a file
77      */
detect(File file)78     public static int detect(File file) throws Exception {
79         try (FileInputStream is = new FileInputStream(file)) {
80             final ExifInterface exif = new ExifInterface(is);
81             return detect(exif, file);
82         }
83     }
84 
85     /**
86      * {@return} special format for a file
87      */
detect(ExifInterface exif, File file)88     public static int detect(ExifInterface exif, File file) throws Exception {
89         if (isMotionPhoto(exif)) {
90             return FileColumns._SPECIAL_FORMAT_MOTION_PHOTO;
91         }
92 
93         return detectGifOrAnimatedWebp(file);
94     }
95 
96     /**
97      * Checks file metadata to detect if the given file is a GIF or Animated Webp.
98      *
99      * Note: This does not respect file extension.
100      *
101      * @return {@link FileColumns#_SPECIAL_FORMAT_GIF} if the file is a GIF file or
102      *         {@link FileColumns#_SPECIAL_FORMAT_ANIMATED_WEBP} if the file is an Animated Webp
103      *         file. Otherwise returns {@link FileColumns#_SPECIAL_FORMAT_NONE}
104      */
detectGifOrAnimatedWebp(File file)105     private static int detectGifOrAnimatedWebp(File file) throws IOException {
106         final BitmapFactory.Options bitmapOptions = new BitmapFactory.Options();
107         // Set options such that the image is not decoded to a bitmap, as we only want mimetype
108         // options
109         bitmapOptions.inSampleSize = 1;
110         bitmapOptions.inJustDecodeBounds = true;
111         BitmapFactory.decodeFile(file.getAbsolutePath(), bitmapOptions);
112 
113         if ("image/gif".equalsIgnoreCase(bitmapOptions.outMimeType)) {
114             return FileColumns._SPECIAL_FORMAT_GIF;
115         }
116         if ("image/webp".equalsIgnoreCase(bitmapOptions.outMimeType) && isAnimatedWebp(file)) {
117             return FileColumns._SPECIAL_FORMAT_ANIMATED_WEBP;
118         }
119         return FileColumns._SPECIAL_FORMAT_NONE;
120     }
121 
isAnimatedWebp(File file)122     private static boolean isAnimatedWebp(File file) throws IOException {
123         final ImageDecoder.Source source = ImageDecoder.createSource(file);
124         final Drawable drawable = ImageDecoder.decodeDrawable(source);
125         return (drawable instanceof AnimatedImageDrawable);
126     }
127 
isMotionPhoto(ExifInterface exif)128     private static boolean isMotionPhoto(ExifInterface exif) throws Exception {
129         if (!exif.hasAttribute(ExifInterface.TAG_XMP)) {
130             return false;
131         }
132         final String xmp = new String(exif.getAttributeBytes(ExifInterface.TAG_XMP),
133                 StandardCharsets.UTF_8);
134 
135         // The below logic is copied from ExoPlayer#XmpMotionPhotoDescriptionParser class
136         XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance();
137         XmlPullParser xpp = xmlPullParserFactory.newPullParser();
138         xpp.setInput(new StringReader(xmp));
139         xpp.next();
140         if (!isStartTag(xpp, XMP_META_TAG)) {
141             Log.d(TAG, "Couldn't find xmp metadata");
142             return false;
143         }
144 
145         Trace.beginSection("FormatDetector.motionPhotoDetectionUsingXpp");
146         try {
147             return isMotionPhoto(xpp);
148         } finally {
149             Trace.endSection();
150         }
151     }
152 
isMotionPhoto(XmlPullParser xpp)153     private static boolean isMotionPhoto(XmlPullParser xpp) throws Exception {
154         boolean isMotionPhotoAttributesFound = false;
155 
156         do {
157             xpp.next();
158             if (!isStartTag(xpp)) {
159                 continue;
160             }
161 
162             switch (xpp.getName()) {
163                 case XMP_RDF_DESCRIPTION_TAG:
164                     if (!isMotionPhotoFlagSet(xpp)) {
165                         // The motion photo flag is not set, so the file should not be treated as a
166                         // motion photo.
167                         return false;
168                     }
169                     isMotionPhotoAttributesFound = isMicroVideoPresent(xpp);
170                     break;
171                 case XMP_CONTAINER_DIRECTORY_PREFIX:
172                     isMotionPhotoAttributesFound = isMotionPhotoDirectory(xpp, XMP_CONTAINER_PREFIX,
173                         XMP_ITEM_PREFIX);
174                     break;
175                 case XMP_GCONTAINER_DIRECTORY_PREFIX:
176                     isMotionPhotoAttributesFound = isMotionPhotoDirectory(xpp,
177                             XMP_GCONTAINER_PREFIX, XMP_GCONTAINER_ITEM_PREFIX);
178                     break;
179                 default: // do nothing
180             }
181 
182             // Return early if motion photo attributes were found in the xpp,
183             // otherwise continue looking
184             if (isMotionPhotoAttributesFound) {
185                 return true;
186             }
187 
188         } while (!isEndTag(xpp, XMP_META_TAG) && xpp.getEventType() != XmlPullParser.END_DOCUMENT);
189 
190         return false;
191     }
192 
isMotionPhotoDirectory(XmlPullParser xpp, String containerNamespacePrefix, String itemNamespacePrefix)193     private static boolean isMotionPhotoDirectory(XmlPullParser xpp,
194             String containerNamespacePrefix, String itemNamespacePrefix)
195             throws XmlPullParserException, IOException {
196         final String itemTagName = containerNamespacePrefix + ":Item";
197         final String directoryTagName = containerNamespacePrefix + ":Directory";
198         final String mimeAttributeName = itemNamespacePrefix + ":Mime";
199         final String semanticAttributeName = itemNamespacePrefix + ":Semantic";
200         final String lengthAttributeName = itemNamespacePrefix + ":Length";
201         boolean isPrimaryImagePresent = false;
202         boolean isMotionPhotoPresent = false;
203 
204         do {
205             xpp.next();
206             if (!isStartTag(xpp, itemTagName)) {
207                 continue;
208             }
209 
210             String semantic = getAttributeValue(xpp, semanticAttributeName);
211             if (getAttributeValue(xpp, mimeAttributeName) == null || semantic == null) {
212                 // Required values are missing.
213                 return false;
214             }
215 
216             switch (semantic) {
217                 case SEMANTIC_PRIMARY:
218                     isPrimaryImagePresent = true;
219                     break;
220                 case SEMANTIC_MOTION_PHOTO:
221                     String length = getAttributeValue(xpp, lengthAttributeName);
222                     isMotionPhotoPresent = (length != null && Integer.parseInt(length) > 0);
223                     break;
224                 default: // do nothing
225             }
226 
227             if (isMotionPhotoPresent && isPrimaryImagePresent) {
228                 return true;
229             }
230         } while (!isEndTag(xpp, directoryTagName) &&
231                 xpp.getEventType() != XmlPullParser.END_DOCUMENT);
232         // We need a primary item (photo) and at least one secondary item (video).
233         return false;
234     }
235 
isMicroVideoPresent(XmlPullParser xpp)236     private static boolean isMicroVideoPresent(XmlPullParser xpp) {
237         for (String attributeName : DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES) {
238             String attributeValue = getAttributeValue(xpp, attributeName);
239             if (attributeValue != null) {
240                 long microVideoOffset = Long.parseLong(attributeValue);
241                 return microVideoOffset > 0;
242             }
243         }
244         return false;
245     }
246 
isMotionPhotoFlagSet(XmlPullParser xpp)247     private static boolean isMotionPhotoFlagSet(XmlPullParser xpp) {
248         for (String attributeName : MOTION_PHOTO_ATTRIBUTE_NAMES) {
249             String attributeValue = getAttributeValue(xpp, attributeName);
250             if (attributeValue != null) {
251                 int motionPhotoFlag = Integer.parseInt(attributeValue);
252                 return motionPhotoFlag == 1;
253             }
254         }
255         return false;
256     }
257 
getAttributeValue(XmlPullParser xpp, String attributeName)258     private static String getAttributeValue(XmlPullParser xpp, String attributeName) {
259         for (int i = 0; i < xpp.getAttributeCount(); i++) {
260             if (xpp.getAttributeName(i).equals(attributeName)) {
261                 return xpp.getAttributeValue(i);
262             }
263         }
264         return null;
265     }
266 
267     /**
268      * Returns whether the current event is an end tag with the specified name.
269      *
270      * @param xpp The {@link XmlPullParser} to query.
271      * @param name The specified name.
272      * @return Whether the current event is an end tag.
273      * @throws XmlPullParserException If an error occurs querying the parser.
274      */
isEndTag(XmlPullParser xpp, String name)275     private static boolean isEndTag(XmlPullParser xpp, String name) throws XmlPullParserException {
276         return xpp.getEventType() == XmlPullParser.END_TAG && xpp.getName().equals(name);
277     }
278 
279     /**
280      * Returns whether the current event is a start tag with the specified name.
281      *
282      * @param xpp The {@link XmlPullParser} to query.
283      * @param name The specified name.
284      * @return Whether the current event is a start tag with the specified name.
285      * @throws XmlPullParserException If an error occurs querying the parser.
286      */
isStartTag(XmlPullParser xpp, String name)287     private static boolean isStartTag(XmlPullParser xpp, String name)
288             throws XmlPullParserException {
289         return isStartTag(xpp) && xpp.getName().equals(name);
290     }
291 
isStartTag(XmlPullParser xpp)292     private static boolean isStartTag(XmlPullParser xpp) throws XmlPullParserException {
293         return xpp.getEventType() == XmlPullParser.START_TAG;
294     }
295 }
296