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