Repository: googleapis/java-storage
Pull request #1388 (2022-05-13 18:37:26)

feat: migrate model classes to use java.time instead of Long
* Add Codec<DateTime, OffsetDateTime> for apiary conversion
* Add Utils#RFC_3339_DATE_TIME_FORMATTER
* Add BackwardCompatibilityUtils#millisOffsetDateTimeCodec to provide compatibility with existing Long centric apis
* Update BucketInfo and BucketInfo.Builder to prefer java.time types instead of Long
* Update ApiaryConversions for BucketInfo to use new java.time types

Update the following files to now be OffestDateTime instead of Long
* BlobInfo#customTime
* BlobInfo#deleteTime
* BlobInfo#updateTime
* BlobInfo#createTime
* BlobInfo#timeStorageClassUpdated
* BlobInfo#retentionExpirationTime
* BucketInfo#retentionEffectiveTime
* IamConfiguration#uniformBucketLevelAccessLockedTime


Patch:
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index a1cb7b6c..e14276a3 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -138,6 +138,10 @@
       <groupId>io.grpc</groupId>
       <artifactId>grpc-stub</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.checkerframework</groupId>
+      <artifactId>checker-qual</artifactId>
+    </dependency>
     <!--
     We're not using this directly, however there appears to be some resolution
     issues when we don't list this dependency. Specifically, the check to ensure the flattened
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java
index fe267a5a..048522bd 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ApiaryConversions.java
@@ -18,6 +18,7 @@ package com.google.cloud.storage;
 
 import static com.google.cloud.storage.Utils.ifNonNull;
 import static com.google.cloud.storage.Utils.lift;
