blob: f5ead4689b6c9f3103fc6263d631c42223590a27 [file] [log] [blame]
/*
* Copyright (C) 2019 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 com.android.providers.media.util;
import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
import static org.xmlpull.v1.XmlPullParser.END_TAG;
import static org.xmlpull.v1.XmlPullParser.START_TAG;
import android.media.ExifInterface;
import android.text.TextUtils;
import android.util.Xml;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.providers.media.MediaProvider;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Set;
import java.util.UUID;
/**
* Parser for Extensible Metadata Platform (XMP) metadata. Designed to mirror
* ergonomics of {@link ExifInterface}.
* <p>
* Since values can be repeated multiple times within the same XMP data, this
* parser prefers the first valid definition of a specific value, and it ignores
* any subsequent attempts to redefine that value.
*/
public class XmpInterface {
private static final String NS_RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
private static final String NS_XMP = "http://ns.adobe.com/xap/1.0/";
private static final String NS_XMPMM = "http://ns.adobe.com/xap/1.0/mm/";
private static final String NS_DC = "http://purl.org/dc/elements/1.1/";
private static final String NS_EXIF = "http://ns.adobe.com/exif/1.0/";
private static final String NAME_DESCRIPTION = "Description";
private static final String NAME_FORMAT = "format";
private static final String NAME_DOCUMENT_ID = "DocumentID";
private static final String NAME_ORIGINAL_DOCUMENT_ID = "OriginalDocumentID";
private static final String NAME_INSTANCE_ID = "InstanceID";
private final LongArray mRedactedRanges = new LongArray();
private @NonNull byte[] mRedactedXmp;
private String mFormat;
private String mDocumentId;
private String mInstanceId;
private String mOriginalDocumentId;
private XmpInterface(@NonNull byte[] rawXmp, @NonNull Set<String> redactedExifTags,
@NonNull long[] xmpOffsets) throws IOException {
mRedactedXmp = rawXmp;
final ByteCountingInputStream in = new ByteCountingInputStream(
new ByteArrayInputStream(rawXmp));
final long xmpOffset = xmpOffsets.length == 0 ? 0 : xmpOffsets[0];
try {
final XmlPullParser parser = Xml.newPullParser();
parser.setInput(in, StandardCharsets.UTF_8.name());
long offset = 0;
int type;
while ((type = parser.next()) != END_DOCUMENT) {
if (type != START_TAG) {
offset = in.getOffset(parser);
continue;
}
// The values we're interested in could be stored in either
// attributes or tags, so we're willing to look for both
final String ns = parser.getNamespace();
final String name = parser.getName();
if (NS_RDF.equals(ns) && NAME_DESCRIPTION.equals(name)) {
mFormat = maybeOverride(mFormat,
parser.getAttributeValue(NS_DC, NAME_FORMAT));
mDocumentId = maybeOverride(mDocumentId,
parser.getAttributeValue(NS_XMPMM, NAME_DOCUMENT_ID));
mInstanceId = maybeOverride(mInstanceId,
parser.getAttributeValue(NS_XMPMM, NAME_INSTANCE_ID));
mOriginalDocumentId = maybeOverride(mOriginalDocumentId,
parser.getAttributeValue(NS_XMPMM, NAME_ORIGINAL_DOCUMENT_ID));
} else if (NS_DC.equals(ns) && NAME_FORMAT.equals(name)) {
mFormat = maybeOverride(mFormat, parser.nextText());
} else if (NS_XMPMM.equals(ns) && NAME_DOCUMENT_ID.equals(name)) {
mDocumentId = maybeOverride(mDocumentId, parser.nextText());
} else if (NS_XMPMM.equals(ns) && NAME_INSTANCE_ID.equals(name)) {
mInstanceId = maybeOverride(mInstanceId, parser.nextText());
} else if (NS_XMPMM.equals(ns) && NAME_ORIGINAL_DOCUMENT_ID.equals(name)) {
mOriginalDocumentId = maybeOverride(mOriginalDocumentId, parser.nextText());
} else if (NS_EXIF.equals(ns) && redactedExifTags.contains(name)) {
long start = offset;
do {
type = parser.next();
} while (type != END_TAG || !parser.getName().equals(name));
offset = in.getOffset(parser);
// Redact range within entire file
mRedactedRanges.add(xmpOffset + start);
mRedactedRanges.add(xmpOffset + offset);
// Redact range within local copy
Arrays.fill(mRedactedXmp, (int) start, (int) offset, (byte) ' ');
}
}
} catch (XmlPullParserException e) {
throw new IOException(e);
}
}
public static @NonNull XmpInterface fromContainer(@NonNull InputStream is)
throws IOException {
return fromContainer(new ExifInterface(is));
}
public static @NonNull XmpInterface fromContainer(@NonNull InputStream is,
@NonNull Set<String> redactedExifTags) throws IOException {
return fromContainer(new ExifInterface(is), redactedExifTags);
}
public static @NonNull XmpInterface fromContainer(@NonNull ExifInterface exif)
throws IOException {
return fromContainer(exif, MediaProvider.sRedactedExifTags);
}
public static @NonNull XmpInterface fromContainer(@NonNull ExifInterface exif,
@NonNull Set<String> redactedExifTags) throws IOException {
final byte[] buf;
long[] xmpOffsets;
if (exif.hasAttribute(ExifInterface.TAG_XMP)) {
buf = exif.getAttributeBytes(ExifInterface.TAG_XMP);
xmpOffsets = exif.getAttributeRange(ExifInterface.TAG_XMP);
} else {
buf = new byte[0];
xmpOffsets = new long[0];
}
return new XmpInterface(buf, redactedExifTags, xmpOffsets);
}
public static @NonNull XmpInterface fromContainer(@NonNull IsoInterface iso)
throws IOException {
return fromContainer(iso, MediaProvider.sRedactedExifTags);
}
public static @NonNull XmpInterface fromContainer(@NonNull IsoInterface iso,
@NonNull Set<String> redactedExifTags) throws IOException {
byte[] buf = null;
long[] xmpOffsets = new long[0];
if (buf == null) {
UUID uuid = UUID.fromString("be7acfcb-97a9-42e8-9c71-999491e3afac");
buf = iso.getBoxBytes(uuid);
xmpOffsets = iso.getBoxRanges(uuid);
}
if (buf == null) {
buf = iso.getBoxBytes(IsoInterface.BOX_XMP);
xmpOffsets = iso.getBoxRanges(IsoInterface.BOX_XMP);
}
if (buf == null) {
buf = new byte[0];
xmpOffsets = new long[0];
}
return new XmpInterface(buf, redactedExifTags, xmpOffsets);
}
public static @NonNull XmpInterface fromSidecar(@NonNull File file)
throws IOException {
return new XmpInterface(Files.readAllBytes(file.toPath()),
MediaProvider.sRedactedExifTags, new long[0]);
}
private static @Nullable String maybeOverride(@Nullable String existing,
@Nullable String current) {
if (!TextUtils.isEmpty(existing)) {
// If already defined, first definition always wins
return existing;
} else if (!TextUtils.isEmpty(current)) {
// If current defined, it wins
return current;
} else {
// Otherwise, null wins to prevent weird empty strings
return null;
}
}
public @Nullable String getFormat() {
return mFormat;
}
public @Nullable String getDocumentId() {
return mDocumentId;
}
public @Nullable String getInstanceId() {
return mInstanceId;
}
public @Nullable String getOriginalDocumentId() {
return mOriginalDocumentId;
}
public @NonNull byte[] getRedactedXmp() {
return mRedactedXmp;
}
/** The [start, end] offsets in the original file where to-be redacted info is stored */
public LongArray getRedactionRanges() {
return mRedactedRanges;
}
@VisibleForTesting
public static class ByteCountingInputStream extends InputStream {
private final InputStream mWrapped;
private final LongArray mOffsets;
private int mLine;
private int mOffset;
public ByteCountingInputStream(InputStream wrapped) {
mWrapped = wrapped;
mOffsets = new LongArray();
mLine = 1;
mOffset = 0;
}
public long getOffset(XmlPullParser parser) {
int line = parser.getLineNumber() - 1; // getLineNumber is 1-based
long lineOffset = line == 0 ? 0 : mOffsets.get(line - 1);
int columnOffset = parser.getColumnNumber() - 1; // meant to be 0-based, but is 1-based?
return lineOffset + columnOffset;
}
@Override
public int read(byte[] b) throws IOException {
return read(b, 0, b.length);
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
final int read = mWrapped.read(b, off, len);
if (read == -1) return -1;
for (int i = 0; i < read; i++) {
if (b[off + i] == '\n') {
mOffsets.add(mLine - 1, mOffset + i + 1);
mLine++;
}
}
mOffset += read;
return read;
}
@Override
public int read() throws IOException {
int r = mWrapped.read();
if (r == -1) return -1;
mOffset++;
if (r == '\n') {
mOffsets.add(mLine - 1, mOffset);
mLine++;
}
return r;
}
@Override
public long skip(long n) throws IOException {
return super.skip(n);
}
@Override
public int available() throws IOException {
return mWrapped.available();
}
@Override
public void close() throws IOException {
mWrapped.close();
}
@Override
public String toString() {
return java.util.Arrays.toString(mOffsets.toArray());
}
}
}