Repository: googleapis/java-storage
Pull request #2215 (2023-09-18 20:58:58)

chore: add MetadataField
_Pre-Work_

When performing a parallel composite upload the various parts will have metadata added to them related to where they exist in the overall object.

This class encapsulates the logic necessary to allow defining a field by its name and a codec for encoding/decoding its values.




Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
index c9736992..36079db3 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
@@ -590,10 +590,7 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
 
   @Override
   public Blob compose(ComposeRequest composeRequest) {
-    Opts<ObjectTargetOpt> opts =
-        Opts.unwrap(composeRequest.getTargetOptions())
-            .resolveFrom(composeRequest.getTarget())
-            .prepend(defaultOpts);
+    Opts<ObjectTargetOpt> opts = composeRequest.getTargetOpts().prepend(defaultOpts);
     GrpcCallContext grpcCallContext =
         opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     ComposeObjectRequest.Builder builder = ComposeObjectRequest.newBuilder();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/MetadataField.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/MetadataField.java
new file mode 100644
index 00000000..fb76af6e
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MetadataField.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * 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.google.cloud.storage;
+
+import com.google.cloud.storage.Conversions.Codec;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableMap;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Objects;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Utility construct which allows defining a field name along with a codec for use in creating
+ * typeful files in {@link BlobInfo#getMetadata()} or {@link BucketInfo#getLabels()}
+ *
+ * @param <T> The type of the value
+ */
+@SuppressWarnings("SameParameterValue")
+final class MetadataField<T> {
+
+  @SuppressWarnings("RedundantTypeArguments")
+  private static final Codec<Long, String> CODEC_LONG =
+      Codec.<Long, String>of(String::valueOf, Long::parseLong).nullable();
+
+  private static final Codec<String, String> CODEC_STRING =
+      Codec.<String, String>of(s -> s, s -> s).nullable();
+  private final String key;
+  private final Codec<T, String> codec;
+
+  private MetadataField(String key, Codec<T, String> codec) {
+    this.key = key;
+    this.codec = codec;
+  }
+
+  void appendTo(@NonNull T t, ImmutableMap.Builder<String, String> b) {
+    b.put(key, codec.encode(t));
+  }
+
+  @Nullable
+  T readFrom(BlobInfo info) {
+    Map<String, String> map = info.getMetadata();
+    if (map != null) {
+      return readFrom(map);
+    }
+    return null;
+  }
+
+  @VisibleForTesting
+  @Nullable
+  T readFrom(Map<String, String> m) {
+    return codec.decode(m.get(key));
+  }
+
+  static MetadataField<Long> forLong(String key) {
+    return of(key, CODEC_LONG);
+  }
+
+  static MetadataField<String> forString(String key) {
+    return of(key, CODEC_STRING);
+  }
+
+  static MetadataField<PartRange> forPartRange(String key) {
+    return of(key, PartRange.CODEC);
+  }
+
+  static final class PartRange {
+    private static final Codec<PartRange, String> CODEC =
+        Codec.of(PartRange::encode, PartRange::decode).nullable();
+    static final Comparator<PartRange> COMP =
+        Comparator.comparingLong(PartRange::getBegin).thenComparingLong(PartRange::getEnd);
+    private final long begin;
+    private final long end;
+
+    private PartRange(long begin, long end) {
+      this.begin = begin;
+      this.end = end;
+    }
+
+    public long getBegin() {
+      return begin;
+    }
+
+    public long getEnd() {
+      return end;
+    }
+
+    String encode() {
+      return String.format("%04d-%04d", begin, end);
+    }
+
+    static PartRange decode(String s) {
+      int splitPoint = s.indexOf("-");
+      long being = Long.parseLong(s.substring(0, splitPoint));
+      long end = Long.parseLong(s.substring(splitPoint + 1));
+      return of(being, end);
+    }
+
+    static PartRange of(long begin) {
+      return of(begin, begin);
+    }
+
+    static PartRange of(long begin, long end) {
+      return new PartRange(begin, end);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof PartRange)) {
+        return false;
+      }
+      PartRange partRange = (PartRange) o;
+      return begin == partRange.begin && end == partRange.end;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(begin, end);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this).add("begin", begin).add("end", end).toString();
+    }
+  }
+
+  private static <T> MetadataField<T> of(String key, Codec<T, String> codec) {
+    return new MetadataField<>(key, codec);
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
index 03729577..abec9c2f 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
@@ -23,6 +23,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.Objects.requireNonNull;
 
 import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
 import com.google.api.core.InternalExtensionOnly;
 import com.google.api.gax.paging.Page;
 import com.google.auth.ServiceAccountSigner;
@@ -47,12 +48,14 @@ import com.google.cloud.storage.UnifiedOpts.NamedField;
 import com.google.cloud.storage.UnifiedOpts.ObjectListOpt;
 import com.google.cloud.storage.UnifiedOpts.ObjectSourceOpt;
 import com.google.cloud.storage.UnifiedOpts.ObjectTargetOpt;
+import com.google.cloud.storage.UnifiedOpts.Opts;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.ObjectInputStream;
 import java.io.OutputStream;
 import java.io.Serializable;
 import java.net.URL;
@@ -2169,6 +2172,8 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     private final BlobInfo target;
     private final List<BlobTargetOption> targetOptions;
 
+    private transient Opts<ObjectTargetOpt> targetOpts;
+
     /** Class for Compose source blobs. */
     @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     public static class SourceBlob implements Serializable {
@@ -2196,11 +2201,13 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
       }
     }
 
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     public static class Builder {
 
       private final List<SourceBlob> sourceBlobs = new LinkedList<>();
       private final Set<BlobTargetOption> targetOptions = new LinkedHashSet<>();
       private BlobInfo target;
+      private Opts<ObjectTargetOpt> opts = Opts.empty();
 
       /** Add source blobs for compose operation. */
       public Builder addSource(Iterable<String> blobs) {
@@ -2227,6 +2234,11 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
         return this;
       }
 
+      Builder setTargetOpts(Opts<ObjectTargetOpt> opts) {
+        this.opts = opts;
+        return this;
+      }
+
       /** Sets compose operation's target blob options. */
       public Builder setTargetOptions(BlobTargetOption... options) {
         Collections.addAll(targetOptions, options);
@@ -2243,6 +2255,7 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
       public ComposeRequest build() {
         checkArgument(!sourceBlobs.isEmpty());
         checkNotNull(target);
+        checkNotNull(opts);
         return new ComposeRequest(this);
       }
     }
@@ -2250,7 +2263,9 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     private ComposeRequest(Builder builder) {
       sourceBlobs = ImmutableList.copyOf(builder.sourceBlobs);
       target = builder.target;
+      // keep targetOptions for serialization even though we will read targetOpts
       targetOptions = ImmutableList.copyOf(builder.targetOptions);
+      targetOpts = builder.opts.prepend(Opts.unwrap(targetOptions).resolveFrom(target));
     }
 
     /** Returns compose operation's source blobs. */
@@ -2268,12 +2283,27 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
       return targetOptions;
     }
 