+import static com.google.cloud.storage.Utils.toImmutableListOf;
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.api.client.util.Data;
@@ -62,15 +63,22 @@ import com.google.cloud.storage.HmacKey.HmacKeyMetadata;
 import com.google.cloud.storage.HmacKey.HmacKeyState;
 import com.google.cloud.storage.NotificationInfo.EventType;
 import com.google.cloud.storage.NotificationInfo.PayloadFormat;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import java.math.BigInteger;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 @InternalApi
 final class ApiaryConversions {
@@ -113,6 +121,29 @@ final class ApiaryConversions {
   private final Codec<NotificationInfo, com.google.api.services.storage.model.Notification>
       notificationInfoCodec = Codec.of(this::notificationEncode, this::notificationDecode);
 
+  @VisibleForTesting
+  final Codec<DateTime, OffsetDateTime> dateTimeCodec =
+      Codec.of(
+          dt -> {
+            long milli = dt.getValue();
+            int timeZoneShiftMinutes = dt.getTimeZoneShift();
+
+            Duration timeZoneShift = Duration.of(timeZoneShiftMinutes, ChronoUnit.MINUTES);
+
+            int hours = Math.toIntExact(timeZoneShift.toHours());
+            int minutes =
+                Math.toIntExact(
+                    timeZoneShift.minusHours(timeZoneShift.toHours()).getSeconds() / 60);
+            ZoneOffset offset = ZoneOffset.ofHoursMinutes(hours, minutes);
+
+            return Instant.ofEpochMilli(milli).atOffset(offset);
+          },
+          odt -> {
+            ZoneOffset offset = odt.getOffset();
+            int i = Math.toIntExact(TimeUnit.SECONDS.toMinutes(offset.getTotalSeconds()));
+            return new DateTime(odt.toInstant().toEpochMilli(), i);
+          });
+
   private ApiaryConversions() {}
 
   Codec<Entity, String> entity() {
@@ -182,21 +213,27 @@ final class ApiaryConversions {
 
   private StorageObject blobInfoEncode(BlobInfo from) {
     StorageObject to = blobIdEncode(from.getBlobId());
-    ifNonNull(from.getAcl(), Utils.toImmutableListOf(objectAcl()::encode), to::setAcl);
-    ifNonNull(from.getDeleteTime(), DateTime::new, to::setTimeDeleted);
-    ifNonNull(from.getUpdateTime(), DateTime::new, to::setUpdated);
-    ifNonNull(from.getCreateTime(), DateTime::new, to::setTimeCreated);
-    ifNonNull(from.getCustomTime(), DateTime::new, to::setCustomTime);
+    ifNonNull(from.getAcl(), toImmutableListOf(objectAcl()::encode), to::setAcl);
+    ifNonNull(from.getDeleteTimeOffsetDateTime(), dateTimeCodec::decode, to::setTimeDeleted);
+    ifNonNull(from.getUpdateTimeOffsetDateTime(), dateTimeCodec::decode, to::setUpdated);
+    ifNonNull(from.getCreateTimeOffsetDateTime(), dateTimeCodec::decode, to::setTimeCreated);
+    ifNonNull(from.getCustomTimeOffsetDateTime(), dateTimeCodec::decode, to::setCustomTime);
     ifNonNull(from.getSize(), BigInteger::valueOf, to::setSize);
     ifNonNull(
         from.getOwner(),
         lift(this::entityEncode).andThen(o -> new Owner().setEntity(o)),
         to::setOwner);
     ifNonNull(from.getStorageClass(), StorageClass::toString, to::setStorageClass);
-    ifNonNull(from.getTimeStorageClassUpdated(), DateTime::new, to::setTimeStorageClassUpdated);
+    ifNonNull(
+        from.getTimeStorageClassUpdatedOffsetDateTime(),
+        dateTimeCodec::decode,
+        to::setTimeStorageClassUpdated);
     ifNonNull(
         from.getCustomerEncryption(), this::customerEncryptionEncode, to::setCustomerEncryption);
-    ifNonNull(from.getRetentionExpirationTime(), DateTime::new, to::setRetentionExpirationTime);
+    ifNonNull(
+        from.getRetentionExpirationTimeOffsetDateTime(),
+        dateTimeCodec::decode,
+        to::setRetentionExpirationTime);
     to.setKmsKeyName(from.getKmsKeyName());
     to.setEventBasedHold(from.getEventBasedHold());
     to.setTemporaryHold(from.getTemporaryHold());
@@ -241,13 +278,13 @@ final class ApiaryConversions {
     ifNonNull(from.getId(), to::setGeneratedId);
     ifNonNull(from.getSelfLink(), to::setSelfLink);
     ifNonNull(from.getMetadata(), to::setMetadata);
-    ifNonNull(from.getTimeDeleted(), DateTime::getValue, to::setDeleteTime);
-    ifNonNull(from.getUpdated(), DateTime::getValue, to::setUpdateTime);
-    ifNonNull(from.getTimeCreated(), DateTime::getValue, to::setCreateTime);
-    ifNonNull(from.getCustomTime(), DateTime::getValue, to::setCustomTime);
+    ifNonNull(from.getTimeDeleted(), dateTimeCodec::encode, to::setDeleteTime);
+    ifNonNull(from.getUpdated(), dateTimeCodec::encode, to::setUpdateTime);
+    ifNonNull(from.getTimeCreated(), dateTimeCodec::encode, to::setCreateTime);
+    ifNonNull(from.getCustomTime(), dateTimeCodec::encode, to::setCustomTime);
     ifNonNull(from.getSize(), BigInteger::longValue, to::setSize);
     ifNonNull(from.getOwner(), lift(Owner::getEntity).andThen(this::entityDecode), to::setOwner);
-    ifNonNull(from.getAcl(), Utils.toImmutableListOf(objectAcl()::decode), to::setAcl);
+    ifNonNull(from.getAcl(), toImmutableListOf(objectAcl()::decode), to::setAcl);
     if (from.containsKey("isDirectory")) {
       to.setIsDirectory(Boolean.TRUE);
     }
@@ -255,12 +292,12 @@ final class ApiaryConversions {
         from.getCustomerEncryption(), this::customerEncryptionDecode, to::setCustomerEncryption);
     ifNonNull(from.getStorageClass(), StorageClass::valueOf, to::setStorageClass);
     ifNonNull(
-        from.getTimeStorageClassUpdated(), DateTime::getValue, to::setTimeStorageClassUpdated);
+        from.getTimeStorageClassUpdated(), dateTimeCodec::encode, to::setTimeStorageClassUpdated);
     ifNonNull(from.getKmsKeyName(), to::setKmsKeyName);
     ifNonNull(from.getEventBasedHold(), to::setEventBasedHold);
     ifNonNull(from.getTemporaryHold(), to::setTemporaryHold);
     ifNonNull(
-        from.getRetentionExpirationTime(), DateTime::getValue, to::setRetentionExpirationTime);
+        from.getRetentionExpirationTime(), dateTimeCodec::encode, to::setRetentionExpirationTime);
     return to.build();
   }
 
@@ -288,13 +325,11 @@ final class ApiaryConversions {
 
   private Bucket bucketInfoEncode(BucketInfo from) {
     Bucket to = new Bucket();
-    ifNonNull(from.getAcl(), Utils.toImmutableListOf(bucketAcl()::encode), to::setAcl);
-    ifNonNull(from.getCors(), Utils.toImmutableListOf(cors()::encode), to::setCors);
-    ifNonNull(from.getCreateTime(), DateTime::new, to::setTimeCreated);
+    ifNonNull(from.getAcl(), toImmutableListOf(bucketAcl()::encode), to::setAcl);
+    ifNonNull(from.getCors(), toImmutableListOf(cors()::encode), to::setCors);
+    ifNonNull(from.getCreateTimeOffsetDateTime(), dateTimeCodec::decode, to::setTimeCreated);
     ifNonNull(
-        from.getDefaultAcl(),
-        Utils.toImmutableListOf(objectAcl()::encode),
-        to::setDefaultObjectAcl);
+        from.getDefaultAcl(), toImmutableListOf(objectAcl()::encode), to::setDefaultObjectAcl);
     ifNonNull(from.getLocation(), to::setLocation);
     ifNonNull(from.getLocationType(), to::setLocationType);
     ifNonNull(from.getMetageneration(), to::setMetageneration);
@@ -304,7 +339,7 @@ final class ApiaryConversions {
         to::setOwner);
     ifNonNull(from.getRpo(), Rpo::toString, to::setRpo);
     ifNonNull(from.getStorageClass(), StorageClass::toString, to::setStorageClass);
-    ifNonNull(from.getUpdateTime(), DateTime::new, to::setUpdated);
+    ifNonNull(from.getUpdateTimeOffsetDateTime(), dateTimeCodec::decode, to::setUpdated);
     ifNonNull(from.versioningEnabled(), b -> new Versioning().setEnabled(b), to::setVersioning);
     to.setEtag(from.getEtag());
     to.setId(from.getGeneratedId());
@@ -362,12 +397,11 @@ final class ApiaryConversions {
       } else {
         Bucket.RetentionPolicy retentionPolicy = new Bucket.RetentionPolicy();
         retentionPolicy.setRetentionPeriod(from.getRetentionPeriod());
-        if (from.getRetentionEffectiveTime() != null) {
-          retentionPolicy.setEffectiveTime(new DateTime(from.getRetentionEffectiveTime()));
-        }
-        if (from.retentionPolicyIsLocked() != null) {
-          retentionPolicy.setIsLocked(from.retentionPolicyIsLocked());
-        }
+        ifNonNull(
+            from.getRetentionEffectiveTimeOffsetDateTime(),
+            dateTimeCodec::decode,
+            retentionPolicy::setEffectiveTime);
+        ifNonNull(from.retentionPolicyIsLocked(), retentionPolicy::setIsLocked);
         to.setRetentionPolicy(retentionPolicy);
       }
     }
@@ -379,12 +413,10 @@ final class ApiaryConversions {
   @SuppressWarnings("deprecation")
   private BucketInfo bucketInfoDecode(com.google.api.services.storage.model.Bucket from) {
     BucketInfo.Builder to = new BuilderImpl(from.getName());
-    ifNonNull(from.getAcl(), Utils.toImmutableListOf(bucketAcl()::decode), to::setAcl);
-    ifNonNull(from.getCors(), Utils.toImmutableListOf(cors()::decode), to::setCors);
+    ifNonNull(from.getAcl(), toImmutableListOf(bucketAcl()::decode), to::setAcl);
+    ifNonNull(from.getCors(), toImmutableListOf(cors()::decode), to::setCors);
     ifNonNull(
-        from.getDefaultObjectAcl(),
-        Utils.toImmutableListOf(objectAcl()::decode),
-        to::setDefaultAcl);
+        from.getDefaultObjectAcl(), toImmutableListOf(objectAcl()::decode), to::setDefaultAcl);
     ifNonNull(from.getEtag(), to::setEtag);
     ifNonNull(from.getId(), to::setGeneratedId);
     ifNonNull(from.getLocation(), to::setLocation);
@@ -395,19 +427,19 @@ final class ApiaryConversions {
     ifNonNull(from.getRpo(), Rpo::valueOf, to::setRpo);
     ifNonNull(from.getSelfLink(), to::setSelfLink);
     ifNonNull(from.getStorageClass(), StorageClass::valueOf, to::setStorageClass);
-    ifNonNull(from.getTimeCreated(), DateTime::getValue, to::setCreateTime);
-    ifNonNull(from.getUpdated(), DateTime::getValue, to::setUpdateTime);
+    ifNonNull(from.getTimeCreated(), dateTimeCodec::encode, to::setCreateTime);
+    ifNonNull(from.getUpdated(), dateTimeCodec::encode, to::setUpdateTime);
     ifNonNull(from.getVersioning(), Versioning::getEnabled, to::setVersioningEnabled);
     ifNonNull(from.getWebsite(), Website::getMainPageSuffix, to::setIndexPage);
     ifNonNull(from.getWebsite(), Website::getNotFoundPage, to::setNotFoundPage);
     ifNonNull(
         from.getLifecycle(),
-        lift(Lifecycle::getRule).andThen(Utils.toImmutableListOf(lifecycleRule()::decode)),
+        lift(Lifecycle::getRule).andThen(toImmutableListOf(lifecycleRule()::decode)),
         to::setLifecycleRules);
     // preserve mapping to deprecated property
     ifNonNull(
         from.getLifecycle(),
-        lift(Lifecycle::getRule).andThen(Utils.toImmutableListOf(deleteRule()::decode)),
+        lift(Lifecycle::getRule).andThen(toImmutableListOf(deleteRule()::decode)),
         to::setDeleteRules);
     ifNonNull(from.getDefaultEventBasedHold(), to::setDefaultEventBasedHold);
     ifNonNull(from.getLabels(), to::setLabels);
@@ -421,7 +453,7 @@ final class ApiaryConversions {
 
     RetentionPolicy retentionPolicy = from.getRetentionPolicy();
     if (retentionPolicy != null && retentionPolicy.getEffectiveTime() != null) {
-      to.setRetentionEffectiveTime(retentionPolicy.getEffectiveTime().getValue());
+      to.setRetentionEffectiveTime(dateTimeCodec.encode(retentionPolicy.getEffectiveTime()));
     }
     ifNonNull(retentionPolicy, RetentionPolicy::getIsLocked, to::setRetentionPolicyIsLocked);
     ifNonNull(retentionPolicy, RetentionPolicy::getRetentionPeriod, to::setRetentionPeriod);
@@ -485,7 +517,8 @@ final class ApiaryConversions {
 
     IamConfiguration.Builder to =
         IamConfiguration.newBuilder().setIsUniformBucketLevelAccessEnabled(ubla.getEnabled());
-    ifNonNull(ubla.getLockedTime(), DateTime::getValue, to::setUniformBucketLevelAccessLockedTime);
+    ifNonNull(
+        ubla.getLockedTime(), dateTimeCodec::encode, to::setUniformBucketLevelAccessLockedTime);
     ifNonNull(
         from.getPublicAccessPrevention(),
         PublicAccessPrevention::parse,
@@ -496,7 +529,10 @@ final class ApiaryConversions {
   private UniformBucketLevelAccess ublaEncode(IamConfiguration from) {
     UniformBucketLevelAccess to = new UniformBucketLevelAccess();
     to.setEnabled(from.isUniformBucketLevelAccessEnabled());
-    ifNonNull(from.getUniformBucketLevelAccessLockedTime(), DateTime::new, to::setLockedTime);
+    ifNonNull(
+        from.getUniformBucketLevelAccessLockedTimeOffsetDateTime(),
+        dateTimeCodec::decode,
+        to::setLockedTime);
     return to;
   }
 
@@ -524,7 +560,7 @@ final class ApiaryConversions {
         from.getCustomTimeBefore(), this::truncateToDateWithNoTzDrift, to::setCustomTimeBefore);
     ifNonNull(
         from.getMatchesStorageClass(),
-        Utils.toImmutableListOf(Object::toString),
+        toImmutableListOf(Object::toString),
         to::setMatchesStorageClass);
     return to;
   }
@@ -575,7 +611,7 @@ final class ApiaryConversions {
             .setDaysSinceCustomTime(condition.getDaysSinceCustomTime());
     ifNonNull(
         condition.getMatchesStorageClass(),
-        Utils.toImmutableListOf(StorageClass::valueOf),
+        toImmutableListOf(StorageClass::valueOf),
         conditionBuilder::setMatchesStorageClass);
 
     return new LifecycleRule(lifecycleAction, conditionBuilder.build());
@@ -604,8 +640,8 @@ final class ApiaryConversions {
     Bucket.Cors to = new Bucket.Cors();
     to.setMaxAgeSeconds(from.getMaxAgeSeconds());
     to.setResponseHeader(from.getResponseHeaders());
-    ifNonNull(from.getMethods(), Utils.toImmutableListOf(Object::toString), to::setMethod);
-    ifNonNull(from.getOrigins(), Utils.toImmutableListOf(Object::toString), to::setOrigin);
+    ifNonNull(from.getMethods(), toImmutableListOf(Object::toString), to::setMethod);
+    ifNonNull(from.getOrigins(), toImmutableListOf(Object::toString), to::setOrigin);
     return to;
   }
 
@@ -619,7 +655,7 @@ final class ApiaryConversions {
                 .map(HttpMethod::valueOf)
                 .collect(ImmutableList.toImmutableList()),
         to::setMethods);
-    ifNonNull(from.getOrigin(), Utils.toImmutableListOf(Origin::of), to::setOrigins);
+    ifNonNull(from.getOrigin(), toImmutableListOf(Origin::of), to::setOrigins);
     to.setResponseHeaders(from.getResponseHeader());
     return to.build();
   }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BackwardCompatibilityUtils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BackwardCompatibilityUtils.java
new file mode 100644
index 00000000..e4674b38
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BackwardCompatibilityUtils.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 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 java.util.Objects.requireNonNull;
+
+import com.google.cloud.storage.Conversions.Codec;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A collection of utilities that only exist to enable backward compatibility.
+ *
+ * <p>In general, the expectation is that any references to this class only come from @Deprecated
+ * things.
+ */
+final class BackwardCompatibilityUtils {
+
+  @SuppressWarnings("RedundantTypeArguments")
+  // the <Long, OffsetDateTime> doesn't auto carry all the way through like intellij thinks it
+  // would.
+  static final Codec<@Nullable Long, @Nullable OffsetDateTime> millisOffsetDateTimeCodec =
+      Codec.<Long, OffsetDateTime>of(
+              m ->
+                  Instant.ofEpochMilli(requireNonNull(m, "m must be non null"))
+                      .atOffset(ZoneOffset.systemDefault().getRules().getOffset(Instant.now())),
+              odt -> requireNonNull(odt, "odt must be non null").toInstant().toEpochMilli())
+          .nullable();
+
+  private BackwardCompatibilityUtils() {}
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
index f5e4550e..c1db1dfa 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.BackwardCompatibilityUtils.millisOffsetDateTimeCodec;
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 
@@ -27,6 +28,7 @@ import com.google.common.collect.ImmutableSet;
 import com.google.common.io.BaseEncoding;
 import java.io.Serializable;
 import java.nio.ByteBuffer;
+import java.time.OffsetDateTime;
 import java.util.AbstractMap;
 import java.util.Collections;
 import java.util.HashMap;
@@ -68,7 +70,7 @@ public class BlobInfo implements Serializable {
   private final String etag;
   private final String md5;
   private final String crc32c;
-  private final Long customTime;
+  private final OffsetDateTime customTime;
   private final String mediaLink;
   /**
    * The getter for this property never returns null, however null awareness is critical for
@@ -79,22 +81,22 @@ public class BlobInfo implements Serializable {
   final Map<String, String> metadata;
 
   private final Long metageneration;
-  private final Long deleteTime;
-  private final Long updateTime;
-  private final Long createTime;
+  private final OffsetDateTime deleteTime;
+  private final OffsetDateTime updateTime;
+  private final OffsetDateTime createTime;
   private final String contentType;
   private final String contentEncoding;
   private final String contentDisposition;
   private final String contentLanguage;
   private final StorageClass storageClass;
-  private final Long timeStorageClassUpdated;
+  private final OffsetDateTime timeStorageClassUpdated;
   private final Integer componentCount;
   private final boolean isDirectory;
   private final CustomerEncryption customerEncryption;
   private final String kmsKeyName;
   private final Boolean eventBasedHold;
   private final Boolean temporaryHold;
-  private final Long retentionExpirationTime;
+  private final OffsetDateTime retentionExpirationTime;
 
   /** This class is meant for internal use only. Users are discouraged from using this class. */
   public static final class ImmutableEmptyMap<K, V> extends AbstractMap<K, V> {
@@ -260,13 +262,35 @@ public class BlobInfo implements Serializable {
      * long customTime = 1598423868301L;
      * BlobInfo blob = BlobInfo.newBuilder(bucketName, blobName).setCustomTime(customTime).build();
      * }</pre>
+     *
+     * @deprecated {@link #setCustomTime(OffsetDateTime)}
      */
+    @Deprecated
     public Builder setCustomTime(Long customTime) {
       throw new UnsupportedOperationException(
           "Override setCustomTime with your own implementation,"
               + " or use com.google.cloud.storage.Blob.");
     }
 
+    /**
+     * Sets the custom time for an object. Once set it can't be unset and only changed to a custom
+     * datetime in the future. To unset the custom time, you must either perform a rewrite operation
+     * or upload the data again.
+     *
+     * <p>Example of setting the custom time.
+     *
+     * <pre>{@code
+     * String bucketName = "my-unique-bucket";
+     * String blobName = "my-blob-name";
+     * OffsetDateTime customTime = Instant.ofEpochMilli(1598423868301L).atOffset(0); // UTC
+     * BlobInfo blob = BlobInfo.newBuilder(bucketName, blobName).setCustomTime(customTime).build();
+     * }</pre>
+     */
+    public Builder setCustomTime(OffsetDateTime customTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      return setCustomTime(millisOffsetDateTimeCodec.decode(customTime));
+    }
+
     /**
      * Sets the CRC32C checksum of blob's data as described in <a
      * href="http://tools.ietf.org/html/rfc4960#appendix-B">RFC 4960, Appendix B;</a> from hex
@@ -286,24 +310,53 @@ public class BlobInfo implements Serializable {
     /**
      * Sets the modification time of an object's storage class. Once set it can't be unset directly,
      * the only way is to rewrite the object with the desired storage class.
+     *
+     * @deprecated {@link #setTimeStorageClassUpdated(OffsetDateTime)}
      */
+    @Deprecated
     public Builder setTimeStorageClassUpdated(Long timeStorageClassUpdated) {
       throw new UnsupportedOperationException(
           "Override setTimeStorageClassUpdated with your own implementation,"
               + " or use com.google.cloud.storage.Blob.");
     }
 
+    public Builder setTimeStorageClassUpdated(OffsetDateTime timeStorageClassUpdated) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      return setTimeStorageClassUpdated(millisOffsetDateTimeCodec.decode(timeStorageClassUpdated));
+    }
+
     /** Sets the blob's user provided metadata. */
     public abstract Builder setMetadata(Map<String, String> metadata);
 
     abstract Builder setMetageneration(Long metageneration);
 
+    /** @deprecated {@link #setDeleteTime(OffsetDateTime)} */
+    @Deprecated
     abstract Builder setDeleteTime(Long deleteTime);
 
+    Builder setDeleteTime(OffsetDateTime deleteTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      return setDeleteTime(millisOffsetDateTimeCodec.decode(deleteTime));
+    }
+
+    /** @deprecated {@link #setUpdateTime(OffsetDateTime)} */
+    @Deprecated
     abstract Builder setUpdateTime(Long updateTime);
 
+    Builder setUpdateTime(OffsetDateTime updateTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      return setUpdateTime(millisOffsetDateTimeCodec.decode(updateTime));
+    }
+
+    /** @deprecated {@link #setCreateTime(OffsetDateTime)} */
+    @Deprecated
     abstract Builder setCreateTime(Long createTime);
 
+    Builder setCreateTime(OffsetDateTime createTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      return setCreateTime(millisOffsetDateTimeCodec.decode(createTime));
+    }
+
     abstract Builder setIsDirectory(boolean isDirectory);
 
     abstract Builder setCustomerEncryption(CustomerEncryption customerEncryption);
@@ -335,9 +388,17 @@ public class BlobInfo implements Serializable {
     @BetaApi
     public abstract Builder setTemporaryHold(Boolean temporaryHold);
 
+    /** @deprecated {@link #setRetentionExpirationTime(OffsetDateTime)} */
     @BetaApi
+    @Deprecated
     abstract Builder setRetentionExpirationTime(Long retentionExpirationTime);
 
+    @BetaApi
+    Builder setRetentionExpirationTime(OffsetDateTime retentionExpirationTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      return setRetentionExpirationTime(millisOffsetDateTimeCodec.decode(retentionExpirationTime));
+    }
+
     /** Creates a {@code BlobInfo} object. */
     public abstract BlobInfo build();
   }
@@ -359,21 +420,21 @@ public class BlobInfo implements Serializable {
     private String selfLink;
     private String md5;
     private String crc32c;
-    private Long customTime;
+    private OffsetDateTime customTime;
     private String mediaLink;
     private Map<String, String> metadata;
     private Long metageneration;
-    private Long deleteTime;
-    private Long updateTime;
-    private Long createTime;
+    private OffsetDateTime deleteTime;
+    private OffsetDateTime updateTime;
+    private OffsetDateTime createTime;
     private Boolean isDirectory;
     private CustomerEncryption customerEncryption;
     private StorageClass storageClass;
-    private Long timeStorageClassUpdated;
+    private OffsetDateTime timeStorageClassUpdated;
     private String kmsKeyName;
     private Boolean eventBasedHold;
     private Boolean temporaryHold;
-    private Long retentionExpirationTime;
+    private OffsetDateTime retentionExpirationTime;
 
     BuilderImpl(BlobId blobId) {
       this.blobId = blobId;
@@ -528,6 +589,11 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setCustomTime(Long customTime) {
+      return setCustomTime(millisOffsetDateTimeCodec.encode(customTime));
+    }
+
+    @Override
+    public Builder setCustomTime(OffsetDateTime customTime) {
       this.customTime = customTime;
       return this;
     }
@@ -581,6 +647,11 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setTimeStorageClassUpdated(Long timeStorageClassUpdated) {
+      return setTimeStorageClassUpdated(millisOffsetDateTimeCodec.encode(timeStorageClassUpdated));
+    }
+
+    @Override
+    public Builder setTimeStorageClassUpdated(OffsetDateTime timeStorageClassUpdated) {
       this.timeStorageClassUpdated = timeStorageClassUpdated;
       return this;
     }
@@ -593,18 +664,33 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setDeleteTime(Long deleteTime) {
+      return setDeleteTime(millisOffsetDateTimeCodec.encode(deleteTime));
+    }
+
+    @Override
+    Builder setDeleteTime(OffsetDateTime deleteTime) {
       this.deleteTime = deleteTime;
       return this;
     }
 
     @Override
     Builder setUpdateTime(Long updateTime) {
+      return setUpdateTime(millisOffsetDateTimeCodec.encode(updateTime));
+    }
+
+    @Override
+    Builder setUpdateTime(OffsetDateTime updateTime) {
       this.updateTime = updateTime;
       return this;
     }
 
     @Override
     Builder setCreateTime(Long createTime) {
+      return setCreateTime(millisOffsetDateTimeCodec.encode(createTime));
+    }
+
+    @Override
+    Builder setCreateTime(OffsetDateTime createTime) {
       this.createTime = createTime;
       return this;
     }
@@ -641,6 +727,11 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setRetentionExpirationTime(Long retentionExpirationTime) {
+      return setRetentionExpirationTime(millisOffsetDateTimeCodec.encode(retentionExpirationTime));
+    }
+
+    @Override
+    Builder setRetentionExpirationTime(OffsetDateTime retentionExpirationTime) {
       this.retentionExpirationTime = retentionExpirationTime;
       return this;
     }
@@ -888,29 +979,63 @@ public class BlobInfo implements Serializable {
   /**
    * Returns the deletion time of the blob expressed as the number of milliseconds since the Unix
    * epoch.
+   *
+   * @deprecated {@link #getDeleteTimeOffsetDateTime()}
    */
+  @Deprecated
   public Long getDeleteTime() {
+    return millisOffsetDateTimeCodec.decode(deleteTime);
+  }
+
+  /** Returns the deletion time of the blob. */
+  public OffsetDateTime getDeleteTimeOffsetDateTime() {
     return deleteTime;
   }
 
   /**
    * Returns the last modification time of the blob's metadata expressed as the number of
    * milliseconds since the Unix epoch.
+   *
+   * @deprecated {@link #getUpdateTimeOffsetDateTime()}
    */
+  @Deprecated
   public Long getUpdateTime() {
+    return millisOffsetDateTimeCodec.decode(updateTime);
+  }
+
+  /** Returns the last modification time of the blob's metadata. */
+  public OffsetDateTime getUpdateTimeOffsetDateTime() {
     return updateTime;
   }
 
   /**
    * Returns the creation time of the blob expressed as the number of milliseconds since the Unix
    * epoch.
+   *
+   * @deprecated {@link #getCreateTimeOffsetDateTime()}
    */
+  @Deprecated
   public Long getCreateTime() {
+    return millisOffsetDateTimeCodec.decode(createTime);
+  }
+
+  /** Returns the creation time of the blob. */
+  public OffsetDateTime getCreateTimeOffsetDateTime() {
     return createTime;
   }
 
-  /** Returns the custom time specified by the user for an object. */
+  /**
+   * Returns the custom time specified by the user for an object.
+   *
+   * @deprecated {@link #getCustomTimeOffsetDateTime()}
+   */
+  @Deprecated
   public Long getCustomTime() {
+    return millisOffsetDateTimeCodec.decode(customTime);
+  }
+
+  /** Returns the custom time specified by the user for an object. */
+  public OffsetDateTime getCustomTimeOffsetDateTime() {
     return customTime;
   }
 
@@ -942,8 +1067,19 @@ public class BlobInfo implements Serializable {
   /**
    * Returns the time that the object's storage class was last changed or the time of the object
    * creation.
+   *
+   * @deprecated {@link #getTimeStorageClassUpdatedOffsetDateTime()}
    */
+  @Deprecated
   public Long getTimeStorageClassUpdated() {
+    return millisOffsetDateTimeCodec.decode(timeStorageClassUpdated);
+  }
+
+  /**
+   * Returns the time that the object's storage class was last changed or the time of the object
+   * creation.
+   */
+  public OffsetDateTime getTimeStorageClassUpdatedOffsetDateTime() {
     return timeStorageClassUpdated;
   }
 
@@ -1009,10 +1145,24 @@ public class BlobInfo implements Serializable {
   /**
    * Returns the retention expiration time of the blob as {@code Long}, if a retention period is
    * defined. If retention period is not defined this value returns {@code null}
+   *
+   * @deprecated {@link #getRetentionExpirationTimeOffsetDateTime()}
    */
   @BetaApi
+  @Deprecated
   public Long getRetentionExpirationTime() {
-    return Data.isNull(retentionExpirationTime) ? null : retentionExpirationTime;
+    return Data.isNull(retentionExpirationTime)
+        ? null
+        : millisOffsetDateTimeCodec.decode(retentionExpirationTime);
+  }
+
+  /**
+   * Returns the retention expiration time of the blob, if a retention period is defined. If
+   * retention period is not defined this value returns {@code null}
+   */
+  @BetaApi
+  public OffsetDateTime getRetentionExpirationTimeOffsetDateTime() {
+    return retentionExpirationTime;
   }
 
   /** Returns a builder for the current blob. */
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
index 20813c44..806cb91b 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.BackwardCompatibilityUtils.millisOffsetDateTimeCodec;
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 
@@ -33,6 +34,7 @@ import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.time.OffsetDateTime;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -65,8 +67,8 @@ public class BucketInfo implements Serializable {
   final List<LifecycleRule> lifecycleRules;
 
   private final String etag;
-  private final Long createTime;
-  private final Long updateTime;
+  private final OffsetDateTime createTime;
+  private final OffsetDateTime updateTime;
   private final Long metageneration;
   private final List<Cors> cors;
   private final List<Acl> acl;
@@ -77,9 +79,9 @@ public class BucketInfo implements Serializable {
   private final Map<String, String> labels;
   private final String defaultKmsKeyName;
   private final Boolean defaultEventBasedHold;
-  private final Long retentionEffectiveTime;
+  private final OffsetDateTime retentionEffectiveTime;
   private final Boolean retentionPolicyIsLocked;
-  private final Long retentionPeriod;
+  private final Long retentionPeriod; // TODO: duration
   private final IamConfiguration iamConfiguration;
   private final String locationType;
   private final Logging logging;
@@ -149,7 +151,7 @@ public class BucketInfo implements Serializable {
     private static final long serialVersionUID = -8671736104909424616L;
 
     private final Boolean isUniformBucketLevelAccessEnabled;
-    private final Long uniformBucketLevelAccessLockedTime;
+    private final OffsetDateTime uniformBucketLevelAccessLockedTime;
     private final PublicAccessPrevention publicAccessPrevention;
 
     @Override
@@ -199,14 +201,20 @@ public class BucketInfo implements Serializable {
     /** Deprecated in favor of uniformBucketLevelAccessLockedTime(). */
     @Deprecated
     public Long getBucketPolicyOnlyLockedTime() {
-      return uniformBucketLevelAccessLockedTime;
+      return getUniformBucketLevelAccessLockedTime();
     }
 
     public Boolean isUniformBucketLevelAccessEnabled() {
       return isUniformBucketLevelAccessEnabled;
     }
 
+    /** @deprecated {@link #getUniformBucketLevelAccessLockedTimeOffsetDateTime()} */
+    @Deprecated
     public Long getUniformBucketLevelAccessLockedTime() {
+      return millisOffsetDateTimeCodec.decode(uniformBucketLevelAccessLockedTime);
+    }
+
+    public OffsetDateTime getUniformBucketLevelAccessLockedTimeOffsetDateTime() {
       return uniformBucketLevelAccessLockedTime;
     }
 
@@ -218,7 +226,7 @@ public class BucketInfo implements Serializable {
     /** Builder for {@code IamConfiguration} */
     public static class Builder {
       private Boolean isUniformBucketLevelAccessEnabled;
-      private Long uniformBucketLevelAccessLockedTime;
+      private OffsetDateTime uniformBucketLevelAccessLockedTime;
       private PublicAccessPrevention publicAccessPrevention;
 
       /** Deprecated in favor of setIsUniformBucketLevelAccessEnabled(). */
@@ -228,11 +236,10 @@ public class BucketInfo implements Serializable {
         return this;
       }
 
-      /** Deprecated in favor of setUniformBucketLevelAccessLockedTime(). */
+      /** @deprecated in favor of {@link #setUniformBucketLevelAccessLockedTime(Long)}. */
       @Deprecated
       Builder setBucketPolicyOnlyLockedTime(Long bucketPolicyOnlyLockedTime) {
-        this.uniformBucketLevelAccessLockedTime = bucketPolicyOnlyLockedTime;
-        return this;
+        return setUniformBucketLevelAccessLockedTime(bucketPolicyOnlyLockedTime);
       }
 
       /**
@@ -254,8 +261,23 @@ public class BucketInfo implements Serializable {
        * After this time passes, calls to do so will fail. This is package-private, since in general
        * this field should never be set by a user--it's automatically set by the backend when {@code
        * enabled} is set to true.
+       *
+       * @deprecated {@link #setUniformBucketLevelAccessLockedTime(OffsetDateTime)}
        */
+      @Deprecated
       Builder setUniformBucketLevelAccessLockedTime(Long uniformBucketLevelAccessLockedTime) {
+        return setUniformBucketLevelAccessLockedTime(
+            millisOffsetDateTimeCodec.encode(uniformBucketLevelAccessLockedTime));
+      }
+
+      /**
+       * Sets the deadline for switching {@code uniformBucketLevelAccess.enabled} back to false.
+       * After this time passes, calls to do so will fail. This is package-private, since in general
+       * this field should never be set by a user--it's automatically set by the backend when {@code
+       * enabled} is set to true.
+       */
+      Builder setUniformBucketLevelAccessLockedTime(
+          OffsetDateTime uniformBucketLevelAccessLockedTime) {
         this.uniformBucketLevelAccessLockedTime = uniformBucketLevelAccessLockedTime;
         return this;
       }
@@ -1051,10 +1073,26 @@ public class BucketInfo implements Serializable {
 
     abstract Builder setEtag(String etag);
 
+    /** @deprecated {@link #setCreateTime(OffsetDateTime)} */
+    @Deprecated
     abstract Builder setCreateTime(Long createTime);
 
+    Builder setCreateTime(OffsetDateTime createTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      setCreateTime(millisOffsetDateTimeCodec.decode(createTime));
+      return this;
+    }
+
+    /** @deprecated {@link #setUpdateTime(OffsetDateTime)} */
+    @Deprecated
     abstract Builder setUpdateTime(Long updateTime);
 
+    Builder setUpdateTime(OffsetDateTime updateTime) {
+      // provide an implementation for source and binary compatibility which we override ourselves
+      setCreateTime(millisOffsetDateTimeCodec.decode(updateTime));
+      return this;
+    }
+
     abstract Builder setMetageneration(Long metageneration);
 
     abstract Builder setLocationType(String locationType);
@@ -1096,9 +1134,16 @@ public class BucketInfo implements Serializable {
     @BetaApi
     public abstract Builder setDefaultEventBasedHold(Boolean defaultEventBasedHold);
 
+    /** @deprecated {@link #setRetentionEffectiveTime(OffsetDateTime)} */
     @BetaApi
+    @Deprecated
     abstract Builder setRetentionEffectiveTime(Long retentionEffectiveTime);
 
+    @BetaApi
+    Builder setRetentionEffectiveTime(OffsetDateTime retentionEffectiveTime) {
+      return setRetentionEffectiveTime(millisOffsetDateTimeCodec.decode(retentionEffectiveTime));
+    }
+
     @BetaApi
     abstract Builder setRetentionPolicyIsLocked(Boolean retentionPolicyIsLocked);
 
@@ -1140,8 +1185,8 @@ public class BucketInfo implements Serializable {
     private StorageClass storageClass;
     private String location;
     private String etag;
-    private Long createTime;
-    private Long updateTime;
+    private OffsetDateTime createTime;
+    private OffsetDateTime updateTime;
     private Long metageneration;
     private List<Cors> cors;
     private List<Acl> acl;
@@ -1149,7 +1194,7 @@ public class BucketInfo implements Serializable {
     private Map<String, String> labels;
     private String defaultKmsKeyName;
     private Boolean defaultEventBasedHold;
-    private Long retentionEffectiveTime;
+    private OffsetDateTime retentionEffectiveTime;
     private Boolean retentionPolicyIsLocked;
     private Long retentionPeriod;
     private IamConfiguration iamConfiguration;
@@ -1286,14 +1331,30 @@ public class BucketInfo implements Serializable {
       return this;
     }
 
+    /** @deprecated {@link #setCreateTime(OffsetDateTime)} */
+    @Deprecated
     @Override
     Builder setCreateTime(Long createTime) {
+      this.createTime = millisOffsetDateTimeCodec.encode(createTime);
+      return this;
+    }
+
+    @Override
+    Builder setCreateTime(OffsetDateTime createTime) {
       this.createTime = createTime;
       return this;
     }
 
+    /** @deprecated {@link #setUpdateTime(OffsetDateTime)} */
+    @Deprecated
     @Override
     Builder setUpdateTime(Long updateTime) {
+      this.updateTime = millisOffsetDateTimeCodec.encode(updateTime);
+      return this;
+    }
+
+    @Override
+    Builder setUpdateTime(OffsetDateTime updateTime) {
       this.updateTime = updateTime;
       return this;
     }
@@ -1354,9 +1415,14 @@ public class BucketInfo implements Serializable {
     }
 
     @Override
+    @Deprecated
     Builder setRetentionEffectiveTime(Long retentionEffectiveTime) {
-      this.retentionEffectiveTime =
-          firstNonNull(retentionEffectiveTime, Data.<Long>nullOf(Long.class));
+      return setRetentionEffectiveTime(millisOffsetDateTimeCodec.encode(retentionEffectiveTime));
+    }
+
+    @Override
+    Builder setRetentionEffectiveTime(OffsetDateTime retentionEffectiveTime) {
+      this.retentionEffectiveTime = retentionEffectiveTime;
       return this;
     }
 
@@ -1531,16 +1597,32 @@ public class BucketInfo implements Serializable {
     return etag;
   }
 
-  /** Returns the time at which the bucket was created. */
+  /**
+   * Returns the time at which the bucket was created.
+   *
+   * @deprecated {@link #getCreateTimeOffsetDateTime()}
+   */
+  @Deprecated
   public Long getCreateTime() {
+    return millisOffsetDateTimeCodec.decode(createTime);
+  }
+
+  public OffsetDateTime getCreateTimeOffsetDateTime() {
     return createTime;
   }
 
   /**
    * Returns the last modification time of the bucket's metadata expressed as the number of
    * milliseconds since the Unix epoch.
+   *
+   * @deprecated {@link #getUpdateTimeOffsetDateTime()}
    */
+  @Deprecated
   public Long getUpdateTime() {
+    return millisOffsetDateTimeCodec.decode(updateTime);
+  }
+
+  public OffsetDateTime getUpdateTimeOffsetDateTime() {
     return updateTime;
   }
 
@@ -1662,7 +1744,16 @@ public class BucketInfo implements Serializable {
    * {@code Long}.
    */
   @BetaApi
+  @Deprecated
   public Long getRetentionEffectiveTime() {
+    return Data.isNull(retentionEffectiveTime)
+        ? null
+        : millisOffsetDateTimeCodec.decode(retentionEffectiveTime);
+  }
+
+  /** Returns the retention effective time a policy took effect if a retention policy is defined. */
+  @BetaApi
+  public OffsetDateTime getRetentionEffectiveTimeOffsetDateTime() {
     return retentionEffectiveTime;
   }
 
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Conversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Conversions.java
index 8b71fc5c..31e3a30d 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Conversions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Conversions.java
@@ -16,6 +16,8 @@
 
 package com.google.cloud.storage;
 
+import org.checkerframework.checker.nullness.qual.Nullable;
+
 final class Conversions {
 
   private Conversions() {}
@@ -42,6 +44,27 @@ final class Conversions {
     static <X, Y> Codec<X, Y> of(Encoder<X, Y> e, Decoder<Y, X> d) {
       return new SimpleCodec<>(e, d);
     }
+
+    /**
+     * Create a new Codec which guards calling each method with a null check.
+     *
+     * <p>If the values provided to either {@link #decode(Object)} or {@link #encode(Object)} is
+     * null, null will be returned.
+     */
+    default Codec<@Nullable A, @Nullable B> nullable() {
+      Codec<A, B> self = this;
+      return new Codec<A, B>() {
+        @Override
+        public A decode(B f) {
+          return f == null ? null : self.decode(f);
+        }
+
+        @Override
+        public B encode(A f) {
+          return f == null ? null : self.encode(f);
+        }
+      };
+    }
   }
 
   private static final class SimpleCodec<A, B> implements Codec<A, B> {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java
index bc6bfb33..af449e3d 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java
@@ -18,6 +18,7 @@ package com.google.cloud.storage;
 
 import com.google.api.core.InternalApi;
 import com.google.common.collect.ImmutableList;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Function;
@@ -32,6 +33,9 @@ import javax.annotation.Nullable;
 @InternalApi
 final class Utils {
 
+  static final DateTimeFormatter RFC_3339_DATE_TIME_FORMATTER =
+      DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+
   private Utils() {}
 
   /**


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/DateTimeCodecPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/DateTimeCodecPropertyTest.java
new file mode 100644
index 00000000..27079f8d
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/DateTimeCodecPropertyTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2022 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.cloud.storage.Utils.RFC_3339_DATE_TIME_FORMATTER;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.client.util.DateTime;
+import com.google.cloud.storage.Conversions.Codec;
+import java.time.OffsetDateTime;
+import java.time.temporal.ChronoUnit;
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.Combinators;
+import net.jqwik.api.Example;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.time.api.DateTimes;
+
+final class DateTimeCodecPropertyTest {
+
+  private static final Codec<DateTime, OffsetDateTime> codec =
+      ApiaryConversions.INSTANCE.dateTimeCodec;
+
+  @Example
+  void codecShouldRoundTrip_UTC() {
+    codecShouldRoundTrip("2019-08-23T07:23:51.396Z");
+  }
+
+  @Example
+  void codecShouldRoundTrip_negative() {
+    codecShouldRoundTrip("2019-08-23T07:23:51.396-08:59");
+  }
+
+  @Example
+  void codecShouldRoundTrip_positive() {
+    codecShouldRoundTrip("2019-08-23T07:23:51.396+00:13");
+  }
+
+  @Property(tries = 10000)
+  void codecShouldRoundTrip(@ForAll("rfc3339") String rfc3339String) {
+    DateTime actual = new DateTime(rfc3339String);
+    OffsetDateTime odt = codec.encode(actual);
+    DateTime dt = codec.decode(odt);
+
+    assertThat(dt.toStringRfc3339()).isEqualTo(rfc3339String);
+  }
+
+  @Provide("rfc3339")
+  Arbitrary<String> arbitraryRfc3339Strings() {
+    return Combinators.combine(DateTimes.offsetDateTimes(), Arbitraries.integers().between(0, 999))
+        .as((odt, millis) -> odt.plus(millis, ChronoUnit.MILLIS))
+        .map(DateTimeCodecPropertyTest::offsetDateTimeToString);
+  }
+
+  private static String offsetDateTimeToString(OffsetDateTime odt) {
+    return odt.format(RFC_3339_DATE_TIME_FORMATTER);
+  }
+}