XmpMotionPhotoDescriptionParser.java

/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.extractor.jpeg;

import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.XmlPullParserUtil;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;

/**
 * Parser for motion photo metadata, handling XMP following the Motion Photo V1 and Micro Video V1b
 * specifications.
 */
/* package */ final class XmpMotionPhotoDescriptionParser {

  /**
   * Attempts to parse the specified XMP data describing the motion photo, returning the resulting
   * {@link MotionPhotoDescription} or {@code null} if it wasn't possible to derive a motion photo
   * description.
   *
   * @param xmpString A string of XML containing XMP motion photo metadata to attempt to parse.
   * @return The {@link MotionPhotoDescription}, or {@code null} if it wasn't possible to derive a
   *     motion photo description.
   * @throws IOException If an error occurs reading data from the stream.
   */
  @Nullable
  public static MotionPhotoDescription parse(String xmpString) throws IOException {
    try {
      return parseInternal(xmpString);
    } catch (XmlPullParserException | ParserException | NumberFormatException e) {
      Log.w(TAG, "Ignoring unexpected XMP metadata");
      return null;
    }
  }

  private static final String TAG = "MotionPhotoXmpParser";

  private static final String[] MOTION_PHOTO_ATTRIBUTE_NAMES =
      new String[] {
        "Camera:MotionPhoto", // Motion Photo V1
        "GCamera:MotionPhoto", // Motion Photo V1 (legacy element naming)
        "Camera:MicroVideo", // Micro Video V1b
        "GCamera:MicroVideo", // Micro Video V1b (legacy element naming)
      };
  private static final String[] DESCRIPTION_MOTION_PHOTO_PRESENTATION_TIMESTAMP_ATTRIBUTE_NAMES =
      new String[] {
        "Camera:MotionPhotoPresentationTimestampUs", // Motion Photo V1
        "GCamera:MotionPhotoPresentationTimestampUs", // Motion Photo V1 (legacy element naming)
        "Camera:MicroVideoPresentationTimestampUs", // Micro Video V1b
        "GCamera:MicroVideoPresentationTimestampUs", // Micro Video V1b (legacy element naming)
      };
  private static final String[] DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES =
      new String[] {
        "Camera:MicroVideoOffset", // Micro Video V1b
        "GCamera:MicroVideoOffset", // Micro Video V1b (legacy element naming)
      };

  @Nullable
  private static MotionPhotoDescription parseInternal(String xmpString)
      throws XmlPullParserException, IOException {
    XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance();
    XmlPullParser xpp = xmlPullParserFactory.newPullParser();
    xpp.setInput(new StringReader(xmpString));
    xpp.next();
    if (!XmlPullParserUtil.isStartTag(xpp, "x:xmpmeta")) {
      throw ParserException.createForMalformedContainer(
          "Couldn't find xmp metadata", /* cause= */ null);
    }
    long motionPhotoPresentationTimestampUs = C.TIME_UNSET;
    List<MotionPhotoDescription.ContainerItem> containerItems = ImmutableList.of();
    do {
      xpp.next();
      if (XmlPullParserUtil.isStartTag(xpp, "rdf:Description")) {
        if (!parseMotionPhotoFlagFromDescription(xpp)) {
          // The motion photo flag is not set, so the file should not be treated as a motion photo.
          return null;
        }
        motionPhotoPresentationTimestampUs =
            parseMotionPhotoPresentationTimestampUsFromDescription(xpp);
        containerItems = parseMicroVideoOffsetFromDescription(xpp);
      } else if (XmlPullParserUtil.isStartTag(xpp, "Container:Directory")) {
        containerItems = parseMotionPhotoV1Directory(xpp, "Container", "Item");
      } else if (XmlPullParserUtil.isStartTag(xpp, "GContainer:Directory")) {
        containerItems = parseMotionPhotoV1Directory(xpp, "GContainer", "GContainerItem");
      }
    } while (!XmlPullParserUtil.isEndTag(xpp, "x:xmpmeta"));
    if (containerItems.isEmpty()) {
      // No motion photo information was parsed.
      return null;
    }
    return new MotionPhotoDescription(motionPhotoPresentationTimestampUs, containerItems);
  }

  private static boolean parseMotionPhotoFlagFromDescription(XmlPullParser xpp) {
    for (String attributeName : MOTION_PHOTO_ATTRIBUTE_NAMES) {
      @Nullable String attributeValue = XmlPullParserUtil.getAttributeValue(xpp, attributeName);
      if (attributeValue != null) {
        int motionPhotoFlag = Integer.parseInt(attributeValue);
        return motionPhotoFlag == 1;
      }
    }
    return false;
  }

  private static long parseMotionPhotoPresentationTimestampUsFromDescription(XmlPullParser xpp) {
    for (String attributeName : DESCRIPTION_MOTION_PHOTO_PRESENTATION_TIMESTAMP_ATTRIBUTE_NAMES) {
      @Nullable String attributeValue = XmlPullParserUtil.getAttributeValue(xpp, attributeName);
      if (attributeValue != null) {
        long presentationTimestampUs = Long.parseLong(attributeValue);
        return presentationTimestampUs == -1 ? C.TIME_UNSET : presentationTimestampUs;
      }
    }
    return C.TIME_UNSET;
  }

  private static ImmutableList<MotionPhotoDescription.ContainerItem>
      parseMicroVideoOffsetFromDescription(XmlPullParser xpp) {
    // We store a new Motion Photo item list based on the MicroVideo offset, so that the same
    // representation is used for both specifications.
    for (String attributeName : DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES) {
      @Nullable String attributeValue = XmlPullParserUtil.getAttributeValue(xpp, attributeName);
      if (attributeValue != null) {
        long microVideoOffset = Long.parseLong(attributeValue);
        return ImmutableList.of(
            new MotionPhotoDescription.ContainerItem(
                MimeTypes.IMAGE_JPEG, "Primary", /* length= */ 0, /* padding= */ 0),
            new MotionPhotoDescription.ContainerItem(
                MimeTypes.VIDEO_MP4,
                "MotionPhoto",
                /* length= */ microVideoOffset,
                /* padding= */ 0));
      }
    }
    return ImmutableList.of();
  }

  private static ImmutableList<MotionPhotoDescription.ContainerItem> parseMotionPhotoV1Directory(
      XmlPullParser xpp, String containerNamespacePrefix, String itemNamespacePrefix)
      throws XmlPullParserException, IOException {
    ImmutableList.Builder<MotionPhotoDescription.ContainerItem> containerItems =
        ImmutableList.builder();
    String itemTagName = containerNamespacePrefix + ":Item";
    String directoryTagName = containerNamespacePrefix + ":Directory";
    do {
      xpp.next();
      if (XmlPullParserUtil.isStartTag(xpp, itemTagName)) {
        String mimeAttributeName = itemNamespacePrefix + ":Mime";
        String semanticAttributeName = itemNamespacePrefix + ":Semantic";
        String lengthAttributeName = itemNamespacePrefix + ":Length";
        String paddinghAttributeName = itemNamespacePrefix + ":Padding";
        @Nullable String mime = XmlPullParserUtil.getAttributeValue(xpp, mimeAttributeName);
        @Nullable String semantic = XmlPullParserUtil.getAttributeValue(xpp, semanticAttributeName);
        @Nullable String length = XmlPullParserUtil.getAttributeValue(xpp, lengthAttributeName);
        @Nullable String padding = XmlPullParserUtil.getAttributeValue(xpp, paddinghAttributeName);
        if (mime == null || semantic == null) {
          // Required values are missing.
          return ImmutableList.of();
        }
        containerItems.add(
            new MotionPhotoDescription.ContainerItem(
                mime,
                semantic,
                length != null ? Long.parseLong(length) : 0,
                padding != null ? Long.parseLong(padding) : 0));
      }
    } while (!XmlPullParserUtil.isEndTag(xpp, directoryTagName));
    return containerItems.build();
  }

  private XmpMotionPhotoDescriptionParser() {}
}