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