1 /*
2  * Copyright (C) 2019 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 static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
20 import static org.xmlpull.v1.XmlPullParser.END_TAG;
21 import static org.xmlpull.v1.XmlPullParser.START_TAG;
22 
23 import android.media.ExifInterface;
24 import android.text.TextUtils;
25 import android.util.Xml;
26 
27 import androidx.annotation.NonNull;
28 import androidx.annotation.Nullable;
29 import androidx.annotation.VisibleForTesting;
30 
31 import com.android.providers.media.MediaProvider;
32 
33 import org.xmlpull.v1.XmlPullParser;
34 import org.xmlpull.v1.XmlPullParserException;
35 
36 import java.io.ByteArrayInputStream;
37 import java.io.File;
38 import java.io.IOException;
39 import java.io.InputStream;
40 import java.nio.charset.StandardCharsets;
41 import java.nio.file.Files;
42 import java.util.Arrays;
43 import java.util.Set;
44 import java.util.UUID;
45 
46 /**
47  * Parser for Extensible Metadata Platform (XMP) metadata. Designed to mirror
48  * ergonomics of {@link ExifInterface}.
49  * <p>
50  * Since values can be repeated multiple times within the same XMP data, this
51  * parser prefers the first valid definition of a specific value, and it ignores
52  * any subsequent attempts to redefine that value.
53  */
54 public class XmpInterface {
55     private static final String NS_RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
56     private static final String NS_XMP = "http://ns.adobe.com/xap/1.0/";
57     private static final String NS_XMPMM = "http://ns.adobe.com/xap/1.0/mm/";
58     private static final String NS_DC = "http://purl.org/dc/elements/1.1/";
59     private static final String NS_EXIF = "http://ns.adobe.com/exif/1.0/";
60 
61     private static final String NAME_DESCRIPTION = "Description";
62     private static final String NAME_FORMAT = "format";
63     private static final String NAME_DOCUMENT_ID = "DocumentID";
64     private static final String NAME_ORIGINAL_DOCUMENT_ID = "OriginalDocumentID";
65     private static final String NAME_INSTANCE_ID = "InstanceID";
66 
67     private final LongArray mRedactedRanges = new LongArray();
68     private byte[] mRedactedXmp;
69     private String mFormat;
70     private String mDocumentId;
71     private String mInstanceId;
72     private String mOriginalDocumentId;
73 
XmpInterface(@onNull byte[] rawXmp, @NonNull Set<String> redactedExifTags, @NonNull long[] xmpOffsets)74     private XmpInterface(@NonNull byte[] rawXmp, @NonNull Set<String> redactedExifTags,
75             @NonNull long[] xmpOffsets) throws IOException {
76         mRedactedXmp = rawXmp;
77 
78         final ByteCountingInputStream in = new ByteCountingInputStream(
79                 new ByteArrayInputStream(rawXmp));
80         final long xmpOffset = xmpOffsets.length == 0 ? 0 : xmpOffsets[0];
81         try {
82             final XmlPullParser parser = Xml.newPullParser();
83             parser.setInput(in, StandardCharsets.UTF_8.name());
84 
85             long offset = 0;
86             int type;
87             while ((type = parser.next()) != END_DOCUMENT) {
88                 if (type != START_TAG) {
89                     offset = in.getOffset(parser);
90                     continue;
91                 }
92 
93                 // The values we're interested in could be stored in either
94                 // attributes or tags, so we're willing to look for both
95 
96                 final String ns = parser.getNamespace();
97                 final String name = parser.getName();
98 
99                 if (NS_RDF.equals(ns) && NAME_DESCRIPTION.equals(name)) {
100                     mFormat = maybeOverride(mFormat,
101                             parser.getAttributeValue(NS_DC, NAME_FORMAT));
102                     mDocumentId = maybeOverride(mDocumentId,
103                             parser.getAttributeValue(NS_XMPMM, NAME_DOCUMENT_ID));
104                     mInstanceId = maybeOverride(mInstanceId,
105                             parser.getAttributeValue(NS_XMPMM, NAME_INSTANCE_ID));
106                     mOriginalDocumentId = maybeOverride(mOriginalDocumentId,
107                             parser.getAttributeValue(NS_XMPMM, NAME_ORIGINAL_DOCUMENT_ID));
108                 } else if (NS_DC.equals(ns) && NAME_FORMAT.equals(name)) {
109                     mFormat = maybeOverride(mFormat, parser.nextText());
110                 } else if (NS_XMPMM.equals(ns) && NAME_DOCUMENT_ID.equals(name)) {
111                     mDocumentId = maybeOverride(mDocumentId, parser.nextText());
112                 } else if (NS_XMPMM.equals(ns) && NAME_INSTANCE_ID.equals(name)) {
113                     mInstanceId = maybeOverride(mInstanceId, parser.nextText());
114                 } else if (NS_XMPMM.equals(ns) && NAME_ORIGINAL_DOCUMENT_ID.equals(name)) {
115                     mOriginalDocumentId = maybeOverride(mOriginalDocumentId, parser.nextText());
116                 } else if (NS_EXIF.equals(ns) && redactedExifTags.contains(name)) {
117                     long start = offset;
118                     do {
119                         type = parser.next();
120                     } while (type != END_TAG || !parser.getName().equals(name));
121                     offset = in.getOffset(parser);
122 
123                     // Redact range within entire file
124                     mRedactedRanges.add(xmpOffset + start);
125                     mRedactedRanges.add(xmpOffset + offset);
126 
127                     // Redact range within local copy
128                     Arrays.fill(mRedactedXmp, (int) start, (int) offset, (byte) ' ');
129                 }
130             }
131         } catch (XmlPullParserException e) {
132             throw new IOException(e);
133         }
134     }
135 
fromContainer(@onNull InputStream is)136     public static @NonNull XmpInterface fromContainer(@NonNull InputStream is)
137             throws IOException {
138         return fromContainer(new ExifInterface(is));
139     }
140 
fromContainer(@onNull InputStream is, @NonNull Set<String> redactedExifTags)141     public static @NonNull XmpInterface fromContainer(@NonNull InputStream is,
142             @NonNull Set<String> redactedExifTags) throws IOException {
143         return fromContainer(new ExifInterface(is), redactedExifTags);
144     }
145 
fromContainer(@onNull ExifInterface exif)146     public static @NonNull XmpInterface fromContainer(@NonNull ExifInterface exif)
147             throws IOException {
148         return fromContainer(exif, MediaProvider.sRedactedExifTags);
149     }
150 
fromContainer(@onNull ExifInterface exif, @NonNull Set<String> redactedExifTags)151     public static @NonNull XmpInterface fromContainer(@NonNull ExifInterface exif,
152             @NonNull Set<String> redactedExifTags) throws IOException {
153         final byte[] buf;
154         long[] xmpOffsets;
155         if (exif.hasAttribute(ExifInterface.TAG_XMP)) {
156             buf = exif.getAttributeBytes(ExifInterface.TAG_XMP);
157             xmpOffsets = exif.getAttributeRange(ExifInterface.TAG_XMP);
158         } else {
159             buf = new byte[0];
160             xmpOffsets = new long[0];
161         }
162         return new XmpInterface(buf, redactedExifTags, xmpOffsets);
163     }
164 
fromContainer(@onNull IsoInterface iso)165     public static @NonNull XmpInterface fromContainer(@NonNull IsoInterface iso)
166             throws IOException {
167         return fromContainer(iso, MediaProvider.sRedactedExifTags);
168     }
169 
fromContainer(@onNull IsoInterface iso, @NonNull Set<String> redactedExifTags)170     public static @NonNull XmpInterface fromContainer(@NonNull IsoInterface iso,
171             @NonNull Set<String> redactedExifTags) throws IOException {
172         byte[] buf = null;
173         long[] xmpOffsets = new long[0];
174         if (buf == null) {
175             UUID uuid = UUID.fromString("be7acfcb-97a9-42e8-9c71-999491e3afac");
176             buf = iso.getBoxBytes(uuid);
177             xmpOffsets = iso.getBoxRanges(uuid);
178         }
179         if (buf == null) {
180             buf = iso.getBoxBytes(IsoInterface.BOX_XMP);
181             xmpOffsets = iso.getBoxRanges(IsoInterface.BOX_XMP);
182         }
183         if (buf == null) {
184             buf = new byte[0];
185             xmpOffsets = new long[0];
186         }
187         return new XmpInterface(buf, redactedExifTags, xmpOffsets);
188     }
189 
fromSidecar(@onNull File file)190     public static @NonNull XmpInterface fromSidecar(@NonNull File file)
191             throws IOException {
192         return new XmpInterface(Files.readAllBytes(file.toPath()),
193                 MediaProvider.sRedactedExifTags, new long[0]);
194     }
195 
maybeOverride(@ullable String existing, @Nullable String current)196     private static @Nullable String maybeOverride(@Nullable String existing,
197             @Nullable String current) {
198         if (!TextUtils.isEmpty(existing)) {
199             // If already defined, first definition always wins
200             return existing;
201         } else if (!TextUtils.isEmpty(current)) {
202             // If current defined, it wins
203             return current;
204         } else {
205             // Otherwise, null wins to prevent weird empty strings
206             return null;
207         }
208     }
209 
getFormat()210     public @Nullable String getFormat() {
211         return mFormat;
212     }
213 
getDocumentId()214     public @Nullable String getDocumentId() {
215         return mDocumentId;
216     }
217 
getInstanceId()218     public @Nullable String getInstanceId() {
219         return mInstanceId;
220     }
221 
getOriginalDocumentId()222     public @Nullable String getOriginalDocumentId() {
223         return mOriginalDocumentId;
224     }
225 
getRedactedXmp()226     public @Nullable byte[] getRedactedXmp() {
227         return mRedactedXmp;
228     }
229 
230     /** The [start, end] offsets in the original file where to-be redacted info is stored */
getRedactionRanges()231     public LongArray getRedactionRanges() {
232         return mRedactedRanges;
233     }
234 
235     @VisibleForTesting
236     public static class ByteCountingInputStream extends InputStream {
237         private final InputStream mWrapped;
238         private final LongArray mOffsets;
239         private int mLine;
240         private int mOffset;
241 
ByteCountingInputStream(InputStream wrapped)242         public ByteCountingInputStream(InputStream wrapped) {
243             mWrapped = wrapped;
244             mOffsets = new LongArray();
245             mLine = 1;
246             mOffset = 0;
247         }
248 
getOffset(XmlPullParser parser)249         public long getOffset(XmlPullParser parser) {
250             int line = parser.getLineNumber() - 1; // getLineNumber is 1-based
251             long lineOffset = line == 0 ? 0 : mOffsets.get(line - 1);
252             int columnOffset = parser.getColumnNumber() - 1; // meant to be 0-based, but is 1-based?
253             return lineOffset + columnOffset;
254         }
255 
256         @Override
read(byte[] b)257         public int read(byte[] b) throws IOException {
258             return read(b, 0, b.length);
259         }
260 
261         @Override
read(byte[] b, int off, int len)262         public int read(byte[] b, int off, int len) throws IOException {
263             final int read = mWrapped.read(b, off, len);
264             if (read == -1) return -1;
265 
266             for (int i = 0; i < read; i++) {
267                 if (b[off + i] == '\n') {
268                     mOffsets.add(mLine - 1, mOffset + i + 1);
269                     mLine++;
270                 }
271             }
272             mOffset += read;
273             return read;
274         }
275 
276         @Override
read()277         public int read() throws IOException {
278             int r = mWrapped.read();
279             if (r == -1) return -1;
280 
281             mOffset++;
282             if (r == '\n') {
283                 mOffsets.add(mLine - 1, mOffset);
284                 mLine++;
285             }
286             return r;
287         }
288 
289         @Override
skip(long n)290         public long skip(long n) throws IOException {
291             return super.skip(n);
292         }
293 
294         @Override
available()295         public int available() throws IOException {
296             return mWrapped.available();
297         }
298 
299         @Override
close()300         public void close() throws IOException {
301             mWrapped.close();
302         }
303 
304         @Override
toString()305         public String toString() {
306             return java.util.Arrays.toString(mOffsets.toArray());
307         }
308     }
309 }
310