+    @InternalApi
+    Opts<ObjectTargetOpt> getTargetOpts() {
+      return targetOpts;
+    }
+
+    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
+      in.defaultReadObject();
+      if (this.targetOptions != null) {
+        this.targetOpts = Opts.unwrap(this.targetOptions);
+      } else {
+        this.targetOpts = Opts.empty();
+      }
+    }
+
     /**
      * Creates a {@code ComposeRequest} object.
      *
      * @param sources source blobs names
      * @param target target blob
      */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     public static ComposeRequest of(Iterable<String> sources, BlobInfo target) {
       return newBuilder().setTarget(target).addSource(sources).build();
     }
@@ -2285,11 +2315,13 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
      * @param sources source blobs names
      * @param target target blob name
      */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     public static ComposeRequest of(String bucket, Iterable<String> sources, String target) {
       return of(sources, BlobInfo.newBuilder(BlobId.of(bucket, target)).build());
     }
 
     /** Returns a {@code ComposeRequest} builder. */
+    @TransportCompatibility({Transport.HTTP, Transport.GRPC})
     public static Builder newBuilder() {
       return new Builder();
     }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java
index d4ad2601..d4590c36 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java
@@ -551,8 +551,7 @@ final class StorageImpl extends BaseService<StorageOptions> implements Storage {
                               target.getBucket(), sourceBlob.getName(), sourceBlob.getGeneration()))
                       .build()));
     }
