POM: Split into -bom, -core, -parent, -bundle.

Change-Id: I1fd4cc766b60266ef9dcc40e943b45d067dd7b90
diff --git a/jgvariant-core/pom.xml b/jgvariant-core/pom.xml
new file mode 100644
index 0000000..29f6742
--- /dev/null
+++ b/jgvariant-core/pom.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
+  xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <version>0.1.4-SNAPSHOT</version>
+
+  <artifactId>jgvariant-core</artifactId>
+  <packaging>jar</packaging>
+
+  <name>JGVariant Core</name>
+  <url>https://gerrit.benkard.de/plugins/gitiles/jgvariant</url>
+
+  <description>
+    GVariant serialization and deserialization.
+  </description>
+
+  <parent>
+    <groupId>eu.mulk.jgvariant</groupId>
+    <artifactId>jgvariant-parent</artifactId>
+    <version>0.1.4-SNAPSHOT</version>
+    <relativePath>../jgvariant-parent/pom.xml</relativePath>
+  </parent>
+
+  <dependencies>
+    <!-- Annotations -->
+    <dependency>
+      <groupId>com.google.errorprone</groupId>
+      <artifactId>error_prone_annotations</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.jetbrains</groupId>
+      <artifactId>annotations</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.apiguardian</groupId>
+      <artifactId>apiguardian-api</artifactId>
+    </dependency>
+
+    <!-- Testing -->
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+</project>
diff --git a/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java
new file mode 100644
index 0000000..d2f2403
--- /dev/null
+++ b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Decoder.java
@@ -0,0 +1,652 @@
+package eu.mulk.jgvariant.core;
+
+import static java.nio.ByteOrder.LITTLE_ENDIAN;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.RecordComponent;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.apiguardian.api.API;
+import org.apiguardian.api.API.Status;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Type class for decodable types.
+ *
+ * <p>Use the {@code of*} family of constructor methods to acquire a suitable {@link Decoder} for
+ * the type you wish to decode.
+ *
+ * <p><strong>Example</strong>
+ *
+ * <p>To parse a GVariant of type {@code "a(si)"}, which is an array of pairs of {@link String} and
+ * {@code int}, you can use the following code:
+ *
+ * <pre>{@code
+ * record ExampleRecord(String s, int i) {}
+ *
+ * var decoder =
+ *   Decoder.ofArray(
+ *     Decoder.ofStructure(
+ *       ExampleRecord.class,
+ *       Decoder.ofString(UTF_8),
+ *       Decoder.ofInt().withByteOrder(LITTLE_ENDIAN)));
+ *
+ * byte[] bytes = ...;
+ * List<ExampleRecord> example = decoder.decode(ByteBuffer.wrap(bytes));
+ * }</pre>
+ *
+ * @param <T> the type that the {@link Decoder} can decode.
+ */
+@SuppressWarnings("java:S1610")
+@API(status = Status.EXPERIMENTAL)
+public abstract class Decoder<T> {
+
+  private Decoder() {}
+
+  /**
+   * Decodes a {@link ByteBuffer} holding a serialized GVariant into a value of type {@code T}.
+   *
+   * <p><strong>Note:</strong> Due to the way the GVariant serialization format works, it is
+   * important that the start and end boundaries of the passed byte slice correspond to the actual
+   * start and end of the serialized value. The format does generally not allow for the dynamic
+   * discovery of the end of the data structure.
+   *
+   * @param byteSlice a byte slice holding a serialized GVariant.
+   * @return the deserialized value.
+   * @throws java.nio.BufferUnderflowException if the byte buffer is shorter than the requested
+   *     data.
+   * @throws IllegalArgumentException if the serialized GVariant is ill-formed
+   */
+  public abstract T decode(ByteBuffer byteSlice);
+
+  abstract byte alignment();
+
+  @Nullable
+  abstract Integer fixedSize();
+
+  final boolean hasFixedSize() {
+    return fixedSize() != null;
+  }
+
+  /**
+   * Switches the input {@link ByteBuffer} to a given {@link ByteOrder} before reading from it.
+   *
+   * @param byteOrder the byte order to use.
+   * @return a new, decorated {@link Decoder}.
+   */
+  public Decoder<T> withByteOrder(ByteOrder byteOrder) {
+    var delegate = this;
+
+    return new Decoder<>() {
+      @Override
+      public byte alignment() {
+        return delegate.alignment();
+      }
+
+      @Override
+      public @Nullable Integer fixedSize() {
+        return delegate.fixedSize();
+      }
+
+      @Override
+      public T decode(ByteBuffer byteSlice) {
+        byteSlice.order(byteOrder);
+        return delegate.decode(byteSlice);
+      }
+    };
+  }
+
+  /**
+   * Creates a {@link Decoder} for an {@code Array} type.
+   *
+   * @param elementDecoder a {@link Decoder} for the elements of the array.
+   * @param <U> the element type.
+   * @return a new {@link Decoder}.
+   */
+  public static <U> Decoder<List<U>> ofArray(Decoder<U> elementDecoder) {
+    return new ArrayDecoder<>(elementDecoder);
+  }
+
+  /**
+   * Creates a {@link Decoder} for a {@code Maybe} type.
+   *
+   * @param elementDecoder a {@link Decoder} for the contained element.
+   * @param <U> the element type.
+   * @return a new {@link Decoder}.
+   */
+  public static <U> Decoder<Optional<U>> ofMaybe(Decoder<U> elementDecoder) {
+    return new MaybeDecoder<>(elementDecoder);
+  }
+
+  /**
+   * Creates a {@link Decoder} for a {@code Structure} type, decoding into a {@link Record}.
+   *
+   * @param recordType the {@link Record} type that represents the components of the structure.
+   * @param componentDecoders a {@link Decoder} for each component of the structure.
+   * @param <U> the {@link Record} type that represents the components of the structure.
+   * @return a new {@link Decoder}.
+   */
+  public static <U extends Record> Decoder<U> ofStructure(
+      Class<U> recordType, Decoder<?>... componentDecoders) {
+    return new StructureDecoder<>(recordType, componentDecoders);
+  }
+
+  /**
+   * Creates a {@link Decoder} for a {@code Structure} type, decoding into a {@link List}.
+   *
+   * <p>Prefer {@link #ofStructure(Class, Decoder[])} if possible, which is both more type-safe and
+   * more convenient.
+   *
+   * @param componentDecoders a {@link Decoder} for each component of the structure.
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Object[]> ofStructure(Decoder<?>... componentDecoders) {
+    return new TupleDecoder(componentDecoders);
+  }
+
+  /**
+   * Creates a {@link Decoder} for the {@link Variant} type.
+   *
+   * <p>The contained {@link Object} can be of one of the following types:
+   *
+   * <ul>
+   *   <li>{@link Boolean}
+   *   <li>{@link Byte}
+   *   <li>{@link Short}
+   *   <li>{@link Integer}
+   *   <li>{@link Long}
+   *   <li>{@link String}
+   *   <li>{@link Optional} (a GVariant {@code Maybe} type)
+   *   <li>{@link List} (a GVariant array)
+   *   <li>{@code Object[]} (a GVariant structure)
+   *   <li>{@link Variant} (a nested variant)
+   * </ul>
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Variant> ofVariant() {
+    return new VariantDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the {@code boolean} type.
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Boolean> ofBoolean() {
+    return new BooleanDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the 8-bit {@code byte} type.
+   *
+   * <p><strong>Note:</strong> It is often useful to apply {@link #withByteOrder(ByteOrder)} to the
+   * result of this method.
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Byte> ofByte() {
+    return new ByteDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the 16-bit {@code short} type.
+   *
+   * <p><strong>Note:</strong> It is often useful to apply {@link #withByteOrder(ByteOrder)} to the
+   * result of this method.
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Short> ofShort() {
+    return new ShortDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the 32-bit {@code int} type.
+   *
+   * <p><strong>Note:</strong> It is often useful to apply {@link #withByteOrder(ByteOrder)} to the
+   * result of this method.
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Integer> ofInt() {
+    return new IntegerDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the 64-bit {@code long} type.
+   *
+   * <p><strong>Note:</strong> It is often useful to apply {@link #withByteOrder(ByteOrder)} to the
+   * result of this method.
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Long> ofLong() {
+    return new LongDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the {@code double} type.
+   *
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<Double> ofDouble() {
+    return new DoubleDecoder();
+  }
+
+  /**
+   * Creates a {@link Decoder} for the {@link String} type.
+   *
+   * <p><strong>Note:</strong> While GVariant does not prescribe any particular encoding, {@link
+   * java.nio.charset.StandardCharsets#UTF_8} is the most common choice.
+   *
+   * @param charset the {@link Charset} the string is encoded in.
+   * @return a new {@link Decoder}.
+   */
+  public static Decoder<String> ofString(Charset charset) {
+    return new StringDecoder(charset);
+  }
+
+  private static int align(int offset, byte alignment) {
+    return offset % alignment == 0 ? offset : offset + alignment - (offset % alignment);
+  }
+
+  private static int getIntN(ByteBuffer byteSlice) {
+    var intBytes = new byte[4];
+    byteSlice.get(intBytes, 0, Math.min(4, byteSlice.limit()));
+    return ByteBuffer.wrap(intBytes).order(LITTLE_ENDIAN).getInt();
+  }
+
+  @SuppressWarnings("java:S3358")
+  private static int byteCount(int n) {
+    return n < (1 << 8) ? 1 : n < (1 << 16) ? 2 : 4;
+  }
+
+  private static class ArrayDecoder<U> extends Decoder<List<U>> {
+
+    private final Decoder<U> elementDecoder;
+
+    ArrayDecoder(Decoder<U> elementDecoder) {
+      this.elementDecoder = elementDecoder;
+    }
+
+    @Override
+    public byte alignment() {
+      return elementDecoder.alignment();
+    }
+
+    @Override
+    @Nullable
+    Integer fixedSize() {
+      return null;
+    }
+
+    @Override
+    public List<U> decode(ByteBuffer byteSlice) {
+      List<U> elements;
+
+      var elementSize = elementDecoder.fixedSize();
+      if (elementSize != null) {
+        // A simple C-style array.
+        elements = new ArrayList<>(byteSlice.limit() / elementSize);
+        for (int i = 0; i < byteSlice.limit(); i += elementSize) {
+          var element = elementDecoder.decode(byteSlice.slice(i, elementSize));
+          elements.add(element);
+        }
+      } else {
+        // An array with aligned elements and a vector of framing offsets in the end.
+        int framingOffsetSize = byteCount(byteSlice.limit());
+        int lastFramingOffset =
+            getIntN(byteSlice.slice(byteSlice.limit() - framingOffsetSize, framingOffsetSize));
+        int elementCount = (byteSlice.limit() - lastFramingOffset) / framingOffsetSize;
+
+        elements = new ArrayList<>(elementCount);
+        int position = 0;
+        for (int i = 0; i < elementCount; i++) {
+          int framingOffset =
+              getIntN(
+                  byteSlice.slice(lastFramingOffset + i * framingOffsetSize, framingOffsetSize));
+          elements.add(elementDecoder.decode(byteSlice.slice(position, framingOffset - position)));
+          position = align(framingOffset, alignment());
+        }
+      }
+
+      return elements;
+    }
+  }
+
+  private static class MaybeDecoder<U> extends Decoder<Optional<U>> {
+
+    private final Decoder<U> elementDecoder;
+
+    MaybeDecoder(Decoder<U> elementDecoder) {
+      this.elementDecoder = elementDecoder;
+    }
+
+    @Override
+    public byte alignment() {
+      return elementDecoder.alignment();
+    }
+
+    @Override
+    @Nullable
+    Integer fixedSize() {
+      return null;
+    }
+
+    @Override
+    public Optional<U> decode(ByteBuffer byteSlice) {
+      if (!byteSlice.hasRemaining()) {
+        return Optional.empty();
+      } else {
+        if (!elementDecoder.hasFixedSize()) {
+          // Remove trailing zero byte.
+          byteSlice.limit(byteSlice.limit() - 1);
+        }
+
+        return Optional.of(elementDecoder.decode(byteSlice));
+      }
+    }
+  }
+
+  private static class StructureDecoder<U extends Record> extends Decoder<U> {
+
+    private final Class<U> recordType;
+    private final TupleDecoder tupleDecoder;
+
+    StructureDecoder(Class<U> recordType, Decoder<?>... componentDecoders) {
+      var recordComponents = recordType.getRecordComponents();
+      if (componentDecoders.length != recordComponents.length) {
+        throw new IllegalArgumentException(
+            "number of decoders (%d) does not match number of structure components (%d)"
+                .formatted(componentDecoders.length, recordComponents.length));
+      }
+
+      this.recordType = recordType;
+      this.tupleDecoder = new TupleDecoder(componentDecoders);
+    }
+
+    @Override
+    public byte alignment() {
+      return tupleDecoder.alignment();
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return tupleDecoder.fixedSize();
+    }
+
+    @Override
+    public U decode(ByteBuffer byteSlice) {
+      Object[] recordConstructorArguments = tupleDecoder.decode(byteSlice);
+
+      try {
+        var recordComponentTypes =
+            Arrays.stream(recordType.getRecordComponents())
+                .map(RecordComponent::getType)
+                .toArray(Class<?>[]::new);
+        var recordConstructor = recordType.getDeclaredConstructor(recordComponentTypes);
+        return recordConstructor.newInstance(recordConstructorArguments);
+      } catch (NoSuchMethodException
+          | InstantiationException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+  }
+
+  private static class TupleDecoder extends Decoder<Object[]> {
+
+    private final Decoder<?>[] componentDecoders;
+
+    TupleDecoder(Decoder<?>... componentDecoders) {
+      this.componentDecoders = componentDecoders;
+    }
+
+    @Override
+    public byte alignment() {
+      return (byte) Arrays.stream(componentDecoders).mapToInt(Decoder::alignment).max().orElse(1);
+    }
+
+    @Override
+    public Integer fixedSize() {
+      int position = 0;
+      for (var componentDecoder : componentDecoders) {
+        var fixedComponentSize = componentDecoder.fixedSize();
+        if (fixedComponentSize == null) {
+          return null;
+        }
+
+        position = align(position, componentDecoder.alignment());
+        position += fixedComponentSize;
+      }
+
+      if (position == 0) {
+        return 1;
+      }
+
+      return align(position, alignment());
+    }
+
+    @Override
+    public Object[] decode(ByteBuffer byteSlice) {
+      int framingOffsetSize = byteCount(byteSlice.limit());
+
+      var objects = new Object[componentDecoders.length];
+
+      int position = 0;
+      int framingOffsetIndex = 0;
+      int componentIndex = 0;
+      for (var componentDecoder : componentDecoders) {
+        position = align(position, componentDecoder.alignment());
+
+        var fixedComponentSize = componentDecoder.fixedSize();
+        if (fixedComponentSize != null) {
+          objects[componentIndex] =
+              componentDecoder.decode(byteSlice.slice(position, fixedComponentSize));
+          position += fixedComponentSize;
+        } else {
+          if (componentIndex == componentDecoders.length - 1) {
+            // The last component never has a framing offset.
+            int endPosition = byteSlice.limit() - framingOffsetIndex * framingOffsetSize;
+            objects[componentIndex] =
+                componentDecoder.decode(byteSlice.slice(position, endPosition - position));
+            position = endPosition;
+          } else {
+            int framingOffset =
+                getIntN(
+                    byteSlice.slice(
+                        byteSlice.limit() - (1 + framingOffsetIndex) * framingOffsetSize,
+                        framingOffsetSize));
+            objects[componentIndex] =
+                componentDecoder.decode(byteSlice.slice(position, framingOffset - position));
+            position = framingOffset;
+            ++framingOffsetIndex;
+          }
+        }
+
+        ++componentIndex;
+      }
+
+      return objects;
+    }
+  }
+
+  private static class VariantDecoder extends Decoder<Variant> {
+
+    @Override
+    public byte alignment() {
+      return 8;
+    }
+
+    @Override
+    @Nullable
+    Integer fixedSize() {
+      return null;
+    }
+
+    @Override
+    public Variant decode(ByteBuffer byteSlice) {
+      for (int i = byteSlice.limit() - 1; i >= 0; --i) {
+        if (byteSlice.get(i) != 0) {
+          continue;
+        }
+
+        var dataBytes = byteSlice.slice(0, i);
+        var signatureBytes = byteSlice.slice(i + 1, byteSlice.limit() - (i + 1));
+
+        Signature signature;
+        try {
+          signature = Signature.parse(signatureBytes);
+        } catch (ParseException e) {
+          throw new IllegalArgumentException(e);
+        }
+
+        return new Variant(signature, signature.decoder().decode(dataBytes));
+      }
+
+      throw new IllegalArgumentException("variant signature not found");
+    }
+  }
+
+  private static class BooleanDecoder extends Decoder<Boolean> {
+
+    @Override
+    public byte alignment() {
+      return 1;
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return 1;
+    }
+
+    @Override
+    public Boolean decode(ByteBuffer byteSlice) {
+      return byteSlice.get() != 0;
+    }
+  }
+
+  private static class ByteDecoder extends Decoder<Byte> {
+
+    @Override
+    public byte alignment() {
+      return 1;
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return 1;
+    }
+
+    @Override
+    public Byte decode(ByteBuffer byteSlice) {
+      return byteSlice.get();
+    }
+  }
+
+  private static class ShortDecoder extends Decoder<Short> {
+
+    @Override
+    public byte alignment() {
+      return 2;
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return 2;
+    }
+
+    @Override
+    public Short decode(ByteBuffer byteSlice) {
+      return byteSlice.getShort();
+    }
+  }
+
+  private static class IntegerDecoder extends Decoder<Integer> {
+
+    @Override
+    public byte alignment() {
+      return 4;
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return 4;
+    }
+
+    @Override
+    public Integer decode(ByteBuffer byteSlice) {
+      return byteSlice.getInt();
+    }
+  }
+
+  private static class LongDecoder extends Decoder<Long> {
+
+    @Override
+    public byte alignment() {
+      return 8;
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return 8;
+    }
+
+    @Override
+    public Long decode(ByteBuffer byteSlice) {
+      return byteSlice.getLong();
+    }
+  }
+
+  private static class DoubleDecoder extends Decoder<Double> {
+
+    @Override
+    public byte alignment() {
+      return 8;
+    }
+
+    @Override
+    public Integer fixedSize() {
+      return 8;
+    }
+
+    @Override
+    public Double decode(ByteBuffer byteSlice) {
+      return byteSlice.getDouble();
+    }
+  }
+
+  private static class StringDecoder extends Decoder<String> {
+
+    private final Charset charset;
+
+    public StringDecoder(Charset charset) {
+      this.charset = charset;
+    }
+
+    @Override
+    public byte alignment() {
+      return 1;
+    }
+
+    @Override
+    @Nullable
+    Integer fixedSize() {
+      return null;
+    }
+
+    @Override
+    public String decode(ByteBuffer byteSlice) {
+      byteSlice.limit(byteSlice.limit() - 1);
+      return charset.decode(byteSlice).toString();
+    }
+  }
+}
diff --git a/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Signature.java b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Signature.java
new file mode 100644
index 0000000..d9de5f1
--- /dev/null
+++ b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Signature.java
@@ -0,0 +1,116 @@
+package eu.mulk.jgvariant.core;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import org.apiguardian.api.API;
+import org.apiguardian.api.API.Status;
+
+/**
+ * A GVariant signature string.
+ *
+ * <p>Describes a type in the GVariant type system. The type can be arbitrarily complex.
+ *
+ * <p><strong>Examples</strong>
+ *
+ * <dl>
+ *   <dt>{@code "i"}
+ *   <dd>a single 32-bit integer
+ *   <dt>{@code "ai"}
+ *   <dd>an array of 32-bit integers
+ *   <dt>{@code "(bbb(sai))"}
+ *   <dd>a record consisting of three booleans and a nested record, which consists of a string and
+ *       an array of 32-bit integers
+ * </dl>
+ */
+@API(status = Status.STABLE)
+public final class Signature {
+
+  private final String signatureString;
+  private final Decoder<?> decoder;
+
+  Signature(ByteBuffer signatureBytes) throws ParseException {
+    this.decoder = parseSignature(signatureBytes);
+
+    signatureBytes.rewind();
+    this.signatureString = StandardCharsets.US_ASCII.decode(signatureBytes).toString();
+  }
+
+  static Signature parse(ByteBuffer signatureBytes) throws ParseException {
+    return new Signature(signatureBytes);
+  }
+
+  static Signature parse(String signatureString) throws ParseException {
+    var signatureBytes = ByteBuffer.wrap(signatureString.getBytes(StandardCharsets.US_ASCII));
+    return parse(signatureBytes);
+  }
+
+  /**
+   * Returns a {@link Decoder} that can decode values conforming to this signature.
+   *
+   * @return a {@link Decoder} for this signature
+   */
+  @SuppressWarnings("unchecked")
+  Decoder<Object> decoder() {
+    return (Decoder<Object>) decoder;
+  }
+
+  /**
+   * Returns the signature formatted as a GVariant signature string.
+   *
+   * @return a GVariant signature string.
+   */
+  @Override
+  public String toString() {
+    return signatureString;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof Signature signature)
+        && Objects.equals(signatureString, signature.signatureString);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(signatureString);
+  }
+
+  private static Decoder<?> parseSignature(ByteBuffer signature) throws ParseException {
+    char c = (char) signature.get();
+    return switch (c) {
+      case 'b' -> Decoder.ofBoolean();
+      case 'y' -> Decoder.ofByte();
+      case 'n', 'q' -> Decoder.ofShort();
+      case 'i', 'u' -> Decoder.ofInt();
+      case 'x', 't' -> Decoder.ofLong();
+      case 'd' -> Decoder.ofDouble();
+      case 's', 'o', 'g' -> Decoder.ofString(StandardCharsets.UTF_8);
+      case 'v' -> Decoder.ofVariant();
+      case 'm' -> Decoder.ofMaybe(parseSignature(signature));
+      case 'a' -> Decoder.ofArray(parseSignature(signature));
+      case '(', '{' -> Decoder.ofStructure(parseTupleTypes(signature).toArray(new Decoder<?>[0]));
+      default -> throw new ParseException(
+          String.format("encountered unknown signature byte '%c'", c), signature.position());
+    };
+  }
+
+  private static List<Decoder<?>> parseTupleTypes(ByteBuffer signature) throws ParseException {
+    List<Decoder<?>> decoders = new ArrayList<>();
+
+    while (true) {
+      char c = (char) signature.get(signature.position());
+      if (c == ')' || c == '}') {
+        signature.get();
+        break;
+      }
+
+      decoders.add(parseSignature(signature));
+    }
+
+    return decoders;
+  }
+}
diff --git a/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Variant.java b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Variant.java
new file mode 100644
index 0000000..d1c1049
--- /dev/null
+++ b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/Variant.java
@@ -0,0 +1,30 @@
+package eu.mulk.jgvariant.core;
+
+import org.apiguardian.api.API;
+import org.apiguardian.api.API.Status;
+
+/**
+ * A dynamically typed GVariant value carrying a {@link Signature} describing its type.
+ *
+ * <p>{@link #value()} can be of one of the following types:
+ *
+ * <ul>
+ *   <li>{@link Boolean}
+ *   <li>{@link Byte}
+ *   <li>{@link Short}
+ *   <li>{@link Integer}
+ *   <li>{@link Long}
+ *   <li>{@link String}
+ *   <li>{@link java.util.Optional} (a GVariant {@code Maybe} type)
+ *   <li>{@link java.util.List} (a GVariant array)
+ *   <li>{@code Object[]} (a GVariant structure)
+ *   <li>{@link Variant} (a nested variant)
+ * </ul>
+ *
+ * @param signature the signature describing the type of the value.
+ * @param value the value itself; one of {@link Boolean}, {@link Byte}, {@link Short}, {@link
+ *     Integer}, {@link Long}, {@link String}, {@link java.util.Optional}, {@link java.util.List},
+ *     {@code Object[]}, {@link Variant}.
+ */
+@API(status = Status.EXPERIMENTAL)
+public record Variant(Signature signature, Object value) {}
diff --git a/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/package-info.java b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/package-info.java
new file mode 100644
index 0000000..1754096
--- /dev/null
+++ b/jgvariant-core/src/main/java/eu/mulk/jgvariant/core/package-info.java
@@ -0,0 +1,27 @@
+/**
+ * Provides {@link eu.mulk.jgvariant.core.Decoder}, the foundational class for <a
+ * href="https://docs.gtk.org/glib/struct.Variant.html">GVariant</a> parsing.
+ *
+ * <p>Instances of {@link eu.mulk.jgvariant.core.Decoder} read a given value type from a {@link
+ * java.nio.ByteBuffer}. The class also contains factory methods to create those instances.
+ *
+ * <p><strong>Example</strong>
+ *
+ * <p>To parse a GVariant of type {@code "a(si)"}, which is an array of pairs of {@link
+ * java.lang.String} and {@code int}, you can use the following code:
+ *
+ * <pre>{@code
+ * record ExampleRecord(String s, int i) {}
+ *
+ * var decoder =
+ *   Decoder.ofArray(
+ *     Decoder.ofStructure(
+ *       ExampleRecord.class,
+ *       Decoder.ofString(UTF_8),
+ *       Decoder.ofInt().withByteOrder(LITTLE_ENDIAN)));
+ *
+ * byte[] bytes = ...;
+ * List<ExampleRecord> example = decoder.decode(ByteBuffer.wrap(bytes));
+ * }</pre>
+ */
+package eu.mulk.jgvariant.core;
diff --git a/jgvariant-core/src/main/java/module-info.java b/jgvariant-core/src/main/java/module-info.java
new file mode 100644
index 0000000..a1830f6
--- /dev/null
+++ b/jgvariant-core/src/main/java/module-info.java
@@ -0,0 +1,79 @@
+/**
+ * Provides a parser for the <a href="https://docs.gtk.org/glib/struct.Variant.html">GVariant</a>
+ * serialization format.
+ *
+ * <ul>
+ *   <li><a href="#sect-overview">Overview</a>
+ *   <li><a href="#sect-installation">Installation</a>
+ * </ul>
+ *
+ * <h2 id="sect-overview">Overview</h2>
+ *
+ * <p>The {@link eu.mulk.jgvariant.core} package contains the {@link eu.mulk.jgvariant.core.Decoder}
+ * type, which contains classes to parse and represent serialized <a
+ * href="https://docs.gtk.org/glib/struct.Variant.html">GVariant</a> values.
+ *
+ * <h2 id="sect-installation">Installation</h2>
+ *
+ * <ul>
+ *   <li><a href="#sect-installation-maven">Usage with Maven</a>
+ *   <li><a href="#sect-installation-gradle">Usage with Gradle</a>
+ * </ul>
+ *
+ * <h3 id="sect-installation-maven">Usage with Maven</h3>
+ *
+ * <pre>{@code
+ * <project>
+ *   ...
+ *
+ *   <dependencyManagement>
+ *     ...
+ *
+ *     <dependencies>
+ *       <dependency>
+ *         <groupId>eu.mulk.jgvariant</groupId>
+ *         <artifactId>jgvariant-bom</artifactId>
+ *         <version>0.1.4</version>
+ *         <type>pom</type>
+ *         <scope>import</scope>
+ *       </dependency>
+ *     </dependencies>
+ *
+ *     ...
+ *   </dependencyManagement>
+ *
+ *   <dependencies>
+ *     ...
+ *
+ *     <dependency>
+ *       <groupId>eu.mulk.jgvariant</groupId>
+ *       <artifactId>jgvariant-core</artifactId>
+ *     </dependency>
+ *
+ *     ...
+ *   </dependencies>
+ *
+ *   ...
+ * </project>
+ * }</pre>
+ *
+ * <h3 id="sect-installation-gradle">Usage with Gradle</h3>
+ *
+ * <pre>{@code
+ * dependencies {
+ *   ...
+ *
+ *   implementation(platform("eu.mulk.jgvariant:jgvariant-bom:0.1.4")
+ *   implementation("eu.mulk.jgvariant:jgvariant-core")
+ *
+ *   ...
+ * }
+ * }</pre>
+ */
+module eu.mulk.jgvariant.core {
+  requires com.google.errorprone.annotations;
+  requires org.jetbrains.annotations;
+  requires org.apiguardian.api;
+
+  exports eu.mulk.jgvariant.core;
+}
diff --git a/jgvariant-core/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java b/jgvariant-core/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java
new file mode 100644
index 0000000..5cf1a1c
--- /dev/null
+++ b/jgvariant-core/src/test/java/eu/mulk/jgvariant/core/DecoderTest.java
@@ -0,0 +1,437 @@
+package eu.mulk.jgvariant.core;
+
+import static java.nio.ByteOrder.BIG_ENDIAN;
+import static java.nio.ByteOrder.LITTLE_ENDIAN;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.ByteBuffer;
+import java.text.ParseException;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests based on the examples given in <a
+ * href="https://people.gnome.org/~desrt/gvariant-serialisation.pdf">~desrt/gvariant-serialisation.pdf</a>.
+ */
+class DecoderTest {
+
+  @Test
+  void testString() {
+    var data = new byte[] {0x68, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F, 0x72, 0x6C, 0x64, 0x00};
+    var decoder = Decoder.ofString(UTF_8);
+    assertEquals("hello world", decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testMaybe() {
+    var data =
+        new byte[] {0x68, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F, 0x72, 0x6C, 0x64, 0x00, 0x00};
+    var decoder = Decoder.ofMaybe(Decoder.ofString(UTF_8));
+    assertEquals(Optional.of("hello world"), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testBooleanArray() {
+    var data = new byte[] {0x01, 0x00, 0x00, 0x01, 0x01};
+    var decoder = Decoder.ofArray(Decoder.ofBoolean());
+    assertEquals(List.of(true, false, false, true, true), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testStructure() {
+    var data =
+        new byte[] {
+          0x66, 0x6F, 0x6F, 0x00, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x04
+        };
+
+    record TestRecord(String s, int i) {}
+
+    var decoder = Decoder.ofStructure(TestRecord.class, Decoder.ofString(UTF_8), Decoder.ofInt());
+    assertEquals(new TestRecord("foo", -1), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testComplexStructureArray() {
+    var data =
+        new byte[] {
+          0x68,
+          0x69,
+          0x00,
+          0x00,
+          (byte) 0xfe,
+          (byte) 0xff,
+          (byte) 0xff,
+          (byte) 0xff,
+          0x03,
+          0x00,
+          0x00,
+          0x00,
+          0x62,
+          0x79,
+          0x65,
+          0x00,
+          (byte) 0xff,
+          (byte) 0xff,
+          (byte) 0xff,
+          (byte) 0xff,
+          0x04,
+          0x09,
+          0x15
+        };
+
+    record TestRecord(String s, int i) {}
+
+    var decoder =
+        Decoder.ofArray(
+            Decoder.ofStructure(
+                TestRecord.class,
+                Decoder.ofString(UTF_8),
+                Decoder.ofInt().withByteOrder(LITTLE_ENDIAN)));
+    assertEquals(
+        List.of(new TestRecord("hi", -2), new TestRecord("bye", -1)),
+        decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testStringArray() {
+    var data =
+        new byte[] {
+          0x69, 0x00, 0x63, 0x61, 0x6E, 0x00, 0x68, 0x61, 0x73, 0x00, 0x73, 0x74, 0x72, 0x69, 0x6E,
+          0x67, 0x73, 0x3F, 0x00, 0x02, 0x06, 0x0a, 0x13
+        };
+    var decoder = Decoder.ofArray(Decoder.ofString(UTF_8));
+    assertEquals(List.of("i", "can", "has", "strings?"), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testNestedStructure() {
+    var data =
+        new byte[] {
+          0x69, 0x63, 0x61, 0x6E, 0x00, 0x68, 0x61, 0x73, 0x00, 0x73, 0x74, 0x72, 0x69, 0x6E, 0x67,
+          0x73, 0x3F, 0x00, 0x04, 0x0d, 0x05
+        };
+
+    record TestChild(byte b, String s) {}
+    record TestParent(TestChild tc, List<String> as) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestParent.class,
+            Decoder.ofStructure(TestChild.class, Decoder.ofByte(), Decoder.ofString(UTF_8)),
+            Decoder.ofArray(Decoder.ofString(UTF_8)));
+
+    assertEquals(
+        new TestParent(new TestChild((byte) 0x69, "can"), List.of("has", "strings?")),
+        decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testNestedStructureVariant() {
+    var data =
+        new byte[] {
+          0x69, 0x63, 0x61, 0x6E, 0x00, 0x68, 0x61, 0x73, 0x00, 0x73, 0x74, 0x72, 0x69, 0x6E, 0x67,
+          0x73, 0x3F, 0x00, 0x04, 0x0d, 0x05, 0x00, 0x28, 0x28, 0x79, 0x73, 0x29, 0x61, 0x73, 0x29
+        };
+
+    var decoder = Decoder.ofVariant();
+    var variant = decoder.decode(ByteBuffer.wrap(data));
+    var result = (Object[]) variant.value();
+
+    assertAll(
+        () -> assertEquals(Signature.parse("((ys)as)"), variant.signature()),
+        () -> assertEquals(2, result.length),
+        () -> assertArrayEquals(new Object[] {(byte) 0x69, "can"}, (Object[]) result[0]),
+        () -> assertEquals(List.of("has", "strings?"), result[1]));
+  }
+
+  @Test
+  void testSimpleStructure() {
+    var data = new byte[] {0x60, 0x70};
+
+    record TestRecord(byte b1, byte b2) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestRecord.class,
+            Decoder.ofByte().withByteOrder(LITTLE_ENDIAN),
+            Decoder.ofByte().withByteOrder(LITTLE_ENDIAN));
+
+    assertEquals(new TestRecord((byte) 0x60, (byte) 0x70), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testPaddedStructureRight() {
+    var data = new byte[] {0x60, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00};
+
+    record TestRecord(int b1, byte b2) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestRecord.class,
+            Decoder.ofInt().withByteOrder(LITTLE_ENDIAN),
+            Decoder.ofByte().withByteOrder(LITTLE_ENDIAN));
+
+    assertEquals(new TestRecord(0x60, (byte) 0x70), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testPaddedStructureLeft() {
+    var data = new byte[] {0x60, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00};
+
+    record TestRecord(byte b1, int b2) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestRecord.class,
+            Decoder.ofByte().withByteOrder(LITTLE_ENDIAN),
+            Decoder.ofInt().withByteOrder(LITTLE_ENDIAN));
+
+    assertEquals(new TestRecord((byte) 0x60, 0x70), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testSimpleStructureArray() {
+    var data =
+        new byte[] {
+          0x60,
+          0x00,
+          0x00,
+          0x00,
+          0x70,
+          0x00,
+          0x00,
+          0x00,
+          (byte) 0x88,
+          0x02,
+          0x00,
+          0x00,
+          (byte) 0xF7,
+          0x00,
+          0x00,
+          0x00
+        };
+
+    record TestRecord(int b1, byte b2) {}
+
+    var decoder =
+        Decoder.ofArray(
+            Decoder.ofStructure(
+                TestRecord.class,
+                Decoder.ofInt().withByteOrder(LITTLE_ENDIAN),
+                Decoder.ofByte().withByteOrder(LITTLE_ENDIAN)));
+
+    assertEquals(
+        List.of(new TestRecord(96, (byte) 0x70), new TestRecord(648, (byte) 0xf7)),
+        decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testByteArray() {
+    var data = new byte[] {0x04, 0x05, 0x06, 0x07};
+
+    var decoder = Decoder.ofArray(Decoder.ofByte());
+
+    assertEquals(
+        List.of((byte) 0x04, (byte) 0x05, (byte) 0x06, (byte) 0x07),
+        decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testIntegerArray() {
+    var data = new byte[] {0x04, 0x00, 0x00, 0x00, 0x02, 0x01, 0x00, 0x00};
+
+    var decoder = Decoder.ofArray(Decoder.ofInt().withByteOrder(LITTLE_ENDIAN));
+
+    assertEquals(List.of(4, 258), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testDictionaryEntry() {
+    var data =
+        new byte[] {0x61, 0x20, 0x6B, 0x65, 0x79, 0x00, 0x00, 0x00, 0x02, 0x02, 0x00, 0x00, 0x06};
+
+    record TestEntry(String key, int value) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestEntry.class, Decoder.ofString(UTF_8), Decoder.ofInt().withByteOrder(LITTLE_ENDIAN));
+    assertEquals(new TestEntry("a key", 514), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testPaddedPrimitives() {
+    var data =
+        new byte[] {
+          0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+          0x00, 0x40, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+        };
+
+    record TestRecord(short s, long l, double d) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestRecord.class,
+            Decoder.ofShort().withByteOrder(BIG_ENDIAN),
+            Decoder.ofLong().withByteOrder(LITTLE_ENDIAN),
+            Decoder.ofDouble());
+    assertEquals(new TestRecord((short) 1, 2, 3.25), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testEmbeddedMaybe() {
+    var data = new byte[] {0x01, 0x01};
+
+    record TestRecord(Optional<Byte> set, Optional<Byte> unset) {}
+
+    var decoder =
+        Decoder.ofStructure(
+            TestRecord.class, Decoder.ofMaybe(Decoder.ofByte()), Decoder.ofMaybe(Decoder.ofByte()));
+    assertEquals(
+        new TestRecord(Optional.of((byte) 1), Optional.empty()),
+        decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testRecordComponentMismatch() {
+    record TestRecord(Optional<Byte> set) {}
+
+    var maybeDecoder = Decoder.ofMaybe(Decoder.ofByte());
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Decoder.ofStructure(TestRecord.class, maybeDecoder, maybeDecoder));
+  }
+
+  @Test
+  void testTrivialRecord() {
+    var data = new byte[] {0x00};
+
+    record TestRecord() {}
+
+    var decoder = Decoder.ofStructure(TestRecord.class);
+    assertEquals(new TestRecord(), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testTwoElementTrivialRecordArray() {
+    var data = new byte[] {0x00, 0x00};
+
+    record TestRecord() {}
+
+    var decoder = Decoder.ofArray(Decoder.ofStructure(TestRecord.class));
+    assertEquals(
+        List.of(new TestRecord(), new TestRecord()), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testSingletonTrivialRecordArray() {
+    var data = new byte[] {0x00};
+
+    record TestRecord() {}
+
+    var decoder = Decoder.ofArray(Decoder.ofStructure(TestRecord.class));
+    assertEquals(List.of(new TestRecord()), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testEmptyTrivialRecordArray() {
+    var data = new byte[] {};
+
+    record TestRecord() {}
+
+    var decoder = Decoder.ofArray(Decoder.ofStructure(TestRecord.class));
+    assertEquals(List.of(), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testVariantArray() {
+    var data = new byte[] {};
+
+    record TestRecord() {}
+
+    var decoder = Decoder.ofArray(Decoder.ofStructure(TestRecord.class));
+    assertEquals(List.of(), decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testInvalidVariantSignature() {
+    var data = new byte[] {0x00, 0x00, 0x2E};
+
+    var decoder = Decoder.ofVariant();
+    assertThrows(IllegalArgumentException.class, () -> decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testMissingVariantSignature() {
+    var data = new byte[] {0x01};
+
+    var decoder = Decoder.ofVariant();
+    assertThrows(IllegalArgumentException.class, () -> decoder.decode(ByteBuffer.wrap(data)));
+  }
+
+  @Test
+  void testSimpleVariantRecord() throws ParseException {
+    // signature: "(bynqiuxtdsogvmiai)"
+    var data =
+        new byte[] {
+          0x01, // b
+          0x02, // y
+          0x00, 0x03, // n
+          0x00, 0x04, // q
+          0x00, 0x00, // (padding)
+          0x00, 0x00, 0x00, 0x05, // i
+          0x00, 0x00, 0x00, 0x06, // u
+          0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, // x
+          0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, // t
+          0x40, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // d
+          0x68, 0x69, 0x00, // s
+          0x68, 0x69, 0x00, // o
+          0x68, 0x69, 0x00, // g
+          0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // (padding)
+          0x00, 0x00, 0x00, 0x09, 0x00, 0x69, // v
+          0x00, 0x00, // (padding)
+          0x00, 0x00, 0x00, 0x0a, // mi
+          0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 0x00, 0x0c, // ai
+          68, 62, 49, 46, 43, // framing offsets
+          0x00, 0x28, 0x62, 0x79, 0x6E, 0x71, 0x69, 0x75, 0x78, 0x74, 0x64, 0x73, 0x6F, 0x67, 0x76,
+          0x6D, 0x69, 0x61, 0x69, 0x29
+        };
+
+    var decoder = Decoder.ofVariant();
+    assertArrayEquals(
+        new Object[] {
+          true,
+          (byte) 2,
+          (short) 3,
+          (short) 4,
+          (int) 5,
+          (int) 6,
+          (long) 7,
+          (long) 8,
+          (double) 3.25,
+          "hi",
+          "hi",
+          "hi",
+          new Variant(Signature.parse("i"), 9),
+          Optional.of(10),
+          List.of(11, 12)
+        },
+        (Object[]) decoder.decode(ByteBuffer.wrap(data)).value());
+  }
+
+  @Test
+  void testSignatureString() throws ParseException {
+    var data =
+        new byte[] {
+          0x28, 0x62, 0x79, 0x6E, 0x71, 0x69, 0x75, 0x78, 0x74, 0x64, 0x73, 0x6F, 0x67, 0x76, 0x6D,
+          0x69, 0x61, 0x69, 0x29
+        };
+
+    var signature = Signature.parse(ByteBuffer.wrap(data));
+    assertEquals("(bynqiuxtdsogvmiai)", signature.toString());
+  }
+}