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.FileInputStream; 33 import java.io.IOException; 34 import java.io.File; 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 (bitmapOptions.outMimeType.equalsIgnoreCase("image/gif")) { 114 return FileColumns._SPECIAL_FORMAT_GIF; 115 } 116 if (bitmapOptions.outMimeType.equalsIgnoreCase("image/webp") && 117 isAnimatedWebp(file)) { 118 return FileColumns._SPECIAL_FORMAT_ANIMATED_WEBP; 119 } 120 return FileColumns._SPECIAL_FORMAT_NONE; 121 } 122 isAnimatedWebp(File file)123 private static boolean isAnimatedWebp(File file) throws IOException { 124 final ImageDecoder.Source source = ImageDecoder.createSource(file); 125 final Drawable drawable = ImageDecoder.decodeDrawable(source); 126 return (drawable instanceof AnimatedImageDrawable); 127 } 128 isMotionPhoto(ExifInterface exif)129 private static boolean isMotionPhoto(ExifInterface exif) throws Exception { 130 if (!exif.hasAttribute(ExifInterface.TAG_XMP)) { 131 return false; 132 } 133 final String xmp = new String(exif.getAttributeBytes(ExifInterface.TAG_XMP), 134 StandardCharsets.UTF_8); 135 136 // The below logic is copied from ExoPlayer#XmpMotionPhotoDescriptionParser class 137 XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance(); 138 XmlPullParser xpp = xmlPullParserFactory.newPullParser(); 139 xpp.setInput(new StringReader(xmp)); 140 xpp.next(); 141 if (!isStartTag(xpp, XMP_META_TAG)) { 142 Log.d(TAG, "Couldn't find xmp metadata"); 143 return false; 144 } 145 146 Trace.beginSection("motionPhotoDetectionUsingXpp"); 147 try { 148 return isMotionPhoto(xpp); 149 } finally { 150 Trace.endSection(); 151 } 152 } 153 isMotionPhoto(XmlPullParser xpp)154 private static boolean isMotionPhoto(XmlPullParser xpp) throws Exception { 155 boolean isMotionPhotoAttributesFound = false; 156 157 do { 158 xpp.next(); 159 if (!isStartTag(xpp)) { 160 continue; 161 } 162 163 switch (xpp.getName()) { 164 case XMP_RDF_DESCRIPTION_TAG: 165 if (!isMotionPhotoFlagSet(xpp)) { 166 // The motion photo flag is not set, so the file should not be treated as a 167 // motion photo. 168 return false; 169 } 170 isMotionPhotoAttributesFound = isMicroVideoPresent(xpp); 171 break; 172 case XMP_CONTAINER_DIRECTORY_PREFIX: 173 isMotionPhotoAttributesFound = isMotionPhotoDirectory(xpp, XMP_CONTAINER_PREFIX, 174 XMP_ITEM_PREFIX); 175 break; 176 case XMP_GCONTAINER_DIRECTORY_PREFIX: 177 isMotionPhotoAttributesFound = isMotionPhotoDirectory(xpp, 178 XMP_GCONTAINER_PREFIX, XMP_GCONTAINER_ITEM_PREFIX); 179 break; 180 default: // do nothing 181 } 182 183 // Return early if motion photo attributes were found in the xpp, 184 // otherwise continue looking 185 if (isMotionPhotoAttributesFound) { 186 return true; 187 } 188 189 } while (!isEndTag(xpp, XMP_META_TAG) && xpp.getEventType() != XmlPullParser.END_DOCUMENT); 190 191 return false; 192 } 193 isMotionPhotoDirectory(XmlPullParser xpp, String containerNamespacePrefix, String itemNamespacePrefix)194 private static boolean isMotionPhotoDirectory(XmlPullParser xpp, 195 String containerNamespacePrefix, String itemNamespacePrefix) 196 throws XmlPullParserException, IOException { 197 final String itemTagName = containerNamespacePrefix + ":Item"; 198 final String directoryTagName = containerNamespacePrefix + ":Directory"; 199 final String mimeAttributeName = itemNamespacePrefix + ":Mime"; 200 final String semanticAttributeName = itemNamespacePrefix + ":Semantic"; 201 final String lengthAttributeName = itemNamespacePrefix + ":Length"; 202 boolean isPrimaryImagePresent = false; 203 boolean isMotionPhotoPresent = false; 204 205 do { 206 xpp.next(); 207 if (!isStartTag(xpp, itemTagName)) { 208 continue; 209 } 210 211 String semantic = getAttributeValue(xpp, semanticAttributeName); 212 if (getAttributeValue(xpp, mimeAttributeName) == null || semantic == null) { 213 // Required values are missing. 214 return false; 215 } 216 217 switch (semantic) { 218 case SEMANTIC_PRIMARY: 219 isPrimaryImagePresent = true; 220 break; 221 case SEMANTIC_MOTION_PHOTO: 222 String length = getAttributeValue(xpp, lengthAttributeName); 223 isMotionPhotoPresent = (length != null && Integer.parseInt(length) > 0); 224 break; 225 default: // do nothing 226 } 227 228 if (isMotionPhotoPresent && isPrimaryImagePresent) { 229 return true; 230 } 231 } while (!isEndTag(xpp, directoryTagName) && 232 xpp.getEventType() != XmlPullParser.END_DOCUMENT); 233 // We need a primary item (photo) and at least one secondary item (video). 234 return false; 235 } 236 isMicroVideoPresent(XmlPullParser xpp)237 private static boolean isMicroVideoPresent(XmlPullParser xpp) { 238 for (String attributeName : DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES) { 239 String attributeValue = getAttributeValue(xpp, attributeName); 240 if (attributeValue != null) { 241 long microVideoOffset = Long.parseLong(attributeValue); 242 return microVideoOffset > 0; 243 } 244 } 245 return false; 246 } 247 isMotionPhotoFlagSet(XmlPullParser xpp)248 private static boolean isMotionPhotoFlagSet(XmlPullParser xpp) { 249 for (String attributeName : MOTION_PHOTO_ATTRIBUTE_NAMES) { 250 String attributeValue = getAttributeValue(xpp, attributeName); 251 if (attributeValue != null) { 252 int motionPhotoFlag = Integer.parseInt(attributeValue); 253 return motionPhotoFlag == 1; 254 } 255 } 256 return false; 257 } 258 getAttributeValue(XmlPullParser xpp, String attributeName)259 private static String getAttributeValue(XmlPullParser xpp, String attributeName) { 260 for (int i = 0; i < xpp.getAttributeCount(); i++) { 261 if (xpp.getAttributeName(i).equals(attributeName)) { 262 return xpp.getAttributeValue(i); 263 } 264 } 265 return null; 266 } 267 268 /** 269 * Returns whether the current event is an end tag with the specified name. 270 * 271 * @param xpp The {@link XmlPullParser} to query. 272 * @param name The specified name. 273 * @return Whether the current event is an end tag. 274 * @throws XmlPullParserException If an error occurs querying the parser. 275 */ isEndTag(XmlPullParser xpp, String name)276 private static boolean isEndTag(XmlPullParser xpp, String name) throws XmlPullParserException { 277 return xpp.getEventType() == XmlPullParser.END_TAG && xpp.getName().equals(name); 278 } 279 280 /** 281 * Returns whether the current event is a start tag with the specified name. 282 * 283 * @param xpp The {@link XmlPullParser} to query. 284 * @param name The specified name. 285 * @return Whether the current event is a start tag with the specified name. 286 * @throws XmlPullParserException If an error occurs querying the parser. 287 */ isStartTag(XmlPullParser xpp, String name)288 private static boolean isStartTag(XmlPullParser xpp, String name) 289 throws XmlPullParserException { 290 return isStartTag(xpp) && xpp.getName().equals(name); 291 } 292 isStartTag(XmlPullParser xpp)293 private static boolean isStartTag(XmlPullParser xpp) throws XmlPullParserException { 294 return xpp.getEventType() == XmlPullParser.START_TAG; 295 } 296 } 297