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 @NonNull 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 @NonNull 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