Repository: firebase/firebase-admin-java
Pull request #498 (2020-12-01 22:07:10)

Add List Versions operation in Remote Config
- Add List Versions operation in Remote Config
- Add  `ListVersionsOptions`, `ListVersionsResult` types
- Add unit tests

Related to #446 

lahirumaramba: Note: Still in draft. Needs to be rebased after #497 

Patch:
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
index 86eac2e..e34d0c5 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
@@ -331,6 +331,75 @@ public final class FirebaseRemoteConfig {
     };
   }
 
+  /**
+   * Gets a list of Remote Config template versions that have been published, sorted in reverse
+   * chronological order. Only the last 300 versions are stored.
+   *
+   * <p>All versions that correspond to non-active Remote Config templates (that is, all except the
+   * template that is being fetched by clients) are also deleted if they are more than 90 days old.
+   *
+   * @return A {@link ListVersionsPage} instance.
+   * @throws FirebaseRemoteConfigException If an error occurs while retrieving versions list.
+   */
+  public ListVersionsPage listVersions() throws FirebaseRemoteConfigException {
+    return listVersionsOp().call();
+  }
+
+  /**
+   * Gets a list of Remote Config template versions that have been published, sorted in reverse
+   * chronological order. Only the last 300 versions are stored.
+   *
+   * <p>All versions that correspond to non-active Remote Config templates (that is, all except the
+   * template that is being fetched by clients) are also deleted if they are more than 90 days old.
+   *
+   * @param options List version options.
+   * @return A {@link ListVersionsPage} instance.
+   * @throws FirebaseRemoteConfigException If an error occurs while retrieving versions list.
+   */
+  public ListVersionsPage listVersions(
+          @NonNull ListVersionsOptions options) throws FirebaseRemoteConfigException {
+    return listVersionsOp(options).call();
+  }
+
+  /**
+   * Similar to {@link #listVersions()} but performs the operation
+   * asynchronously.
+   *
+   * @return A {@link ListVersionsPage} instance.
+   */
+  public ApiFuture<ListVersionsPage> listVersionsAsync() {
+    return listVersionsOp().callAsync(app);
+  }
+
+  /**
+   * Similar to {@link #listVersions(ListVersionsOptions options)} but performs the operation
+   * asynchronously.
+   *
+   * @param options List version options.
+   * @return A {@link ListVersionsPage} instance.
+   */
+  public ApiFuture<ListVersionsPage> listVersionsAsync(@NonNull ListVersionsOptions options) {
+    return listVersionsOp(options).callAsync(app);
+  }
+
+  private CallableOperation<ListVersionsPage, FirebaseRemoteConfigException> listVersionsOp() {
+    return listVersionsOp(null);
+  }
+
+  private CallableOperation<ListVersionsPage, FirebaseRemoteConfigException> listVersionsOp(
+          final ListVersionsOptions options) {
+    final FirebaseRemoteConfigClient remoteConfigClient = getRemoteConfigClient();
+    final ListVersionsPage.DefaultVersionSource source =
+            new ListVersionsPage.DefaultVersionSource(remoteConfigClient);
+    final ListVersionsPage.Factory factory = new ListVersionsPage.Factory(source, options);
+    return new CallableOperation<ListVersionsPage, FirebaseRemoteConfigException>() {
+      @Override
+      protected ListVersionsPage execute() throws FirebaseRemoteConfigException {
+        return factory.create();
+      }
+    };
+  }
+
   @VisibleForTesting
   FirebaseRemoteConfigClient getRemoteConfigClient() {
     return remoteConfigClient;
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
index fba0bfd..9fdb596 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
@@ -16,6 +16,8 @@
 
 package com.google.firebase.remoteconfig;
 
+import com.google.firebase.remoteconfig.internal.TemplateResponse.ListVersionsResponse;
+
 /**
  * An interface for managing Firebase Remote Config templates.
  */
@@ -35,4 +37,7 @@ interface FirebaseRemoteConfigClient {
                                   boolean forcePublish) throws FirebaseRemoteConfigException;
 
   Template rollback(String versionNumber) throws FirebaseRemoteConfigException;
+
+  ListVersionsResponse listVersions(
+          ListVersionsOptions options) throws FirebaseRemoteConfigException;
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
index 824d91a..aff5439 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
@@ -105,7 +105,7 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
   @Override
   public Template getTemplateAtVersion(
           @NonNull String versionNumber) throws FirebaseRemoteConfigException {
-    checkArgument(isValidVersionNumber(versionNumber),
+    checkArgument(RemoteConfigUtil.isValidVersionNumber(versionNumber),
             "Version number must be a non-empty string in int64 format.");
     HttpRequestInfo request = HttpRequestInfo.buildGetRequest(remoteConfigUrl)
             .addAllHeaders(COMMON_HEADERS)
@@ -141,7 +141,7 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
 
   @Override
   public Template rollback(@NonNull String versionNumber) throws FirebaseRemoteConfigException {
-    checkArgument(isValidVersionNumber(versionNumber),
+    checkArgument(RemoteConfigUtil.isValidVersionNumber(versionNumber),
             "Version number must be a non-empty string in int64 format.");
     Map<String, String> content = ImmutableMap.of("versionNumber", versionNumber);
     HttpRequestInfo request = HttpRequestInfo
@@ -153,6 +153,17 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
     return template.setETag(getETag(response));
   }
 
+  @Override
+  public TemplateResponse.ListVersionsResponse listVersions(
+          ListVersionsOptions options) throws FirebaseRemoteConfigException {
+    HttpRequestInfo request = HttpRequestInfo.buildGetRequest(remoteConfigUrl + ":listVersions")
+            .addAllHeaders(COMMON_HEADERS);
+    if (options != null) {
+      request.addAllParameters(options.wrapForTransport());
+    }
+    return httpClient.sendAndParse(request, TemplateResponse.ListVersionsResponse.class);
+  }
+
   private String getETag(IncomingHttpResponse response) {
     List<String> etagList = (List<String>) response.getHeaders().get("etag");
     checkState(etagList != null && !etagList.isEmpty(),
@@ -165,10 +176,6 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
     return etag;
   }
 
-  private boolean isValidVersionNumber(String versionNumber) {
-    return !Strings.isNullOrEmpty(versionNumber) && versionNumber.matches("^\\d+$");
-  }
-
   static FirebaseRemoteConfigClientImpl fromApp(FirebaseApp app) {
     String projectId = ImplFirebaseTrampolines.getProjectId(app);
     checkArgument(!Strings.isNullOrEmpty(projectId),
diff --git a/src/main/java/com/google/firebase/remoteconfig/ListVersionsOptions.java b/src/main/java/com/google/firebase/remoteconfig/ListVersionsOptions.java
new file mode 100644
index 0000000..be7a62e
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/ListVersionsOptions.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2020 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.firebase.remoteconfig;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TimeZone;
+
+/**
+ * A class representing options for Remote Config list versions operation.
+ */
+public final class ListVersionsOptions {
+
+  private final Integer pageSize;
+  private final String pageToken;
+  private final String endVersionNumber;
+  private final String startTime;
+  private final String endTime;
+
+  private ListVersionsOptions(Builder builder) {
+    if (builder.pageSize != null) {
+      checkArgument(builder.pageSize > 0 && builder.pageSize < 301,
+              "pageSize must be a number between 1 and 300 (inclusive).");
+    }
+    if (builder.endVersionNumber != null) {
+      checkArgument(RemoteConfigUtil.isValidVersionNumber(builder.endVersionNumber)
+                      && (Integer.parseInt(builder.endVersionNumber) > 0),
+              "endVersionNumber must be a non-empty string in int64 format and must be"
+                      + " greater than 0.");
+    }
+    this.pageSize = builder.pageSize;
+    this.pageToken = builder.pageToken;
+    this.endVersionNumber = builder.endVersionNumber;
+    this.startTime = builder.startTime;
+    this.endTime = builder.endTime;
+  }
+
+  Map<String, Object> wrapForTransport() {
+    Map<String, Object> optionsMap = new HashMap<>();
+    if (this.pageSize != null) {
+      optionsMap.put("pageSize", this.pageSize);
+    }
+    if (this.pageToken != null) {
+      optionsMap.put("pageToken", this.pageToken);
+    }
+    if (this.endVersionNumber != null) {
+      optionsMap.put("endVersionNumber", this.endVersionNumber);
+    }
+    if (this.startTime != null) {
+      optionsMap.put("startTime", this.startTime);
+    }
+    if (this.endTime != null) {
+      optionsMap.put("endTime", this.endTime);
+    }
+    return optionsMap;
+  }
+
+  String getPageToken() {
+    return pageToken;
+  }
+
+  /**
+   * Creates a new {@link ListVersionsOptions.Builder}.
+   *
+   * @return A {@link ListVersionsOptions.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /**
+   * Creates a new {@code Builder} from the options object.
+   *
+   * <p>The new builder is not backed by this object's values; that is, changes made to the new
+   * builder don't change the values of the origin object.
+   */
+  public Builder toBuilder() {
+    return new Builder(this);
+  }
+
+  public static class Builder {
+    private Integer pageSize;
+    private String pageToken;
+    private String endVersionNumber;
+    private String startTime;
+    private String endTime;
+
+    private Builder() {}
+
+    private Builder(ListVersionsOptions options) {
+      this.pageSize = options.pageSize;
+      this.pageToken = options.pageToken;
+      this.endVersionNumber = options.endVersionNumber;
+      this.startTime = options.startTime;
+      this.endTime = options.endTime;
+    }
+
+    /**
+     * Sets the page size.
+     *
+     * @param pageSize The maximum number of items to return per page.
+     * @return This builder.
+     */
+    public Builder setPageSize(int pageSize) {
+      this.pageSize = pageSize;
+      return this;
+    }
+
+    /**
+     * Sets the page token.
+     *
+     * @param pageToken The {@code nextPageToken} value returned from a previous List request,
+     *                  if any.
+     * @return This builder.
+     */
+    public Builder setPageToken(String pageToken) {
+      this.pageToken = pageToken;
+      return this;
+    }
+
+    /**
+     * Sets the newest version number to include in the results.
+     *
+     * @param endVersionNumber Specify the newest version number to include in the results.
+     *                         If specified, must be greater than zero. Defaults to the newest
+     *                         version.
+     * @return This builder.
+     */
+    public Builder setEndVersionNumber(String endVersionNumber) {
+      this.endVersionNumber = endVersionNumber;
+      return this;
+    }
+
+    /**
+     * Sets the newest version number to include in the results.
+     *
+     * @param endVersionNumber Specify the newest version number to include in the results.
+     *                         If specified, must be greater than zero. Defaults to the newest
+     *                         version.
+     * @return This builder.
+     */
+    public Builder setEndVersionNumber(long endVersionNumber) {
+      this.endVersionNumber = String.valueOf(endVersionNumber);;
+      return this;
+    }
+
+    /**
+     * Sets the earliest update time to include in the results.
+     *
+     * @param startTimeMillis Specify the earliest update time to include in the results.
+     *                        Any entries updated before this time are omitted.
+     * @return This builder.
+     */
+    public Builder setStartTimeMillis(long startTimeMillis) {
+      this.startTime = RemoteConfigUtil.convertToUtcZuluFormat(startTimeMillis);
+      return this;
+    }
+
+    /**
+     * Sets the latest update time to include in the results.
+     *
+     * @param endTimeMillis Specify the latest update time to include in the results.
+     *                      Any entries updated on or after this time are omitted.
+     * @return This builder.
+     */
+    public Builder setEndTimeMillis(long endTimeMillis) {
+      this.endTime = RemoteConfigUtil.convertToUtcZuluFormat(endTimeMillis);
+      return this;
+    }
+
+    /**
+     * Builds a new {@link ListVersionsOptions} instance from the fields set on this builder.
+     *
+     * @return A non-null {@link ListVersionsOptions}.
+     */
+    public ListVersionsOptions build() {
+      return new ListVersionsOptions(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/ListVersionsPage.java b/src/main/java/com/google/firebase/remoteconfig/ListVersionsPage.java
new file mode 100644
index 0000000..22431f7
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/ListVersionsPage.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2020 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.firebase.remoteconfig;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.gax.paging.Page;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+/**
+ * Represents a page of {@link Version} instances. Provides methods for iterating
+ * over the versions in the current page, and calling up subsequent pages of versions. Instances of
+ * this class are thread-safe and immutable.
+ */
+public final class ListVersionsPage implements Page<Version> {
+
+  static final String END_OF_LIST = "";
+
+  private final VersionsResultBatch currentBatch;
+  private final VersionSource source;
+  private final ListVersionsOptions listVersionsOptions;
+
+  private ListVersionsPage(
+          @NonNull VersionsResultBatch currentBatch, @NonNull VersionSource source,
+          @NonNull ListVersionsOptions listVersionsOptions) {
+    this.currentBatch = checkNotNull(currentBatch);
+    this.source = checkNotNull(source);
+    this.listVersionsOptions = listVersionsOptions;
+  }
+
+  /**
+   * Checks if there is another page of versions available to retrieve.
+   *
+   * @return true if another page is available, or false otherwise.
+   */
+  @Override
+  public boolean hasNextPage() {
+    return !END_OF_LIST.equals(currentBatch.getNextPageToken());
+  }
+
+  /**
+   * Returns the next page of versions.
+   *
+   * @return A new {@link ListVersionsPage} instance, or null if there are no more pages.
+   */
+  @NonNull
+  @Override
+  public ListVersionsPage getNextPage() {
+    if (hasNextPage()) {
+      ListVersionsOptions options;
+      if (listVersionsOptions != null) {
+        options = listVersionsOptions.toBuilder().setPageToken(currentBatch.getNextPageToken())
+                .build();
+      } else {
+        options = ListVersionsOptions.builder().setPageToken(currentBatch.getNextPageToken())
+                .build();
+      }
+      Factory factory = new Factory(source, options);
+      try {
+        return factory.create();
+      } catch (FirebaseRemoteConfigException e) {
+        throw new RuntimeException(e);
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Returns the string token that identifies the next page. Never returns null. Returns empty
+   * string if there are no more pages available to be retrieved.
+   *
+   * @return A non-null string token (possibly empty, representing no more pages)
+   */
+  @NonNull
+  @Override
+  public String getNextPageToken() {
+    return currentBatch.getNextPageToken();
+  }
+
+  /**
+   * Returns an {@code Iterable} over the versions in this page.
+   *
+   * @return a {@code Iterable<Version>} instance.
+   */
+  @NonNull
+  @Override
+  public Iterable<Version> getValues() {
+    return currentBatch.getVersions();
+  }
+
+  /**
+   * Returns an {@code Iterable} that facilitates transparently iterating over all the versions in
+   * the current Firebase project, starting from this page. The {@code Iterator} instances produced
+   * by the returned {@code Iterable} never buffers more than one page of versions at a time. It is
+   * safe to abandon the iterators (i.e. break the loops) at any time.
+   *
+   * @return a new {@code Iterable<Version>} instance.
+   */
+  @NonNull
+  @Override
+  public Iterable<Version> iterateAll() {
+    return new VersionIterable(this);
+  }
+
+  private static class VersionIterable implements Iterable<Version> {
+
+    private final ListVersionsPage startingPage;
+
+    VersionIterable(@NonNull ListVersionsPage startingPage) {
+      this.startingPage = checkNotNull(startingPage, "starting page must not be null");
+    }
+
+    @Override
+    @NonNull
+    public Iterator<Version> iterator() {
+      return new VersionIterable.VersionIterator(startingPage);
+    }
+
+    /**
+     * An {@code Iterator} that cycles through versions, one at a time. It buffers the
+     * last retrieved batch of versions in memory.
+     */
+    private static class VersionIterator implements Iterator<Version> {
+
+      private ListVersionsPage currentPage;
+      private List<Version> batch;
+      private int index = 0;
+
+      private VersionIterator(ListVersionsPage startingPage) {
+        setCurrentPage(startingPage);
+      }
+
+      @Override
+      public boolean hasNext() {
+        if (index == batch.size()) {
+          if (currentPage.hasNextPage()) {
+            setCurrentPage(currentPage.getNextPage());
+          } else {
+            return false;
+          }
+        }
+
+        return index < batch.size();
+      }
+
+      @Override
+      public Version next() {
+        if (!hasNext()) {
+          throw new NoSuchElementException();
+        }
+        return batch.get(index++);
+      }
+
+      @Override
+      public void remove() {
+        throw new UnsupportedOperationException("remove operation not supported");
+      }
+
+      private void setCurrentPage(ListVersionsPage page) {
+        this.currentPage = checkNotNull(page);
+        this.batch = ImmutableList.copyOf(page.getValues());
+        this.index = 0;
+      }
+    }
+  }
+
+  static final class VersionsResultBatch {
+
+    private final List<Version> versions;
+    private final String nextPageToken;
+
+    VersionsResultBatch(@NonNull List<Version> versions, @NonNull String nextPageToken) {
+      this.versions = checkNotNull(versions);
+      this.nextPageToken = checkNotNull(nextPageToken); // Can be empty
+    }
+
+    @NonNull
+    List<Version> getVersions() {
+      return versions;
+    }
+
+    @NonNull
+    String getNextPageToken() {
+      return nextPageToken;
+    }
+  }
+
+  /**
+   * Represents a source of Remote Config version data that can be queried to load a batch
+   * of versions.
+   */
+  interface VersionSource {
+    @NonNull
+    VersionsResultBatch fetch(
+            ListVersionsOptions listVersionsOptions) throws FirebaseRemoteConfigException;
+  }
+
+  static class DefaultVersionSource implements VersionSource {
+
+    private final FirebaseRemoteConfigClient remoteConfigClient;
+
+    DefaultVersionSource(FirebaseRemoteConfigClient remoteConfigClient) {
+      this.remoteConfigClient = checkNotNull(remoteConfigClient,
+              "remote config client must not be null");
+    }
+
+    @Override
+    public VersionsResultBatch fetch(
+            ListVersionsOptions listVersionsOptions) throws FirebaseRemoteConfigException {
+      TemplateResponse.ListVersionsResponse response = remoteConfigClient
+              .listVersions(listVersionsOptions);
+      ImmutableList.Builder<Version> builder = ImmutableList.builder();
+      if (response.hasVersions()) {
+        for (TemplateResponse.VersionResponse versionResponse : response.getVersions()) {
+          builder.add(new Version(versionResponse));
+        }
+      }
+      String nextPageToken = response.getNextPageToken() != null
+              ? response.getNextPageToken() : END_OF_LIST;
+      return new VersionsResultBatch(builder.build(), nextPageToken);
+    }
+  }
+
+  /**
+   * A simple factory class for {@link ListVersionsPage} instances. Performs argument validation
+   * before attempting to load any version data (which is expensive, and hence may be performed
+   * asynchronously on a separate thread).
+   */
+  static class Factory {
+
+    private final VersionSource source;
+    private final ListVersionsOptions listVersionsOptions;
+
+    Factory(@NonNull VersionSource source) {
+      this(source, null);
+    }
+
+    Factory(@NonNull VersionSource source, @NonNull ListVersionsOptions listVersionsOptions) {
+      this.source = checkNotNull(source, "source must not be null");
+      this.listVersionsOptions = listVersionsOptions;
+    }
+
+    ListVersionsPage create() throws FirebaseRemoteConfigException {
+      VersionsResultBatch batch = source.fetch(listVersionsOptions);
+      return new ListVersionsPage(batch, source, listVersionsOptions);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java
index 6aa9402..6d79450 100644
--- a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java
+++ b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java
@@ -30,4 +30,31 @@ public enum RemoteConfigErrorCode {
    * Internal server error.
    */
   INTERNAL,
+
+  /**
+   * Request cannot be executed in the current system state, such as deleting a non-empty
+   * directory.
+   */
+  FAILED_PRECONDITION,
+
+  /**
+   * User is not authenticated.
+   */
+  UNAUTHENTICATED,
+
+  /**
+   * The resource that a client tried to create already exists.
+   */
+  ALREADY_EXISTS,
+
+  /**
+   * Failed to validate Remote Config data.
+   */
+  VALIDATION_ERROR,
+
+  /**
+   * The current version specified in an update request
+   * did not match the actual version in the database.
+   */
+  VERSION_MISMATCH,
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java
new file mode 100644
index 0000000..13a4907
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020 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.firebase.remoteconfig;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.TimeZone;
+
+final class RemoteConfigUtil {
+
+  static boolean isValidVersionNumber(String versionNumber) {
+    return !Strings.isNullOrEmpty(versionNumber) && versionNumber.matches("^\\d+$");
+  }
+
+  static String convertToUtcZuluFormat(long millis) {
+    checkArgument(millis >= 0, "Milliseconds duration must not be negative");
+    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'");
+    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    return dateFormat.format(new Date(millis));
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java
index 0c517e1..050add9 100644
--- a/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java
+++ b/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java
@@ -38,6 +38,11 @@ public final class RemoteConfigServiceErrorResponse extends GenericJson {
           ImmutableMap.<String, RemoteConfigErrorCode>builder()
                   .put("INTERNAL", RemoteConfigErrorCode.INTERNAL)
                   .put("INVALID_ARGUMENT", RemoteConfigErrorCode.INVALID_ARGUMENT)
+                  .put("FAILED_PRECONDITION", RemoteConfigErrorCode.FAILED_PRECONDITION)
+                  .put("UNAUTHENTICATED", RemoteConfigErrorCode.UNAUTHENTICATED)
+                  .put("ALREADY_EXISTS", RemoteConfigErrorCode.ALREADY_EXISTS)
+                  .put("VALIDATION_ERROR", RemoteConfigErrorCode.VALIDATION_ERROR)
+                  .put("VERSION_MISMATCH", RemoteConfigErrorCode.VERSION_MISMATCH)
                   .build();
 
   private static final Pattern RC_ERROR_CODE_PATTERN = Pattern.compile("^\\[(\\w+)\\]:.*$");
diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
index 9a209a2..b89abf1 100644
--- a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
+++ b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
@@ -372,4 +372,39 @@ public final class TemplateResponse {
       return this;
     }
   }
+
+  /**
+   * The Data Transfer Object for parsing Remote Config versions list responses from the
+   * Remote Config service.
+   **/
+  public static final class ListVersionsResponse {
+    @Key("versions")
+    private List<VersionResponse> versions;
+
+    @Key("nextPageToken")
+    private String nextPageToken;
+
+    public List<VersionResponse> getVersions() {
+      return versions;
+    }
+
+    public boolean hasVersions() {
+      return versions != null && !versions.isEmpty();
+    }
+
+    public String getNextPageToken() {
+      return nextPageToken;
+    }
+
+    public ListVersionsResponse setNextPageToken(String nextPageToken) {
+      this.nextPageToken = nextPageToken;
+      return this;
+    }
+
+    public ListVersionsResponse setVersions(
+            List<VersionResponse> versions) {
+      this.versions = versions;
+      return this;
+    }
+  }
 }


Test patch:
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
index 5bf7c8d..690abcb 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
@@ -17,6 +17,7 @@
 package com.google.firebase.remoteconfig;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
@@ -49,6 +50,7 @@ import com.google.firebase.testing.TestUtils;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.net.URLDecoder;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -71,6 +73,9 @@ public class FirebaseRemoteConfigClientImplTest {
   private static final String MOCK_TEMPLATE_RESPONSE = TestUtils
           .loadResource("getRemoteConfig.json");
 
+  private static final String MOCK_LIST_VERSIONS_RESPONSE = TestUtils
+          .loadResource("listRemoteConfigVersions.json");
+
   private static final String TEST_ETAG = "etag-123456789012-1";
 
   private static final Map<String, Parameter> EXPECTED_PARAMETERS = ImmutableMap.of(
@@ -914,6 +919,191 @@ public class FirebaseRemoteConfigClientImplTest {
     }
   }
 
+  // Test listVersions
+
+  @Test
+  public void testListVersionsWithNullOptions() throws Exception {
+    response.setContent(MOCK_LIST_VERSIONS_RESPONSE);
+
+    TemplateResponse.ListVersionsResponse versionsList = client.listVersions(null);
+
+    assertTrue(versionsList.hasVersions());
+    assertEquals("28", versionsList.getNextPageToken());
+    assertEquals(4, versionsList.getVersions().size());
+    checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+  }
+
+  @Test
+  public void testListVersionsWithOptions() throws Exception {
+    response.setContent(MOCK_LIST_VERSIONS_RESPONSE);
+
+    TemplateResponse.ListVersionsResponse versionsList = client.listVersions(
+            ListVersionsOptions.builder()
+                    .setPageSize(10)
+                    .setPageToken("token")
+                    .setStartTimeMillis(1605219122000L)
+                    .setEndTimeMillis(1606245035000L)
+                    .setEndVersionNumber("29").build());
+
+    assertTrue(versionsList.hasVersions());
+
+    HttpRequest request = interceptor.getLastRequest();
+    String urlWithoutParameters = request.getUrl().toString()
+            .substring(0, request.getUrl().toString().lastIndexOf('?'));
+    final Map<String, String> expectedQuery = ImmutableMap.of(
+            "endVersionNumber", "29",
+            "pageSize", "10",
+            "pageToken", "token",
+            "startTime", "2020-11-12T22:12:02.000000000Z",
+            "endTime", "2020-11-24T19:10:35.000000000Z"
+    );
+    Map<String, String> actualQuery = new HashMap<>();
+    String query = request.getUrl().toURI().getQuery();
+    String[] pairs = query.split("&");
+    for (String pair : pairs) {
+      int idx = pair.indexOf("=");
+      actualQuery.put(URLDecoder.decode(pair.substring(0, idx), "UTF-8"),
+              URLDecoder.decode(pair.substring(idx + 1), "UTF-8"));
+    }
+
+    assertEquals("GET", request.getRequestMethod());
+    assertEquals(TEST_REMOTE_CONFIG_URL + ":listVersions", urlWithoutParameters);
+    HttpHeaders headers = request.getHeaders();
+    assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
+    assertEquals("gzip", headers.getAcceptEncoding());
+    assertEquals(expectedQuery, actualQuery);
+  }
+
+  @Test
+  public void testListVersionsWithEmptyResponse() throws Exception {
+    response.setContent("{}");
+
+    TemplateResponse.ListVersionsResponse versionsList = client.listVersions(null);
+
+    assertFalse(versionsList.hasVersions());
+    assertNull(versionsList.getNextPageToken());
+    assertNull(versionsList.getVersions());
+    checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+  }
+
+  @Test
+  public void testListVersionsHttpError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.listVersions(null);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+    }
+  }
+
+  @Test
+  public void testListVersionsTransportError() {
+    client = initClientWithFaultyTransport();
+
+    try {
+      client.listVersions(null);
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertEquals("Unknown error while making a remote service call: transport error",
+              error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+      assertNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+  }
+
+  @Test
+  public void testListVersionsSuccessResponseWithUnexpectedPayload() {
+    response.setContent("not valid json");
+
+    try {
+      client.listVersions(null);
+      fail("No error thrown for malformed response");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertTrue(error.getMessage().startsWith("Error while parsing HTTP response: "));
+      assertNotNull(error.getCause());
+      assertNotNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+    checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+  }
+
+  @Test
+  public void testListVersionsErrorWithZeroContentResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.listVersions(null);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+    }
+  }
+
+  @Test
+  public void testListVersionsErrorWithMalformedResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("not json");
+
+      try {
+        client.listVersions(null);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+    }
+  }
+
+  @Test
+  public void testListVersionsErrorWithDetails() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.listVersions(null);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error",
+                HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+    }
+  }
+
+  @Test
+  public void testListVersionsErrorWithRcError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+                      + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}");
+
+      try {
+        client.listVersions(null);
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT,
+                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error",
+                HttpMethods.GET);
+      }
+      checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions");
+    }
+  }
+
   // App related tests
 
   @Test(expected = IllegalArgumentException.class)
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
index cd3bfd8..d3e7fbf 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
@@ -28,6 +28,8 @@ import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
 import com.google.firebase.auth.MockGoogleCredentials;
 
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
+
 import java.util.concurrent.ExecutionException;
 
 import org.junit.After;
@@ -495,6 +497,102 @@ public class FirebaseRemoteConfigTest {
     }
   }
 
+  // List versions tests
+
+  @Test
+  public void testListVersionsWithNoOptions() throws FirebaseRemoteConfigException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse(
+            new TemplateResponse.ListVersionsResponse().setNextPageToken("token"));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    ListVersionsPage listVersionsPage = remoteConfig.listVersions();
+
+    assertEquals("token", listVersionsPage.getNextPageToken());
+  }
+
+  @Test
+  public void testListVersionsWithNoOptionsFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.listVersions();
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testListVersionsAsyncWithNoOptions() throws Exception {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse(
+            new TemplateResponse.ListVersionsResponse().setNextPageToken("token"));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    ListVersionsPage listVersionsPage = remoteConfig.listVersionsAsync().get();
+
+    assertEquals("token", listVersionsPage.getNextPageToken());
+  }
+
+  @Test
+  public void testListVersionsAsyncWithNoOptionsFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.listVersionsAsync().get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
+  @Test
+  public void testListVersionsWithOptions() throws FirebaseRemoteConfigException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse(
+            new TemplateResponse.ListVersionsResponse().setNextPageToken("token"));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    ListVersionsPage listVersionsPage = remoteConfig.listVersions(
+            ListVersionsOptions.builder().build());
+
+    assertEquals("token", listVersionsPage.getNextPageToken());
+  }
+
+  @Test
+  public void testListVersionsWithOptionsFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.listVersions(ListVersionsOptions.builder().build());
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testListVersionsAsyncWithOptions() throws Exception {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse(
+            new TemplateResponse.ListVersionsResponse().setNextPageToken("token"));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    ListVersionsPage listVersionsPage = remoteConfig.listVersionsAsync(
+            ListVersionsOptions.builder().build()).get();
+
+    assertEquals("token", listVersionsPage.getNextPageToken());
+  }
+
+  @Test
+  public void testListVersionsAsyncWithOptionsFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.listVersionsAsync(ListVersionsOptions.builder().build()).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
   private FirebaseRemoteConfig getRemoteConfig(FirebaseRemoteConfigClient client) {
     FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
     return new FirebaseRemoteConfig(app, client);
diff --git a/src/test/java/com/google/firebase/remoteconfig/ListVersionsPageTest.java b/src/test/java/com/google/firebase/remoteconfig/ListVersionsPageTest.java
new file mode 100644
index 0000000..a04c7ab
--- /dev/null
+++ b/src/test/java/com/google/firebase/remoteconfig/ListVersionsPageTest.java
@@ -0,0 +1,338 @@
+/*
+ * Copyright 2020 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.firebase.remoteconfig;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.Test;
+
+public class ListVersionsPageTest {
+
+  @Test
+  public void testSinglePage() throws FirebaseRemoteConfigException {
+    TestVersionSource source = new TestVersionSource(3);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    assertFalse(page.hasNextPage());
+    assertEquals(ListVersionsPage.END_OF_LIST, page.getNextPageToken());
+    assertNull(page.getNextPage());
+
+    ImmutableList<Version> versions = ImmutableList.copyOf(page.getValues());
+    assertEquals(3, versions.size());
+    for (int i = 0; i < 3; i++) {
+      assertEquals("1" + i, versions.get(i).getVersionNumber());
+    }
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+  }
+
+  @Test
+  public void testMultiplePages() throws FirebaseRemoteConfigException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.of(
+                    newVersion("10"),
+                    newVersion("11"),
+                    newVersion("12")), "token");
+    TestVersionSource source = new TestVersionSource(result);
+    ListVersionsPage page1 = new ListVersionsPage.Factory(source).create();
+
+    assertTrue(page1.hasNextPage());
+    assertEquals("token", page1.getNextPageToken());
+    ImmutableList<Version> versions = ImmutableList.copyOf(page1.getValues());
+    assertEquals(3, versions.size());
+    for (int i = 0; i < 3; i++) {
+      assertEquals("1" + i, versions.get(i).getVersionNumber());
+    }
+
+    result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.of(
+                    newVersion("13"),
+                    newVersion("14"),
+                    newVersion("15")), ListVersionsPage.END_OF_LIST);
+    source.result = result;
+    ListVersionsPage page2 = page1.getNextPage();
+
+    assertNotNull(page2);
+    assertFalse(page2.hasNextPage());
+    assertEquals(ListVersionsPage.END_OF_LIST, page2.getNextPageToken());
+    versions = ImmutableList.copyOf(page2.getValues());
+    assertEquals(3, versions.size());
+    for (int i = 3; i < 6; i++) {
+      assertEquals("1" + i, versions.get(i - 3).getVersionNumber());
+    }
+
+    assertEquals(2, source.calls.size());
+    assertNull(source.calls.get(0));
+    assertEquals("token", source.calls.get(1).getPageToken());
+
+    // Should iterate all versions from both pages
+    int iterations = 0;
+    for (Version ignored : page1.iterateAll()) {
+      iterations++;
+    }
+    assertEquals(6, iterations);
+    assertEquals(3, source.calls.size());
+    assertEquals("token", source.calls.get(2).getPageToken());
+
+    // Should only iterate versions in the last page
+    iterations = 0;
+    for (Version ignored : page2.iterateAll()) {
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(3, source.calls.size());
+  }
+
+  @Test
+  public void testListVersionsIterable() throws FirebaseRemoteConfigException {
+    TestVersionSource source = new TestVersionSource(3);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    Iterable<Version> versions = page.iterateAll();
+
+    int iterations = 0;
+    for (Version version : versions) {
+      assertEquals("1" + iterations, version.getVersionNumber());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+
+    // Should result in a new iterator
+    iterations = 0;
+    for (Version version : versions) {
+      assertEquals("1" + iterations, version.getVersionNumber());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+  }
+
+  @Test
+  public void testListVersionsIterator() throws FirebaseRemoteConfigException {
+    TestVersionSource source = new TestVersionSource(3);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    Iterable<Version> versions = page.iterateAll();
+    Iterator<Version> iterator = versions.iterator();
+    int iterations = 0;
+    while (iterator.hasNext()) {
+      assertEquals("1" + iterations, iterator.next().getVersionNumber());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+
+    while (iterator.hasNext()) {
+      fail("Should not be able to to iterate any more");
+    }
+    try {
+      iterator.next();
+      fail("Should not be able to iterate any more");
+    } catch (NoSuchElementException expected) {
+      // expected
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testListVersionsPagedIterable() throws FirebaseRemoteConfigException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.of(
+                    newVersion("10"),
+                    newVersion("11"),
+                    newVersion("12")), "token");
+    TestVersionSource source = new TestVersionSource(result);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    int iterations = 0;
+    for (Version version : page.iterateAll()) {
+      assertEquals("1" + iterations, version.getVersionNumber());
+      iterations++;
+      if (iterations == 3) {
+        assertEquals(1, source.calls.size());
+        assertNull(source.calls.get(0));
+        result = new ListVersionsPage.VersionsResultBatch(
+                ImmutableList.of(
+                        newVersion("13"),
+                        newVersion("14"),
+                        newVersion("15")), ListVersionsPage.END_OF_LIST);
+        source.result = result;
+      }
+    }
+
+    assertEquals(6, iterations);
+    assertEquals(2, source.calls.size());
+    assertEquals("token", source.calls.get(1).getPageToken());
+  }
+
+  @Test
+  public void testListVersionsPagedIterator() throws FirebaseRemoteConfigException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.of(
+                    newVersion("10"),
+                    newVersion("11"),
+                    newVersion("12")), "token");
+    TestVersionSource source = new TestVersionSource(result);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    Iterator<Version> versions = page.iterateAll().iterator();
+    int iterations = 0;
+    while (versions.hasNext()) {
+      assertEquals("1" + iterations, versions.next().getVersionNumber());
+      iterations++;
+      if (iterations == 3) {
+        assertEquals(1, source.calls.size());
+        assertNull(source.calls.get(0));
+        result = new ListVersionsPage.VersionsResultBatch(
+                ImmutableList.of(
+                        newVersion("13"),
+                        newVersion("14"),
+                        newVersion("15")), ListVersionsPage.END_OF_LIST);
+        source.result = result;
+      }
+    }
+
+    assertEquals(6, iterations);
+    assertEquals(2, source.calls.size());
+    assertEquals("token", source.calls.get(1).getPageToken());
+    assertFalse(versions.hasNext());
+    try {
+      versions.next();
+    } catch (NoSuchElementException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testPageWithNoVersions() throws FirebaseRemoteConfigException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.<Version>of(),
+            ListVersionsPage.END_OF_LIST);
+    TestVersionSource source = new TestVersionSource(result);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+
+    assertFalse(page.hasNextPage());
+    assertEquals(ListVersionsPage.END_OF_LIST, page.getNextPageToken());
+    assertNull(page.getNextPage());
+    assertEquals(0, ImmutableList.copyOf(page.getValues()).size());
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testIterableWithNoVersions() throws FirebaseRemoteConfigException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.<Version>of(),
+            ListVersionsPage.END_OF_LIST);
+    TestVersionSource source = new TestVersionSource(result);
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    for (Version version : page.iterateAll()) {
+      fail("Should not be able to iterate, but got: " + version);
+    }
+
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testIteratorWithNoVersions() throws FirebaseRemoteConfigException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.<Version>of(),
+            ListVersionsPage.END_OF_LIST);
+    TestVersionSource source = new TestVersionSource(result);
+
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    Iterator<Version> iterator = page.iterateAll().iterator();
+
+    assertFalse(iterator.hasNext());
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testRemove() throws FirebaseRemoteConfigException, IOException {
+    ListVersionsPage.VersionsResultBatch result = new ListVersionsPage.VersionsResultBatch(
+            ImmutableList.of(newVersion("10")),
+            ListVersionsPage.END_OF_LIST);
+    TestVersionSource source = new TestVersionSource(result);
+
+    ListVersionsPage page = new ListVersionsPage.Factory(source).create();
+    Iterator<Version> iterator = page.iterateAll().iterator();
+    while (iterator.hasNext()) {
+      assertNotNull(iterator.next());
+      try {
+        iterator.remove();
+      } catch (UnsupportedOperationException expected) {
+        // expected
+      }
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testNullSource() {
+    new ListVersionsPage.Factory(null);
+  }
+
+  private static Version newVersion(String versionNumber) {
+    TemplateResponse.VersionResponse versionResponse = new TemplateResponse.VersionResponse()
+            .setVersionNumber(versionNumber)
+            .setUpdateTime("2020-11-15T06:57:26.342763941Z")
+            .setUpdateOrigin("ADMIN_SDK")
+            .setUpdateType("INCREMENTAL_UPDATE")
+            .setUpdateUser(new TemplateResponse.UserResponse()
+                    .setEmail("firebase-user@account.com")
+                    .setName("dev-admin"))
+            .setDescription("test version: " + versionNumber);
+    return new Version(versionResponse);
+  }
+
+  private static class TestVersionSource implements ListVersionsPage.VersionSource {
+
+    private ListVersionsPage.VersionsResultBatch result;
+    private List<ListVersionsOptions> calls = new ArrayList<>();
+
+    TestVersionSource(int versionCount) {
+      ImmutableList.Builder<Version> versions = ImmutableList.builder();
+      for (int i = 0; i < versionCount; i++) {
+        versions.add(newVersion("1" + i));
+      }
+      this.result = new ListVersionsPage.VersionsResultBatch(versions.build(),
+              ListVersionsPage.END_OF_LIST);
+    }
+
+    TestVersionSource(ListVersionsPage.VersionsResultBatch result) {
+      this.result = result;
+    }
+
+    @Override
+    public ListVersionsPage.VersionsResultBatch fetch(
+            ListVersionsOptions listVersionsOptions) {
+      calls.add(listVersionsOptions);
+      return result;
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
index 3d0fd59..9ca5850 100644
--- a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
+++ b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
@@ -16,23 +16,33 @@
 
 package com.google.firebase.remoteconfig;
 
+import com.google.firebase.remoteconfig.internal.TemplateResponse.ListVersionsResponse;
+
 public class MockRemoteConfigClient implements FirebaseRemoteConfigClient{
 
-  private Template resultTemplate;
-  private FirebaseRemoteConfigException exception;
+  private final Template resultTemplate;
+  private final FirebaseRemoteConfigException exception;
+  private final ListVersionsResponse listVersionsResponse;
 
   private MockRemoteConfigClient(Template resultTemplate,
+                                 ListVersionsResponse listVersionsResponse,
                                  FirebaseRemoteConfigException exception) {
     this.resultTemplate = resultTemplate;
+    this.listVersionsResponse = listVersionsResponse;
     this.exception = exception;
   }
 
   static MockRemoteConfigClient fromTemplate(Template resultTemplate) {
-    return new MockRemoteConfigClient(resultTemplate, null);
+    return new MockRemoteConfigClient(resultTemplate, null, null);
+  }
+
+  static MockRemoteConfigClient fromListVersionsResponse(
+          ListVersionsResponse listVersionsResponse) {
+    return new MockRemoteConfigClient(null, listVersionsResponse, null);
   }
 
   static MockRemoteConfigClient fromException(FirebaseRemoteConfigException exception) {
-    return new MockRemoteConfigClient(null, exception);
+    return new MockRemoteConfigClient(null, null, exception);
   }
 
   @Override
@@ -67,4 +77,13 @@ public class MockRemoteConfigClient implements FirebaseRemoteConfigClient{
     }
     return resultTemplate;
   }
+
+  @Override
+  public ListVersionsResponse listVersions(
+          ListVersionsOptions options) throws FirebaseRemoteConfigException {
+    if (exception != null) {
+      throw exception;
+    }
+    return listVersionsResponse;
+  }
 }
diff --git a/src/test/resources/listRemoteConfigVersions.json b/src/test/resources/listRemoteConfigVersions.json
new file mode 100644
index 0000000..2823db3
--- /dev/null
+++ b/src/test/resources/listRemoteConfigVersions.json
@@ -0,0 +1,50 @@
+{
+  "versions": [
+    {
+      "versionNumber": "32",
+      "updateTime": "2020-11-24T19:10:35.506793Z",
+      "updateUser": {
+        "email": "admin@email.com",
+        "imageUrl": "https://photo.jpg"
+      },
+      "description": "Rollback to version 29",
+      "updateOrigin": "REST_API",
+      "updateType": "ROLLBACK",
+      "rollbackSource": "29"
+    },
+    {
+      "versionNumber": "31",
+      "updateTime": "2020-11-24T19:09:40.249437Z",
+      "updateUser": {
+        "email": "admin@email.com",
+        "imageUrl": "https://photo.jpg"
+      },
+      "description": "new template",
+      "updateOrigin": "REST_API",
+      "updateType": "INCREMENTAL_UPDATE"
+    },
+    {
+      "versionNumber": "30",
+      "updateTime": "2020-11-24T19:08:30.613942Z",
+      "updateUser": {
+        "email": "admin@email.com",
+        "imageUrl": "https://photo.jpg"
+      },
+      "description": "new template",
+      "updateOrigin": "REST_API",
+      "updateType": "INCREMENTAL_UPDATE"
+    },
+    {
+      "versionNumber": "29",
+      "updateTime": "2020-11-12T22:12:02.180181Z",
+      "updateUser": {
+        "email": "admin@email.com",
+        "imageUrl": "https://photo.jpg"
+      },
+      "description": "new template",
+      "updateOrigin": "REST_API",
+      "updateType": "INCREMENTAL_UPDATE"
+    }
+  ],
+  "nextPageToken": "28"
+}