Repository: firebase/firebase-admin-java
Pull request #211 (2018-10-29 14:04:58)

Add App-scoped API For Firebase Project management
App-scoped actions for Firebase apps within Firebase projects. Uses Firebase Management REST APIs (https://firebase.google.com/docs/projects/api/reference/rest/)

schmidt-sebastian: This PR only contains previously reviewed changes.
weixifan: I fixed some iOS vs Android mistakes in the Javadoc for various methods in `FirebaseProjectManagement`. I will assign a reviewer in case there are comments about that change.
weixifan: I also just adjusted the error codes list. Both Android certificates and Apps are handled by the same error codes map, so I changed the term "App" to simply "resource." Please take a look.
schmidt-sebastian: Still LGTM
weixifan: Unfortunately I discovered more things to fix. :(
weixifan: Thanks for the review. Aside from the asynchronous polling question that remains, we have another small issue: I noticed that for the `AndroidApp.deleteShaCertificate()` method, the caller must pass in a `ShaCertificate`, constructed with the `name` field set. This means we either force the caller to only be able to pass in a `ShaCertificate` returned by the `getShaCertificates()` method, or we should expose a public method allowing them to create a `ShaCertificate` with a caller-supplied `name` field. I think we should allow the user to delete Android SHA certificates without having to call `getShaCertificates` first, if they happen to know the full resource name of the certificate they want to delete prior to calling it, but this isn't that critical. Please let me know what you think.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a26bb32..b08e7c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
 # Unreleased
 
--
+- [fixed] Fixing error handling in FCM. The SDK now checks the key
+  `type.googleapis.com/google.firebase.fcm.v1.FcmError` to set error
+  code.
+- [fixed] FCM errors sent by the back-end now include more details
+  that are helpful when debugging problems.
 
 # v6.5.0
 
diff --git a/pom.xml b/pom.xml
index acc0b42..9269f3b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -370,6 +370,23 @@
         </plugins>
     </build>
 
+    <reporting>
+        <plugins>
+            <plugin>
+                <artifactId>maven-project-info-reports-plugin</artifactId>
+                <version>2.9</version>
+                <reportSets>
+                    <reportSet>
+                        <configuration>
+                            <!-- Disable additional Maven reports, since we do not use them. -->
+                            <skip>true</skip>
+                        </configuration>
+                    </reportSet>
+                </reportSets>
+            </plugin>
+        </plugins>
+    </reporting>
+
     <dependencies>
         <!-- Google Cloud Platform dependencies -->
         <dependency>
diff --git a/src/main/java/com/google/firebase/FirebaseApp.java b/src/main/java/com/google/firebase/FirebaseApp.java
index 59a79e4..c7f2447 100644
--- a/src/main/java/com/google/firebase/FirebaseApp.java
+++ b/src/main/java/com/google/firebase/FirebaseApp.java
@@ -276,8 +276,8 @@ public class FirebaseApp {
     return name;
   }
 
-  /** 
-   * Returns the specified {@link FirebaseOptions}. 
+  /**
+   * Returns the specified {@link FirebaseOptions}.
    */
   @NonNull
   public FirebaseOptions getOptions() {
@@ -390,6 +390,10 @@ public class FirebaseApp {
     return threadManager.getThreadFactory();
   }
 
+  ScheduledExecutorService getScheduledExecutorService() {
+    return ensureScheduledExecutorService();
+  }
+
   <T> ApiFuture<T> submit(Callable<T> command) {
     checkNotNull(command);
     return new ListenableFuture2ApiFuture<>(executors.getListeningExecutor().submit(command));
@@ -405,6 +409,17 @@ public class FirebaseApp {
     }
   }
 
+  ScheduledFuture<?> schedule(Runnable runnable, long delayMillis) {
+    checkNotNull(runnable);
+    try {
+      return ensureScheduledExecutorService()
+          .schedule(runnable, delayMillis, TimeUnit.MILLISECONDS);
+    } catch (Exception e) {
+      // This may fail if the underlying ThreadFactory does not support long-lived threads.
+      throw new UnsupportedOperationException("Scheduled tasks not supported", e);
+    }
+  }
+
   void startTokenRefresher() {
     synchronized (lock) {
       checkNotDeleted();
diff --git a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java
index 4b56e83..7a50e0b 100644
--- a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java
+++ b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java
@@ -16,13 +16,18 @@
 
 package com.google.firebase;
 
+import com.google.api.core.ApiAsyncFunction;
+import com.google.api.core.ApiFunction;
 import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.cloud.firestore.FirestoreOptions;
 import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
 
 import java.util.concurrent.Callable;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ThreadFactory;
 
 /**
@@ -67,6 +72,23 @@ public final class ImplFirebaseTrampolines {
     return app.getThreadFactory();
   }
 
+  public static <V, X> ApiFuture<X> transform(
+      ApiFuture<? extends V> input,
+      final ApiFunction<? super V, ? extends X> function,
+      @NonNull FirebaseApp app) {
+    return ApiFutures.transform(input, function, app.getScheduledExecutorService());
+  }
+
+  public static <V, X> ApiFuture<X> transformAsync(
+      ApiFuture<V> input, final ApiAsyncFunction<V, X> function, @NonNull FirebaseApp app) {
+    return ApiFutures.transformAsync(input, function, app.getScheduledExecutorService());
+  }
+
+  public static ScheduledFuture<?> schedule(
+      @NonNull FirebaseApp app, @NonNull Runnable runnable, long delayMillis) {
+    return app.schedule(runnable, delayMillis);
+  }
+
   public static <T> ApiFuture<T> submitCallable(
       @NonNull FirebaseApp app, @NonNull Callable<T> command) {
     return app.submit(command);
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
index 7ba1edd..34ed364 100644
--- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
@@ -56,7 +56,7 @@ public class FirebaseMessaging {
 
   private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
   private static final String FCM_ERROR_TYPE =
-      "type.googleapis.com/google.firebase.fcm.v1.FcmErrorCode";
+      "type.googleapis.com/google.firebase.fcm.v1.FcmError";
 
   private static final String INTERNAL_ERROR = "internal-error";
   private static final String UNKNOWN_ERROR = "unknown-error";
@@ -249,6 +249,7 @@ public class FirebaseMessaging {
         try {
           HttpRequest request = requestFactory.buildPostRequest(
               new GenericUrl(url), new JsonHttpContent(jsonFactory, payload.build()));
+          request.getHeaders().set("X-GOOG-API-FORMAT-VERSION", "2");
           request.setParser(new JsonObjectParser(jsonFactory));
           request.setResponseInterceptor(interceptor);
           response = request.execute();
diff --git a/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java b/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java
new file mode 100644
index 0000000..d1af6db
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/AndroidApp.java
@@ -0,0 +1,166 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.api.core.ApiFuture;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import java.util.List;
+
+/**
+ * An instance of this class is a reference to an Android App within a Firebase Project; it can be
+ * used to query detailed information about the App, modify the display name of the App, or download
+ * the configuration file for the App.
+ *
+ * <p>Note: the methods in this class make RPCs.
+ */
+public class AndroidApp {
+
+  private final AndroidAppService androidAppService;
+  private final String appId;
+
+  AndroidApp(String appId, AndroidAppService androidAppService) {
+    Preconditions.checkArgument(!Strings.isNullOrEmpty(appId), "app ID cannot be null or empty");
+    this.appId = appId;
+    this.androidAppService = androidAppService;
+  }
+
+  String getAppId() {
+    return appId;
+  }
+
+  /**
+   * Retrieves detailed information about this Android App.
+   *
+   * @return an {@link AndroidAppMetadata} instance describing this App
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public AndroidAppMetadata getMetadata() throws Exception {
+    return androidAppService.getAndroidApp(appId);
+  }
+
+  /**
+   * Asynchronously retrieves information about this Android App.
+   *
+   * @return an {@code ApiFuture} containing an {@link AndroidAppMetadata} instance describing this
+   *     App
+   */
+  public ApiFuture<AndroidAppMetadata> getMetadataAsync() {
+    return androidAppService.getAndroidAppAsync(appId);
+  }
+
+  /**
+   * Updates the Display Name attribute of this Android App to the one given.
+   *
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public void setDisplayName(String newDisplayName) throws FirebaseProjectManagementException {
+    androidAppService.setAndroidDisplayName(appId, newDisplayName);
+  }
+
+  /**
+   * Asynchronously updates the Display Name attribute of this Android App to the one given.
+   */
+  public ApiFuture<Void> setDisplayNameAsync(String newDisplayName) {
+    return androidAppService.setAndroidDisplayNameAsync(appId, newDisplayName);
+  }
+
+  /**
+   * Retrieves the configuration artifact associated with this Android App.
+   *
+   * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public String getConfig() throws FirebaseProjectManagementException {
+    return androidAppService.getAndroidConfig(appId);
+  }
+
+  /**
+   * Asynchronously retrieves the configuration artifact associated with this Android App.
+   *
+   * @return an {@code ApiFuture} of a UTF-8 encoded {@code String} containing the contents of the
+   *     artifact
+   */
+  public ApiFuture<String> getConfigAsync() {
+    return androidAppService.getAndroidConfigAsync(appId);
+  }
+
+  /**
+   * Retrieves the entire list of SHA certificates associated with this Android app.
+   *
+   * @return a list of {@link ShaCertificate} containing resource name, SHA hash and certificate
+   *     type
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public List<ShaCertificate> getShaCertificates() throws FirebaseProjectManagementException {
+    return androidAppService.getShaCertificates(appId);
+  }
+
+  /**
+   * Asynchronously retrieves the entire list of SHA certificates associated with this Android app.
+   *
+   * @return an {@code ApiFuture} of a list of {@link ShaCertificate} containing resource name,
+   *     SHA hash and certificate type
+   */
+  public ApiFuture<List<ShaCertificate>> getShaCertificatesAsync() {
+    return androidAppService.getShaCertificatesAsync(appId);
+  }
+
+  /**
+   * Adds a SHA certificate to this Android app.
+   *
+   * @param certificateToAdd the SHA certificate to be added to this Android app
+   * @return a {@link ShaCertificate} that was created for this Android app, containing resource
+   *     name, SHA hash, and certificate type
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public ShaCertificate createShaCertificate(ShaCertificate certificateToAdd)
+      throws FirebaseProjectManagementException {
+    return androidAppService.createShaCertificate(appId, certificateToAdd);
+  }
+
+  /**
+   * Asynchronously adds a SHA certificate to this Android app.
+   *
+   * @param certificateToAdd the SHA certificate to be added to this Android app
+   * @return a {@code ApiFuture} of a {@link ShaCertificate} that was created for this Android app,
+   *     containing resource name, SHA hash, and certificate type
+   */
+  public ApiFuture<ShaCertificate> createShaCertificateAsync(ShaCertificate certificateToAdd) {
+    return androidAppService.createShaCertificateAsync(appId, certificateToAdd);
+  }
+
+  /**
+   * Removes a SHA certificate from this Android app.
+   *
+   * @param certificateToRemove the SHA certificate to be removed from this Android app
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public void deleteShaCertificate(ShaCertificate certificateToRemove)
+      throws FirebaseProjectManagementException {
+    androidAppService.deleteShaCertificate(certificateToRemove.getName());
+  }
+
+  /**
+   * Asynchronously removes a SHA certificate from this Android app.
+   *
+   * @param certificateToRemove the SHA certificate to be removed from this Android app
+   */
+  public ApiFuture<Void> deleteShaCertificateAsync(ShaCertificate certificateToRemove) {
+    return androidAppService.deleteShaCertificateAsync(certificateToRemove.getName());
+  }
+
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java b/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java
new file mode 100644
index 0000000..f60a0b0
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/AndroidAppMetadata.java
@@ -0,0 +1,111 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import com.google.common.base.Preconditions;
+
+/**
+ * Contains detailed information about an Android App. Instances of this class are immutable.
+ */
+public class AndroidAppMetadata {
+
+  private final String name;
+  private final String appId;
+  private final String displayName;
+  private final String projectId;
+  private final String packageName;
+
+  AndroidAppMetadata(
+      String name, String appId, String displayName, String projectId, String packageName) {
+    this.name = Preconditions.checkNotNull(name, "Null name");
+    this.appId = Preconditions.checkNotNull(appId, "Null appId");
+    this.displayName = Preconditions.checkNotNull(displayName, "Null displayName");
+    this.projectId = Preconditions.checkNotNull(projectId, "Null projectId");
+    this.packageName = Preconditions.checkNotNull(packageName, "Null packageName");
+  }
+
+  /**
+   * Returns the fully qualified resource name of this Android App.
+   */
+  public String getName() {
+    return name;
+  }
+
+  /**
+   * Returns the globally unique, Firebase-assigned identifier of this Android App. This ID is
+   * unique even across Apps of different platforms, such as iOS Apps.
+   */
+  public String getAppId() {
+    return appId;
+  }
+
+  /**
+   * Returns the user-assigned display name of this Android App.
+   */
+  public String getDisplayName() {
+    return displayName;
+  }
+
+  /**
+   * Returns the permanent, globally unique, user-assigned ID of the parent Project for this Android
+   * App.
+   */
+  public String getProjectId() {
+    return projectId;
+  }
+
+  /**
+   * Returns the canonical package name of this Android app as it would appear in Play store.
+   */
+  public String getPackageName() {
+    return packageName;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("AndroidAppMetadata")
+        .add("name", name)
+        .add("appId", appId)
+        .add("displayName", displayName)
+        .add("projectId", projectId)
+        .add("packageName", packageName)
+        .toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o == this) {
+      return true;
+    }
+    if (o instanceof AndroidAppMetadata) {
+      AndroidAppMetadata that = (AndroidAppMetadata) o;
+      return Objects.equal(this.name, that.name)
+          && Objects.equal(this.appId, that.appId)
+          && Objects.equal(this.displayName, that.displayName)
+          && Objects.equal(this.projectId, that.projectId)
+          && Objects.equal(this.packageName, that.packageName);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(name, appId, displayName, projectId, packageName);
+  }
+
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java b/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java
new file mode 100644
index 0000000..081b446
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/AndroidAppService.java
@@ -0,0 +1,175 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.api.core.ApiFuture;
+import java.util.List;
+
+/**
+ * An interface to interact with the Android-specific functionalities in the Firebase Project
+ * Management Service.
+ *
+ * <p>Note: Implementations of methods in this service may make RPCs.
+ */
+interface AndroidAppService {
+  /**
+   * Creates a new Android App in the given project.
+   *
+   * @param projectId the Project ID of the project in which to create the App
+   * @param packageName the package name of the Android App to be created
+   * @param displayName the user-defined display name for the Android App to be created
+   * @return an {@link AndroidApp} reference
+   */
+  AndroidApp createAndroidApp(String projectId, String packageName, String displayName)
+      throws FirebaseProjectManagementException;
+
+  /**
+   * Creates a new Android App in the given project.
+   *
+   * @param projectId the Project ID of the project in which to create the App
+   * @param packageName the package name of the Android App to be created
+   * @param displayName the user-defined display name for the Android App to be created
+   * @return an {@link AndroidApp} reference
+   */
+  ApiFuture<AndroidApp> createAndroidAppAsync(
+      String projectId, String packageName, String displayName);
+
+  /**
+   * Retrieve information about an existing Android App, identified by its App ID.
+   *
+   * @param appId the App ID of the Android App
+   * @return an {@link AndroidAppMetadata} instance describing the Android App
+   */
+  AndroidAppMetadata getAndroidApp(String appId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously retrieves information about an existing Android App, identified by its App ID.
+   *
+   * @param appId the App ID of the iOS App
+   * @return an {@link AndroidAppMetadata} instance describing the Android App
+   */
+  ApiFuture<AndroidAppMetadata> getAndroidAppAsync(String appId);
+
+  /**
+   * Lists all the Android Apps belonging to the given project. The returned list cannot be
+   * modified.
+   *
+   * @param projectId the Project ID of the project
+   * @return a read-only list of {@link AndroidApp} references
+   */
+  List<AndroidApp> listAndroidApps(String projectId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously lists all the Android Apps belonging to the given project. The returned list
+   * cannot be modified.
+   *
+   * @param projectId the project ID of the project
+   * @return an {@link ApiFuture} of a read-only list of {@link AndroidApp} references
+   */
+  ApiFuture<List<AndroidApp>> listAndroidAppsAsync(String projectId);
+
+  /**
+   * Updates the Display Name of the given Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @param newDisplayName the new Display Name
+   */
+  void setAndroidDisplayName(String appId, String newDisplayName)
+      throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously updates the Display Name of the given Android App.
+   *
+   * @param appId the App ID of the iOS App
+   * @param newDisplayName the new Display Name
+   */
+  ApiFuture<Void> setAndroidDisplayNameAsync(String appId, String newDisplayName);
+
+  /**
+   * Retrieves the configuration artifact associated with the specified Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact
+   */
+  String getAndroidConfig(String appId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously retrieves the configuration artifact associated with the specified Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @return an {@link ApiFuture} of a modified UTF-8 encoded {@code String} containing the contents
+   *     of the artifact
+   */
+  ApiFuture<String> getAndroidConfigAsync(String appId);
+
+  /**
+   * Retrieves the entire list of SHA certificates associated with this Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @return a list of {@link ShaCertificate} containing resource name, SHA hash and certificate
+   *     type
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  List<ShaCertificate> getShaCertificates(String appId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously retrieves the entire list of SHA certificates associated with this Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @return an {@link ApiFuture} of a list of {@link ShaCertificate} containing resource name,
+   *     SHA hash and certificate type
+   */
+  ApiFuture<List<ShaCertificate>> getShaCertificatesAsync(String appId);
+
+
+  /**
+   * Adds a SHA certificate to this Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @param certificateToAdd the SHA certificate to be added to this Android App
+   * @return a {@link ShaCertificate} that was created for this Android App, containing resource
+   *     name, SHA hash, and certificate type
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  ShaCertificate createShaCertificate(String appId, ShaCertificate certificateToAdd)
+      throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously adds a SHA certificate to this Android App.
+   *
+   * @param appId the App ID of the Android App
+   * @param certificateToAdd the SHA certificate to be added to this Android App
+   * @return a {@link ApiFuture} of a {@link ShaCertificate} that was created for this Android App,
+   *     containing resource name, SHA hash, and certificate type
+   */
+  ApiFuture<ShaCertificate> createShaCertificateAsync(
+      String appId, ShaCertificate certificateToAdd);
+
+  /**
+   * Removes a SHA certificate from this Android App.
+   *
+   * @param resourceName the fully qualified resource name of the SHA certificate
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  void deleteShaCertificate(String resourceName) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously removes a SHA certificate from this Android App.
+   *
+   * @param resourceName the fully qualified resource name of the SHA certificate
+   */
+  ApiFuture<Void> deleteShaCertificateAsync(String resourceName);
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java
new file mode 100644
index 0000000..fe5472e
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.core.ApiFuture;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.internal.FirebaseService;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+import java.util.List;
+
+/**
+ * This class is the entry point for all Firebase Project Management actions.
+ *
+ * <p>You can get an instance of FirebaseProjectManagement via {@link #getInstance(FirebaseApp)},
+ * and then use it to modify or retrieve information about your Firebase project, as well as create,
+ * modify, or retrieve information about the Android or iOS Apps in your Firebase project.
+ */
+public class FirebaseProjectManagement {
+  private static final String SERVICE_ID = FirebaseProjectManagement.class.getName();
+
+  private static final Object GET_INSTANCE_LOCK = new Object();
+
+  private final String projectId;
+  private AndroidAppService androidAppService;
+  private IosAppService iosAppService;
+
+  private FirebaseProjectManagement(String projectId) {
+    checkArgument(!Strings.isNullOrEmpty(projectId),
+        "Project ID is required to access the Firebase Project Management service. Use a service "
+            + "account credential or set the project ID explicitly via FirebaseOptions. "
+            + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT "
+            + "environment variable.");
+
+    this.projectId = projectId;
+  }
+
+  @VisibleForTesting
+  void setAndroidAppService(AndroidAppService androidAppService) {
+    this.androidAppService = androidAppService;
+  }
+
+  @VisibleForTesting
+  void setIosAppService(IosAppService iosAppService) {
+    this.iosAppService = iosAppService;
+  }
+
+  /**
+   * Gets the {@link FirebaseProjectManagement} instance for the default {@link FirebaseApp}.
+   *
+   * @return the {@link FirebaseProjectManagement} instance for the default {@link FirebaseApp}
+   */
+  @NonNull
+  public static FirebaseProjectManagement getInstance() {
+    return getInstance(FirebaseApp.getInstance());
+  }
+
+  /**
+   * Gets the {@link FirebaseProjectManagement} instance for the specified {@link FirebaseApp}.
+   *
+   * @return the {@link FirebaseProjectManagement} instance for the specified {@link FirebaseApp}
+   */
+  @NonNull
+  public static FirebaseProjectManagement getInstance(FirebaseApp app) {
+    synchronized (GET_INSTANCE_LOCK) {
+      FirebaseProjectManagementService service = ImplFirebaseTrampolines.getService(
+          app, SERVICE_ID, FirebaseProjectManagementService.class);
+      if (service == null) {
+        service =
+            ImplFirebaseTrampolines.addService(app, new FirebaseProjectManagementService(app));
+      }
+      return service.getInstance();
+    }
+  }
+
+  /* Android App */
+
+  /**
+   * Obtains an {@link AndroidApp} reference to an Android App in the associated Firebase project.
+   *
+   * @param appId the App ID that identifies this Android App.
+   * @see AndroidApp
+   */
+  @NonNull
+  public AndroidApp getAndroidApp(@NonNull String appId) {
+    return new AndroidApp(appId, androidAppService);
+  }
+
+  /**
+   * Lists all Android Apps in the associated Firebase project, returning a list of {@link
+   * AndroidApp} references to each. This returned list is read-only and cannot be modified.
+   *
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   * @see AndroidApp
+   */
+  @NonNull
+  public List<AndroidApp> listAndroidApps() throws FirebaseProjectManagementException {
+    return androidAppService.listAndroidApps(projectId);
+  }
+
+  /**
+   * Asynchronously lists all Android Apps in the associated Firebase project, returning an {@code
+   * ApiFuture} of a list of {@link AndroidApp} references to each. This returned list is read-only
+   * and cannot be modified.
+   *
+   * @see AndroidApp
+   */
+  @NonNull
+  public ApiFuture<List<AndroidApp>> listAndroidAppsAsync() {
+    return androidAppService.listAndroidAppsAsync(projectId);
+  }
+
+  /**
+   * Creates a new Android App in the associated Firebase project and returns an {@link AndroidApp}
+   * reference to it.
+   *
+   * @param packageName the package name of the Android App to be created
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   * @see AndroidApp
+   */
+  @NonNull
+  public AndroidApp createAndroidApp(@NonNull String packageName)
+      throws FirebaseProjectManagementException {
+    return createAndroidApp(packageName, /* displayName= */ null);
+  }
+
+  /**
+   * Creates a new Android App in the associated Firebase project and returns an {@link AndroidApp}
+   * reference to it.
+   *
+   * @param packageName the package name of the Android App to be created
+   * @param displayName a nickname for this Android App
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   * @see AndroidApp
+   */
+  @NonNull
+  public AndroidApp createAndroidApp(@NonNull String packageName, @Nullable String displayName)
+      throws FirebaseProjectManagementException {
+    return androidAppService.createAndroidApp(projectId, packageName, displayName);
+  }
+
+  /**
+   * Asynchronously creates a new Android App in the associated Firebase project and returns an
+   * {@code ApiFuture} that will eventually contain the {@link AndroidApp} reference to it.
+   *
+   * @param packageName the package name of the Android App to be created
+   * @see AndroidApp
+   */
+  @NonNull
+  public ApiFuture<AndroidApp> createAndroidAppAsync(@NonNull String packageName) {
+    return createAndroidAppAsync(packageName, /* displayName= */ null);
+  }
+
+  /**
+   * Asynchronously creates a new Android App in the associated Firebase project and returns an
+   * {@code ApiFuture} that will eventually contain the {@link AndroidApp} reference to it.
+   *
+   * @param packageName the package name of the Android App to be created
+   * @param displayName a nickname for this Android App
+   * @see AndroidApp
+   */
+  @NonNull
+  public ApiFuture<AndroidApp> createAndroidAppAsync(
+      @NonNull String packageName, @Nullable String displayName) {
+    return androidAppService.createAndroidAppAsync(projectId, packageName, displayName);
+  }
+
+  /* iOS App */
+
+  /**
+   * Obtains an {@link IosApp} reference to an iOS App in the associated Firebase project.
+   *
+   * @param appId the App ID that identifies this iOS App.
+   * @see IosApp
+   */
+  @NonNull
+  public IosApp getIosApp(@NonNull String appId) {
+    return new IosApp(appId, iosAppService);
+  }
+
+  /**
+   * Lists all iOS Apps in the associated Firebase project, returning a list of {@link IosApp}
+   * references to each. This returned list is read-only and cannot be modified.
+   *
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   * @see IosApp
+   */
+  @NonNull
+  public List<IosApp> listIosApps() throws FirebaseProjectManagementException {
+    return iosAppService.listIosApps(projectId);
+  }
+
+  /**
+   * Asynchronously lists all iOS Apps in the associated Firebase project, returning an {@code
+   * ApiFuture} of a list of {@link IosApp} references to each. This returned list is read-only and
+   * cannot be modified.
+   *
+   * @see IosApp
+   */
+  @NonNull
+  public ApiFuture<List<IosApp>> listIosAppsAsync() {
+    return iosAppService.listIosAppsAsync(projectId);
+  }
+
+  /**
+   * Creates a new iOS App in the associated Firebase project and returns an {@link IosApp}
+   * reference to it.
+   *
+   * @param bundleId the bundle ID of the iOS App to be created
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   * @see IosApp
+   */
+  @NonNull
+  public IosApp createIosApp(@NonNull String bundleId) throws FirebaseProjectManagementException {
+    return createIosApp(bundleId, /* displayName= */ null);
+  }
+
+  /**
+   * Creates a new iOS App in the associated Firebase project and returns an {@link IosApp}
+   * reference to it.
+   *
+   * @param bundleId the bundle ID of the iOS App to be created
+   * @param displayName a nickname for this iOS App
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   * @see IosApp
+   */
+  @NonNull
+  public IosApp createIosApp(@NonNull String bundleId, @Nullable String displayName)
+      throws FirebaseProjectManagementException {
+    return iosAppService.createIosApp(projectId, bundleId, displayName);
+  }
+
+  /**
+   * Asynchronously creates a new iOS App in the associated Firebase project and returns an {@code
+   * ApiFuture} that will eventually contain the {@link IosApp} reference to it.
+   *
+   * @param bundleId the bundle ID of the iOS App to be created
+   * @see IosApp
+   */
+  @NonNull
+  public ApiFuture<IosApp> createIosAppAsync(@NonNull String bundleId) {
+    return createIosAppAsync(bundleId, /* displayName= */ null);
+  }
+
+  /**
+   * Asynchronously creates a new iOS App in the associated Firebase project and returns an {@code
+   * ApiFuture} that will eventually contain the {@link IosApp} reference to it.
+   *
+   * @param bundleId the bundle ID of the iOS App to be created
+   * @param displayName a nickname for this iOS App
+   * @see IosApp
+   */
+  @NonNull
+  public ApiFuture<IosApp> createIosAppAsync(
+      @NonNull String bundleId, @Nullable String displayName) {
+    return iosAppService.createIosAppAsync(projectId, bundleId, displayName);
+  }
+
+  private static class FirebaseProjectManagementService
+      extends FirebaseService<FirebaseProjectManagement> {
+    private final FirebaseProjectManagementServiceImpl serviceImpl;
+
+    private FirebaseProjectManagementService(FirebaseApp app) {
+      super(SERVICE_ID, new FirebaseProjectManagement(ImplFirebaseTrampolines.getProjectId(app)));
+      serviceImpl = new FirebaseProjectManagementServiceImpl(app);
+      FirebaseProjectManagement serviceInstance = getInstance();
+      serviceInstance.setAndroidAppService(serviceImpl);
+      serviceInstance.setIosAppService(serviceImpl);
+    }
+
+    @Override
+    public void destroy() {
+      serviceImpl.destroy();
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java
new file mode 100644
index 0000000..fa939b0
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java
@@ -0,0 +1,32 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.firebase.FirebaseException;
+import com.google.firebase.internal.Nullable;
+
+/**
+ * An exception encountered while interacting with the Firebase Project Management Service.
+ */
+public class FirebaseProjectManagementException extends FirebaseException {
+  FirebaseProjectManagementException(String detailMessage) {
+    this(detailMessage, null);
+  }
+
+  FirebaseProjectManagementException(String detailMessage, @Nullable Throwable cause) {
+    super(detailMessage, cause);
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java
new file mode 100644
index 0000000..474b8b0
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java
@@ -0,0 +1,761 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.util.Base64;
+import com.google.api.client.util.Key;
+import com.google.api.core.ApiAsyncFunction;
+import com.google.api.core.ApiFunction;
+import com.google.api.core.ApiFuture;
+import com.google.api.core.SettableApiFuture;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.internal.CallableOperation;
+import com.google.firebase.internal.FirebaseRequestInitializer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppService {
+
+  @VisibleForTesting static final String FIREBASE_PROJECT_MANAGEMENT_URL =
+      "https://firebase.googleapis.com";
+  @VisibleForTesting static final int MAXIMUM_LIST_APPS_PAGE_SIZE = 100;
+
+  private static final int MAXIMUM_POLLING_ATTEMPTS = 7;
+  private static final long POLL_BASE_WAIT_TIME_MILLIS = 500L;
+  private static final double POLL_EXPONENTIAL_BACKOFF_FACTOR = 1.5;
+  private static final String ANDROID_APPS_RESOURCE_NAME = "androidApps";
+  private static final String IOS_APPS_RESOURCE_NAME = "iosApps";
+  private static final String ANDROID_NAMESPACE_PROPERTY = "package_name";
+  private static final String IOS_NAMESPACE_PROPERTY = "bundle_id";
+
+  private final FirebaseApp app;
+  private final HttpHelper httpHelper;
+
+  private final CreateAndroidAppFromAppIdFunction createAndroidAppFromAppIdFunction =
+      new CreateAndroidAppFromAppIdFunction();
+  private final CreateIosAppFromAppIdFunction createIosAppFromAppIdFunction =
+      new CreateIosAppFromAppIdFunction();
+
+  FirebaseProjectManagementServiceImpl(FirebaseApp app) {
+    this.app = app;
+    this.httpHelper = new HttpHelper(
+        app.getOptions().getJsonFactory(),
+        app.getOptions().getHttpTransport().createRequestFactory(
+            new FirebaseRequestInitializer(app)));
+  }
+
+  @VisibleForTesting
+  void setInterceptor(HttpResponseInterceptor interceptor) {
+    httpHelper.setInterceptor(interceptor);
+  }
+
+  void destroy() {
+    // NOTE: We don't explicitly tear down anything here. Any instance of IosApp, AndroidApp, or
+    // FirebaseProjectManagement that depends on this instance will no longer be able to make RPC
+    // calls. All polling or waiting iOS or Android App creations will be interrupted, even though
+    // the initial creation RPC (if made successfully) is still processed normally (asynchronously)
+    // by the server.
+  }
+
+  /* getAndroidApp */
+
+  @Override
+  public AndroidAppMetadata getAndroidApp(String appId) throws FirebaseProjectManagementException {
+    return getAndroidAppOp(appId).call();
+  }
+
+  @Override
+  public ApiFuture<AndroidAppMetadata> getAndroidAppAsync(String appId) {
+    return getAndroidAppOp(appId).callAsync(app);
+  }
+
+  private CallableOperation<AndroidAppMetadata, FirebaseProjectManagementException> getAndroidAppOp(
+      final String appId) {
+    return new CallableOperation<AndroidAppMetadata, FirebaseProjectManagementException>() {
+      @Override
+      protected AndroidAppMetadata execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/-/androidApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, appId);
+        AndroidAppResponse parsedResponse = new AndroidAppResponse();
+        httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID");
+        return new AndroidAppMetadata(
+            parsedResponse.name,
+            parsedResponse.appId,
+            Strings.nullToEmpty(parsedResponse.displayName),
+            parsedResponse.projectId,
+            parsedResponse.packageName);
+      }
+    };
+  }
+
+  /* getIosApp */
+
+  @Override
+  public IosAppMetadata getIosApp(String appId) throws FirebaseProjectManagementException {
+    return getIosAppOp(appId).call();
+  }
+
+  @Override
+  public ApiFuture<IosAppMetadata> getIosAppAsync(String appId) {
+    return getIosAppOp(appId).callAsync(app);
+  }
+
+  private CallableOperation<IosAppMetadata, FirebaseProjectManagementException> getIosAppOp(
+      final String appId) {
+    return new CallableOperation<IosAppMetadata, FirebaseProjectManagementException>() {
+      @Override
+      protected IosAppMetadata execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/-/iosApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, appId);
+        IosAppResponse parsedResponse = new IosAppResponse();
+        httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID");
+        return new IosAppMetadata(
+            parsedResponse.name,
+            parsedResponse.appId,
+            Strings.nullToEmpty(parsedResponse.displayName),
+            parsedResponse.projectId,
+            parsedResponse.bundleId);
+      }
+    };
+  }
+
+  /* listAndroidApps, listIosApps */
+
+  @Override
+  public List<AndroidApp> listAndroidApps(String projectId)
+      throws FirebaseProjectManagementException {
+    return listAndroidAppsOp(projectId).call();
+  }
+
+  @Override
+  public ApiFuture<List<AndroidApp>> listAndroidAppsAsync(String projectId) {
+    return listAndroidAppsOp(projectId).callAsync(app);
+  }
+
+  @Override
+  public List<IosApp> listIosApps(String projectId) throws FirebaseProjectManagementException {
+    return listIosAppsOp(projectId).call();
+  }
+
+  @Override
+  public ApiFuture<List<IosApp>> listIosAppsAsync(String projectId) {
+    return listIosAppsOp(projectId).callAsync(app);
+  }
+
+  private CallableOperation<List<AndroidApp>, FirebaseProjectManagementException> listAndroidAppsOp(
+      String projectId) {
+    return listAppsOp(projectId, ANDROID_APPS_RESOURCE_NAME, createAndroidAppFromAppIdFunction);
+  }
+
+  private CallableOperation<List<IosApp>, FirebaseProjectManagementException> listIosAppsOp(
+      String projectId) {
+    return listAppsOp(projectId, IOS_APPS_RESOURCE_NAME, createIosAppFromAppIdFunction);
+  }
+
+  private <T> CallableOperation<List<T>, FirebaseProjectManagementException> listAppsOp(
+      final String projectId,
+      final String platformResourceName,
+      final CreateAppFromAppIdFunction<T> createAppFromAppIdFunction) {
+    return new CallableOperation<List<T>, FirebaseProjectManagementException>() {
+      @Override
+      protected List<T> execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/%s/%s?page_size=%d",
+            FIREBASE_PROJECT_MANAGEMENT_URL,
+            projectId,
+            platformResourceName,
+            MAXIMUM_LIST_APPS_PAGE_SIZE);
+        ImmutableList.Builder<T> builder = ImmutableList.<T>builder();
+        ListAppsResponse parsedResponse;
+        do {
+          parsedResponse = new ListAppsResponse();
+          httpHelper.makeGetRequest(url, parsedResponse, projectId, "Project ID");
+          if (parsedResponse.apps == null) {
+            break;
+          }
+          for (AppResponse app : parsedResponse.apps) {
+            builder.add(createAppFromAppIdFunction.apply(app.appId));
+          }
+          url = String.format(
+              "%s/v1beta1/projects/%s/%s?page_token=%s&page_size=%d",
+              FIREBASE_PROJECT_MANAGEMENT_URL,
+              projectId,
+              platformResourceName,
+              parsedResponse.nextPageToken,
+              MAXIMUM_LIST_APPS_PAGE_SIZE);
+        } while (!Strings.isNullOrEmpty(parsedResponse.nextPageToken));
+        return builder.build();
+      }
+    };
+  }
+
+  private static class ListAppsResponse {
+    @Key("apps")
+    private List<AppResponse> apps;
+
+    @Key("nextPageToken")
+    private String nextPageToken;
+  }
+
+  /* createAndroidApp, createIosApp */
+
+  @Override
+  public AndroidApp createAndroidApp(String projectId, String packageName, String displayName)
+      throws FirebaseProjectManagementException {
+    String operationName = createAndroidAppOp(projectId, packageName, displayName).call();
+    String appId = pollOperation(projectId, operationName);
+    return new AndroidApp(appId, this);
+  }
+
+  @Override
+  public ApiFuture<AndroidApp> createAndroidAppAsync(
+      String projectId, String packageName, String displayName) {
+    checkArgument(!Strings.isNullOrEmpty(packageName), "package name must not be null or empty");
+    return
+        ImplFirebaseTrampolines.transform(
+            ImplFirebaseTrampolines.transformAsync(
+                createAndroidAppOp(projectId, packageName, displayName).callAsync(app),
+                new WaitOperationFunction(projectId),
+                app),
+            createAndroidAppFromAppIdFunction,
+            app);
+  }
+
+  @Override
+  public IosApp createIosApp(String projectId, String bundleId, String displayName)
+      throws FirebaseProjectManagementException {
+    String operationName = createIosAppOp(projectId, bundleId, displayName).call();
+    String appId = pollOperation(projectId, operationName);
+    return new IosApp(appId, this);
+  }
+
+  @Override
+  public ApiFuture<IosApp> createIosAppAsync(
+      String projectId, String bundleId, String displayName) {
+    checkArgument(!Strings.isNullOrEmpty(bundleId), "bundle ID must not be null or empty");
+    return
+        ImplFirebaseTrampolines.transform(
+            ImplFirebaseTrampolines.transformAsync(
+                createIosAppOp(projectId, bundleId, displayName).callAsync(app),
+                new WaitOperationFunction(projectId),
+                app),
+            createIosAppFromAppIdFunction,
+            app);
+  }
+
+  private CallableOperation<String, FirebaseProjectManagementException> createAndroidAppOp(
+      String projectId, String namespace, String displayName) {
+    return createAppOp(
+        projectId, namespace, displayName, ANDROID_NAMESPACE_PROPERTY, ANDROID_APPS_RESOURCE_NAME);
+  }
+
+  private CallableOperation<String, FirebaseProjectManagementException> createIosAppOp(
+      String projectId, String namespace, String displayName) {
+    return createAppOp(
+        projectId, namespace, displayName, IOS_NAMESPACE_PROPERTY, IOS_APPS_RESOURCE_NAME);
+  }
+
+  private CallableOperation<String, FirebaseProjectManagementException> createAppOp(
+      final String projectId,
+      final String namespace,
+      final String displayName,
+      final String platformNamespaceProperty,
+      final String platformResourceName) {
+    return new CallableOperation<String, FirebaseProjectManagementException>() {
+      @Override
+      protected String execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/%s/%s",
+            FIREBASE_PROJECT_MANAGEMENT_URL,
+            projectId,
+            platformResourceName);
+        ImmutableMap.Builder<String, String> payloadBuilder =
+            ImmutableMap.<String, String>builder().put(platformNamespaceProperty, namespace);
+        if (!Strings.isNullOrEmpty(displayName)) {
+          payloadBuilder.put("display_name", displayName);
+        }
+        OperationResponse operationResponseInstance = new OperationResponse();
+        httpHelper.makePostRequest(
+            url, payloadBuilder.build(), operationResponseInstance, projectId, "Project ID");
+        if (Strings.isNullOrEmpty(operationResponseInstance.name)) {
+          throw HttpHelper.createFirebaseProjectManagementException(
+              namespace,
+              "Bundle ID",
+              "Unable to create App: server returned null operation name.",
+              /* cause= */ null);
+        }
+        return operationResponseInstance.name;
+      }
+    };
+  }
+
+  private String pollOperation(String projectId, String operationName)
+      throws FirebaseProjectManagementException {
+    String url = String.format("%s/v1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, operationName);
+    for (int currentAttempt = 0; currentAttempt < MAXIMUM_POLLING_ATTEMPTS; currentAttempt++) {
+      long delayMillis = (long) (
+          POLL_BASE_WAIT_TIME_MILLIS
+              * Math.pow(POLL_EXPONENTIAL_BACKOFF_FACTOR, currentAttempt));
+      sleepOrThrow(projectId, delayMillis);
+      OperationResponse operationResponseInstance = new OperationResponse();
+      httpHelper.makeGetRequest(url, operationResponseInstance, projectId, "Project ID");
+      if (!operationResponseInstance.done) {
+        continue;
+      }
+      // The Long Running Operation API guarantees that when done == true, exactly one of 'response'
+      // or 'error' is set.
+      if (operationResponseInstance.response == null
+          || Strings.isNullOrEmpty(operationResponseInstance.response.appId)) {
+        throw HttpHelper.createFirebaseProjectManagementException(
+            projectId,
+            "Project ID",
+            "Unable to create App: internal server error.",
+            /* cause= */ null);
+      }
+      return operationResponseInstance.response.appId;
+    }
+    throw HttpHelper.createFirebaseProjectManagementException(
+        projectId,
+        "Project ID",
+        "Unable to create App: deadline exceeded.",
+        /* cause= */ null);
+  }
+
+  /**
+   * An {@link ApiAsyncFunction} that transforms a Long Running Operation name to an {@link IosApp}
+   * or an {@link AndroidApp} instance by repeatedly polling the server (with exponential backoff)
+   * until the App is created successfully, or until the number of poll attempts exceeds the maximum
+   * allowed.
+   */
+  private class WaitOperationFunction implements ApiAsyncFunction<String, String> {
+    private final String projectId;
+
+    private WaitOperationFunction(String projectId) {
+      this.projectId = projectId;
+    }
+
+    /**
+     * Returns an {@link ApiFuture} that will eventually contain the App ID of the new created App,
+     * or an exception if an error occurred during polling.
+     */
+    @Override
+    public ApiFuture<String> apply(String operationName) throws FirebaseProjectManagementException {
+      SettableApiFuture<String> settableFuture = SettableApiFuture.<String>create();
+      ImplFirebaseTrampolines.schedule(
+          app,
+          new WaitOperationRunnable(
+              /* numberOfPreviousPolls= */ 0,
+              operationName,
+              projectId,
+              settableFuture),
+          /* delayMillis= */ POLL_BASE_WAIT_TIME_MILLIS);
+      return settableFuture;
+    }
+  }
+
+  /**
+   * A poller that repeatedly polls a Long Running Operation (with exponential backoff) until its
+   * status is "done", or until the number of polling attempts exceeds the maximum allowed.
+   */
+  private class WaitOperationRunnable implements Runnable {
+    private final int numberOfPreviousPolls;
+    private final String operationName;
+    private final String projectId;
+    private final SettableApiFuture<String> settableFuture;
+
+    private WaitOperationRunnable(
+        int numberOfPreviousPolls,
+        String operationName,
+        String projectId,
+        SettableApiFuture<String> settableFuture) {
+      this.numberOfPreviousPolls = numberOfPreviousPolls;
+      this.operationName = operationName;
+      this.projectId = projectId;
+      this.settableFuture = settableFuture;
+    }
+
+    @Override
+    public void run() {
+      String url = String.format("%s/v1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, operationName);
+      OperationResponse operationResponseInstance = new OperationResponse();
+      try {
+        httpHelper.makeGetRequest(url, operationResponseInstance, projectId, "Project ID");
+      } catch (FirebaseProjectManagementException e) {
+        settableFuture.setException(e);
+        return;
+      }
+      if (!operationResponseInstance.done) {
+        if (numberOfPreviousPolls + 1 >= MAXIMUM_POLLING_ATTEMPTS) {
+          settableFuture.setException(HttpHelper.createFirebaseProjectManagementException(
+              projectId,
+              "Project ID",
+              "Unable to create App: deadline exceeded.",
+              /* cause= */ null));
+        } else {
+          long delayMillis = (long) (
+              POLL_BASE_WAIT_TIME_MILLIS
+                  * Math.pow(POLL_EXPONENTIAL_BACKOFF_FACTOR, numberOfPreviousPolls + 1));
+          ImplFirebaseTrampolines.schedule(
+              app,
+              new WaitOperationRunnable(
+                  numberOfPreviousPolls + 1,
+                  operationName,
+                  projectId,
+                  settableFuture),
+              delayMillis);
+        }
+        return;
+      }
+      // The Long Running Operation API guarantees that when done == true, exactly one of 'response'
+      // or 'error' is set.
+      if (operationResponseInstance.response == null
+          || Strings.isNullOrEmpty(operationResponseInstance.response.appId)) {
+        settableFuture.setException(HttpHelper.createFirebaseProjectManagementException(
+            projectId,
+            "Project ID",
+            "Unable to create App: internal server error.",
+            /* cause= */ null));
+      } else {
+        settableFuture.set(operationResponseInstance.response.appId);
+      }
+    }
+  }
+
+  // This class is public due to the way parsing nested JSON objects work, and is needed by
+  // create{Android|Ios}App and list{Android|Ios}Apps. In any case, the containing class,
+  // FirebaseProjectManagementServiceImpl, is package-private.
+  public static class AppResponse {
+    @Key("name")
+    protected String name;
+
+    @Key("appId")
+    protected String appId;
+
+    @Key("displayName")
+    protected String displayName;
+
+    @Key("projectId")
+    protected String projectId;
+  }
+
+  private static class AndroidAppResponse extends AppResponse {
+    @Key("packageName")
+    private String packageName;
+  }
+
+  private static class IosAppResponse extends AppResponse {
+    @Key("bundleId")
+    private String bundleId;
+  }
+
+  // This class is public due to the way parsing nested JSON objects work, and is needed by
+  // createIosApp and createAndroidApp. In any case, the containing class,
+  // FirebaseProjectManagementServiceImpl, is package-private.
+  public static class StatusResponse {
+    @Key("code")
+    private int code;
+
+    @Key("message")
+    private String message;
+  }
+
+  private static class OperationResponse {
+    @Key("name")
+    private String name;
+
+    @Key("metadata")
+    private String metadata;
+
+    @Key("done")
+    private boolean done;
+
+    @Key("error")
+    private StatusResponse error;
+
+    @Key("response")
+    private AppResponse response;
+  }
+
+  /* setAndroidDisplayName, setIosDisplayName */
+
+  @Override
+  public void setAndroidDisplayName(String appId, String newDisplayName)
+      throws FirebaseProjectManagementException {
+    setAndroidDisplayNameOp(appId, newDisplayName).call();
+  }
+
+  @Override
+  public ApiFuture<Void> setAndroidDisplayNameAsync(String appId, String newDisplayName) {
+    return setAndroidDisplayNameOp(appId, newDisplayName).callAsync(app);
+  }
+
+  @Override
+  public void setIosDisplayName(String appId, String newDisplayName)
+      throws FirebaseProjectManagementException {
+    setIosDisplayNameOp(appId, newDisplayName).call();
+  }
+
+  @Override
+  public ApiFuture<Void> setIosDisplayNameAsync(String appId, String newDisplayName) {
+    return setIosDisplayNameOp(appId, newDisplayName).callAsync(app);
+  }
+
+  private CallableOperation<Void, FirebaseProjectManagementException> setAndroidDisplayNameOp(
+      String appId, String newDisplayName) {
+    return setDisplayNameOp(appId, newDisplayName, ANDROID_APPS_RESOURCE_NAME);
+  }
+
+  private CallableOperation<Void, FirebaseProjectManagementException> setIosDisplayNameOp(
+      String appId, String newDisplayName) {
+    return setDisplayNameOp(appId, newDisplayName, IOS_APPS_RESOURCE_NAME);
+  }
+
+  private CallableOperation<Void, FirebaseProjectManagementException> setDisplayNameOp(
+      final String appId, final String newDisplayName, final String platformResourceName) {
+    checkArgument(
+        !Strings.isNullOrEmpty(newDisplayName), "new Display Name must not be null or empty");
+    return new CallableOperation<Void, FirebaseProjectManagementException>() {
+      @Override
+      protected Void execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/-/%s/%s?update_mask=display_name",
+            FIREBASE_PROJECT_MANAGEMENT_URL,
+            platformResourceName,
+            appId);
+        ImmutableMap<String, String> payload =
+            ImmutableMap.<String, String>builder().put("display_name", newDisplayName).build();
+        EmptyResponse emptyResponseInstance = new EmptyResponse();
+        httpHelper.makePatchRequest(url, payload, emptyResponseInstance, appId, "App ID");
+        return null;
+      }
+    };
+  }
+
+  private static class EmptyResponse {}
+
+  /* getAndroidConfig, getIosConfig */
+
+  @Override
+  public String getAndroidConfig(String appId) throws FirebaseProjectManagementException {
+    return getAndroidConfigOp(appId).call();
+  }
+
+  @Override
+  public ApiFuture<String> getAndroidConfigAsync(String appId) {
+    return getAndroidConfigOp(appId).callAsync(app);
+  }
+
+  @Override
+  public String getIosConfig(String appId) throws FirebaseProjectManagementException {
+    return getIosConfigOp(appId).call();
+  }
+
+  @Override
+  public ApiFuture<String> getIosConfigAsync(String appId) {
+    return getIosConfigOp(appId).callAsync(app);
+  }
+
+  private CallableOperation<String, FirebaseProjectManagementException> getAndroidConfigOp(
+      String appId) {
+    return getConfigOp(appId, ANDROID_APPS_RESOURCE_NAME);
+  }
+
+  private CallableOperation<String, FirebaseProjectManagementException> getIosConfigOp(
+      String appId) {
+    return getConfigOp(appId, IOS_APPS_RESOURCE_NAME);
+  }
+
+  private CallableOperation<String, FirebaseProjectManagementException> getConfigOp(
+      final String appId, final String platformResourceName) {
+    return new CallableOperation<String, FirebaseProjectManagementException>() {
+      @Override
+      protected String execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/-/%s/%s/config",
+            FIREBASE_PROJECT_MANAGEMENT_URL,
+            platformResourceName,
+            appId);
+        AppConfigResponse parsedResponse = new AppConfigResponse();
+        httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID");
+        return new String(
+            Base64.decodeBase64(parsedResponse.configFileContents), StandardCharsets.UTF_8);
+      }
+    };
+  }
+
+  private static class AppConfigResponse {
+    @Key("configFilename")
+    String configFilename;
+
+    @Key("configFileContents")
+    String configFileContents;
+  }
+
+  /* getShaCertificates */
+
+  @Override
+  public List<ShaCertificate> getShaCertificates(String appId)
+      throws FirebaseProjectManagementException {
+    return getShaCertificatesOp(appId).call();
+  }
+
+  @Override
+  public ApiFuture<List<ShaCertificate>> getShaCertificatesAsync(String appId) {
+    return getShaCertificatesOp(appId).callAsync(app);
+  }
+
+  private CallableOperation<List<ShaCertificate>, FirebaseProjectManagementException>
+      getShaCertificatesOp(final String appId) {
+    return new CallableOperation<List<ShaCertificate>, FirebaseProjectManagementException>() {
+      @Override
+      protected List<ShaCertificate> execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/-/androidApps/%s/sha", FIREBASE_PROJECT_MANAGEMENT_URL, appId);
+        ListShaCertificateResponse parsedResponse = new ListShaCertificateResponse();
+        httpHelper.makeGetRequest(url, parsedResponse, appId, "App ID");
+        List<ShaCertificate> certificates = new ArrayList<>();
+        if (parsedResponse.certificates == null) {
+          return certificates;
+        }
+        for (ShaCertificateResponse certificate : parsedResponse.certificates) {
+          certificates.add(ShaCertificate.create(certificate.name, certificate.shaHash));
+        }
+        return certificates;
+      }
+    };
+  }
+
+  /* createShaCertificate */
+
+  @Override
+  public ShaCertificate createShaCertificate(String appId, ShaCertificate certificateToAdd)
+      throws FirebaseProjectManagementException {
+    return createShaCertificateOp(appId, certificateToAdd).call();
+  }
+
+  @Override
+  public ApiFuture<ShaCertificate> createShaCertificateAsync(
+      String appId, ShaCertificate certificateToAdd) {
+    return createShaCertificateOp(appId, certificateToAdd).callAsync(app);
+  }
+
+  private CallableOperation<ShaCertificate, FirebaseProjectManagementException>
+      createShaCertificateOp(final String appId, final ShaCertificate certificateToAdd) {
+    return new CallableOperation<ShaCertificate, FirebaseProjectManagementException>() {
+      @Override
+      protected ShaCertificate execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/projects/-/androidApps/%s/sha", FIREBASE_PROJECT_MANAGEMENT_URL, appId);
+        ShaCertificateResponse parsedResponse = new ShaCertificateResponse();
+        ImmutableMap<String, String> payload = ImmutableMap.<String, String>builder()
+            .put("sha_hash", certificateToAdd.getShaHash())
+            .put("cert_type", certificateToAdd.getCertType().toString())
+            .build();
+        httpHelper.makePostRequest(url, payload, parsedResponse, appId, "App ID");
+        return ShaCertificate.create(parsedResponse.name, parsedResponse.shaHash);
+      }
+    };
+  }
+
+  /* deleteShaCertificate */
+
+  @Override
+  public void deleteShaCertificate(String resourceName)
+      throws FirebaseProjectManagementException {
+    deleteShaCertificateOp(resourceName).call();
+  }
+
+  @Override
+  public ApiFuture<Void> deleteShaCertificateAsync(String resourceName) {
+    return deleteShaCertificateOp(resourceName).callAsync(app);
+  }
+
+  private CallableOperation<Void, FirebaseProjectManagementException> deleteShaCertificateOp(
+      final String resourceName) {
+    return new CallableOperation<Void, FirebaseProjectManagementException>() {
+      @Override
+      protected Void execute() throws FirebaseProjectManagementException {
+        String url = String.format(
+            "%s/v1beta1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, resourceName);
+        EmptyResponse parsedResponse = new EmptyResponse();
+        httpHelper.makeDeleteRequest(url, parsedResponse, resourceName, "SHA name");
+        return null;
+      }
+    };
+  }
+
+  private static class ListShaCertificateResponse {
+    @Key("certificates")
+    private List<ShaCertificateResponse> certificates;
+  }
+
+  // This class is public due to the way parsing nested JSON objects work, and is needed by
+  // getShaCertificates. In any case, the containing class, FirebaseProjectManagementServiceImpl, is
+  // package-private.
+  public static class ShaCertificateResponse {
+    @Key("name")
+    private String name;
+
+    @Key("shaHash")
+    private String shaHash;
+
+    @Key("certType")
+    private String certType;
+  }
+
+  /* Helper methods. */
+
+  private void sleepOrThrow(String projectId, long delayMillis)
+      throws FirebaseProjectManagementException {
+    try {
+      Thread.sleep(delayMillis);
+    } catch (InterruptedException e) {
+      throw HttpHelper.createFirebaseProjectManagementException(
+          projectId,
+          "Project ID",
+          "Unable to create App: exponential backoff interrupted.",
+          /* cause= */ null);
+    }
+  }
+
+  /* Helper types. */
+
+  private interface CreateAppFromAppIdFunction<T> extends ApiFunction<String, T> {}
+
+  private class CreateAndroidAppFromAppIdFunction
+      implements CreateAppFromAppIdFunction<AndroidApp> {
+    @Override
+    public AndroidApp apply(String appId) {
+      return new AndroidApp(appId, FirebaseProjectManagementServiceImpl.this);
+    }
+  }
+
+  private class CreateIosAppFromAppIdFunction implements CreateAppFromAppIdFunction<IosApp> {
+    @Override
+    public IosApp apply(String appId) {
+      return new IosApp(appId, FirebaseProjectManagementServiceImpl.this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java
new file mode 100644
index 0000000..2143c14
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.Nullable;
+import com.google.firebase.internal.SdkUtils;
+import java.io.IOException;
+
+class HttpHelper {
+
+  @VisibleForTesting static final String PATCH_OVERRIDE_KEY = "X-HTTP-Method-Override";
+  @VisibleForTesting static final String PATCH_OVERRIDE_VALUE = "PATCH";
+  private static final ImmutableMap<Integer, String> ERROR_CODES =
+      ImmutableMap.<Integer, String>builder()
+          .put(401, "Request not authorized.")
+          .put(403, "Client does not have sufficient privileges.")
+          .put(404, "Failed to find the resource.")
+          .put(409, "The resource already exists.")
+          .put(429, "Request throttled by the backend server.")
+          .put(500, "Internal server error.")
+          .put(503, "Backend servers are over capacity. Try again later.")
+          .build();
+  private static final String CLIENT_VERSION_HEADER = "X-Client-Version";
+
+  private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion();
+  private final JsonFactory jsonFactory;
+  private final HttpRequestFactory requestFactory;
+  private HttpResponseInterceptor interceptor;
+
+  HttpHelper(JsonFactory jsonFactory, HttpRequestFactory requestFactory) {
+    this.jsonFactory = jsonFactory;
+    this.requestFactory = requestFactory;
+  }
+
+  void setInterceptor(HttpResponseInterceptor interceptor) {
+    this.interceptor = interceptor;
+  }
+
+  <T> void makeGetRequest(
+      String url,
+      T parsedResponseInstance,
+      String requestIdentifier,
+      String requestIdentifierDescription) throws FirebaseProjectManagementException {
+    try {
+      makeRequest(
+          requestFactory.buildGetRequest(new GenericUrl(url)),
+          parsedResponseInstance,
+          requestIdentifier,
+          requestIdentifierDescription);
+    } catch (IOException e) {
+      handleError(requestIdentifier, requestIdentifierDescription, e);
+    }
+  }
+
+  <T> void makePostRequest(
+      String url,
+      Object payload,
+      T parsedResponseInstance,
+      String requestIdentifier,
+      String requestIdentifierDescription) throws FirebaseProjectManagementException {
+    try {
+      makeRequest(
+          requestFactory.buildPostRequest(
+              new GenericUrl(url), new JsonHttpContent(jsonFactory, payload)),
+          parsedResponseInstance,
+          requestIdentifier,
+          requestIdentifierDescription);
+    } catch (IOException e) {
+      handleError(requestIdentifier, requestIdentifierDescription, e);
+    }
+  }
+
+  <T> void makePatchRequest(
+      String url,
+      Object payload,
+      T parsedResponseInstance,
+      String requestIdentifier,
+      String requestIdentifierDescription) throws FirebaseProjectManagementException {
+    try {
+      HttpRequest baseRequest = requestFactory.buildPostRequest(
+          new GenericUrl(url), new JsonHttpContent(jsonFactory, payload));
+      baseRequest.getHeaders().set(PATCH_OVERRIDE_KEY, PATCH_OVERRIDE_VALUE);
+      makeRequest(
+          baseRequest, parsedResponseInstance, requestIdentifier, requestIdentifierDescription);
+    } catch (IOException e) {
+      handleError(requestIdentifier, requestIdentifierDescription, e);
+    }
+  }
+
+  <T> void makeDeleteRequest(
+      String url,
+      T parsedResponseInstance,
+      String requestIdentifier,
+      String requestIdentifierDescription) throws FirebaseProjectManagementException {
+    try {
+      makeRequest(
+          requestFactory.buildDeleteRequest(new GenericUrl(url)),
+          parsedResponseInstance,
+          requestIdentifier,
+          requestIdentifierDescription);
+    } catch (IOException e) {
+      handleError(requestIdentifier, requestIdentifierDescription, e);
+    }
+  }
+
+  <T> void makeRequest(
+      HttpRequest baseRequest,
+      T parsedResponseInstance,
+      String requestIdentifier,
+      String requestIdentifierDescription) throws FirebaseProjectManagementException {
+    HttpResponse response = null;
+    try {
+      baseRequest.getHeaders().set(CLIENT_VERSION_HEADER, clientVersion);
+      baseRequest.setParser(new JsonObjectParser(jsonFactory));
+      baseRequest.setResponseInterceptor(interceptor);
+      response = baseRequest.execute();
+      jsonFactory.createJsonParser(response.getContent(), Charsets.UTF_8)
+          .parseAndClose(parsedResponseInstance);
+    } catch (Exception e) {
+      handleError(requestIdentifier, requestIdentifierDescription, e);
+    } finally {
+      disconnectQuietly(response);
+    }
+  }
+
+  private static void disconnectQuietly(HttpResponse response) {
+    if (response != null) {
+      try {
+        response.disconnect();
+      } catch (IOException ignored) {
+        // Ignored.
+      }
+    }
+  }
+
+  private static void handleError(
+      String requestIdentifier, String requestIdentifierDescription, Exception e)
+          throws FirebaseProjectManagementException {
+    String messageBody = "Error while invoking Firebase Project Management service.";
+    if (e instanceof HttpResponseException) {
+      int statusCode = ((HttpResponseException) e).getStatusCode();
+      if (ERROR_CODES.containsKey(statusCode)) {
+        messageBody = ERROR_CODES.get(statusCode);
+      }
+    }
+    throw createFirebaseProjectManagementException(
+        requestIdentifier, requestIdentifierDescription, messageBody, e);
+  }
+
+  static FirebaseProjectManagementException createFirebaseProjectManagementException(
+      String requestIdentifier,
+      String requestIdentifierDescription,
+      String messageBody,
+      @Nullable Exception cause) {
+    return new FirebaseProjectManagementException(
+        String.format(
+            "%s \"%s\": %s", requestIdentifierDescription, requestIdentifier, messageBody),
+        cause);
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/IosApp.java b/src/main/java/com/google/firebase/projectmanagement/IosApp.java
new file mode 100644
index 0000000..afd54e7
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/IosApp.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.api.core.ApiFuture;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+
+/**
+ * An instance of this class is a reference to an iOS App within a Firebase Project; it can be used
+ * to query detailed information about the App, modify the display name of the App, or download the
+ * configuration file for the App.
+ *
+ * <p>Note: the methods in this class make RPCs.
+ */
+public class IosApp {
+  private final String appId;
+  private final IosAppService iosAppService;
+
+  IosApp(String appId, IosAppService iosAppService) {
+    Preconditions.checkArgument(!Strings.isNullOrEmpty(appId), "app ID cannot be null or empty");
+    this.appId = appId;
+    this.iosAppService = iosAppService;
+  }
+
+  String getAppId() {
+    return appId;
+  }
+
+  /**
+   * Retrieves detailed information about this iOS App.
+   *
+   * @return an {@link IosAppMetadata} instance describing this App
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public IosAppMetadata getMetadata() throws FirebaseProjectManagementException {
+    return iosAppService.getIosApp(appId);
+  }
+
+  /**
+   * Asynchronously retrieves information about this iOS App.
+   *
+   * @return an {@code ApiFuture} containing an {@link IosAppMetadata} instance describing this App
+   */
+  public ApiFuture<IosAppMetadata> getMetadataAsync() {
+    return iosAppService.getIosAppAsync(appId);
+  }
+
+  /**
+   * Updates the Display Name attribute of this iOS App to the one given.
+   *
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public void setDisplayName(String newDisplayName) throws FirebaseProjectManagementException {
+    iosAppService.setIosDisplayName(appId, newDisplayName);
+  }
+
+  /**
+   * Asynchronously updates the Display Name attribute of this iOS App to the one given.
+   */
+  public ApiFuture<Void> setDisplayNameAsync(String newDisplayName) {
+    return iosAppService.setIosDisplayNameAsync(appId, newDisplayName);
+  }
+
+  /**
+   * Retrieves the configuration artifact associated with this iOS App.
+   *
+   * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact
+   * @throws FirebaseProjectManagementException if there was an error during the RPC
+   */
+  public String getConfig() throws FirebaseProjectManagementException {
+    return iosAppService.getIosConfig(appId);
+  }
+
+  /**
+   * Asynchronously retrieves the configuration artifact associated with this iOS App.
+   *
+   * @return an {@code ApiFuture} of a UTF-8 encoded {@code String} containing the contents of the
+   *     artifact
+   */
+  public ApiFuture<String> getConfigAsync() {
+    return iosAppService.getIosConfigAsync(appId);
+  }
+
+  @Override
+  public String toString() {
+    return String.format("iOS App %s", getAppId());
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java b/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java
new file mode 100644
index 0000000..bbd8167
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/IosAppMetadata.java
@@ -0,0 +1,109 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.api.client.util.Preconditions;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+
+/**
+ * Contains detailed information about an iOS App. Instances of this class are immutable.
+ */
+public class IosAppMetadata {
+  private final String name;
+  private final String appId;
+  private final String displayName;
+  private final String projectId;
+  private final String bundleId;
+
+  IosAppMetadata(
+      String name, String appId, String displayName, String projectId, String bundleId) {
+    this.name = Preconditions.checkNotNull(name, "Null name");
+    this.appId = Preconditions.checkNotNull(appId, "Null appId");
+    this.displayName = Preconditions.checkNotNull(displayName, "Null displayName");
+    this.projectId = Preconditions.checkNotNull(projectId, "Null projectId");
+    this.bundleId = Preconditions.checkNotNull(bundleId, "Null bundleId");
+  }
+
+  /**
+   * Returns the fully qualified resource name of this iOS App.
+   */
+  public String getName() {
+    return name;
+  }
+
+  /**
+   * Returns the globally unique, Firebase-assigned identifier of this iOS App. This ID is unique
+   * even across Apps of different platforms, such as Android Apps.
+   */
+  public String getAppId() {
+    return appId;
+  }
+
+  /**
+   * Returns the user-assigned display name of this iOS App.
+   */
+  public String getDisplayName() {
+    return displayName;
+  }
+
+  /**
+   * Returns the permanent, globally unique, user-assigned ID of the parent Project for this iOS
+   * App.
+   */
+  public String getProjectId() {
+    return projectId;
+  }
+
+  /**
+   * Returns the canonical bundle ID of this iOS App as it would appear in the iOS AppStore.
+   */
+  public String getBundleId() {
+    return bundleId;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("IosAppMetadata")
+        .add("name", name)
+        .add("appId", appId)
+        .add("displayName", displayName)
+        .add("projectId", projectId)
+        .add("bundleId", bundleId)
+        .toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o == this) {
+      return true;
+    }
+    if (o instanceof IosAppMetadata) {
+      IosAppMetadata that = (IosAppMetadata) o;
+      return Objects.equal(this.name, that.name)
+          && Objects.equal(this.appId, that.appId)
+          && Objects.equal(this.displayName, that.displayName)
+          && Objects.equal(this.projectId, that.projectId)
+          && Objects.equal(this.bundleId, that.bundleId);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(name, appId, displayName, projectId, bundleId);
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/IosAppService.java b/src/main/java/com/google/firebase/projectmanagement/IosAppService.java
new file mode 100644
index 0000000..c1e68fa
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/IosAppService.java
@@ -0,0 +1,115 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.api.core.ApiFuture;
+import java.util.List;
+
+/**
+ * An interface to interact with the iOS-specific functionalities in the Firebase Project Management
+ * Service.
+ *
+ * <p>Note: Implementations of methods in this service may make RPCs.
+ */
+interface IosAppService {
+  /**
+   * Creates a new iOS App in the given project with the given display name.
+   *
+   * @param projectId the Project ID of the project in which to create the App
+   * @param bundleId the bundle ID of the iOS App to create
+   * @param displayName a nickname for this iOS App
+   * @return an {@link IosApp} reference
+   */
+  IosApp createIosApp(String projectId, String bundleId, String displayName)
+      throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously creates a new iOS App in the given project with the given display name.
+   *
+   * @param projectId the Project ID of the project in which to create the App
+   * @param bundleId the bundle ID of the iOS App to create
+   * @param displayName a nickname for this iOS App
+   * @return an {@link ApiFuture} of an {@link IosApp} reference
+   */
+  ApiFuture<IosApp> createIosAppAsync(String projectId, String bundleId, String displayName);
+
+  /**
+   * Retrieve information about an existing iOS App, identified by its App ID.
+   *
+   * @param appId the App ID of the iOS App
+   * @return an {@link IosAppMetadata} instance describing the iOS App
+   */
+  IosAppMetadata getIosApp(String appId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously retrieves information about an existing iOS App, identified by its App ID.
+   *
+   * @param appId the App ID of the iOS App
+   * @return an {@link IosAppMetadata} instance describing the iOS App
+   */
+  ApiFuture<IosAppMetadata> getIosAppAsync(String appId);
+
+  /**
+   * Lists all the iOS Apps belonging to the given project. The returned list cannot be modified.
+   *
+   * @param projectId the Project ID of the project
+   * @return a read-only list of {@link IosApp} references
+   */
+  List<IosApp> listIosApps(String projectId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously lists all the iOS Apps belonging to the given project. The returned list cannot
+   * be modified.
+   *
+   * @param projectId the project ID of the project
+   * @return an {@link ApiFuture} of a read-only list of {@link IosApp} references
+   */
+  ApiFuture<List<IosApp>> listIosAppsAsync(String projectId);
+
+  /**
+   * Updates the Display Name of the given iOS App.
+   *
+   * @param appId the App ID of the iOS App
+   * @param newDisplayName the new Display Name
+   */
+  void setIosDisplayName(String appId, String newDisplayName)
+      throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously updates the Display Name of the given iOS App.
+   *
+   * @param appId the App ID of the iOS App
+   * @param newDisplayName the new Display Name
+   */
+  ApiFuture<Void> setIosDisplayNameAsync(String appId, String newDisplayName);
+
+  /**
+   * Retrieves the configuration artifact associated with the specified iOS App.
+   *
+   * @param appId the App ID of the iOS App
+   * @return a modified UTF-8 encoded {@code String} containing the contents of the artifact
+   */
+  String getIosConfig(String appId) throws FirebaseProjectManagementException;
+
+  /**
+   * Asynchronously retrieves the configuration artifact associated with the specified iOS App.
+   *
+   * @param appId the App ID of the iOS App
+   * @return an {@link ApiFuture} of a modified UTF-8 encoded {@code String} containing the contents
+   *     of the artifact
+   */
+  ApiFuture<String> getIosConfigAsync(String appId);
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java b/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java
new file mode 100644
index 0000000..805ac3d
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/ShaCertificate.java
@@ -0,0 +1,123 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Ascii;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import com.google.common.base.Preconditions;
+import java.util.regex.Pattern;
+
+/**
+ * Contains detailed information of a SHA certificate, which can be associated to an Android app.
+ */
+public class ShaCertificate {
+
+  private static final Pattern SHA1_PATTERN = Pattern.compile("[0-9a-fA-F]{40}");
+  private static final Pattern SHA256_PATTERN = Pattern.compile("[0-9a-fA-F]{64}");
+
+  private final String name;
+  private final String shaHash;
+  private final ShaCertificateType certType;
+
+  private ShaCertificate(String name, String shaHash, ShaCertificateType certType) {
+    this.name = Preconditions.checkNotNull(name, "Null name");
+    this.shaHash = Preconditions.checkNotNull(shaHash, "Null shaHash");
+    this.certType = Preconditions.checkNotNull(certType, "Null certType");
+  }
+
+  /**
+   * Creates a {@link ShaCertificate} from certificate hash. Name will be left as empty string since
+   * the certificate doesn't have a generated name yet.
+   *
+   * @param shaHash SHA hash of the certificate
+   * @return a SHA certificate
+   */
+  public static ShaCertificate create(String shaHash) {
+    return new ShaCertificate("", shaHash, getTypeFromHash(shaHash));
+  }
+
+  static ShaCertificate create(String name, String shaHash) {
+    return new ShaCertificate(name, shaHash, getTypeFromHash(shaHash));
+  }
+
+  /**
+   * Returns the type of the certificate based on its hash.
+   *
+   * @throws IllegalArgumentException if the SHA hash is neither SHA-1 nor SHA-256
+   */
+  @VisibleForTesting
+  static ShaCertificateType getTypeFromHash(String shaHash) {
+    Preconditions.checkNotNull(shaHash, "Null shaHash");
+    shaHash = Ascii.toLowerCase(shaHash);
+    if (SHA1_PATTERN.matcher(shaHash).matches()) {
+      return ShaCertificateType.SHA_1;
+    } else if (SHA256_PATTERN.matcher(shaHash).matches()) {
+      return ShaCertificateType.SHA_256;
+    }
+    throw new IllegalArgumentException("Invalid SHA hash, it is neither SHA-1 nor SHA-256.");
+  }
+
+  /**
+   * Returns the fully qualified resource name of this SHA certificate.
+   */
+  public String getName() {
+    return name;
+  }
+
+  /**
+   * Returns the hash of this SHA certificate.
+   */
+  public String getShaHash() {
+    return shaHash;
+  }
+
+  /**
+   * Returns the type {@link ShaCertificateType} of this SHA certificate.
+   */
+  public ShaCertificateType getCertType() {
+    return certType;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o == this) {
+      return true;
+    }
+    if (o instanceof ShaCertificate) {
+      ShaCertificate that = (ShaCertificate) o;
+      return (this.name.equals(that.getName()))
+          && (this.shaHash.equals(that.getShaHash()))
+          && (this.certType.equals(that.getCertType()));
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("ShaCertificate")
+        .add("name", name)
+        .add("shaHash", shaHash)
+        .add("certType", certType)
+        .toString();
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(name, shaHash, certType);
+  }
+}
diff --git a/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java b/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java
new file mode 100644
index 0000000..a367ee4
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/ShaCertificateType.java
@@ -0,0 +1,26 @@
+/* Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+/**
+ * Enum denoting types of SHA certificates currently supported by Firebase.
+ */
+public enum ShaCertificateType {
+  /** Certificate generated by SHA-1 hashing algorithm. */
+  SHA_1,
+  /** Certificate generated by SHA-256 hashing algorithm. */
+  SHA_256
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
index b269978..4b51513 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -278,14 +278,14 @@ public class FirebaseMessagingTest {
   }
 
   @Test
-  public void testSendErrorWithFcmErrorCode() throws Exception {
+  public void testSendErrorWithFcmError() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
     FirebaseMessaging messaging = initMessaging(response);
     for (int code : HTTP_ERRORS) {
       response.setStatusCode(code).setContent(
           "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
               + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
-              + ".v1.FcmErrorCode\", \"errorCode\": \"UNREGISTERED\"}]}}");
+              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
       TestResponseInterceptor interceptor = new TestResponseInterceptor();
       messaging.setInterceptor(interceptor);
       try {
@@ -512,6 +512,7 @@ public class FirebaseMessagingTest {
     assertEquals("POST", request.getRequestMethod());
     assertEquals(TEST_FCM_URL, request.getUrl().toString());
     assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    assertEquals("2", request.getHeaders().get("X-GOOG-API-FORMAT-VERSION"));
     return request;
   }
 
diff --git a/src/test/java/com/google/firebase/projectmanagement/AndroidAppTest.java b/src/test/java/com/google/firebase/projectmanagement/AndroidAppTest.java
new file mode 100644
index 0000000..3f6529c
--- /dev/null
+++ b/src/test/java/com/google/firebase/projectmanagement/AndroidAppTest.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.when;
+
+import com.google.api.core.SettableApiFuture;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class AndroidAppTest {
+
+  private static final String APP_NAME = "mock-name";
+  private static final String APP_ID = "1:12345:android:deadbeef";
+  private static final String APP_DISPLAY_NAME = "test-android-app";
+  private static final String NEW_DISPLAY_NAME = "new-test-android-app";
+  private static final String APP_PACKAGE_NAME = "abc.def";
+  private static final String PROJECT_ID = "test-project-id";
+  private static final String ANDROID_CONFIG = "android.config";
+  private static final String CERTIFICATE_NAME =
+      "projects/test-project/androidApps/1:11111:android:11111/sha/111111";
+  private static final String SHA_HASH = "1111111111111111111111111111111111111111";
+  private static final AndroidAppMetadata ANDROID_APP_METADATA =
+      new AndroidAppMetadata(APP_NAME, APP_ID, APP_DISPLAY_NAME, PROJECT_ID, APP_PACKAGE_NAME);
+  private static final ShaCertificate SHA_CERTIFICATE =
+      ShaCertificate.create(CERTIFICATE_NAME, SHA_HASH);
+
+  @Rule
+  public final MockitoRule mockitoRule = MockitoJUnit.rule();
+
+  @Mock(answer = Answers.RETURNS_SMART_NULLS)
+  private AndroidAppService androidAppService;
+
+  private AndroidApp androidApp;
+
+  @Before
+  public void setUp() {
+    androidApp = new AndroidApp(APP_ID, androidAppService);
+  }
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void testGetMetadata() throws Exception {
+    when(androidAppService.getAndroidApp(APP_ID)).thenReturn(ANDROID_APP_METADATA);
+
+    AndroidAppMetadata metadata = androidApp.getMetadata();
+
+    assertEquals(metadata, ANDROID_APP_METADATA);
+  }
+
+  @Test
+  public void testGetMetadataAsync() throws Exception {
+    when(androidAppService.getAndroidAppAsync(APP_ID))
+        .thenReturn(createApiFuture(ANDROID_APP_METADATA));
+
+    AndroidAppMetadata metadata = androidApp.getMetadataAsync().get();
+
+    assertEquals(metadata, ANDROID_APP_METADATA);
+  }
+
+  @Test
+  public void testSetDisplayName() throws Exception {
+    doNothing().when(androidAppService).setAndroidDisplayName(APP_ID, NEW_DISPLAY_NAME);
+
+    androidApp.setDisplayName(NEW_DISPLAY_NAME);
+
+    Mockito.verify(androidAppService).setAndroidDisplayName(APP_ID, NEW_DISPLAY_NAME);
+  }
+
+  @Test
+  public void testSetDisplayNameAsync() throws Exception {
+    when(androidAppService.setAndroidDisplayNameAsync(APP_ID, NEW_DISPLAY_NAME))
+        .thenReturn(createApiFuture((Void) null));
+
+    androidApp.setDisplayNameAsync(NEW_DISPLAY_NAME).get();
+
+    Mockito.verify(androidAppService).setAndroidDisplayNameAsync(APP_ID, NEW_DISPLAY_NAME);
+  }
+
+  @Test
+  public void testGetConfig() throws Exception {
+    when(androidAppService.getAndroidConfig(APP_ID)).thenReturn(ANDROID_CONFIG);
+
+    String config = androidApp.getConfig();
+
+    assertEquals(config, ANDROID_CONFIG);
+  }
+
+  @Test
+  public void testGetConfigAsync() throws Exception {
+    when(androidAppService.getAndroidConfigAsync(APP_ID))
+        .thenReturn(createApiFuture(ANDROID_CONFIG));
+
+    String config = androidApp.getConfigAsync().get();
+
+    assertEquals(config, ANDROID_CONFIG);
+  }
+
+  @Test
+  public void testGetShaCertificates() throws Exception {
+    List<ShaCertificate> certificateList = new ArrayList<>();
+    certificateList.add(SHA_CERTIFICATE);
+    when(androidAppService.getShaCertificates(APP_ID)).thenReturn(certificateList);
+
+    List<ShaCertificate> res = androidApp.getShaCertificates();
+
+    assertEquals(res, certificateList);
+  }
+
+  @Test
+  public void testGetShaCertificatesAsync() throws Exception {
+    List<ShaCertificate> certificateList = new ArrayList<>();
+    certificateList.add(SHA_CERTIFICATE);
+    when(androidAppService.getShaCertificatesAsync(APP_ID))
+        .thenReturn(createApiFuture(certificateList));
+
+    List<ShaCertificate> res = androidApp.getShaCertificatesAsync().get();
+
+    assertEquals(res, certificateList);
+  }
+
+  @Test
+  public void testCreateShaCertificate() throws Exception {
+    when(androidAppService.createShaCertificate(APP_ID, ShaCertificate.create(SHA_HASH)))
+        .thenReturn(SHA_CERTIFICATE);
+
+    ShaCertificate certificate = androidApp.createShaCertificate(ShaCertificate.create(SHA_HASH));
+
+    assertEquals(certificate, SHA_CERTIFICATE);
+  }
+
+  @Test
+  public void testCreateShaCertificateAsync() throws Exception {
+    when(androidAppService.createShaCertificateAsync(APP_ID, ShaCertificate.create(SHA_HASH)))
+        .thenReturn(createApiFuture(SHA_CERTIFICATE));
+
+    ShaCertificate certificate = androidApp
+        .createShaCertificateAsync(ShaCertificate.create(SHA_HASH)).get();
+
+    assertEquals(certificate, SHA_CERTIFICATE);
+  }
+
+  @Test
+  public void testDeleteShaCertificate() throws Exception {
+    doNothing().when(androidAppService).deleteShaCertificate(CERTIFICATE_NAME);
+
+    androidApp.deleteShaCertificate(ShaCertificate.create(CERTIFICATE_NAME, SHA_HASH));
+
+    Mockito.verify(androidAppService).deleteShaCertificate(CERTIFICATE_NAME);
+  }
+
+  @Test
+  public void testDeleteShaCertificateAsync() throws Exception {
+    when(androidAppService.deleteShaCertificateAsync(CERTIFICATE_NAME))
+        .thenReturn(createApiFuture((Void) null));
+
+    androidApp.deleteShaCertificateAsync(ShaCertificate.create(CERTIFICATE_NAME, SHA_HASH)).get();
+
+    Mockito.verify(androidAppService).deleteShaCertificateAsync(CERTIFICATE_NAME);
+  }
+
+  private <T> SettableApiFuture<T> createApiFuture(T value) {
+    final SettableApiFuture<T> future = SettableApiFuture.create();
+    future.set(value);
+    return future;
+  }
+}
diff --git a/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementIT.java b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementIT.java
new file mode 100644
index 0000000..b6dd7a4
--- /dev/null
+++ b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementIT.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Strings;
+import com.google.firebase.testing.IntegrationTestUtils;
+import java.util.List;
+import java.util.Random;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class FirebaseProjectManagementIT {
+
+  private static final String TEST_APP_DISPLAY_NAME_PREFIX =
+      "Created By Firebase AdminSDK Java Integration Testing";
+  private static final String TEST_APP_BUNDLE_ID = "com.firebase.adminsdk-java-integration-test";
+  private static final String TEST_APP_PACKAGE_NAME = "com.firebase.adminsdk_java_integration_test";
+  private static final String TEST_SHA1_CERTIFICATE = "1111111111111111111111111111111111111111";
+  private static final String TEST_SHA256_CERTIFICATE =
+      "AAAACCCCAAAACCCCAAAACCCCAAAACCCCAAAACCCCAAAACCCCAAAACCCCAAAACCCC";
+  private static final Random random = new Random();
+
+  private static String testIosAppId;
+  private static String testAndroidAppId;
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    IntegrationTestUtils.ensureDefaultApp();
+    FirebaseProjectManagement projectManagement = FirebaseProjectManagement.getInstance();
+    // Ensure that we have created a Test iOS App.
+    List<IosApp> iosApps = projectManagement.listIosApps();
+    for (IosApp iosApp : iosApps) {
+      if (iosApp.getMetadata().getDisplayName().startsWith(TEST_APP_DISPLAY_NAME_PREFIX)) {
+        testIosAppId = iosApp.getAppId();
+      }
+    }
+    if (Strings.isNullOrEmpty(testIosAppId)) {
+      IosApp iosApp =
+          projectManagement.createIosApp(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME_PREFIX);
+      testIosAppId = iosApp.getAppId();
+    }
+    // Ensure that we have created a Test Android App.
+    List<AndroidApp> androidApps = projectManagement.listAndroidApps();
+    for (AndroidApp androidApp : androidApps) {
+      if (androidApp.getMetadata().getDisplayName().startsWith(TEST_APP_DISPLAY_NAME_PREFIX)) {
+        testAndroidAppId = androidApp.getAppId();
+      }
+    }
+    if (Strings.isNullOrEmpty(testAndroidAppId)) {
+      AndroidApp androidApp =
+          projectManagement.createAndroidApp(TEST_APP_PACKAGE_NAME, TEST_APP_DISPLAY_NAME_PREFIX);
+      testAndroidAppId = androidApp.getAppId();
+    }
+  }
+
+  /* Android App Integration Tests */
+
+  @Test
+  public void testAndroidSetDisplayNameAndGetMetadata() throws Exception {
+    FirebaseProjectManagement projectManagement = FirebaseProjectManagement.getInstance();
+    AndroidApp androidApp = projectManagement.getAndroidApp(testAndroidAppId);
+
+    // Use the synchronous version of the API.
+    {
+      int randomInt = Math.abs(random.nextInt());
+      String newDisplayName = TEST_APP_DISPLAY_NAME_PREFIX + " helloworld " + randomInt;
+
+      androidApp.setDisplayName(newDisplayName);
+      AndroidAppMetadata metadata = androidApp.getMetadata();
+
+      assertEquals(
+          String.format(
+              "projects/%s/androidApps/%s", IntegrationTestUtils.getProjectId(), testAndroidAppId),
+          metadata.getName());
+      assertEquals(IntegrationTestUtils.getProjectId(), metadata.getProjectId());
+      assertEquals(testAndroidAppId, metadata.getAppId());
+      assertEquals(newDisplayName, metadata.getDisplayName());
+      assertEquals(TEST_APP_PACKAGE_NAME, metadata.getPackageName());
+    }
+
+    // Change the display name back when done. Use the asynchronous version.
+    {
+      androidApp.setDisplayNameAsync(TEST_APP_DISPLAY_NAME_PREFIX).get();
+      AndroidAppMetadata metadata = androidApp.getMetadataAsync().get();
+
+      assertEquals(
+          String.format(
+              "projects/%s/androidApps/%s", IntegrationTestUtils.getProjectId(), testAndroidAppId),
+          metadata.getName());
+      assertEquals(IntegrationTestUtils.getProjectId(), metadata.getProjectId());
+      assertEquals(testAndroidAppId, metadata.getAppId());
+      assertEquals(TEST_APP_DISPLAY_NAME_PREFIX, metadata.getDisplayName());
+      assertEquals(TEST_APP_PACKAGE_NAME, metadata.getPackageName());
+    }
+  }
+
+  @Test
+  public void testAndroidCertificates() throws Exception {
+    FirebaseProjectManagement projectManagement = FirebaseProjectManagement.getInstance();
+    AndroidApp androidApp = projectManagement.getAndroidApp(testAndroidAppId);
+
+    // Use the Synchronous version of the API.
+    {
+      // Add SHA-1 certificate.
+      androidApp.createShaCertificate(ShaCertificate.create(TEST_SHA1_CERTIFICATE));
+      List<ShaCertificate> certificates = androidApp.getShaCertificates();
+      ShaCertificate expectedCertificate = null;
+      for (ShaCertificate certificate : certificates) {
+        if (certificate.getShaHash().equals(TEST_SHA1_CERTIFICATE.toLowerCase())) {
+          expectedCertificate  = certificate;
+        }
+      }
+      assertNotNull(expectedCertificate);
+      assertEquals(expectedCertificate.getCertType(), ShaCertificateType.SHA_1);
+
+      // Delete SHA-1 certificate.
+      androidApp.deleteShaCertificate(expectedCertificate);
+      for (ShaCertificate certificate : androidApp.getShaCertificates()) {
+        if (certificate.getShaHash().equals(TEST_SHA1_CERTIFICATE)) {
+          fail("Test SHA-1 certificate is not deleted.");
+        }
+      }
+    }
+
+    // Use the asynchronous version of the API.
+    {
+      // Add SHA-256 certificate.
+      androidApp.createShaCertificateAsync(ShaCertificate.create(TEST_SHA256_CERTIFICATE)).get();
+      List<ShaCertificate> certificates = androidApp.getShaCertificatesAsync().get();
+      ShaCertificate expectedCertificate = null;
+      for (ShaCertificate certificate : certificates) {
+        if (certificate.getShaHash().equals(TEST_SHA256_CERTIFICATE.toLowerCase())) {
+          expectedCertificate  = certificate;
+        }
+      }
+      assertNotNull(expectedCertificate);
+      assertEquals(expectedCertificate.getCertType(), ShaCertificateType.SHA_256);
+
+      // Delete SHA-256 certificate.
+      androidApp.deleteShaCertificateAsync(expectedCertificate).get();
+      for (ShaCertificate certificate : androidApp.getShaCertificatesAsync().get()) {
+        if (certificate.getShaHash().equals(TEST_SHA256_CERTIFICATE)) {
+          fail("Test SHA-1 certificate is not deleted.");
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testAndroidGetConfig() throws Exception {
+    FirebaseProjectManagement projectManagement = FirebaseProjectManagement.getInstance();
+    AndroidApp androidApp = projectManagement.getAndroidApp(testAndroidAppId);
+
+    // Test the synchronous version.
+    {
+      String config = androidApp.getConfig();
+
+      assertTrue(config.contains(IntegrationTestUtils.getProjectId()));
+      assertTrue(config.contains(testAndroidAppId));
+    }
+
+    // Test the asynchronous version.
+    {
+      String config = androidApp.getConfigAsync().get();
+
+      assertTrue(config.contains(IntegrationTestUtils.getProjectId()));
+      assertTrue(config.contains(testAndroidAppId));
+    }
+  }
+
+  /* iOS App Integration Tests */
+
+  @Test
+  public void testIosSetDisplayNameAndGetMetadata() throws Exception {
+    FirebaseProjectManagement projectManagement = FirebaseProjectManagement.getInstance();
+    IosApp iosApp = projectManagement.getIosApp(testIosAppId);
+
+    // Use the synchronous version of the API.
+    {
+      int randomInt = Math.abs(random.nextInt());
+      String newDisplayName = TEST_APP_DISPLAY_NAME_PREFIX + " helloworld " + randomInt;
+
+      iosApp.setDisplayName(newDisplayName);
+      IosAppMetadata metadata = iosApp.getMetadata();
+
+      assertEquals(
+          String.format(
+              "projects/%s/iosApps/%s", IntegrationTestUtils.getProjectId(), testIosAppId),
+          metadata.getName());
+      assertEquals(IntegrationTestUtils.getProjectId(), metadata.getProjectId());
+      assertEquals(testIosAppId, metadata.getAppId());
+      assertEquals(newDisplayName, metadata.getDisplayName());
+      assertEquals(TEST_APP_BUNDLE_ID, metadata.getBundleId());
+    }
+
+    // Change the display name back when done. Use the asynchronous version.
+    {
+      iosApp.setDisplayNameAsync(TEST_APP_DISPLAY_NAME_PREFIX).get();
+      IosAppMetadata metadata = iosApp.getMetadataAsync().get();
+
+      assertEquals(
+          String.format(
+              "projects/%s/iosApps/%s", IntegrationTestUtils.getProjectId(), testIosAppId),
+          metadata.getName());
+      assertEquals(IntegrationTestUtils.getProjectId(), metadata.getProjectId());
+      assertEquals(testIosAppId, metadata.getAppId());
+      assertEquals(TEST_APP_DISPLAY_NAME_PREFIX, metadata.getDisplayName());
+      assertEquals(TEST_APP_BUNDLE_ID, metadata.getBundleId());
+    }
+  }
+
+  @Test
+  public void testIosGetConfig() throws Exception {
+    FirebaseProjectManagement projectManagement = FirebaseProjectManagement.getInstance();
+    IosApp iosApp = projectManagement.getIosApp(testIosAppId);
+
+    // Test the synchronous version.
+    {
+      String config = iosApp.getConfig();
+
+      assertTrue(config.contains(IntegrationTestUtils.getProjectId()));
+      assertTrue(config.contains(testIosAppId));
+    }
+
+    // Test the asynchronous version.
+    {
+      String config = iosApp.getConfigAsync().get();
+
+      assertTrue(config.contains(IntegrationTestUtils.getProjectId()));
+      assertTrue(config.contains(testIosAppId));
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java
new file mode 100644
index 0000000..5ba86b2
--- /dev/null
+++ b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java
@@ -0,0 +1,908 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static com.google.firebase.projectmanagement.FirebaseProjectManagementServiceImpl.FIREBASE_PROJECT_MANAGEMENT_URL;
+import static com.google.firebase.projectmanagement.FirebaseProjectManagementServiceImpl.MAXIMUM_LIST_APPS_PAGE_SIZE;
+import static com.google.firebase.projectmanagement.HttpHelper.PATCH_OVERRIDE_KEY;
+import static com.google.firebase.projectmanagement.HttpHelper.PATCH_OVERRIDE_VALUE;
+import static com.google.firebase.projectmanagement.ShaCertificateType.SHA_1;
+import static com.google.firebase.projectmanagement.ShaCertificateType.SHA_256;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.util.Base64;
+import com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.testing.MultiRequestMockHttpTransport;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+// TODO(weixifan): Add unit tests for create{Android|Ios}App.
+public class FirebaseProjectManagementServiceImplTest {
+
+  private static final String PROJECT_ID = "test-project-id";
+  private static final String BUNDLE_ID = "test.ios.app";
+  private static final String PACKAGE_NAME = "test.android.app";
+  private static final String DISPLAY_NAME = "test-admin-sdk-app";
+  private static final String ANDROID_APP_ID = "test-android-app-id";
+  private static final String IOS_APP_ID = "test-ios-app-id";
+  private static final String IOS_APP_RESOURCE_NAME = "ios/11111";
+  private static final String ANDROID_APP_RESOURCE_NAME = "android/11111";
+  private static final IosAppMetadata IOS_APP_METADATA =
+      new IosAppMetadata(IOS_APP_RESOURCE_NAME, IOS_APP_ID, DISPLAY_NAME, PROJECT_ID, BUNDLE_ID);
+  private static final AndroidAppMetadata ANDROID_APP_METADATA =
+      new AndroidAppMetadata(
+          ANDROID_APP_RESOURCE_NAME, ANDROID_APP_ID, DISPLAY_NAME, PROJECT_ID, PACKAGE_NAME);
+
+  private static final String IOS_CONFIG_CONTENT = "ios-config-content";
+  private static final String ANDROID_CONFIG_CONTENT = "android-config-content";
+  private static final String SHA1_RESOURCE_NAME = "test-project/sha/11111";
+  private static final String SHA1_HASH = "1111111111111111111111111111111111111111";
+  private static final String SHA256_HASH =
+      "2222222222222222222222222222222222222222222222222222222222222222";
+
+  private static final String CREATE_IOS_RESPONSE =
+      "{\"name\" : \"operations/projects/test-project-id/apps/SomeToken\", "
+          + "\"done\" : \"false\"}";
+  private static final String CREATE_IOS_GET_OPERATION_ATTEMPT_1_RESPONSE =
+      "{\"name\" : \"operations/projects/test-project-id/apps/SomeToken\", "
+          + "\"done\" : \"false\"}";
+  private static final String CREATE_IOS_GET_OPERATION_ATTEMPT_2_RESPONSE =
+      "{\"name\" : \"operations/projects/test-project-id/apps/SomeToken\", "
+          + "\"done\" : \"true\", "
+          + "\"response\" :"
+          + "{\"name\" : \"test-project/sha/11111\", "
+          + "\"appId\" : \"test-ios-app-id\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"bundleId\" : \"test.ios.app1\"}}";
+
+  private static final String GET_IOS_RESPONSE =
+      "{\"name\" : \"ios/11111\", "
+          + "\"appId\" : \"test-ios-app-id\", "
+          + "\"displayName\" : \"%s\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"bundleId\" : \"test.ios.app\"}";
+  private static final String GET_IOS_CONFIG_RESPONSE =
+      "{\"configFilename\" : \"test-ios-app-config-name\", "
+          + "\"configFileContents\" : \"ios-config-content\"}";
+  private static final String LIST_IOS_APPS_RESPONSE =
+      "{\"apps\": ["
+          + "{\"name\" : \"test-project/sha/11111\", "
+          + "\"appId\" : \"test-ios-app-id-1\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"bundleId\" : \"test.ios.app1\"}, "
+          + "{\"name\" : \"test-project/sha/11112\", "
+          + "\"appId\" : \"test-ios-app-id-2\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"bundleId\" : \"test.ios.app2\"}]}";
+
+  private static final String LIST_IOS_APPS_PAGE_1_RESPONSE =
+      "{\"apps\": ["
+          + "{\"name\" : \"projects/test-project-id/iosApps/test-ios-app-id-1\", "
+          + "\"appId\" : \"test-ios-app-id-1\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"bundleId\" : \"test.ios.app1\"}], "
+          + "\"nextPageToken\" : \"next-page-token\"}";
+  private static final String LIST_IOS_APPS_PAGE_2_RESPONSE =
+      "{\"apps\": ["
+          + "{\"name\" : \"projects/test-project-id/iosApps/test-ios-app-id-2\", "
+          + "\"appId\" : \"test-ios-app-id-2\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"bundleId\" : \"test.ios.app2\"}]}";
+
+  private static final String CREATE_ANDROID_RESPONSE =
+      "{\"name\" : \"operations/projects/test-project-id/apps/SomeToken\", "
+          + "\"done\" : \"false\"}";
+  private static final String CREATE_ANDROID_GET_OPERATION_ATTEMPT_1_RESPONSE =
+      "{\"name\" : \"operations/projects/test-project-id/apps/SomeToken\", "
+          + "\"done\" : \"false\"}";
+  private static final String CREATE_ANDROID_GET_OPERATION_ATTEMPT_2_RESPONSE =
+      "{\"name\" : \"operations/projects/test-project-id/apps/SomeToken\", "
+          + "\"done\" : \"true\", "
+          + "\"response\" :"
+          + "{\"name\" : \"test-project/sha/11111\", "
+          + "\"appId\" : \"test-android-app-id\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"packageName\" : \"test.android.app1\"}}";
+
+  private static final String GET_ANDROID_RESPONSE =
+      "{\"name\" : \"android/11111\", "
+          + "\"appId\" : \"test-android-app-id\", "
+          + "\"displayName\" : \"%s\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"packageName\" : \"test.android.app\"}";
+  private static final String GET_ANDROID_CONFIG_RESPONSE =
+      "{\"configFilename\" : \"test-android-app-config-name\", "
+          + "\"configFileContents\" : \"android-config-content\"}";
+  private static final String LIST_ANDROID_APPS_RESPONSE =
+      "{\"apps\": ["
+          + "{\"name\" : \"test-project/sha/11111\", "
+          + "\"appId\" : \"test-android-app-id-1\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"packageName\" : \"test.android.app1\"}, "
+          + "{\"name\" : \"test-project/sha/11112\", "
+          + "\"appId\" : \"test-android-app-id-2\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"packageName\" : \"test.android.app2\"}]}";
+
+  private static final String LIST_ANDROID_APPS_PAGE_1_RESPONSE =
+      "{\"apps\": ["
+          + "{\"name\" : \"projects/test-project-id/androidApps/test-android-app-id-1\", "
+          + "\"appId\" : \"test-android-app-id-1\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"packageName\" : \"test.android.app1\"}], "
+          + "\"nextPageToken\" : \"next-page-token\"}";
+  private static final String LIST_ANDROID_APPS_PAGE_2_RESPONSE =
+      "{\"apps\": ["
+          + "{\"name\" : \"projects/test-project-id/androidApps/test-android-app-id-2\", "
+          + "\"appId\" : \"test-android-app-id-2\", "
+          + "\"displayName\" : \"display-name\", "
+          + "\"projectId\" : \"test-project-id\", "
+          + "\"packageName\" : \"test.android.app2\"}]}";
+
+  private static final String GET_SHA_CERTIFICATES_RESPONSE =
+      "{\"certificates\": ["
+          + "{\"name\" : \"test-project/sha/11111\", "
+          + "\"shaHash\" : \"1111111111111111111111111111111111111111\", "
+          + "\"certType\" : \"SHA_1\"}, "
+          + "{\"name\" : \"test-project/sha/11111\", "
+          + "\"shaHash\" : \"2222222222222222222222222222222222222222222222222222222222222222\", "
+          + "\"certType\" : \"SHA_256\"}]}";
+  private static final String CREATE_SHA_CERTIFICATE_RESPONSE =
+          "{\"name\" : \"test-project/sha/11111\", "
+          + "\"shaHash\" : \"%s\", "
+          + "\"certType\" : \"%s\"}";
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  private FirebaseProjectManagementServiceImpl serviceImpl;
+  private MockLowLevelHttpResponse firstRpcResponse;
+  private MultiRequestTestResponseInterceptor interceptor;
+
+  @Before
+  public void setUp() {
+    interceptor = new MultiRequestTestResponseInterceptor();
+    firstRpcResponse = new MockLowLevelHttpResponse();
+  }
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void getIosApp() throws Exception {
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/iosApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, IOS_APP_ID);
+    firstRpcResponse.setContent(String.format(GET_IOS_RESPONSE, DISPLAY_NAME));
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    IosAppMetadata iosAppMetadata = serviceImpl.getIosApp(IOS_APP_ID);
+
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(iosAppMetadata, IOS_APP_METADATA);
+  }
+
+  @Test
+  public void getIosAppAsync() throws Exception {
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/iosApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, IOS_APP_ID);
+    firstRpcResponse.setContent(String.format(GET_IOS_RESPONSE, DISPLAY_NAME));
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    IosAppMetadata iosAppMetadata = serviceImpl.getIosAppAsync(IOS_APP_ID).get();
+
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(iosAppMetadata, IOS_APP_METADATA);
+  }
+
+  @Test
+  public void listIosApps() throws Exception {
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    firstRpcResponse.setContent(LIST_IOS_APPS_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    List<IosApp> iosAppList = serviceImpl.listIosApps(PROJECT_ID);
+
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(iosAppList.size(), 2);
+    assertEquals(iosAppList.get(0).getAppId(), "test-ios-app-id-1");
+    assertEquals(iosAppList.get(1).getAppId(), "test-ios-app-id-2");
+  }
+
+  @Test
+  public void listIosAppsAsync() throws Exception {
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    firstRpcResponse.setContent(LIST_IOS_APPS_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    List<IosApp> iosAppList = serviceImpl.listIosAppsAsync(PROJECT_ID).get();
+
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(iosAppList.size(), 2);
+    assertEquals(iosAppList.get(0).getAppId(), "test-ios-app-id-1");
+    assertEquals(iosAppList.get(1).getAppId(), "test-ios-app-id-2");
+  }
+
+  @Test
+  public void listIosAppsMultiplePages() throws Exception {
+    firstRpcResponse.setContent(LIST_IOS_APPS_PAGE_1_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(LIST_IOS_APPS_PAGE_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(firstRpcResponse, secondRpcResponse),
+        interceptor);
+
+    List<IosApp> iosAppList = serviceImpl.listIosApps(PROJECT_ID);
+
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps?page_token=next-page-token&page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    assertEquals(iosAppList.size(), 2);
+    assertEquals(iosAppList.get(0).getAppId(), "test-ios-app-id-1");
+    assertEquals(iosAppList.get(1).getAppId(), "test-ios-app-id-2");
+  }
+
+  @Test
+  public void listIosAppsAsyncMultiplePages() throws Exception {
+    firstRpcResponse.setContent(LIST_IOS_APPS_PAGE_1_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(LIST_IOS_APPS_PAGE_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(firstRpcResponse, secondRpcResponse),
+        interceptor);
+
+    List<IosApp> iosAppList = serviceImpl.listIosAppsAsync(PROJECT_ID).get();
+
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps?page_token=next-page-token&page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    assertEquals(iosAppList.size(), 2);
+    assertEquals(iosAppList.get(0).getAppId(), "test-ios-app-id-1");
+    assertEquals(iosAppList.get(1).getAppId(), "test-ios-app-id-2");
+  }
+
+  @Test
+  public void createIosApp() throws Exception {
+    firstRpcResponse.setContent(CREATE_IOS_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(CREATE_IOS_GET_OPERATION_ATTEMPT_1_RESPONSE);
+    MockLowLevelHttpResponse thirdRpcResponse = new MockLowLevelHttpResponse();
+    thirdRpcResponse.setContent(CREATE_IOS_GET_OPERATION_ATTEMPT_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(
+            firstRpcResponse, secondRpcResponse, thirdRpcResponse),
+        interceptor);
+
+    IosApp iosApp = serviceImpl.createIosApp(PROJECT_ID, BUNDLE_ID, DISPLAY_NAME);
+
+    assertEquals(IOS_APP_ID, iosApp.getAppId());
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps", FIREBASE_PROJECT_MANAGEMENT_URL, PROJECT_ID);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1/operations/projects/test-project-id/apps/SomeToken",
+        FIREBASE_PROJECT_MANAGEMENT_URL);
+    String thirdRpcExpectedUrl = secondRpcExpectedUrl;
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.POST);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(2, thirdRpcExpectedUrl, HttpMethod.GET);
+    ImmutableMap<String, String> firstRpcPayload = ImmutableMap.<String, String>builder()
+        .put("bundle_id", BUNDLE_ID)
+        .put("display_name", DISPLAY_NAME)
+        .build();
+    checkRequestPayload(0, firstRpcPayload);
+  }
+
+  @Test
+  public void createIosAppAsync() throws Exception {
+    firstRpcResponse.setContent(CREATE_IOS_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(CREATE_IOS_GET_OPERATION_ATTEMPT_1_RESPONSE);
+    MockLowLevelHttpResponse thirdRpcResponse = new MockLowLevelHttpResponse();
+    thirdRpcResponse.setContent(CREATE_IOS_GET_OPERATION_ATTEMPT_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(
+            firstRpcResponse, secondRpcResponse, thirdRpcResponse),
+        interceptor);
+
+    IosApp iosApp = serviceImpl.createIosAppAsync(PROJECT_ID, BUNDLE_ID, DISPLAY_NAME).get();
+
+    assertEquals(IOS_APP_ID, iosApp.getAppId());
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/iosApps", FIREBASE_PROJECT_MANAGEMENT_URL, PROJECT_ID);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1/operations/projects/test-project-id/apps/SomeToken",
+        FIREBASE_PROJECT_MANAGEMENT_URL);
+    String thirdRpcExpectedUrl = secondRpcExpectedUrl;
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.POST);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(2, thirdRpcExpectedUrl, HttpMethod.GET);
+    ImmutableMap<String, String> firstRpcPayload = ImmutableMap.<String, String>builder()
+        .put("bundle_id", BUNDLE_ID)
+        .put("display_name", DISPLAY_NAME)
+        .build();
+    checkRequestPayload(0, firstRpcPayload);
+  }
+
+  @Test
+  public void setIosDisplayName() throws Exception {
+    firstRpcResponse.setContent("{}");
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    serviceImpl.setIosDisplayName(IOS_APP_ID, DISPLAY_NAME);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/iosApps/%s?update_mask=display_name",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        IOS_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.POST);
+    ImmutableMap<String, String> payload =
+        ImmutableMap.<String, String>builder().put("display_name", DISPLAY_NAME).build();
+    checkRequestPayload(payload);
+    checkPatchRequest();
+  }
+
+  @Test
+  public void setIosDisplayNameAsync() throws Exception {
+    firstRpcResponse.setContent("{}");
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    serviceImpl.setIosDisplayNameAsync(IOS_APP_ID, DISPLAY_NAME).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/iosApps/%s?update_mask=display_name",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        IOS_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.POST);
+    ImmutableMap<String, String> payload =
+        ImmutableMap.<String, String>builder().put("display_name", DISPLAY_NAME).build();
+    checkRequestPayload(payload);
+    checkPatchRequest();
+  }
+
+  @Test
+  public void getIosConfig() throws Exception {
+    firstRpcResponse.setContent(GET_IOS_CONFIG_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    String content = serviceImpl.getIosConfig(IOS_APP_ID);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/iosApps/%s/config", FIREBASE_PROJECT_MANAGEMENT_URL, IOS_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(new String(Base64.decodeBase64(IOS_CONFIG_CONTENT), Charsets.UTF_8), content);
+  }
+
+  @Test
+  public void getIosConfigAsync() throws Exception {
+    firstRpcResponse.setContent(GET_IOS_CONFIG_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    String content = serviceImpl.getIosConfigAsync(IOS_APP_ID).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/iosApps/%s/config", FIREBASE_PROJECT_MANAGEMENT_URL, IOS_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(new String(Base64.decodeBase64(IOS_CONFIG_CONTENT), Charsets.UTF_8), content);
+  }
+
+  @Test
+  public void getAndroidApp() throws Exception {
+    firstRpcResponse.setContent(String.format(GET_ANDROID_RESPONSE, DISPLAY_NAME));
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    AndroidAppMetadata androidAppMetadata = serviceImpl.getAndroidApp(ANDROID_APP_ID);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(androidAppMetadata, ANDROID_APP_METADATA);
+  }
+
+  @Test
+  public void getAndroidAppAsync() throws Exception {
+    firstRpcResponse.setContent(String.format(GET_ANDROID_RESPONSE, DISPLAY_NAME));
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    AndroidAppMetadata androidAppMetadata = serviceImpl.getAndroidAppAsync(ANDROID_APP_ID).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s", FIREBASE_PROJECT_MANAGEMENT_URL, ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(androidAppMetadata, ANDROID_APP_METADATA);
+  }
+
+  @Test
+  public void listAndroidApps() throws Exception {
+    firstRpcResponse.setContent(LIST_ANDROID_APPS_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    List<AndroidApp> androidAppList = serviceImpl.listAndroidApps(PROJECT_ID);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(androidAppList.size(), 2);
+    assertEquals(androidAppList.get(0).getAppId(), "test-android-app-id-1");
+    assertEquals(androidAppList.get(1).getAppId(), "test-android-app-id-2");
+  }
+
+  @Test
+  public void listAndroidAppsAsync() throws Exception {
+    firstRpcResponse.setContent(LIST_ANDROID_APPS_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    List<AndroidApp> androidAppList = serviceImpl.listAndroidAppsAsync(PROJECT_ID).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(androidAppList.size(), 2);
+    assertEquals(androidAppList.get(0).getAppId(), "test-android-app-id-1");
+    assertEquals(androidAppList.get(1).getAppId(), "test-android-app-id-2");
+  }
+
+  @Test
+  public void listAndroidAppsMultiplePages() throws Exception {
+    firstRpcResponse.setContent(LIST_ANDROID_APPS_PAGE_1_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(LIST_ANDROID_APPS_PAGE_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(firstRpcResponse, secondRpcResponse),
+        interceptor);
+
+    List<AndroidApp> androidAppList = serviceImpl.listAndroidApps(PROJECT_ID);
+
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps?page_token=next-page-token&page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    assertEquals(androidAppList.size(), 2);
+    assertEquals(androidAppList.get(0).getAppId(), "test-android-app-id-1");
+    assertEquals(androidAppList.get(1).getAppId(), "test-android-app-id-2");
+  }
+
+  @Test
+  public void listAndroidAppsAsyncMultiplePages() throws Exception {
+    firstRpcResponse.setContent(LIST_ANDROID_APPS_PAGE_1_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(LIST_ANDROID_APPS_PAGE_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(firstRpcResponse, secondRpcResponse),
+        interceptor);
+
+    List<AndroidApp> androidAppList = serviceImpl.listAndroidAppsAsync(PROJECT_ID).get();
+
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps?page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps?page_token=next-page-token&page_size=%d",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        PROJECT_ID,
+        MAXIMUM_LIST_APPS_PAGE_SIZE);
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    assertEquals(androidAppList.size(), 2);
+    assertEquals(androidAppList.get(0).getAppId(), "test-android-app-id-1");
+    assertEquals(androidAppList.get(1).getAppId(), "test-android-app-id-2");
+  }
+
+  @Test
+  public void createAndroidApp() throws Exception {
+    firstRpcResponse.setContent(CREATE_ANDROID_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(CREATE_ANDROID_GET_OPERATION_ATTEMPT_1_RESPONSE);
+    MockLowLevelHttpResponse thirdRpcResponse = new MockLowLevelHttpResponse();
+    thirdRpcResponse.setContent(CREATE_ANDROID_GET_OPERATION_ATTEMPT_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(
+            firstRpcResponse, secondRpcResponse, thirdRpcResponse),
+        interceptor);
+
+    AndroidApp androidApp =
+        serviceImpl.createAndroidApp(PROJECT_ID, PACKAGE_NAME, DISPLAY_NAME);
+
+    assertEquals(ANDROID_APP_ID, androidApp.getAppId());
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps", FIREBASE_PROJECT_MANAGEMENT_URL, PROJECT_ID);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1/operations/projects/test-project-id/apps/SomeToken",
+        FIREBASE_PROJECT_MANAGEMENT_URL);
+    String thirdRpcExpectedUrl = secondRpcExpectedUrl;
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.POST);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(2, thirdRpcExpectedUrl, HttpMethod.GET);
+    ImmutableMap<String, String> firstRpcPayload = ImmutableMap.<String, String>builder()
+        .put("package_name", PACKAGE_NAME)
+        .put("display_name", DISPLAY_NAME)
+        .build();
+    checkRequestPayload(0, firstRpcPayload);
+  }
+
+  @Test
+  public void createAndroidAppAsync() throws Exception {
+    firstRpcResponse.setContent(CREATE_ANDROID_RESPONSE);
+    MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
+    secondRpcResponse.setContent(CREATE_ANDROID_GET_OPERATION_ATTEMPT_1_RESPONSE);
+    MockLowLevelHttpResponse thirdRpcResponse = new MockLowLevelHttpResponse();
+    thirdRpcResponse.setContent(CREATE_ANDROID_GET_OPERATION_ATTEMPT_2_RESPONSE);
+    serviceImpl = initServiceImpl(
+        ImmutableList.<MockLowLevelHttpResponse>of(
+            firstRpcResponse, secondRpcResponse, thirdRpcResponse),
+        interceptor);
+
+    AndroidApp androidApp =
+        serviceImpl.createAndroidAppAsync(PROJECT_ID, PACKAGE_NAME, DISPLAY_NAME).get();
+
+    assertEquals(ANDROID_APP_ID, androidApp.getAppId());
+    String firstRpcExpectedUrl = String.format(
+        "%s/v1beta1/projects/%s/androidApps", FIREBASE_PROJECT_MANAGEMENT_URL, PROJECT_ID);
+    String secondRpcExpectedUrl = String.format(
+        "%s/v1/operations/projects/test-project-id/apps/SomeToken",
+        FIREBASE_PROJECT_MANAGEMENT_URL);
+    String thirdRpcExpectedUrl = secondRpcExpectedUrl;
+    checkRequestHeader(0, firstRpcExpectedUrl, HttpMethod.POST);
+    checkRequestHeader(1, secondRpcExpectedUrl, HttpMethod.GET);
+    checkRequestHeader(2, thirdRpcExpectedUrl, HttpMethod.GET);
+    ImmutableMap<String, String> firstRpcPayload = ImmutableMap.<String, String>builder()
+        .put("package_name", PACKAGE_NAME)
+        .put("display_name", DISPLAY_NAME)
+        .build();
+    checkRequestPayload(0, firstRpcPayload);
+  }
+
+  @Test
+  public void setAndroidDisplayName() throws Exception {
+    firstRpcResponse.setContent("{}");
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    serviceImpl.setAndroidDisplayName(ANDROID_APP_ID, DISPLAY_NAME);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s?update_mask=display_name",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.POST);
+    ImmutableMap<String, String> payload =
+        ImmutableMap.<String, String>builder().put("display_name", DISPLAY_NAME).build();
+    checkRequestPayload(payload);
+    checkPatchRequest();
+  }
+
+  @Test
+  public void setAndroidDisplayNameAsync() throws Exception {
+    firstRpcResponse.setContent("{}");
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    serviceImpl.setAndroidDisplayNameAsync(ANDROID_APP_ID, DISPLAY_NAME).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s?update_mask=display_name",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.POST);
+    ImmutableMap<String, String> payload =
+        ImmutableMap.<String, String>builder().put("display_name", DISPLAY_NAME).build();
+    checkRequestPayload(payload);
+    checkPatchRequest();
+  }
+
+  @Test
+  public void getAndroidConfig() throws Exception {
+    firstRpcResponse.setContent(GET_ANDROID_CONFIG_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    String content = serviceImpl.getAndroidConfig(ANDROID_APP_ID);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s/config",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(new String(Base64.decodeBase64(ANDROID_CONFIG_CONTENT), Charsets.UTF_8), content);
+  }
+
+  @Test
+  public void getAndroidConfigAsync() throws Exception {
+    firstRpcResponse.setContent(GET_ANDROID_CONFIG_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    String content = serviceImpl.getAndroidConfigAsync(ANDROID_APP_ID).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s/config",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(new String(Base64.decodeBase64(ANDROID_CONFIG_CONTENT), Charsets.UTF_8), content);
+  }
+
+  @Test
+  public void getShaCertificates() throws Exception {
+    firstRpcResponse.setContent(GET_SHA_CERTIFICATES_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    List<ShaCertificate> certificateList = serviceImpl.getShaCertificates(ANDROID_APP_ID);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s/sha",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(certificateList.size(), 2);
+    assertEquals(
+        certificateList.get(0),
+        ShaCertificate.create("test-project/sha/11111", SHA1_HASH));
+    assertEquals(
+        certificateList.get(1),
+        ShaCertificate.create("test-project/sha/11111", SHA256_HASH));
+  }
+
+  @Test
+  public void getShaCertificatesAsync() throws Exception {
+    firstRpcResponse.setContent(GET_SHA_CERTIFICATES_RESPONSE);
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    List<ShaCertificate> certificateList =
+        serviceImpl.getShaCertificatesAsync(ANDROID_APP_ID).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s/sha",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.GET);
+    assertEquals(certificateList.size(), 2);
+    assertEquals(
+        certificateList.get(0),
+        ShaCertificate.create("test-project/sha/11111", SHA1_HASH));
+    assertEquals(
+        certificateList.get(1),
+        ShaCertificate.create("test-project/sha/11111", SHA256_HASH));
+  }
+
+  @Test
+  public void createShaCertificate() throws Exception {
+    firstRpcResponse.setContent(
+        String.format(CREATE_SHA_CERTIFICATE_RESPONSE, SHA1_HASH, SHA_1.name()));
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    ShaCertificate certificate = serviceImpl
+        .createShaCertificate(ANDROID_APP_ID, ShaCertificate.create(SHA1_HASH));
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s/sha",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.POST);
+    ImmutableMap<String, String> payload = ImmutableMap.<String, String>builder()
+        .put("sha_hash", SHA1_HASH)
+        .put("cert_type", SHA_1.toString())
+        .build();
+    checkRequestPayload(payload);
+    assertEquals(certificate, ShaCertificate.create("test-project/sha/11111", SHA1_HASH));
+  }
+
+  @Test
+  public void createShaCertificateAsync() throws Exception {
+    firstRpcResponse
+        .setContent(String.format(CREATE_SHA_CERTIFICATE_RESPONSE, SHA256_HASH, SHA_256.name()));
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    ShaCertificate certificate = serviceImpl
+        .createShaCertificateAsync(ANDROID_APP_ID, ShaCertificate.create(SHA256_HASH)).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/projects/-/androidApps/%s/sha",
+        FIREBASE_PROJECT_MANAGEMENT_URL,
+        ANDROID_APP_ID);
+    checkRequestHeader(expectedUrl, HttpMethod.POST);
+    ImmutableMap<String, String> payload = ImmutableMap.<String, String>builder()
+        .put("sha_hash", SHA256_HASH)
+        .put("cert_type", SHA_256.toString())
+        .build();
+    checkRequestPayload(payload);
+    assertEquals(ShaCertificate.create("test-project/sha/11111", SHA256_HASH), certificate);
+  }
+
+  @Test
+  public void deleteShaCertificate() throws Exception {
+    firstRpcResponse.setContent("{}");
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    serviceImpl.deleteShaCertificate(SHA1_RESOURCE_NAME);
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, SHA1_RESOURCE_NAME);
+    checkRequestHeader(expectedUrl, HttpMethod.DELETE);
+  }
+
+  @Test
+  public void deleteShaCertificateAsync() throws Exception {
+    firstRpcResponse.setContent("{}");
+    serviceImpl = initServiceImpl(firstRpcResponse, interceptor);
+
+    serviceImpl.deleteShaCertificateAsync(SHA1_RESOURCE_NAME).get();
+
+    String expectedUrl = String.format(
+        "%s/v1beta1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, SHA1_RESOURCE_NAME);
+    checkRequestHeader(expectedUrl, HttpMethod.DELETE);
+  }
+
+  private static FirebaseProjectManagementServiceImpl initServiceImpl(
+      MockLowLevelHttpResponse mockResponse,
+      MultiRequestTestResponseInterceptor interceptor) {
+    return initServiceImpl(ImmutableList.<MockLowLevelHttpResponse>of(mockResponse), interceptor);
+  }
+
+  private static FirebaseProjectManagementServiceImpl initServiceImpl(
+      List<MockLowLevelHttpResponse> mockResponses,
+      MultiRequestTestResponseInterceptor interceptor) {
+    MockHttpTransport transport = new MultiRequestMockHttpTransport(mockResponses);
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId(PROJECT_ID)
+        .setHttpTransport(transport)
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+    FirebaseProjectManagementServiceImpl serviceImpl =
+        new FirebaseProjectManagementServiceImpl(app);
+    serviceImpl.setInterceptor(interceptor);
+    return serviceImpl;
+  }
+
+  private void checkRequestHeader(String expectedUrl, HttpMethod httpMethod) {
+    assertEquals(
+        "The number of HttpResponses is not equal to 1.", 1, interceptor.getNumberOfResponses());
+    checkRequestHeader(0, expectedUrl, httpMethod);
+  }
+
+  private void checkRequestHeader(int index, String expectedUrl, HttpMethod httpMethod) {
+    assertNotNull(interceptor.getResponse(index));
+    HttpRequest request = interceptor.getResponse(index).getRequest();
+    assertEquals(httpMethod.name(), request.getRequestMethod());
+    assertEquals(expectedUrl, request.getUrl().toString());
+    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+  }
+
+  private void checkRequestPayload(Map<String, String> expected) throws IOException {
+    assertEquals(
+        "The number of HttpResponses is not equal to 1.", 1, interceptor.getNumberOfResponses());
+    checkRequestPayload(0, expected);
+  }
+
+  private void checkRequestPayload(int index, Map<String, String> expected) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    interceptor.getResponse(index).getRequest().getContent().writeTo(out);
+    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
+    Map<String, String> parsed = new HashMap<>();
+    parser.parseAndClose(parsed);
+    assertEquals(expected, parsed);
+  }
+
+  private void checkPatchRequest() {
+    assertEquals(
+        "The number of HttpResponses is not equal to 1.", 1, interceptor.getNumberOfResponses());
+    checkPatchRequest(0);
+  }
+
+  private void checkPatchRequest(int index) {
+    assertTrue(interceptor.getResponse(index).getRequest().getHeaders()
+        .getHeaderStringValues(PATCH_OVERRIDE_KEY).contains(PATCH_OVERRIDE_VALUE));
+  }
+
+  private enum HttpMethod {
+    GET,
+    POST,
+    DELETE
+  }
+
+  /**
+   * Can be used to intercept multiple HTTP requests and responses made by the SDK during tests.
+   */
+  private class MultiRequestTestResponseInterceptor implements HttpResponseInterceptor {
+    private final List<HttpResponse> responsesList = new ArrayList<>();
+
+    @Override
+    public void interceptResponse(HttpResponse response) throws IOException {
+      this.responsesList.add(response);
+    }
+
+    public int getNumberOfResponses() {
+      return responsesList.size();
+    }
+
+    public HttpResponse getResponse(int index) {
+      return responsesList.get(index);
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementTest.java b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementTest.java
new file mode 100644
index 0000000..3d51eec
--- /dev/null
+++ b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementTest.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static com.google.api.core.ApiFutures.immediateFuture;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.when;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.MockGoogleCredentials;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class FirebaseProjectManagementTest {
+
+  private static final String TEST_APP_ID = "1:1234567890:ios:cafef00ddeadbeef";
+  private static final String TEST_APP_ID_2 = "1:1234567890:ios:f00ddeadbeefcafe";
+  private static final String TEST_APP_ID_3 = "1:1234567890:ios:deadbeefcafef00d";
+  private static final String TEST_APP_DISPLAY_NAME = "Hello World!";
+  private static final String NULL_DISPLAY_NAME = null;
+  private static final String TEST_PROJECT_ID = "hello-world";
+  private static final String TEST_APP_BUNDLE_ID = "com.hello.world";
+  private static final FirebaseProjectManagementException FIREBASE_PROJECT_MANAGEMENT_EXCEPTION =
+      new FirebaseProjectManagementException("Error!", null);
+
+  @Rule
+  public final MockitoRule mockitoRule = MockitoJUnit.rule();
+
+  @Mock(answer = Answers.RETURNS_SMART_NULLS)
+  private AndroidAppService androidAppService;
+
+  @Mock(answer = Answers.RETURNS_SMART_NULLS)
+  private IosAppService iosAppService;
+
+  private FirebaseProjectManagement projectManagement;
+
+  @BeforeClass
+  public static void setUpClass() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId(TEST_PROJECT_ID)
+        .build();
+    FirebaseApp.initializeApp(options);
+  }
+
+  @Before
+  public void setUp() {
+    projectManagement = FirebaseProjectManagement.getInstance();
+    projectManagement.setAndroidAppService(androidAppService);
+    projectManagement.setIosAppService(iosAppService);
+  }
+
+  @AfterClass
+  public static void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  /* Android */
+
+  @Test
+  public void testCreateAndroidAppShouldSucceed() throws Exception {
+    when(androidAppService.createAndroidApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenReturn(new AndroidApp(TEST_APP_ID, androidAppService));
+
+    AndroidApp androidApp = projectManagement.createAndroidApp(TEST_APP_BUNDLE_ID);
+
+    assertEquals(TEST_APP_ID, androidApp.getAppId());
+  }
+
+  @Test
+  public void testCreateAndroidAppShouldRethrow() throws Exception {
+    when(androidAppService.createAndroidApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    try {
+      projectManagement.createAndroidApp(TEST_APP_BUNDLE_ID);
+      fail("createAndroidApp did not rethrow");
+    } catch (FirebaseProjectManagementException e) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testCreateAndroidAppWithDisplayNameShouldSucceed() throws Exception {
+    when(androidAppService
+            .createAndroidApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenReturn(new AndroidApp(TEST_APP_ID, androidAppService));
+
+    AndroidApp androidApp =
+        projectManagement.createAndroidApp(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME);
+
+    assertEquals(TEST_APP_ID, androidApp.getAppId());
+  }
+
+  @Test
+  public void testCreateAndroidAppWithDisplayNameShouldRethrow() throws Exception {
+    when(androidAppService
+            .createAndroidApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    try {
+      projectManagement.createAndroidApp(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME);
+      fail("createAndroidApp did not rethrow");
+    } catch (FirebaseProjectManagementException e) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testCreateAndroidAppAsyncShouldSucceed() throws Exception {
+    when(androidAppService
+            .createAndroidAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenReturn(immediateFuture(new AndroidApp(TEST_APP_ID, androidAppService)));
+
+    AndroidApp androidApp = projectManagement.createAndroidAppAsync(TEST_APP_BUNDLE_ID).get();
+
+    assertEquals(TEST_APP_ID, androidApp.getAppId());
+  }
+
+  @Test
+  public void testCreateAndroidAppAsyncShouldRethrow() throws Exception {
+    when(androidAppService
+            .createAndroidAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenReturn(immediateAndroidAppFailedFuture());
+
+    try {
+      projectManagement.createAndroidAppAsync(TEST_APP_BUNDLE_ID).get();
+      fail("createAndroidAppAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  @Test
+  public void testCreateAndroidAppAsyncWithDisplayNameShouldSucceed() throws Exception {
+    when(androidAppService
+            .createAndroidAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenReturn(immediateFuture(new AndroidApp(TEST_APP_ID, androidAppService)));
+
+    AndroidApp androidApp =
+        projectManagement.createAndroidAppAsync(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME).get();
+
+    assertEquals(TEST_APP_ID, androidApp.getAppId());
+  }
+
+  @Test
+  public void testCreateAndroidAppAsyncWithDisplayNameShouldRethrow() throws Exception {
+    when(androidAppService
+            .createAndroidAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenReturn(immediateAndroidAppFailedFuture());
+
+    try {
+      projectManagement.createAndroidAppAsync(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME).get();
+      fail("createAndroidAppAsync (with display name) did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  @Test
+  public void testListAndroidAppsShouldSucceed() throws Exception {
+    AndroidApp app1 = new AndroidApp(TEST_APP_ID, androidAppService);
+    AndroidApp app2 = new AndroidApp(TEST_APP_ID_2, androidAppService);
+    AndroidApp app3 = new AndroidApp(TEST_APP_ID_3, androidAppService);
+    when(androidAppService.listAndroidApps(TEST_PROJECT_ID))
+        .thenReturn(ImmutableList.of(app1, app2, app3));
+
+    List<AndroidApp> androidApps = projectManagement.listAndroidApps();
+
+    ImmutableSet.Builder<String> appIdsBuilder = ImmutableSet.<String>builder();
+    for (AndroidApp androidApp : androidApps) {
+      appIdsBuilder.add(androidApp.getAppId());
+    }
+    assertEquals(ImmutableSet.of(TEST_APP_ID, TEST_APP_ID_2, TEST_APP_ID_3), appIdsBuilder.build());
+  }
+
+  @Test
+  public void testListAndroidAppsShouldRethrow() throws Exception {
+    when(androidAppService.listAndroidApps(TEST_PROJECT_ID))
+        .thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    try {
+      projectManagement.listAndroidApps();
+      fail("listAndroidApps did not rethrow");
+    } catch (FirebaseProjectManagementException e) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testListAndroidAppsAsyncShouldSucceed() throws Exception {
+    AndroidApp app1 = new AndroidApp(TEST_APP_ID, androidAppService);
+    AndroidApp app2 = new AndroidApp(TEST_APP_ID_2, androidAppService);
+    AndroidApp app3 = new AndroidApp(TEST_APP_ID_3, androidAppService);
+    List<AndroidApp> androidApps = ImmutableList.of(app1, app2, app3);
+    when(androidAppService.listAndroidAppsAsync(TEST_PROJECT_ID))
+        .thenReturn(immediateFuture(androidApps));
+
+    List<AndroidApp> actualAndroidApps = projectManagement.listAndroidAppsAsync().get();
+
+    ImmutableSet.Builder<String> appIdsBuilder = ImmutableSet.<String>builder();
+    for (AndroidApp androidApp : actualAndroidApps) {
+      appIdsBuilder.add(androidApp.getAppId());
+    }
+    assertEquals(ImmutableSet.of(TEST_APP_ID, TEST_APP_ID_2, TEST_APP_ID_3), appIdsBuilder.build());
+  }
+
+  @Test
+  public void testListAndroidAppsAsyncShouldRethrow() throws Exception {
+    when(androidAppService.listAndroidAppsAsync(TEST_PROJECT_ID))
+        .thenReturn(immediateListAndroidAppFailedFuture());
+
+    try {
+      projectManagement.listAndroidAppsAsync().get();
+      fail("listAndroidAppsAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  /* iOS */
+
+  @Test
+  public void testCreateIosAppShouldSucceed() throws Exception {
+    when(iosAppService.createIosApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenReturn(new IosApp(TEST_APP_ID, iosAppService));
+
+    IosApp iosApp = projectManagement.createIosApp(TEST_APP_BUNDLE_ID);
+
+    assertEquals(TEST_APP_ID, iosApp.getAppId());
+  }
+
+  @Test
+  public void testCreateIosAppShouldRethrow() throws Exception {
+    when(iosAppService.createIosApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    try {
+      projectManagement.createIosApp(TEST_APP_BUNDLE_ID);
+      fail("createIosApp did not rethrow");
+    } catch (FirebaseProjectManagementException e) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testCreateIosAppWithDisplayNameShouldSucceed() throws Exception {
+    when(iosAppService.createIosApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenReturn(new IosApp(TEST_APP_ID, iosAppService));
+
+    IosApp iosApp = projectManagement.createIosApp(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME);
+
+    assertEquals(TEST_APP_ID, iosApp.getAppId());
+  }
+
+  @Test
+  public void testCreateIosAppWithDisplayNameShouldRethrow() throws Exception {
+    when(iosAppService.createIosApp(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    try {
+      projectManagement.createIosApp(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME);
+      fail("createIosApp did not rethrow");
+    } catch (FirebaseProjectManagementException e) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testCreateIosAppAsyncShouldSucceed() throws Exception {
+    when(iosAppService.createIosAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenReturn(immediateFuture(new IosApp(TEST_APP_ID, iosAppService)));
+
+    IosApp iosApp = projectManagement.createIosAppAsync(TEST_APP_BUNDLE_ID).get();
+
+    assertEquals(TEST_APP_ID, iosApp.getAppId());
+  }
+
+  @Test
+  public void testCreateIosAppAsyncShouldRethrow() throws Exception {
+    when(iosAppService.createIosAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, NULL_DISPLAY_NAME))
+        .thenReturn(immediateIosAppFailedFuture());
+
+    try {
+      projectManagement.createIosAppAsync(TEST_APP_BUNDLE_ID).get();
+      fail("createIosAppAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  @Test
+  public void testCreateIosAppAsyncWithDisplayNameShouldSucceed() throws Exception {
+    when(iosAppService
+            .createIosAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenReturn(immediateFuture(new IosApp(TEST_APP_ID, iosAppService)));
+
+    IosApp iosApp =
+        projectManagement.createIosAppAsync(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME).get();
+
+    assertEquals(TEST_APP_ID, iosApp.getAppId());
+  }
+
+  @Test
+  public void testCreateIosAppAsyncWithDisplayNameShouldRethrow() throws Exception {
+    when(iosAppService
+            .createIosAppAsync(TEST_PROJECT_ID, TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME))
+        .thenReturn(immediateIosAppFailedFuture());
+
+    try {
+      projectManagement.createIosAppAsync(TEST_APP_BUNDLE_ID, TEST_APP_DISPLAY_NAME).get();
+      fail("createIosAppAsync (with display name) did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  @Test
+  public void testListIosAppsShouldSucceed() throws Exception {
+    IosApp app1 = new IosApp(TEST_APP_ID, iosAppService);
+    IosApp app2 = new IosApp(TEST_APP_ID_2, iosAppService);
+    IosApp app3 = new IosApp(TEST_APP_ID_3, iosAppService);
+    when(iosAppService.listIosApps(TEST_PROJECT_ID)).thenReturn(ImmutableList.of(app1, app2, app3));
+
+    List<IosApp> iosApps = projectManagement.listIosApps();
+
+    ImmutableSet.Builder<String> appIdsBuilder = ImmutableSet.<String>builder();
+    for (IosApp iosApp : iosApps) {
+      appIdsBuilder.add(iosApp.getAppId());
+    }
+    assertEquals(ImmutableSet.of(TEST_APP_ID, TEST_APP_ID_2, TEST_APP_ID_3), appIdsBuilder.build());
+  }
+
+  @Test
+  public void testListIosAppsShouldRethrow() throws Exception {
+    when(iosAppService.listIosApps(TEST_PROJECT_ID))
+        .thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    try {
+      projectManagement.listIosApps();
+      fail("listIosApps did not rethrow");
+    } catch (FirebaseProjectManagementException e) {
+      // Pass.
+    }
+  }
+
+  @Test
+  public void testListIosAppsAsyncShouldSucceed() throws Exception {
+    IosApp app1 = new IosApp(TEST_APP_ID, iosAppService);
+    IosApp app2 = new IosApp(TEST_APP_ID_2, iosAppService);
+    IosApp app3 = new IosApp(TEST_APP_ID_3, iosAppService);
+    List<IosApp> iosApps = ImmutableList.of(app1, app2, app3);
+    when(iosAppService.listIosAppsAsync(TEST_PROJECT_ID)).thenReturn(immediateFuture(iosApps));
+
+    List<IosApp> actualIosApps = projectManagement.listIosAppsAsync().get();
+
+    ImmutableSet.Builder<String> appIdsBuilder = ImmutableSet.<String>builder();
+    for (IosApp iosApp : actualIosApps) {
+      appIdsBuilder.add(iosApp.getAppId());
+    }
+    assertEquals(ImmutableSet.of(TEST_APP_ID, TEST_APP_ID_2, TEST_APP_ID_3), appIdsBuilder.build());
+  }
+
+  @Test
+  public void testListIosAppsAsyncShouldRethrow() throws Exception {
+    when(iosAppService.listIosAppsAsync(TEST_PROJECT_ID))
+        .thenReturn(immediateListIosAppFailedFuture());
+
+    try {
+      projectManagement.listIosAppsAsync().get();
+      fail("listIosAppsAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  private ApiFuture<AndroidApp> immediateAndroidAppFailedFuture() {
+    return ApiFutures.<AndroidApp>immediateFailedFuture(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+
+  private ApiFuture<IosApp> immediateIosAppFailedFuture() {
+    return ApiFutures.<IosApp>immediateFailedFuture(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+
+  private ApiFuture<List<AndroidApp>> immediateListAndroidAppFailedFuture() {
+    return ApiFutures.<List<AndroidApp>>immediateFailedFuture(
+        FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+
+  private ApiFuture<List<IosApp>> immediateListIosAppFailedFuture() {
+    return ApiFutures.<List<IosApp>>immediateFailedFuture(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+}
diff --git a/src/test/java/com/google/firebase/projectmanagement/IosAppTest.java b/src/test/java/com/google/firebase/projectmanagement/IosAppTest.java
new file mode 100644
index 0000000..fbc4fc8
--- /dev/null
+++ b/src/test/java/com/google/firebase/projectmanagement/IosAppTest.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static com.google.api.core.ApiFutures.immediateFuture;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import java.util.concurrent.ExecutionException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class IosAppTest {
+
+  private static final String TEST_APP_NAME =
+      "projects/hello-world/iosApps/1:1234567890:ios:cafef00ddeadbeef";
+  private static final String TEST_APP_ID = "1:1234567890:ios:cafef00ddeadbeef";
+  private static final String TEST_APP_DISPLAY_NAME = "Hello World!";
+  private static final String NEW_DISPLAY_NAME = "Hello?";
+  private static final String TEST_PROJECT_ID = "hello-world";
+  private static final String TEST_APP_BUNDLE_ID = "com.hello.world";
+  private static final String TEST_APP_CONFIG =
+      "<plist version=\"1.0\"><dict>"
+          + "<key>SOME_KEY</key><string>some-value</string>"
+          + "<key>SOME_OTHER_KEY</key><string>some-other-value</string>"
+          + "</dict></plist>";
+  private static final FirebaseProjectManagementException FIREBASE_PROJECT_MANAGEMENT_EXCEPTION =
+      new FirebaseProjectManagementException("Error!", null);
+
+  private static final IosAppMetadata TEST_IOS_APP_METADATA = new IosAppMetadata(
+      TEST_APP_NAME,
+      TEST_APP_ID,
+      TEST_APP_DISPLAY_NAME,
+      TEST_PROJECT_ID,
+      TEST_APP_BUNDLE_ID);
+
+  @Rule
+  public final MockitoRule mockitoRule = MockitoJUnit.rule();
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Mock(answer = Answers.RETURNS_SMART_NULLS)
+  private IosAppService iosAppService;
+
+  private IosApp iosApp;
+
+  @Before
+  public void setUp() {
+    iosApp = new IosApp(TEST_APP_ID, iosAppService);
+  }
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void testGetIosApp() throws Exception {
+    assertEquals(TEST_APP_ID, iosApp.getAppId());
+  }
+
+  @Test
+  public void testGetIosAppShouldSucceed() throws Exception {
+    when(iosAppService.getIosApp(TEST_APP_ID)).thenReturn(TEST_IOS_APP_METADATA);
+
+    assertEquals(TEST_IOS_APP_METADATA, iosApp.getMetadata());
+  }
+
+  @Test
+  public void testGetIosAppShouldRethrow() throws Exception {
+    when(iosAppService.getIosApp(TEST_APP_ID)).thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    thrown.expect(FirebaseProjectManagementException.class);
+
+    iosApp.getMetadata();
+  }
+
+  @Test
+  public void testGetIosAppAsyncShouldSucceed() throws Exception {
+    when(iosAppService.getIosAppAsync(TEST_APP_ID))
+        .thenReturn(immediateFuture(TEST_IOS_APP_METADATA));
+
+    assertEquals(TEST_IOS_APP_METADATA, iosApp.getMetadataAsync().get());
+  }
+
+  @Test
+  public void testGetIosAppAsyncShouldRethrow() throws Exception {
+    when(iosAppService.getIosAppAsync(TEST_APP_ID))
+        .thenReturn(immediateIosAppMetadataFailedFuture());
+
+    try {
+      iosApp.getMetadataAsync().get();
+      fail("getMetadataAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  @Test
+  public void testSetDisplayNameShouldSucceed() throws Exception {
+    iosApp.setDisplayName(NEW_DISPLAY_NAME);
+
+    verify(iosAppService).setIosDisplayName(TEST_APP_ID, NEW_DISPLAY_NAME);
+  }
+
+  @Test
+  public void testSetDisplayNameShouldRethrow() throws Exception {
+    doThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION)
+        .when(iosAppService)
+        .setIosDisplayName(TEST_APP_ID, NEW_DISPLAY_NAME);
+
+    thrown.expect(FirebaseProjectManagementException.class);
+
+    iosApp.setDisplayName(NEW_DISPLAY_NAME);
+  }
+
+  @Test
+  public void testSetDisplayNameAsyncShouldSucceed() throws Exception {
+    when(iosAppService.setIosDisplayNameAsync(TEST_APP_ID, NEW_DISPLAY_NAME))
+        .thenReturn(immediateFuture((Void) null));
+
+    iosApp.setDisplayNameAsync(NEW_DISPLAY_NAME).get();
+  }
+
+  @Test
+  public void testSetDisplayNameAsyncShouldRethrow() throws Exception {
+    when(iosAppService.setIosDisplayNameAsync(TEST_APP_ID, NEW_DISPLAY_NAME))
+        .thenReturn(immediateVoidFailedFuture());
+
+    try {
+      iosApp.setDisplayNameAsync(NEW_DISPLAY_NAME).get();
+      fail("setDisplayNameAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  @Test
+  public void testGetConfigShouldSucceed() throws Exception {
+    when(iosAppService.getIosConfig(TEST_APP_ID)).thenReturn(TEST_APP_CONFIG);
+
+    assertEquals(TEST_APP_CONFIG, iosApp.getConfig());
+  }
+
+  @Test
+  public void testGetConfigShouldRethrow() throws Exception {
+    when(iosAppService.getIosConfig(TEST_APP_ID)).thenThrow(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+
+    thrown.expect(FirebaseProjectManagementException.class);
+
+    iosApp.getConfig();
+  }
+
+  @Test
+  public void testGetConfigAsyncShouldSucceed() throws Exception {
+    when(iosAppService.getIosConfigAsync(TEST_APP_ID)).thenReturn(immediateFuture(TEST_APP_CONFIG));
+
+    assertEquals(TEST_APP_CONFIG, iosApp.getConfigAsync().get());
+  }
+
+  @Test
+  public void testGetConfigAsyncShouldRethrow() throws Exception {
+    when(iosAppService.getIosConfigAsync(TEST_APP_ID)).thenReturn(immediateStringFailedFuture());
+
+    try {
+      iosApp.getConfigAsync().get();
+      fail("getConfigAsync did not rethrow");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseProjectManagementException);
+    }
+  }
+
+  private ApiFuture<IosAppMetadata> immediateIosAppMetadataFailedFuture() {
+    return ApiFutures.<IosAppMetadata>immediateFailedFuture(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+
+  private ApiFuture<Void> immediateVoidFailedFuture() {
+    return ApiFutures.<Void>immediateFailedFuture(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+
+  private ApiFuture<String> immediateStringFailedFuture() {
+    return ApiFutures.<String>immediateFailedFuture(FIREBASE_PROJECT_MANAGEMENT_EXCEPTION);
+  }
+}
diff --git a/src/test/java/com/google/firebase/projectmanagement/ShaCertificateTest.java b/src/test/java/com/google/firebase/projectmanagement/ShaCertificateTest.java
new file mode 100644
index 0000000..f5ef091
--- /dev/null
+++ b/src/test/java/com/google/firebase/projectmanagement/ShaCertificateTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.projectmanagement;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class ShaCertificateTest {
+
+  @Test
+  public void getTypeFromHashSha1() {
+    assertEquals(
+        ShaCertificate.getTypeFromHash("1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA"),
+        ShaCertificateType.SHA_1);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void getTypeFromHashSha1WithSpecialCharacter() {
+    ShaCertificate.getTypeFromHash("&111AAAA1111AAAA1111AAAA1111AAAA1111AAA$");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void getTypeFromHashSha1WithIncorrectSize() {
+    ShaCertificate.getTypeFromHash("1111AAAA1111AAAA1111AAAA1111AAAA1111");
+  }
+
+  @Test
+  public void getTypeFromHashSha256() {
+    assertEquals(
+        ShaCertificate.getTypeFromHash(
+            "1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA"),
+        ShaCertificateType.SHA_256);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void getTypeFromHashSha256WithSpecialCharacter() {
+    ShaCertificate.getTypeFromHash(
+        "&111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAA&");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void getTypeFromHashSha256WithIncorrectSize() {
+    ShaCertificate.getTypeFromHash(
+        "1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA");
+  }
+}