diff --git a/apct-tests/perftests/core/src/android/util/XmlPerfTest.java b/apct-tests/perftests/core/src/android/util/XmlPerfTest.java new file mode 100644 index 0000000000000..e05bd2aad20e4 --- /dev/null +++ b/apct-tests/perftests/core/src/android/util/XmlPerfTest.java @@ -0,0 +1,292 @@ +/* + * Copyright (C) 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 android.util; + +import static org.junit.Assert.assertEquals; + +import android.os.Bundle; +import android.os.Debug; +import android.perftests.utils.BenchmarkState; +import android.perftests.utils.PerfStatusReporter; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.LargeTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.internal.util.HexDump; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.xmlpull.v1.XmlPullParser; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class XmlPerfTest { + /** + * Since allocation measurement adds overhead, it's disabled by default for + * performance runs. It can be manually enabled to compare GC behavior. + */ + private static final boolean MEASURE_ALLOC = false; + + @Rule + public PerfStatusReporter mPerfStatusReporter = new PerfStatusReporter(); + + @Test + public void timeWrite_Fast() throws Exception { + doWrite(() -> Xml.newFastSerializer()); + } + + @Test + public void timeWrite_Binary() throws Exception { + doWrite(() -> Xml.newBinarySerializer()); + } + + private void doWrite(Supplier outFactory) throws Exception { + if (MEASURE_ALLOC) { + Debug.startAllocCounting(); + } + + int iterations = 0; + final BenchmarkState state = mPerfStatusReporter.getBenchmarkState(); + while (state.keepRunning()) { + iterations++; + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + final TypedXmlSerializer out = outFactory.get(); + out.setOutput(os, StandardCharsets.UTF_8.name()); + write(out); + } + } + + if (MEASURE_ALLOC) { + Debug.stopAllocCounting(); + final Bundle results = new Bundle(); + results.putLong("threadAllocCount_mean", Debug.getThreadAllocCount() / iterations); + results.putLong("threadAllocSize_mean", Debug.getThreadAllocSize() / iterations); + InstrumentationRegistry.getInstrumentation().sendStatus(0, results); + } + } + + @Test + public void timeRead_Fast() throws Exception { + doRead(() -> Xml.newFastSerializer(), () -> Xml.newFastPullParser()); + } + + @Test + public void timeRead_Binary() throws Exception { + doRead(() -> Xml.newBinarySerializer(), () -> Xml.newBinaryPullParser()); + } + + private void doRead(Supplier outFactory, + Supplier inFactory) throws Exception { + final byte[] raw; + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + TypedXmlSerializer out = outFactory.get(); + out.setOutput(os, StandardCharsets.UTF_8.name()); + write(out); + raw = os.toByteArray(); + } + + if (MEASURE_ALLOC) { + Debug.startAllocCounting(); + } + + int iterations = 0; + final BenchmarkState state = mPerfStatusReporter.getBenchmarkState(); + while (state.keepRunning()) { + iterations++; + try (ByteArrayInputStream is = new ByteArrayInputStream(raw)) { + TypedXmlPullParser xml = inFactory.get(); + xml.setInput(is, StandardCharsets.UTF_8.name()); + read(xml); + } + } + + if (MEASURE_ALLOC) { + Debug.stopAllocCounting(); + final Bundle results = new Bundle(); + results.putLong("sizeBytes", raw.length); + results.putLong("threadAllocCount_mean", Debug.getThreadAllocCount() / iterations); + results.putLong("threadAllocSize_mean", Debug.getThreadAllocSize() / iterations); + InstrumentationRegistry.getInstrumentation().sendStatus(0, results); + } else { + final Bundle results = new Bundle(); + results.putLong("sizeBytes", raw.length); + InstrumentationRegistry.getInstrumentation().sendStatus(0, results); + } + } + + /** + * Not even joking, this is a typical public key blob stored in + * {@code packages.xml}. + */ + private static final byte[] KEY_BLOB = HexDump.hexStringToByteArray("" + + "308204a830820390a003020102020900a1573d0f45bea193300d06092a864886f70d010105050030819" + + "4310b3009060355040613025553311330110603550408130a43616c69666f726e696131163014060355" + + "0407130d4d6f756e7461696e20566965773110300e060355040a1307416e64726f69643110300e06035" + + "5040b1307416e64726f69643110300e06035504031307416e64726f69643122302006092a864886f70d" + + "0109011613616e64726f696440616e64726f69642e636f6d301e170d3131303931393138343232355a1" + + "70d3339303230343138343232355a308194310b3009060355040613025553311330110603550408130a" + + "43616c69666f726e6961311630140603550407130d4d6f756e7461696e20566965773110300e0603550" + + "40a1307416e64726f69643110300e060355040b1307416e64726f69643110300e06035504031307416e" + + "64726f69643122302006092a864886f70d0109011613616e64726f696440616e64726f69642e636f6d3" + + "0820120300d06092a864886f70d01010105000382010d00308201080282010100de1b51336afc909d8b" + + "cca5920fcdc8940578ec5c253898930e985481cfdea75ba6fc54b1f7bb492a03d98db471ab4200103a8" + + "314e60ee25fef6c8b83bc1b2b45b084874cffef148fa2001bb25c672b6beba50b7ac026b546da762ea2" + + "23829a22b80ef286131f059d2c9b4ca71d54e515a8a3fd6bf5f12a2493dfc2619b337b032a7cf8bbd34" + + "b833f2b93aeab3d325549a93272093943bb59dfc0197ae4861ff514e019b73f5cf10023ad1a032adb4b" + + "9bbaeb4debecb4941d6a02381f1165e1ac884c1fca9525c5854dce2ad8ec839b8ce78442c16367efc07" + + "778a337d3ca2cdf9792ac722b95d67c345f1c00976ec372f02bfcbef0262cc512a6845e71cfea0d0201" + + "03a381fc3081f9301d0603551d0e0416041478a0fc4517fb70ff52210df33c8d32290a44b2bb3081c90" + + "603551d230481c13081be801478a0fc4517fb70ff52210df33c8d32290a44b2bba1819aa48197308194" + + "310b3009060355040613025553311330110603550408130a43616c69666f726e6961311630140603550" + + "407130d4d6f756e7461696e20566965773110300e060355040a1307416e64726f69643110300e060355" + + "040b1307416e64726f69643110300e06035504031307416e64726f69643122302006092a864886f70d0" + + "109011613616e64726f696440616e64726f69642e636f6d820900a1573d0f45bea193300c0603551d13" + + "040530030101ff300d06092a864886f70d01010505000382010100977302dfbf668d7c61841c9c78d25" + + "63bcda1b199e95e6275a799939981416909722713531157f3cdcfea94eea7bb79ca3ca972bd8058a36a" + + "d1919291df42d7190678d4ea47a4b9552c9dfb260e6d0d9129b44615cd641c1080580e8a990dd768c6a" + + "b500c3b964e185874e4105109d94c5bd8c405deb3cf0f7960a563bfab58169a956372167a7e2674a04c" + + "4f80015d8f7869a7a4139aecbbdca2abc294144ee01e4109f0e47a518363cf6e9bf41f7560e94bdd4a5" + + "d085234796b05c7a1389adfd489feec2a107955129d7991daa49afb3d327dc0dc4fe959789372b093a8" + + "9c8dbfa41554f771c18015a6cb242a17e04d19d55d3b4664eae12caf2a11cd2b836e"); + + /** + * Typical list of permissions referenced in {@code packages.xml}. + */ + private static final String[] PERMS = new String[] { + "android.permission.ACCESS_CACHE_FILESYSTEM", + "android.permission.WRITE_SETTINGS", + "android.permission.MANAGE_EXTERNAL_STORAGE", + "android.permission.SEND_DOWNLOAD_COMPLETED_INTENTS", + "android.permission.FOREGROUND_SERVICE", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.WRITE_MEDIA_STORAGE", + "android.permission.INTERNET", + "android.permission.UPDATE_DEVICE_STATS", + "android.permission.RECEIVE_DEVICE_CUSTOMIZATION_READY", + "android.permission.MANAGE_USB", + "android.permission.ACCESS_ALL_DOWNLOADS", + "android.permission.ACCESS_DOWNLOAD_MANAGER", + "android.permission.MANAGE_USERS", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_MTP", + "android.permission.INTERACT_ACROSS_USERS", + "android.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS", + "android.permission.CLEAR_APP_CACHE", + "android.permission.CONNECTIVITY_INTERNAL", + "android.permission.START_ACTIVITIES_FROM_BACKGROUND", + "android.permission.QUERY_ALL_PACKAGES", + "android.permission.WAKE_LOCK", + "android.permission.UPDATE_APP_OPS_STATS", + }; + + /** + * Write a typical {@code packages.xml} file containing 100 applications, + * each of which defines signing key and permission information. + */ + private static void write(TypedXmlSerializer out) throws IOException { + out.startDocument(null, true); + out.startTag(null, "packages"); + for (int i = 0; i < 100; i++) { + out.startTag(null, "package"); + out.attribute(null, "name", "com.android.providers.media"); + out.attribute(null, "codePath", "/system/priv-app/MediaProviderLegacy"); + out.attribute(null, "nativeLibraryPath", "/system/priv-app/MediaProviderLegacy/lib"); + out.attributeLong(null, "publicFlags", 944258629L); + out.attributeLong(null, "privateFlags", -1946152952L); + out.attributeLong(null, "ft", 1603899064000L); + out.attributeLong(null, "it", 1603899064000L); + out.attributeLong(null, "ut", 1603899064000L); + out.attributeInt(null, "version", 1024); + out.attributeInt(null, "sharedUserId", 10100); + out.attributeBoolean(null, "isOrphaned", true); + + out.startTag(null, "sigs"); + out.startTag(null, "cert"); + out.attributeInt(null, "index", 10); + out.attributeBytesHex(null, "key", KEY_BLOB); + out.endTag(null, "cert"); + out.endTag(null, "sigs"); + + out.startTag(null, "perms"); + for (String perm : PERMS) { + out.startTag(null, "item"); + out.attributeInterned(null, "name", perm); + out.attributeBoolean(null, "granted", true); + out.attributeInt(null, "flags", 0); + out.endTag(null, "item"); + } + out.endTag(null, "perms"); + + out.endTag(null, "package"); + } + out.endTag(null, "packages"); + out.endDocument(); + } + + /** + * Read a typical {@code packages.xml} file containing 100 applications, and + * verify that data passes smell test. + */ + private static void read(TypedXmlPullParser xml) throws Exception { + int type; + int packages = 0; + int certs = 0; + int perms = 0; + while ((type = xml.next()) != XmlPullParser.END_DOCUMENT) { + final String tag = xml.getName(); + if (type == XmlPullParser.START_TAG) { + if ("package".equals(tag)) { + xml.getAttributeValue(null, "name"); + xml.getAttributeValue(null, "codePath"); + xml.getAttributeValue(null, "nativeLibraryPath"); + xml.getAttributeLong(null, "publicFlags"); + assertEquals(-1946152952L, xml.getAttributeLong(null, "privateFlags")); + xml.getAttributeLong(null, "ft"); + xml.getAttributeLong(null, "it"); + xml.getAttributeLong(null, "ut"); + xml.getAttributeInt(null, "version"); + xml.getAttributeInt(null, "sharedUserId"); + xml.getAttributeBoolean(null, "isOrphaned"); + packages++; + } else if ("cert".equals(tag)) { + xml.getAttributeInt(null, "index"); + xml.getAttributeBytesHex(null, "key"); + certs++; + } else if ("item".equals(tag)) { + xml.getAttributeValue(null, "name"); + xml.getAttributeBoolean(null, "granted"); + xml.getAttributeInt(null, "flags"); + perms++; + } + } else if (type == XmlPullParser.TEXT) { + xml.getText(); + } + } + + assertEquals(100, packages); + assertEquals(packages * 1, certs); + assertEquals(packages * PERMS.length, perms); + } +} diff --git a/core/java/android/util/Xml.java b/core/java/android/util/Xml.java index 9849c10c1604e..cc6ed2e4539eb 100644 --- a/core/java/android/util/Xml.java +++ b/core/java/android/util/Xml.java @@ -17,7 +17,10 @@ package android.util; import android.annotation.NonNull; +import android.annotation.Nullable; +import com.android.internal.util.BinaryXmlPullParser; +import com.android.internal.util.BinaryXmlSerializer; import com.android.internal.util.FastXmlSerializer; import com.android.internal.util.XmlUtils; @@ -120,6 +123,18 @@ public class Xml { return XmlUtils.makeTyped(newPullParser()); } + /** + * Creates a new {@link XmlPullParser} that reads XML documents using a + * custom binary wire protocol which benchmarking has shown to be 8.5x + * faster than {@code Xml.newFastPullParser()} for a typical + * {@code packages.xml}. + * + * @hide + */ + public static @NonNull TypedXmlPullParser newBinaryPullParser() { + return new BinaryXmlPullParser(); + } + /** * Creates a new {@link XmlPullParser} which is optimized for use inside the * system, typically by supporting only a basic set of features. @@ -166,6 +181,18 @@ public class Xml { return XmlUtils.makeTyped(new FastXmlSerializer()); } + /** + * Creates a new {@link XmlSerializer} that writes XML documents using a + * custom binary wire protocol which benchmarking has shown to be 4.4x + * faster and use 2.8x less disk space than {@code Xml.newFastSerializer()} + * for a typical {@code packages.xml}. + * + * @hide + */ + public static @NonNull TypedXmlSerializer newBinarySerializer() { + return new BinaryXmlSerializer(); + } + /** * Creates a new {@link XmlSerializer} which is optimized for use inside the * system, typically by supporting only a basic set of features. @@ -188,6 +215,82 @@ public class Xml { return xml; } + /** + * Copy the first XML document into the second document. + *

+ * Implemented by reading all events from the given {@link XmlPullParser} + * and writing them directly to the given {@link XmlSerializer}. This can be + * useful for transparently converting between underlying wire protocols. + * + * @hide + */ + public static void copy(@NonNull XmlPullParser in, @NonNull XmlSerializer out) + throws XmlPullParserException, IOException { + // Some parsers may have already consumed the event that starts the + // document, so we manually emit that event here for consistency + if (in.getEventType() == XmlPullParser.START_DOCUMENT) { + out.startDocument(in.getInputEncoding(), true); + } + + while (true) { + final int token = in.nextToken(); + switch (token) { + case XmlPullParser.START_DOCUMENT: + out.startDocument(in.getInputEncoding(), true); + break; + case XmlPullParser.END_DOCUMENT: + out.endDocument(); + return; + case XmlPullParser.START_TAG: + out.startTag(normalizeNamespace(in.getNamespace()), in.getName()); + for (int i = 0; i < in.getAttributeCount(); i++) { + out.attribute(normalizeNamespace(in.getAttributeNamespace(i)), + in.getAttributeName(i), in.getAttributeValue(i)); + } + break; + case XmlPullParser.END_TAG: + out.endTag(normalizeNamespace(in.getNamespace()), in.getName()); + break; + case XmlPullParser.TEXT: + out.text(in.getText()); + break; + case XmlPullParser.CDSECT: + out.cdsect(in.getText()); + break; + case XmlPullParser.ENTITY_REF: + out.entityRef(in.getName()); + break; + case XmlPullParser.IGNORABLE_WHITESPACE: + out.ignorableWhitespace(in.getText()); + break; + case XmlPullParser.PROCESSING_INSTRUCTION: + out.processingInstruction(in.getText()); + break; + case XmlPullParser.COMMENT: + out.comment(in.getText()); + break; + case XmlPullParser.DOCDECL: + out.docdecl(in.getText()); + break; + default: + throw new IllegalStateException("Unknown token " + token); + } + } + } + + /** + * Some parsers may return an empty string {@code ""} when a namespace in + * unsupported, which can confuse serializers. This method normalizes empty + * strings to be {@code null}. + */ + private static @Nullable String normalizeNamespace(@Nullable String namespace) { + if (namespace == null || namespace.isEmpty()) { + return null; + } else { + return namespace; + } + } + /** * Supported character encodings. */ diff --git a/core/java/com/android/internal/util/BinaryXmlPullParser.java b/core/java/com/android/internal/util/BinaryXmlPullParser.java new file mode 100644 index 0000000000000..da16eca5239c3 --- /dev/null +++ b/core/java/com/android/internal/util/BinaryXmlPullParser.java @@ -0,0 +1,899 @@ +/* + * Copyright (C) 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 com.android.internal.util; + +import static com.android.internal.util.BinaryXmlSerializer.ATTRIBUTE; +import static com.android.internal.util.BinaryXmlSerializer.PROTOCOL_MAGIC_VERSION_0; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_BOOLEAN_FALSE; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_BOOLEAN_TRUE; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_BYTES_BASE64; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_BYTES_HEX; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_DOUBLE; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_FLOAT; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_INT; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_INT_HEX; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_LONG; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_LONG_HEX; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_NULL; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_STRING; +import static com.android.internal.util.BinaryXmlSerializer.TYPE_STRING_INTERNED; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.text.TextUtils; +import android.util.Base64; +import android.util.TypedXmlPullParser; +import android.util.TypedXmlSerializer; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * Parser that reads XML documents using a custom binary wire protocol which + * benchmarking has shown to be 8.5x faster than {@link Xml.newFastPullParser()} + * for a typical {@code packages.xml}. + *

+ * The high-level design of the wire protocol is to directly serialize the event + * stream, while efficiently and compactly writing strongly-typed primitives + * delivered through the {@link TypedXmlSerializer} interface. + *

+ * Each serialized event is a single byte where the lower half is a normal + * {@link XmlPullParser} token and the upper half is an optional data type + * signal, such as {@link #TYPE_INT}. + *

+ * This parser has some specific limitations: + *

    + *
  • Only the UTF-8 encoding is supported. + *
  • Variable length values, such as {@code byte[]} or {@link String}, are + * limited to 65,535 bytes in length. Note that {@link String} values are stored + * as UTF-8 on the wire. + *
  • Namespaces, prefixes, properties, and options are unsupported. + *
+ */ +public final class BinaryXmlPullParser implements TypedXmlPullParser { + /** + * Default buffer size, which matches {@code FastXmlSerializer}. This should + * be kept in sync with {@link BinaryXmlPullParser}. + */ + private static final int BUFFER_SIZE = 32_768; + + private FastDataInput mIn; + + private int mCurrentToken = START_DOCUMENT; + private int mCurrentDepth = 0; + private String mCurrentName; + private String mCurrentText; + + /** + * Pool of attributes parsed for the currently tag. All interactions should + * be done via {@link #obtainAttribute()}, {@link #findAttribute(String)}, + * and {@link #resetAttributes()}. + */ + private int mAttributeCount = 0; + private Attribute[] mAttributes; + + @Override + public void setInput(InputStream is, String inputEncoding) throws XmlPullParserException { + if (inputEncoding != null && !StandardCharsets.UTF_8.name().equals(inputEncoding)) { + throw new UnsupportedOperationException(); + } + + mIn = new FastDataInput(is, BUFFER_SIZE); + + mCurrentToken = START_DOCUMENT; + mCurrentDepth = 0; + mCurrentName = null; + mCurrentText = null; + + mAttributeCount = 0; + mAttributes = new Attribute[8]; + for (int i = 0; i < mAttributes.length; i++) { + mAttributes[i] = new Attribute(); + } + + try { + final byte[] magic = new byte[4]; + mIn.readFully(magic); + if (!Arrays.equals(magic, PROTOCOL_MAGIC_VERSION_0)) { + throw new IOException("Unexpected magic " + bytesToHexString(magic)); + } + + // We're willing to immediately consume a START_DOCUMENT if present, + // but we're okay if it's missing + if (peekNextExternalToken() == START_DOCUMENT) { + consumeToken(); + } + } catch (IOException e) { + throw new XmlPullParserException(e.toString()); + } + } + + @Override + public void setInput(Reader in) throws XmlPullParserException { + throw new UnsupportedOperationException(); + } + + @Override + public int next() throws XmlPullParserException, IOException { + while (true) { + final int token = nextToken(); + switch (token) { + case START_TAG: + case END_TAG: + case END_DOCUMENT: + return token; + case TEXT: + consumeAdditionalText(); + // Per interface docs, empty text regions are skipped + if (mCurrentText == null || mCurrentText.length() == 0) { + continue; + } else { + return TEXT; + } + } + } + } + + @Override + public int nextToken() throws XmlPullParserException, IOException { + if (mCurrentToken == XmlPullParser.END_TAG) { + mCurrentDepth--; + } + + int token; + try { + token = peekNextExternalToken(); + consumeToken(); + } catch (EOFException e) { + token = END_DOCUMENT; + } + switch (token) { + case XmlPullParser.START_TAG: + // We need to peek forward to find the next external token so + // that we parse all pending INTERNAL_ATTRIBUTE tokens + peekNextExternalToken(); + mCurrentDepth++; + break; + } + mCurrentToken = token; + return token; + } + + /** + * Peek at the next "external" token without consuming it. + *

+ * External tokens, such as {@link #START_TAG}, are expected by typical + * {@link XmlPullParser} clients. In contrast, internal tokens, such as + * {@link #ATTRIBUTE}, are not expected by typical clients. + *

+ * This method consumes any internal events until it reaches the next + * external event. + */ + private int peekNextExternalToken() throws IOException, XmlPullParserException { + while (true) { + final int token = peekNextToken(); + switch (token) { + case ATTRIBUTE: + consumeToken(); + continue; + default: + return token; + } + } + } + + /** + * Peek at the next token in the underlying stream without consuming it. + */ + private int peekNextToken() throws IOException { + return mIn.peekByte() & 0x0f; + } + + /** + * Parse and consume the next token in the underlying stream. + */ + private void consumeToken() throws IOException, XmlPullParserException { + final int event = mIn.readByte(); + final int token = event & 0x0f; + final int type = event & 0xf0; + switch (token) { + case ATTRIBUTE: { + final Attribute attr = obtainAttribute(); + attr.name = mIn.readInternedUTF(); + attr.type = type; + switch (type) { + case TYPE_NULL: + case TYPE_BOOLEAN_TRUE: + case TYPE_BOOLEAN_FALSE: + // Nothing extra to fill in + break; + case TYPE_STRING: + attr.valueString = mIn.readUTF(); + break; + case TYPE_STRING_INTERNED: + attr.valueString = mIn.readInternedUTF(); + break; + case TYPE_BYTES_HEX: + case TYPE_BYTES_BASE64: + final int len = mIn.readUnsignedShort(); + final byte[] res = new byte[len]; + mIn.readFully(res); + attr.valueBytes = res; + break; + case TYPE_INT: + case TYPE_INT_HEX: + attr.valueInt = mIn.readInt(); + break; + case TYPE_LONG: + case TYPE_LONG_HEX: + attr.valueLong = mIn.readLong(); + break; + case TYPE_FLOAT: + attr.valueFloat = mIn.readFloat(); + break; + case TYPE_DOUBLE: + attr.valueDouble = mIn.readDouble(); + break; + default: + throw new IOException("Unexpected data type " + type); + } + break; + } + case XmlPullParser.START_DOCUMENT: { + break; + } + case XmlPullParser.END_DOCUMENT: { + break; + } + case XmlPullParser.START_TAG: { + mCurrentName = mIn.readInternedUTF(); + resetAttributes(); + break; + } + case XmlPullParser.END_TAG: { + mCurrentName = mIn.readInternedUTF(); + resetAttributes(); + break; + } + case XmlPullParser.TEXT: + case XmlPullParser.CDSECT: + case XmlPullParser.PROCESSING_INSTRUCTION: + case XmlPullParser.COMMENT: + case XmlPullParser.DOCDECL: + case XmlPullParser.IGNORABLE_WHITESPACE: { + mCurrentText = mIn.readUTF(); + break; + } + case XmlPullParser.ENTITY_REF: { + mCurrentName = mIn.readUTF(); + mCurrentText = resolveEntity(mCurrentName); + break; + } + default: { + throw new IOException("Unknown token " + token + " with type " + type); + } + } + } + + /** + * When the current tag is {@link #TEXT}, consume all subsequent "text" + * events, as described by {@link #next}. When finished, the current event + * will still be {@link #TEXT}. + */ + private void consumeAdditionalText() throws IOException, XmlPullParserException { + String combinedText = mCurrentText; + while (true) { + final int token = peekNextExternalToken(); + switch (token) { + case COMMENT: + case PROCESSING_INSTRUCTION: + // Quietly consumed + consumeToken(); + break; + case TEXT: + case CDSECT: + case ENTITY_REF: + // Additional text regions collected + consumeToken(); + combinedText += mCurrentText; + break; + default: + // Next token is something non-text, so wrap things up + mCurrentToken = TEXT; + mCurrentName = null; + mCurrentText = combinedText; + return; + } + } + } + + static @NonNull String resolveEntity(@NonNull String entity) + throws XmlPullParserException { + switch (entity) { + case "lt": return "<"; + case "gt": return ">"; + case "amp": return "&"; + case "apos": return "'"; + case "quot": return "\""; + } + if (entity.length() > 1 && entity.charAt(0) == '#') { + final char c = (char) Integer.parseInt(entity.substring(1)); + return new String(new char[] { c }); + } + throw new XmlPullParserException("Unknown entity " + entity); + } + + @Override + public void require(int type, String namespace, String name) + throws XmlPullParserException, IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + if (mCurrentToken != type || !Objects.equals(mCurrentName, name)) { + throw new XmlPullParserException(getPositionDescription()); + } + } + + @Override + public String nextText() throws XmlPullParserException, IOException { + if (getEventType() != START_TAG) { + throw new XmlPullParserException(getPositionDescription()); + } + int eventType = next(); + if (eventType == TEXT) { + String result = getText(); + eventType = next(); + if (eventType != END_TAG) { + throw new XmlPullParserException(getPositionDescription()); + } + return result; + } else if (eventType == END_TAG) { + return ""; + } else { + throw new XmlPullParserException(getPositionDescription()); + } + } + + @Override + public int nextTag() throws XmlPullParserException, IOException { + int eventType = next(); + if (eventType == TEXT && isWhitespace()) { + eventType = next(); + } + if (eventType != START_TAG && eventType != END_TAG) { + throw new XmlPullParserException(getPositionDescription()); + } + return eventType; + } + + /** + * Allocate and return a new {@link Attribute} associated with the tag being + * currently processed. This will automatically grow the internal pool as + * needed. + */ + private @NonNull Attribute obtainAttribute() { + if (mAttributeCount == mAttributes.length) { + final int before = mAttributes.length; + final int after = before + (before >> 1); + mAttributes = Arrays.copyOf(mAttributes, after); + for (int i = before; i < after; i++) { + mAttributes[i] = new Attribute(); + } + } + return mAttributes[mAttributeCount++]; + } + + /** + * Clear any {@link Attribute} instances that have been allocated by + * {@link #obtainAttribute()}, returning them into the pool for recycling. + */ + private void resetAttributes() { + for (int i = 0; i < mAttributeCount; i++) { + mAttributes[i].reset(); + } + mAttributeCount = 0; + } + + /** + * Search through the pool of currently allocated {@link Attribute} + * instances for one that matches the given name. + */ + private @NonNull Attribute findAttribute(@NonNull String name) throws IOException { + for (int i = 0; i < mAttributeCount; i++) { + if (Objects.equals(mAttributes[i].name, name)) { + return mAttributes[i]; + } + } + throw new IOException("Missing attribute " + name); + } + + @Override + public String getAttributeValue(String namespace, String name) { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + try { + return findAttribute(name).getValueString(); + } catch (IOException e) { + // Missing attributes default to null + return null; + } + } + + @Override + public String getAttributeValue(int index) { + return mAttributes[index].getValueString(); + } + + @Override + public byte[] getAttributeBytesHex(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueBytesHex(); + } + + @Override + public byte[] getAttributeBytesBase64(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueBytesBase64(); + } + + @Override + public int getAttributeInt(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueInt(); + } + + @Override + public int getAttributeIntHex(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueIntHex(); + } + + @Override + public long getAttributeLong(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueLong(); + } + + @Override + public long getAttributeLongHex(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueLongHex(); + } + + @Override + public float getAttributeFloat(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueFloat(); + } + + @Override + public double getAttributeDouble(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueDouble(); + } + + @Override + public boolean getAttributeBoolean(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + return findAttribute(name).getValueBoolean(); + } + + @Override + public String getText() { + return mCurrentText; + } + + @Override + public char[] getTextCharacters(int[] holderForStartAndLength) { + final char[] chars = mCurrentText.toCharArray(); + holderForStartAndLength[0] = 0; + holderForStartAndLength[1] = chars.length; + return chars; + } + + @Override + public String getInputEncoding() { + return StandardCharsets.UTF_8.name(); + } + + @Override + public int getDepth() { + return mCurrentDepth; + } + + @Override + public String getPositionDescription() { + // Not very helpful, but it's the best information we have + return "Token " + mCurrentToken + " at depth " + mCurrentDepth; + } + + @Override + public int getLineNumber() { + return -1; + } + + @Override + public int getColumnNumber() { + return -1; + } + + @Override + public boolean isWhitespace() throws XmlPullParserException { + switch (mCurrentToken) { + case IGNORABLE_WHITESPACE: + return true; + case TEXT: + case CDSECT: + return !TextUtils.isGraphic(mCurrentText); + default: + throw new XmlPullParserException("Not applicable for token " + mCurrentToken); + } + } + + @Override + public String getNamespace() { + switch (mCurrentToken) { + case START_TAG: + case END_TAG: + // Namespaces are unsupported + return NO_NAMESPACE; + default: + return null; + } + } + + @Override + public String getName() { + return mCurrentName; + } + + @Override + public String getPrefix() { + // Prefixes are not supported + return null; + } + + @Override + public boolean isEmptyElementTag() throws XmlPullParserException { + switch (mCurrentToken) { + case START_TAG: + try { + return (peekNextExternalToken() == END_TAG); + } catch (IOException e) { + throw new XmlPullParserException(e.toString()); + } + default: + throw new XmlPullParserException("Not at START_TAG"); + } + } + + @Override + public int getAttributeCount() { + return mAttributeCount; + } + + @Override + public String getAttributeNamespace(int index) { + // Namespaces are unsupported + return NO_NAMESPACE; + } + + @Override + public String getAttributeName(int index) { + return mAttributes[index].name; + } + + @Override + public String getAttributePrefix(int index) { + // Prefixes are not supported + return null; + } + + @Override + public String getAttributeType(int index) { + // Validation is not supported + return "CDATA"; + } + + @Override + public boolean isAttributeDefault(int index) { + // Validation is not supported + return false; + } + + @Override + public int getEventType() throws XmlPullParserException { + return mCurrentToken; + } + + @Override + public int getNamespaceCount(int depth) throws XmlPullParserException { + // Namespaces are unsupported + return 0; + } + + @Override + public String getNamespacePrefix(int pos) throws XmlPullParserException { + // Namespaces are unsupported + throw new UnsupportedOperationException(); + } + + @Override + public String getNamespaceUri(int pos) throws XmlPullParserException { + // Namespaces are unsupported + throw new UnsupportedOperationException(); + } + + @Override + public String getNamespace(String prefix) { + // Namespaces are unsupported + throw new UnsupportedOperationException(); + } + + @Override + public void defineEntityReplacementText(String entityName, String replacementText) + throws XmlPullParserException { + // Custom entities are not supported + throw new UnsupportedOperationException(); + } + + @Override + public void setFeature(String name, boolean state) throws XmlPullParserException { + // Features are not supported + throw new UnsupportedOperationException(); + } + + @Override + public boolean getFeature(String name) { + // Features are not supported + throw new UnsupportedOperationException(); + } + + @Override + public void setProperty(String name, Object value) throws XmlPullParserException { + // Properties are not supported + throw new UnsupportedOperationException(); + } + + @Override + public Object getProperty(String name) { + // Properties are not supported + throw new UnsupportedOperationException(); + } + + private static IllegalArgumentException illegalNamespace() { + throw new IllegalArgumentException("Namespaces are not supported"); + } + + /** + * Holder representing a single attribute. This design enables object + * recycling without resorting to autoboxing. + *

+ * To support conversion between human-readable XML and binary XML, the + * various accessor methods will transparently convert from/to + * human-readable values when needed. + */ + private static class Attribute { + public String name; + public int type; + + public String valueString; + public byte[] valueBytes; + public int valueInt; + public long valueLong; + public float valueFloat; + public double valueDouble; + + public void reset() { + name = null; + valueString = null; + valueBytes = null; + } + + public @Nullable String getValueString() { + switch (type) { + case TYPE_NULL: + return null; + case TYPE_STRING: + case TYPE_STRING_INTERNED: + return valueString; + case TYPE_BYTES_HEX: + return bytesToHexString(valueBytes); + case TYPE_BYTES_BASE64: + return Base64.encodeToString(valueBytes, Base64.NO_WRAP); + case TYPE_INT: + return Integer.toString(valueInt); + case TYPE_INT_HEX: + return Integer.toString(valueInt, 16); + case TYPE_LONG: + return Long.toString(valueLong); + case TYPE_LONG_HEX: + return Long.toString(valueLong, 16); + case TYPE_FLOAT: + return Float.toString(valueFloat); + case TYPE_DOUBLE: + return Double.toString(valueDouble); + case TYPE_BOOLEAN_TRUE: + return "true"; + case TYPE_BOOLEAN_FALSE: + return "false"; + default: + // Unknown data type; null is the best we can offer + return null; + } + } + + public @Nullable byte[] getValueBytesHex() throws IOException { + switch (type) { + case TYPE_NULL: + return null; + case TYPE_BYTES_HEX: + case TYPE_BYTES_BASE64: + return valueBytes; + case TYPE_STRING: + return hexStringToBytes(valueString); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public @Nullable byte[] getValueBytesBase64() throws IOException { + switch (type) { + case TYPE_NULL: + return null; + case TYPE_BYTES_HEX: + case TYPE_BYTES_BASE64: + return valueBytes; + case TYPE_STRING: + return Base64.decode(valueString, Base64.NO_WRAP); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public int getValueInt() throws IOException { + switch (type) { + case TYPE_INT: + case TYPE_INT_HEX: + return valueInt; + case TYPE_STRING: + return Integer.parseInt(valueString); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public int getValueIntHex() throws IOException { + switch (type) { + case TYPE_INT: + case TYPE_INT_HEX: + return valueInt; + case TYPE_STRING: + return Integer.parseInt(valueString, 16); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public long getValueLong() throws IOException { + switch (type) { + case TYPE_LONG: + case TYPE_LONG_HEX: + return valueLong; + case TYPE_STRING: + return Long.parseLong(valueString); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public long getValueLongHex() throws IOException { + switch (type) { + case TYPE_LONG: + case TYPE_LONG_HEX: + return valueLong; + case TYPE_STRING: + return Long.parseLong(valueString, 16); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public float getValueFloat() throws IOException { + switch (type) { + case TYPE_FLOAT: + return valueFloat; + case TYPE_STRING: + return Float.parseFloat(valueString); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public double getValueDouble() throws IOException { + switch (type) { + case TYPE_DOUBLE: + return valueDouble; + case TYPE_STRING: + return Double.parseDouble(valueString); + default: + throw new IOException("Invalid conversion from " + type); + } + } + + public boolean getValueBoolean() throws IOException { + switch (type) { + case TYPE_BOOLEAN_TRUE: + return true; + case TYPE_BOOLEAN_FALSE: + return false; + case TYPE_STRING: + if ("true".equalsIgnoreCase(valueString)) { + return true; + } else if ("false".equalsIgnoreCase(valueString)) { + return false; + } else { + throw new IOException("Invalid boolean: " + valueString); + } + default: + throw new IOException("Invalid conversion from " + type); + } + } + } + + // NOTE: To support unbundled clients, we include an inlined copy + // of hex conversion logic from HexDump below + private final static char[] HEX_DIGITS = + { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; + + private static int toByte(char c) throws IOException { + if (c >= '0' && c <= '9') return (c - '0'); + if (c >= 'A' && c <= 'F') return (c - 'A' + 10); + if (c >= 'a' && c <= 'f') return (c - 'a' + 10); + throw new IOException("Invalid hex char '" + c + "'"); + } + + static String bytesToHexString(byte[] value) { + final int length = value.length; + final char[] buf = new char[length * 2]; + int bufIndex = 0; + for (int i = 0; i < length; i++) { + byte b = value[i]; + buf[bufIndex++] = HEX_DIGITS[(b >>> 4) & 0x0F]; + buf[bufIndex++] = HEX_DIGITS[b & 0x0F]; + } + return new String(buf); + } + + static byte[] hexStringToBytes(String value) throws IOException { + final int length = value.length(); + if (length % 2 != 0) { + throw new IOException("Invalid hex length " + length); + } + byte[] buffer = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + buffer[i / 2] = (byte) ((toByte(value.charAt(i)) << 4) + | toByte(value.charAt(i + 1))); + } + return buffer; + } +} diff --git a/core/java/com/android/internal/util/BinaryXmlSerializer.java b/core/java/com/android/internal/util/BinaryXmlSerializer.java new file mode 100644 index 0000000000000..d3fcf71ba3996 --- /dev/null +++ b/core/java/com/android/internal/util/BinaryXmlSerializer.java @@ -0,0 +1,396 @@ +/* + * Copyright (C) 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 com.android.internal.util; + +import static org.xmlpull.v1.XmlPullParser.CDSECT; +import static org.xmlpull.v1.XmlPullParser.COMMENT; +import static org.xmlpull.v1.XmlPullParser.DOCDECL; +import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT; +import static org.xmlpull.v1.XmlPullParser.END_TAG; +import static org.xmlpull.v1.XmlPullParser.ENTITY_REF; +import static org.xmlpull.v1.XmlPullParser.IGNORABLE_WHITESPACE; +import static org.xmlpull.v1.XmlPullParser.PROCESSING_INSTRUCTION; +import static org.xmlpull.v1.XmlPullParser.START_DOCUMENT; +import static org.xmlpull.v1.XmlPullParser.START_TAG; +import static org.xmlpull.v1.XmlPullParser.TEXT; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.util.TypedXmlSerializer; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Serializer that writes XML documents using a custom binary wire protocol + * which benchmarking has shown to be 4.3x faster and use 2.4x less disk space + * than {@code Xml.newFastSerializer()} for a typical {@code packages.xml}. + *

+ * The high-level design of the wire protocol is to directly serialize the event + * stream, while efficiently and compactly writing strongly-typed primitives + * delivered through the {@link TypedXmlSerializer} interface. + *

+ * Each serialized event is a single byte where the lower half is a normal + * {@link XmlPullParser} token and the upper half is an optional data type + * signal, such as {@link #TYPE_INT}. + *

+ * This serializer has some specific limitations: + *

    + *
  • Only the UTF-8 encoding is supported. + *
  • Variable length values, such as {@code byte[]} or {@link String}, are + * limited to 65,535 bytes in length. Note that {@link String} values are stored + * as UTF-8 on the wire. + *
  • Namespaces, prefixes, properties, and options are unsupported. + *
+ */ +public final class BinaryXmlSerializer implements TypedXmlSerializer { + /** + * The wire protocol always begins with a well-known magic value of + * {@code ABX_}, representing "Android Binary XML." The final byte is a + * version number which may be incremented as the protocol changes. + */ + static final byte[] PROTOCOL_MAGIC_VERSION_0 = new byte[] { 0x41, 0x42, 0x58, 0x00 }; + + /** + * Internal token which represents an attribute associated with the most + * recent {@link #START_TAG} token. + */ + static final int ATTRIBUTE = 15; + + static final int TYPE_NULL = 1 << 4; + static final int TYPE_STRING = 2 << 4; + static final int TYPE_STRING_INTERNED = 3 << 4; + static final int TYPE_BYTES_HEX = 4 << 4; + static final int TYPE_BYTES_BASE64 = 5 << 4; + static final int TYPE_INT = 6 << 4; + static final int TYPE_INT_HEX = 7 << 4; + static final int TYPE_LONG = 8 << 4; + static final int TYPE_LONG_HEX = 9 << 4; + static final int TYPE_FLOAT = 10 << 4; + static final int TYPE_DOUBLE = 11 << 4; + static final int TYPE_BOOLEAN_TRUE = 12 << 4; + static final int TYPE_BOOLEAN_FALSE = 13 << 4; + + /** + * Default buffer size, which matches {@code FastXmlSerializer}. This should + * be kept in sync with {@link BinaryXmlPullParser}. + */ + private static final int BUFFER_SIZE = 32_768; + + private FastDataOutput mOut; + + /** + * Stack of tags which are currently active via {@link #startTag} and which + * haven't been terminated via {@link #endTag}. + */ + private int mTagCount = 0; + private String[] mTagNames; + + /** + * Write the given token and optional {@link String} into our buffer. + */ + private void writeToken(int token, @Nullable String text) throws IOException { + if (text != null) { + mOut.writeByte(token | TYPE_STRING); + mOut.writeUTF(text); + } else { + mOut.writeByte(token | TYPE_NULL); + } + } + + @Override + public void setOutput(@NonNull OutputStream os, @Nullable String encoding) throws IOException { + if (encoding != null && !StandardCharsets.UTF_8.name().equals(encoding)) { + throw new UnsupportedOperationException(); + } + + mOut = new FastDataOutput(os, BUFFER_SIZE); + mOut.write(PROTOCOL_MAGIC_VERSION_0); + + mTagCount = 0; + mTagNames = new String[8]; + } + + @Override + public void setOutput(Writer writer) { + throw new UnsupportedOperationException(); + } + + @Override + public void flush() throws IOException { + mOut.flush(); + } + + @Override + public void startDocument(@Nullable String encoding, @Nullable Boolean standalone) + throws IOException { + if (encoding != null && !StandardCharsets.UTF_8.name().equals(encoding)) { + throw new UnsupportedOperationException(); + } + mOut.writeByte(START_DOCUMENT | TYPE_NULL); + } + + @Override + public void endDocument() throws IOException { + mOut.writeByte(END_DOCUMENT | TYPE_NULL); + flush(); + } + + @Override + public int getDepth() { + return mTagCount; + } + + @Override + public String getNamespace() { + // Namespaces are unsupported + return XmlPullParser.NO_NAMESPACE; + } + + @Override + public String getName() { + return mTagNames[mTagCount - 1]; + } + + @Override + public XmlSerializer startTag(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + if (mTagCount == mTagNames.length) { + mTagNames = Arrays.copyOf(mTagNames, mTagCount + (mTagCount >> 1)); + } + mTagNames[mTagCount++] = name; + mOut.writeByte(START_TAG | TYPE_STRING_INTERNED); + mOut.writeInternedUTF(name); + return this; + } + + @Override + public XmlSerializer endTag(String namespace, String name) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mTagCount--; + mOut.writeByte(END_TAG | TYPE_STRING_INTERNED); + mOut.writeInternedUTF(name); + return this; + } + + @Override + public XmlSerializer attribute(String namespace, String name, String value) throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_STRING); + mOut.writeInternedUTF(name); + mOut.writeUTF(value); + return this; + } + + @Override + public XmlSerializer attributeInterned(String namespace, String name, String value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_STRING_INTERNED); + mOut.writeInternedUTF(name); + mOut.writeInternedUTF(value); + return this; + } + + @Override + public XmlSerializer attributeBytesHex(String namespace, String name, byte[] value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_BYTES_HEX); + mOut.writeInternedUTF(name); + mOut.writeShort(value.length); + mOut.write(value); + return this; + } + + @Override + public XmlSerializer attributeBytesBase64(String namespace, String name, byte[] value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_BYTES_BASE64); + mOut.writeInternedUTF(name); + mOut.writeShort(value.length); + mOut.write(value); + return this; + } + + @Override + public XmlSerializer attributeInt(String namespace, String name, int value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_INT); + mOut.writeInternedUTF(name); + mOut.writeInt(value); + return this; + } + + @Override + public XmlSerializer attributeIntHex(String namespace, String name, int value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_INT_HEX); + mOut.writeInternedUTF(name); + mOut.writeInt(value); + return this; + } + + @Override + public XmlSerializer attributeLong(String namespace, String name, long value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_LONG); + mOut.writeInternedUTF(name); + mOut.writeLong(value); + return this; + } + + @Override + public XmlSerializer attributeLongHex(String namespace, String name, long value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_LONG_HEX); + mOut.writeInternedUTF(name); + mOut.writeLong(value); + return this; + } + + @Override + public XmlSerializer attributeFloat(String namespace, String name, float value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_FLOAT); + mOut.writeInternedUTF(name); + mOut.writeFloat(value); + return this; + } + + @Override + public XmlSerializer attributeDouble(String namespace, String name, double value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + mOut.writeByte(ATTRIBUTE | TYPE_DOUBLE); + mOut.writeInternedUTF(name); + mOut.writeDouble(value); + return this; + } + + @Override + public XmlSerializer attributeBoolean(String namespace, String name, boolean value) + throws IOException { + if (namespace != null && !namespace.isEmpty()) throw illegalNamespace(); + if (value) { + mOut.writeByte(ATTRIBUTE | TYPE_BOOLEAN_TRUE); + mOut.writeInternedUTF(name); + } else { + mOut.writeByte(ATTRIBUTE | TYPE_BOOLEAN_FALSE); + mOut.writeInternedUTF(name); + } + return this; + } + + @Override + public XmlSerializer text(char[] buf, int start, int len) throws IOException { + writeToken(TEXT, new String(buf, start, len)); + return this; + } + + @Override + public XmlSerializer text(String text) throws IOException { + writeToken(TEXT, text); + return this; + } + + @Override + public void cdsect(String text) throws IOException { + writeToken(CDSECT, text); + } + + @Override + public void entityRef(String text) throws IOException { + writeToken(ENTITY_REF, text); + } + + @Override + public void processingInstruction(String text) throws IOException { + writeToken(PROCESSING_INSTRUCTION, text); + } + + @Override + public void comment(String text) throws IOException { + writeToken(COMMENT, text); + } + + @Override + public void docdecl(String text) throws IOException { + writeToken(DOCDECL, text); + } + + @Override + public void ignorableWhitespace(String text) throws IOException { + writeToken(IGNORABLE_WHITESPACE, text); + } + + @Override + public void setFeature(String name, boolean state) { + // Quietly handle no-op features + if ("http://xmlpull.org/v1/doc/features.html#indent-output".equals(name)) { + return; + } + // Features are not supported + throw new UnsupportedOperationException(); + } + + @Override + public boolean getFeature(String name) { + // Features are not supported + throw new UnsupportedOperationException(); + } + + @Override + public void setProperty(String name, Object value) { + // Properties are not supported + throw new UnsupportedOperationException(); + } + + @Override + public Object getProperty(String name) { + // Properties are not supported + throw new UnsupportedOperationException(); + } + + @Override + public void setPrefix(String prefix, String namespace) { + // Prefixes are not supported + throw new UnsupportedOperationException(); + } + + @Override + public String getPrefix(String namespace, boolean generatePrefix) { + // Prefixes are not supported + throw new UnsupportedOperationException(); + } + + private static IllegalArgumentException illegalNamespace() { + throw new IllegalArgumentException("Namespaces are not supported"); + } +} diff --git a/core/tests/coretests/src/android/util/BinaryXmlTest.java b/core/tests/coretests/src/android/util/BinaryXmlTest.java new file mode 100644 index 0000000000000..be63a0ecc65fb --- /dev/null +++ b/core/tests/coretests/src/android/util/BinaryXmlTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 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 android.util; + +import static android.util.XmlTest.assertNext; +import static android.util.XmlTest.buildPersistableBundle; +import static android.util.XmlTest.doPersistableBundleRead; +import static android.util.XmlTest.doPersistableBundleWrite; + +import static org.junit.Assert.assertEquals; +import static org.xmlpull.v1.XmlPullParser.START_TAG; + +import android.os.PersistableBundle; + +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +@RunWith(AndroidJUnit4.class) +public class BinaryXmlTest { + /** + * Verify that we can write and read large numbers of interned + * {@link String} values. + */ + @Test + public void testLargeInterned_Binary() throws Exception { + // We're okay with the tag itself being interned + final int count = (1 << 16) - 2; + + final TypedXmlSerializer out = Xml.newBinarySerializer(); + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + out.setOutput(os, StandardCharsets.UTF_8.name()); + out.startTag(null, "tag"); + for (int i = 0; i < count; i++) { + out.attribute(null, "name" + i, "value"); + } + out.endTag(null, "tag"); + out.flush(); + + final TypedXmlPullParser in = Xml.newBinaryPullParser(); + final ByteArrayInputStream is = new ByteArrayInputStream(os.toByteArray()); + in.setInput(is, StandardCharsets.UTF_8.name()); + assertNext(in, START_TAG, "tag", 1); + assertEquals(count, in.getAttributeCount()); + } + + @Test + public void testTranscode_FastToBinary() throws Exception { + doTranscode(Xml.newFastSerializer(), Xml.newFastPullParser(), + Xml.newBinarySerializer(), Xml.newBinaryPullParser()); + } + + @Test + public void testTranscode_BinaryToFast() throws Exception { + doTranscode(Xml.newBinarySerializer(), Xml.newBinaryPullParser(), + Xml.newFastSerializer(), Xml.newFastPullParser()); + } + + /** + * Verify that a complex {@link PersistableBundle} can be transcoded using + * the two given formats with the original structure intact. + */ + private static void doTranscode(TypedXmlSerializer firstOut, TypedXmlPullParser firstIn, + TypedXmlSerializer secondOut, TypedXmlPullParser secondIn) throws Exception { + final PersistableBundle expected = buildPersistableBundle(); + final byte[] firstRaw = doPersistableBundleWrite(firstOut, expected); + + // Perform actual transcoding between the two formats + final ByteArrayInputStream is = new ByteArrayInputStream(firstRaw); + firstIn.setInput(is, StandardCharsets.UTF_8.name()); + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + secondOut.setOutput(os, StandardCharsets.UTF_8.name()); + Xml.copy(firstIn, secondOut); + + // Yes, this string-based check is fragile, but kindofEquals() is broken + // when working with nested objects and arrays + final PersistableBundle actual = doPersistableBundleRead(secondIn, os.toByteArray()); + assertEquals(expected.toString(), actual.toString()); + } +} diff --git a/core/tests/coretests/src/android/util/XmlTest.java b/core/tests/coretests/src/android/util/XmlTest.java index 602b672a2b82d..2ae9cdfedb11f 100644 --- a/core/tests/coretests/src/android/util/XmlTest.java +++ b/core/tests/coretests/src/android/util/XmlTest.java @@ -52,13 +52,19 @@ public class XmlTest { Xml.newFastPullParser()); } + @Test + public void testLargeValues_Binary() throws Exception { + doLargeValues(Xml.newBinarySerializer(), + Xml.newBinaryPullParser()); + } + /** * Verify that we can write and read large {@link String} and {@code byte[]} * without issues. */ private static void doLargeValues(TypedXmlSerializer out, TypedXmlPullParser in) throws Exception { - final char[] chars = new char[32_768]; + final char[] chars = new char[(1 << 16) - 1]; Arrays.fill(chars, '!'); final String string = new String(chars); @@ -93,6 +99,12 @@ public class XmlTest { Xml.newFastPullParser()); } + @Test + public void testPersistableBundle_Binary() throws Exception { + doPersistableBundle(Xml.newBinarySerializer(), + Xml.newBinaryPullParser()); + } + /** * Verify that a complex {@link PersistableBundle} can be serialized out and * then parsed in with the original structure intact. @@ -108,7 +120,7 @@ public class XmlTest { assertEquals(expected.toString(), actual.toString()); } - private static PersistableBundle buildPersistableBundle() { + static PersistableBundle buildPersistableBundle() { final PersistableBundle outer = new PersistableBundle(); outer.putBoolean("boolean", true); @@ -130,7 +142,7 @@ public class XmlTest { return outer; } - private static byte[] doPersistableBundleWrite(TypedXmlSerializer out, PersistableBundle bundle) + static byte[] doPersistableBundleWrite(TypedXmlSerializer out, PersistableBundle bundle) throws Exception { // We purposefully omit START/END_DOCUMENT events here to verify correct // behavior of what PersistableBundle does internally @@ -143,7 +155,7 @@ public class XmlTest { return os.toByteArray(); } - private static PersistableBundle doPersistableBundleRead(TypedXmlPullParser in, byte[] raw) + static PersistableBundle doPersistableBundleRead(TypedXmlPullParser in, byte[] raw) throws Exception { final ByteArrayInputStream is = new ByteArrayInputStream(raw); in.setInput(is, StandardCharsets.UTF_8.name()); @@ -163,6 +175,12 @@ public class XmlTest { Xml.newFastPullParser()); } + @Test + public void testVerify_Binary() throws Exception { + doVerify(Xml.newBinarySerializer(), + Xml.newBinaryPullParser()); + } + /** * Verify that example test data is correctly serialized and parsed * end-to-end using the given objects. @@ -268,7 +286,7 @@ public class XmlTest { assertNext(in, END_DOCUMENT); } - private static void assertNext(TypedXmlPullParser in, int token) throws Exception { + static void assertNext(TypedXmlPullParser in, int token) throws Exception { // We're willing to skip over empty text regions, which some // serializers emit transparently int event; @@ -278,7 +296,7 @@ public class XmlTest { assertEquals("getEventType", token, in.getEventType()); } - private static void assertNext(TypedXmlPullParser in, int token, String name, int depth) + static void assertNext(TypedXmlPullParser in, int token, String name, int depth) throws Exception { assertNext(in, token); assertEquals("getName", name, in.getName());