-    Opts<ObjectTargetOpt> targetOpts =
-        Opts.unwrap(composeRequest.getTargetOptions()).resolveFrom(target);
+    Opts<ObjectTargetOpt> targetOpts = composeRequest.getTargetOpts();
     StorageObject targetPb = codecs.blobInfo().encode(composeRequest.getTarget());
     Map<StorageRpc.Option, ?> targetOptions = targetOpts.getRpcOptions();
     ResultRetryAlgorithm<?> algorithm =
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
index dbbb4e93..bbafc35f 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/UnifiedOpts.java
@@ -2352,6 +2352,23 @@ final class UnifiedOpts {
       return new Opts<>(filterTo(c).collect(ImmutableList.toImmutableList()));
     }
 
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Opts)) {
+        return false;
+      }
+      Opts<?> opts1 = (Opts<?>) o;
+      return Objects.equals(opts, opts1.opts);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(opts);
+    }
+
     private Mapper<ImmutableMap.Builder<StorageRpc.Option, Object>> rpcOptionMapper() {
       return fuseMappers(RpcOptVal.class, RpcOptVal::mapper);
     }


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/MetadataFieldTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/MetadataFieldTest.java
new file mode 100644
index 00000000..9f7a3f4c
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/MetadataFieldTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * 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.google.cloud.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.storage.MetadataField.PartRange;
+import com.google.common.collect.ImmutableMap;
+import java.util.function.Consumer;
+import org.junit.Test;
+
+public final class MetadataFieldTest {
+
+  @Test
+  public void appendTo_long_encode_works() {
+    MetadataField<Long> l = MetadataField.forLong("long");
+    ImmutableMap<String, String> map = mapFrom(b -> l.appendTo(37L, b));
+    assertThat(map).containsEntry("long", "37");
+  }
+
+  @Test
+  public void readFrom_long_decode_null() {
+    ImmutableMap<String, String> map = ImmutableMap.of();
+    MetadataField<Long> l = MetadataField.forLong("long");
+    Long read = l.readFrom(map);
+    assertThat(read).isNull();
+  }
+
+  @Test
+  public void readFrom_long_decode_nonNull() {
+    ImmutableMap<String, String> map = ImmutableMap.of("long", "37");
+    MetadataField<Long> l = MetadataField.forLong("long");
+    Long read = l.readFrom(map);
+    assertThat(read).isNotNull();
+    assertThat(read).isEqualTo(37L);
+  }
+
+  @Test
+  public void readFrom_long_decode_blobInfo_null() {
+    BlobInfo info = BlobInfo.newBuilder("b", "o").build();
+    MetadataField<Long> l = MetadataField.forLong("long");
+    Long read = l.readFrom(info);
+    assertThat(read).isNull();
+  }
+
+  @Test
+  public void readFrom_long_decode_blobInfo_nonNull() {
+    ImmutableMap<String, String> map = ImmutableMap.of("long", "37");
+    BlobInfo info = BlobInfo.newBuilder("b", "o").setMetadata(map).build();
+    MetadataField<Long> l = MetadataField.forLong("long");
+    Long read = l.readFrom(info);
+    assertThat(read).isNotNull();
+    assertThat(read).isEqualTo(37L);
+  }
+
+  @Test
+  public void appendTo_string_encode_works() {
+    MetadataField<String> l = MetadataField.forString("string");
+    ImmutableMap<String, String> map = mapFrom(b -> l.appendTo("blah", b));
+    assertThat(map).containsEntry("string", "blah");
+  }
+
+  @Test
+  public void readFrom_string_decode_null() {
+    ImmutableMap<String, String> map = ImmutableMap.of();
+    MetadataField<String> l = MetadataField.forString("string");
+    String read = l.readFrom(map);
+    assertThat(read).isNull();
+  }
+
+  @Test
+  public void readFrom_string_decode_nonNull() {
+    ImmutableMap<String, String> map = ImmutableMap.of("string", "blah");
+    MetadataField<String> l = MetadataField.forString("string");
+    String read = l.readFrom(map);
+    assertThat(read).isNotNull();
+    assertThat(read).isEqualTo("blah");
+  }
+
+  @Test
+  public void appendTo_partRange_encode_works() {
+    MetadataField<PartRange> l = MetadataField.forPartRange("partRange");
+    ImmutableMap<String, String> map = mapFrom(b -> l.appendTo(PartRange.of(37L), b));
+    assertThat(map).containsEntry("partRange", "0037-0037");
+  }
+
+  @Test
+  public void readFrom_partRange_decode_null() {
+    ImmutableMap<String, String> map = ImmutableMap.of();
+    MetadataField<PartRange> l = MetadataField.forPartRange("partRange");
+    PartRange read = l.readFrom(map);
+    assertThat(read).isNull();
+  }
+
+  @Test
+  public void readFrom_partRange_decode_nonNull() {
+    ImmutableMap<String, String> map = ImmutableMap.of("partRange", "0037-0037");
+    MetadataField<PartRange> l = MetadataField.forPartRange("partRange");
+    PartRange read = l.readFrom(map);
+    assertThat(read).isNotNull();
+    assertThat(read).isEqualTo(PartRange.of(37L));
+  }
+
+  @Test
+  public void partRange_handlesNumbersWithMoreThanFourDigits_encode() {
+
+    PartRange r = PartRange.of(0, 123456);
+
+    String encode = r.encode();
+    assertThat(encode).isEqualTo("0000-123456");
+  }
+
+  @Test
+  public void partRange_handlesNumbersWithMoreThanFourDigits_decode() {
+
+    PartRange expected = PartRange.of(0, 123456);
+
+    PartRange decode = PartRange.decode("0000-123456");
+    assertThat(decode).isEqualTo(expected);
+  }
+
+  private static ImmutableMap<String, String> mapFrom(
+      Consumer<ImmutableMap.Builder<String, String>> f) {
+    ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
+    f.accept(builder);
+    return builder.build();
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/SerializationTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/SerializationTest.java
index 2493b59a..52bb45d3 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/SerializationTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/SerializationTest.java
@@ -32,9 +32,12 @@ import com.google.cloud.storage.Acl.Project.ProjectRole;
 import com.google.cloud.storage.BlobReadChannelV2.BlobReadChannelContext;
 import com.google.cloud.storage.BlobReadChannelV2.BlobReadChannelV2State;
 import com.google.cloud.storage.BlobWriteChannelV2.BlobWriteChannelV2State;
+import com.google.cloud.storage.Storage.BlobTargetOption;
 import com.google.cloud.storage.Storage.BucketField;
+import com.google.cloud.storage.Storage.ComposeRequest;
 import com.google.cloud.storage.Storage.PredefinedAcl;
 import com.google.cloud.storage.UnifiedOpts.Opt;
+import com.google.cloud.storage.UnifiedOpts.Opts;
 import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -318,6 +321,35 @@ public class SerializationTest extends BaseSerializationTest {
     }
   }
 
+  @Test
+  public void composeRequest() throws IOException, ClassNotFoundException {
+
+    Properties properties = new Properties();
+    try (InputStream is =
+        SerializationTest.class
+            .getClassLoader()
+            .getResourceAsStream("com/google/cloud/storage/composeRequest.ser.properties")) {
+      properties.load(is);
+    }
+    String b64bytes = properties.getProperty("b64bytes");
+    assertThat(b64bytes).isNotEmpty();
+
+    byte[] decode = Base64.getDecoder().decode(b64bytes);
+    try (ByteArrayInputStream bais = new ByteArrayInputStream(decode);
+        ObjectInputStream ois = new ObjectInputStream(bais)) {
+      Object o = ois.readObject();
+      assertThat(o).isInstanceOf(ComposeRequest.class);
+      ComposeRequest composeRequest = (ComposeRequest) o;
+      assertThat(composeRequest.getSourceBlobs()).hasSize(4);
+      assertThat(composeRequest.getTarget().getBucket()).isEqualTo("buck");
+      assertThat(composeRequest.getTarget().getName()).isEqualTo("comp");
+      assertThat(composeRequest.getTargetOptions())
+          .containsExactly(BlobTargetOption.doesNotExist());
+      assertThat(composeRequest.getTargetOpts())
+          .isEqualTo(Opts.from(UnifiedOpts.generationMatch(0)));
+    }
+  }
+
   /**
    * Here we override the super classes implementation to remove the "assertNotSame".
    *
diff --git a/google-cloud-storage/src/test/resources/com/google/cloud/storage/composeRequest.ser.properties b/google-cloud-storage/src/test/resources/com/google/cloud/storage/composeRequest.ser.properties
new file mode 100644
index 00000000..1396e567
--- /dev/null
+++ b/google-cloud-storage/src/test/resources/com/google/cloud/storage/composeRequest.ser.properties
@@ -0,0 +1,67 @@
+#
+# Copyright 2023 Google LLC
+#
+# 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.
+#
+
+# Base 64 Encoded bytes of a BlobReadChannel circa v2.27.0
+# Generated using the following snippet:
+#
+#    ComposeRequest.Builder b = ComposeRequest.newBuilder();
+#    b.addSource("object1", 1L);
+#    b.addSource("object2", 2L);
+#    b.addSource("object3", 3L);
+#    b.addSource("object4", 4L);
+#
+#    BlobInfo info = BlobInfo.newBuilder("buck", "comp").build();
+#    b.setTarget(info);
+#    b.setTargetOptions(BlobTargetOption.doesNotExist());
+#
+#    ComposeRequest composeRequest = b.build();
+#
+#    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+#    try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+#      oos.writeObject(composeRequest);
+#    }
+#
+#    byte[] bytes = baos.toByteArray();
+#    String b64bytes = Base64.getEncoder().encodeToString(bytes);
+#
+#    System.out.println("b64bytes = " + b64bytes);
+#
+#
+b64bytes = \
+  rO0ABXNyAC9jb20uZ29vZ2xlLmNsb3VkLnN0b3JhZ2UuU3RvcmFnZSRDb21wb3NlUmVxdWVzdFvDRQIVwX+/AgADTAALc291cmNlQmxvYnN0ABBMamF2YS91dGlsL0x\
+  pc3Q7TAAGdGFyZ2V0dAAjTGNvbS9nb29nbGUvY2xvdWQvc3RvcmFnZS9CbG9iSW5mbztMAA10YXJnZXRPcHRpb25zcQB+AAF4cHNyADZjb20uZ29vZ2xlLmNvbW1vbi\
+  5jb2xsZWN0LkltbXV0YWJsZUxpc3QkU2VyaWFsaXplZEZvcm0AAAAAAAAAAAIAAVsACGVsZW1lbnRzdAATW0xqYXZhL2xhbmcvT2JqZWN0O3hwdXIAE1tMamF2YS5sY\
+  W5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAABHNyADpjb20uZ29vZ2xlLmNsb3VkLnN0b3JhZ2UuU3RvcmFnZSRDb21wb3NlUmVxdWVzdCRTb3VyY2VCbG9i/c/2djN5\
+  tW4CAAJMAApnZW5lcmF0aW9udAAQTGphdmEvbGFuZy9Mb25nO0wABG5hbWV0ABJMamF2YS9sYW5nL1N0cmluZzt4cHNyAA5qYXZhLmxhbmcuTG9uZzuL5JDMjyPfAgA\
+  BSgAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAAAAAAAAXQAB29iamVjdDFzcQB+AAlzcQB+AA0AAAAAAAAAAnQAB29iamVjdDJzcQB+AA\
+  lzcQB+AA0AAAAAAAAAA3QAB29iamVjdDNzcQB+AAlzcQB+AA0AAAAAAAAABHQAB29iamVjdDRzcgAhY29tLmdvb2dsZS5jbG91ZC5zdG9yYWdlLkJsb2JJbmZv3XARn\
+  1ocC74CAB5aAAtpc0RpcmVjdG9yeUwAA2FjbHEAfgABTAAGYmxvYklkdAAhTGNvbS9nb29nbGUvY2xvdWQvc3RvcmFnZS9CbG9iSWQ7TAAMY2FjaGVDb250cm9scQB+\
+  AAtMAA5jb21wb25lbnRDb3VudHQAE0xqYXZhL2xhbmcvSW50ZWdlcjtMABJjb250ZW50RGlzcG9zaXRpb25xAH4AC0wAD2NvbnRlbnRFbmNvZGluZ3EAfgALTAAPY29\
+  udGVudExhbmd1YWdlcQB+AAtMAAtjb250ZW50VHlwZXEAfgALTAAGY3JjMzJjcQB+AAtMAApjcmVhdGVUaW1ldAAaTGphdmEvdGltZS9PZmZzZXREYXRlVGltZTtMAA\
+  pjdXN0b21UaW1lcQB+AB1MABJjdXN0b21lckVuY3J5cHRpb250ADZMY29tL2dvb2dsZS9jbG91ZC9zdG9yYWdlL0Jsb2JJbmZvJEN1c3RvbWVyRW5jcnlwdGlvbjtMA\
+  ApkZWxldGVUaW1lcQB+AB1MAARldGFncQB+AAtMAA5ldmVudEJhc2VkSG9sZHQAE0xqYXZhL2xhbmcvQm9vbGVhbjtMAAtnZW5lcmF0ZWRJZHEAfgALTAAKa21zS2V5\
+  TmFtZXEAfgALTAADbWQ1cQB+AAtMAAltZWRpYUxpbmtxAH4AC0wACG1ldGFkYXRhdAAPTGphdmEvdXRpbC9NYXA7TAAObWV0YWdlbmVyYXRpb25xAH4ACkwABW93bmV\
+  ydAAlTGNvbS9nb29nbGUvY2xvdWQvc3RvcmFnZS9BY2wkRW50aXR5O0wAF3JldGVudGlvbkV4cGlyYXRpb25UaW1lcQB+AB1MAAhzZWxmTGlua3EAfgALTAAEc2l6ZX\
+  EAfgAKTAAMc3RvcmFnZUNsYXNzdAAnTGNvbS9nb29nbGUvY2xvdWQvc3RvcmFnZS9TdG9yYWdlQ2xhc3M7TAANdGVtcG9yYXJ5SG9sZHEAfgAfTAAXdGltZVN0b3JhZ\
+  2VDbGFzc1VwZGF0ZWRxAH4AHUwACnVwZGF0ZVRpbWVxAH4AHXhwAHBzcgAfY29tLmdvb2dsZS5jbG91ZC5zdG9yYWdlLkJsb2JJZHHR3lY1j9ndAgADTAAGYnVja2V0\
+  cQB+AAtMAApnZW5lcmF0aW9ucQB+AApMAARuYW1lcQB+AAt4cHQABGJ1Y2twdAAEY29tcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHNxAH4ABHVxAH4ABwAAAAF\
+  zcgAxY29tLmdvb2dsZS5jbG91ZC5zdG9yYWdlLlN0b3JhZ2UkQmxvYlRhcmdldE9wdGlvbrLpPC5jbC91AgAAeHIAH2NvbS5nb29nbGUuY2xvdWQuc3RvcmFnZS5PcH\
+  Rpb26Wztg8QRDfQAIAAHhyAC9jb20uZ29vZ2xlLmNsb3VkLnN0b3JhZ2UuVW5pZmllZE9wdHMkT3B0aW9uU2hpbS9VbRFY0ia8AgABTAADb3B0dAAqTGNvbS9nb29nb\
+  GUvY2xvdWQvc3RvcmFnZS9VbmlmaWVkT3B0cyRPcHQ7eHBzcgA0Y29tLmdvb2dsZS5jbG91ZC5zdG9yYWdlLlVuaWZpZWRPcHRzJEdlbmVyYXRpb25NYXRjaCS2w9uv\
+  Zq0PAgAAeHIALmNvbS5nb29nbGUuY2xvdWQuc3RvcmFnZS5VbmlmaWVkT3B0cyRScGNPcHRWYWx/Q2Qd8xl6FAIAAkwAA2tleXQAM0xjb20vZ29vZ2xlL2Nsb3VkL3N\
+  0b3JhZ2Uvc3BpL3YxL1N0b3JhZ2VScGMkT3B0aW9uO0wAA3ZhbHQAEkxqYXZhL2xhbmcvT2JqZWN0O3hwfnIAMWNvbS5nb29nbGUuY2xvdWQuc3RvcmFnZS5zcGkudj\
+  EuU3RvcmFnZVJwYyRPcHRpb24AAAAAAAAAABIAAHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAAEgAAeHB0ABNJRl9HRU5FUkFUSU9OX01BVENIc3EAfgANAAAAAAAAA\
+  AA=