blob: 57552f301bd6fd36d710250c190239ed5db7b3fb [file] [log] [blame]
/*
* 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}.
* <p>
* 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.
* <p>
* 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}.
* <p>
* This parser has some specific limitations:
* <ul>
* <li>Only the UTF-8 encoding is supported.
* <li>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.
* <li>Namespaces, prefixes, properties, and options are unsupported.
* </ul>
*/
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 encoding) throws XmlPullParserException {
if (encoding != null && !StandardCharsets.UTF_8.name().equalsIgnoreCase(encoding)) {
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.
* <p>
* 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.
* <p>
* 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: {
mCurrentName = null;
mCurrentText = null;
if (mAttributeCount > 0) resetAttributes();
break;
}
case XmlPullParser.END_DOCUMENT: {
mCurrentName = null;
mCurrentText = null;
if (mAttributeCount > 0) resetAttributes();
break;
}
case XmlPullParser.START_TAG: {
mCurrentName = mIn.readInternedUTF();
mCurrentText = null;
if (mAttributeCount > 0) resetAttributes();
break;
}
case XmlPullParser.END_TAG: {
mCurrentName = mIn.readInternedUTF();
mCurrentText = null;
if (mAttributeCount > 0) resetAttributes();
break;
}
case XmlPullParser.TEXT:
case XmlPullParser.CDSECT:
case XmlPullParser.PROCESSING_INSTRUCTION:
case XmlPullParser.COMMENT:
case XmlPullParser.DOCDECL:
case XmlPullParser.IGNORABLE_WHITESPACE: {
mCurrentName = null;
mCurrentText = mIn.readUTF();
if (mAttributeCount > 0) resetAttributes();
break;
}
case XmlPullParser.ENTITY_REF: {
mCurrentName = mIn.readUTF();
mCurrentText = resolveEntity(mCurrentName);
if (mAttributeCount > 0) resetAttributes();
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;
}
@Override
public int getAttributeIndex(String namespace, String name) {
if (namespace != null && !namespace.isEmpty()) throw illegalNamespace();
for (int i = 0; i < mAttributeCount; i++) {
if (Objects.equals(mAttributes[i].name, name)) {
return i;
}
}
return -1;
}
@Override
public String getAttributeValue(String namespace, String name) {
final int index = getAttributeIndex(namespace, name);
if (index != -1) {
return mAttributes[index].getValueString();
} else {
return null;
}
}
@Override
public String getAttributeValue(int index) {
return mAttributes[index].getValueString();
}
@Override
public byte[] getAttributeBytesHex(int index) throws XmlPullParserException {
return mAttributes[index].getValueBytesHex();
}
@Override
public byte[] getAttributeBytesBase64(int index) throws XmlPullParserException {
return mAttributes[index].getValueBytesBase64();
}
@Override
public int getAttributeInt(int index) throws XmlPullParserException {
return mAttributes[index].getValueInt();
}
@Override
public int getAttributeIntHex(int index) throws XmlPullParserException {
return mAttributes[index].getValueIntHex();
}
@Override
public long getAttributeLong(int index) throws XmlPullParserException {
return mAttributes[index].getValueLong();
}
@Override
public long getAttributeLongHex(int index) throws XmlPullParserException {
return mAttributes[index].getValueLongHex();
}
@Override
public float getAttributeFloat(int index) throws XmlPullParserException {
return mAttributes[index].getValueFloat();
}
@Override
public double getAttributeDouble(int index) throws XmlPullParserException {
return mAttributes[index].getValueDouble();
}
@Override
public boolean getAttributeBoolean(int index) throws XmlPullParserException {
return mAttributes[index].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.
* <p>
* 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 XmlPullParserException {
switch (type) {
case TYPE_NULL:
return null;
case TYPE_BYTES_HEX:
case TYPE_BYTES_BASE64:
return valueBytes;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return hexStringToBytes(valueString);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public @Nullable byte[] getValueBytesBase64() throws XmlPullParserException {
switch (type) {
case TYPE_NULL:
return null;
case TYPE_BYTES_HEX:
case TYPE_BYTES_BASE64:
return valueBytes;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Base64.decode(valueString, Base64.NO_WRAP);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public int getValueInt() throws XmlPullParserException {
switch (type) {
case TYPE_INT:
case TYPE_INT_HEX:
return valueInt;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Integer.parseInt(valueString);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public int getValueIntHex() throws XmlPullParserException {
switch (type) {
case TYPE_INT:
case TYPE_INT_HEX:
return valueInt;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Integer.parseInt(valueString, 16);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public long getValueLong() throws XmlPullParserException {
switch (type) {
case TYPE_LONG:
case TYPE_LONG_HEX:
return valueLong;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Long.parseLong(valueString);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public long getValueLongHex() throws XmlPullParserException {
switch (type) {
case TYPE_LONG:
case TYPE_LONG_HEX:
return valueLong;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Long.parseLong(valueString, 16);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public float getValueFloat() throws XmlPullParserException {
switch (type) {
case TYPE_FLOAT:
return valueFloat;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Float.parseFloat(valueString);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public double getValueDouble() throws XmlPullParserException {
switch (type) {
case TYPE_DOUBLE:
return valueDouble;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
try {
return Double.parseDouble(valueString);
} catch (Exception e) {
throw new XmlPullParserException("Invalid attribute " + name + ": " + e);
}
default:
throw new XmlPullParserException("Invalid conversion from " + type);
}
}
public boolean getValueBoolean() throws XmlPullParserException {
switch (type) {
case TYPE_BOOLEAN_TRUE:
return true;
case TYPE_BOOLEAN_FALSE:
return false;
case TYPE_STRING:
case TYPE_STRING_INTERNED:
if ("true".equalsIgnoreCase(valueString)) {
return true;
} else if ("false".equalsIgnoreCase(valueString)) {
return false;
} else {
throw new XmlPullParserException(
"Invalid attribute " + name + ": " + valueString);
}
default:
throw new XmlPullParserException("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) {
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 IllegalArgumentException("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) {
final int length = value.length();
if (length % 2 != 0) {
throw new IllegalArgumentException("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;
}
}