Repository: firebase/firebase-admin-java
Pull request #219 (2018-11-23 17:05:16)

Add subtitle in ApsAlert 
Add subtitle field in ApsAlert payload + tests


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b08e7c2..96fad10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
   code.
 - [fixed] FCM errors sent by the back-end now include more details
   that are helpful when debugging problems.
+- [changed] Migrated the `FirebaseAuth` user management API to the
+  new Identity Toolkit endpoint.
 
 # v6.5.0
 
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/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 39b3e37..294f10a 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpContent;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpResponse;
@@ -36,6 +37,7 @@ 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.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.auth.internal.DownloadAccountResponse;
@@ -45,7 +47,9 @@ import com.google.firebase.auth.internal.HttpErrorResponse;
 import com.google.firebase.auth.internal.UploadAccountResponse;
 import com.google.firebase.internal.FirebaseRequestInitializer;
 import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
 import com.google.firebase.internal.SdkUtils;
+
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -91,9 +95,10 @@ class FirebaseUserManager {
       "iss", "jti", "nbf", "nonce", "sub", "firebase");
 
   private static final String ID_TOOLKIT_URL =
-      "https://www.googleapis.com/identitytoolkit/v3/relyingparty/";
+      "https://identitytoolkit.googleapis.com/v1/projects/%s";
   private static final String CLIENT_VERSION_HEADER = "X-Client-Version";
 
+  private final String baseUrl;
   private final JsonFactory jsonFactory;
   private final HttpRequestFactory requestFactory;
   private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion();
@@ -107,6 +112,12 @@ class FirebaseUserManager {
    */
   FirebaseUserManager(@NonNull FirebaseApp app) {
     checkNotNull(app, "FirebaseApp must not be null");
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkArgument(!Strings.isNullOrEmpty(projectId),
+        "Project ID is required to access the auth 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.baseUrl = String.format(ID_TOOLKIT_URL, projectId);
     this.jsonFactory = app.getOptions().getJsonFactory();
     HttpTransport transport = app.getOptions().getHttpTransport();
     this.requestFactory = transport.createRequestFactory(new FirebaseRequestInitializer(app));
@@ -121,7 +132,7 @@ class FirebaseUserManager {
     final Map<String, Object> payload = ImmutableMap.<String, Object>of(
         "localId", ImmutableList.of(uid));
     GetAccountInfoResponse response = post(
-        "getAccountInfo", payload, GetAccountInfoResponse.class);
+        "/accounts:lookup", payload, GetAccountInfoResponse.class);
     if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) {
       throw new FirebaseAuthException(USER_NOT_FOUND_ERROR,
           "No user record found for the provided user ID: " + uid);
@@ -133,7 +144,7 @@ class FirebaseUserManager {
     final Map<String, Object> payload = ImmutableMap.<String, Object>of(
         "email", ImmutableList.of(email));
     GetAccountInfoResponse response = post(
-        "getAccountInfo", payload, GetAccountInfoResponse.class);
+        "/accounts:lookup", payload, GetAccountInfoResponse.class);
     if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) {
       throw new FirebaseAuthException(USER_NOT_FOUND_ERROR,
           "No user record found for the provided email: " + email);
@@ -145,7 +156,7 @@ class FirebaseUserManager {
     final Map<String, Object> payload = ImmutableMap.<String, Object>of(
         "phoneNumber", ImmutableList.of(phoneNumber));
     GetAccountInfoResponse response = post(
-        "getAccountInfo", payload, GetAccountInfoResponse.class);
+        "/accounts:lookup", payload, GetAccountInfoResponse.class);
     if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) {
       throw new FirebaseAuthException(USER_NOT_FOUND_ERROR,
           "No user record found for the provided phone number: " + phoneNumber);
@@ -155,7 +166,7 @@ class FirebaseUserManager {
 
   String createUser(CreateRequest request) throws FirebaseAuthException {
     GenericJson response = post(
-        "signupNewUser", request.getProperties(), GenericJson.class);
+        "/accounts", request.getProperties(), GenericJson.class);
     if (response != null) {
       String uid = (String) response.get("localId");
       if (!Strings.isNullOrEmpty(uid)) {
@@ -167,7 +178,7 @@ class FirebaseUserManager {
 
   void updateUser(UpdateRequest request, JsonFactory jsonFactory) throws FirebaseAuthException {
     GenericJson response = post(
-        "setAccountInfo", request.getProperties(jsonFactory), GenericJson.class);
+        "/accounts:update", request.getProperties(jsonFactory), GenericJson.class);
     if (response == null || !request.getUid().equals(response.get("localId"))) {
       throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to update user: " + request.getUid());
     }
@@ -176,7 +187,7 @@ class FirebaseUserManager {
   void deleteUser(String uid) throws FirebaseAuthException {
     final Map<String, Object> payload = ImmutableMap.<String, Object>of("localId", uid);
     GenericJson response = post(
-        "deleteAccount", payload, GenericJson.class);
+        "/accounts:delete", payload, GenericJson.class);
     if (response == null || !response.containsKey("kind")) {
       throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to delete user: " + uid);
     }
@@ -190,8 +201,10 @@ class FirebaseUserManager {
       builder.put("nextPageToken", pageToken);
     }
 
-    DownloadAccountResponse response = post(
-        "downloadAccount", builder.build(), DownloadAccountResponse.class);
+    GenericUrl url = new GenericUrl(baseUrl + "/accounts:batchGet");
+    url.putAll(builder.build());
+    DownloadAccountResponse response = sendRequest(
+            "GET", url, null, DownloadAccountResponse.class);
     if (response == null) {
       throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to retrieve users.");
     }
@@ -200,7 +213,8 @@ class FirebaseUserManager {
 
   UserImportResult importUsers(UserImportRequest request) throws FirebaseAuthException {
     checkNotNull(request);
-    UploadAccountResponse response = post("uploadAccount", request, UploadAccountResponse.class);
+    UploadAccountResponse response = post(
+            "/accounts:batchCreate", request, UploadAccountResponse.class);
     if (response == null) {
       throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to import users.");
     }
@@ -211,7 +225,7 @@ class FirebaseUserManager {
       SessionCookieOptions options) throws FirebaseAuthException {
     final Map<String, Object> payload = ImmutableMap.<String, Object>of(
         "idToken", idToken, "validDuration", options.getExpiresInSeconds());
-    GenericJson response = post("createSessionCookie", payload, GenericJson.class);
+    GenericJson response = post(":createSessionCookie", payload, GenericJson.class);
     if (response != null) {
       String cookie = (String) response.get("sessionCookie");
       if (!Strings.isNullOrEmpty(cookie)) {
@@ -223,14 +237,22 @@ class FirebaseUserManager {
 
   private <T> T post(String path, Object content, Class<T> clazz) throws FirebaseAuthException {
     checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty");
-    checkNotNull(content, "content must not be null");
-    checkNotNull(clazz, "response class must not be null");
+    checkNotNull(content, "content must not be null for POST requests");
+    GenericUrl url = new GenericUrl(baseUrl + path);
+    return sendRequest("POST", url, content, clazz);
+  }
 
-    GenericUrl url = new GenericUrl(ID_TOOLKIT_URL + path);
+  private <T> T sendRequest(
+          String method, GenericUrl url,
+          @Nullable Object content, Class<T> clazz) throws FirebaseAuthException {
+
+    checkArgument(!Strings.isNullOrEmpty(method), "method must not be null or empty");
+    checkNotNull(url, "url must not be null");
+    checkNotNull(clazz, "response class must not be null");
     HttpResponse response = null;
     try {
-      HttpRequest request = requestFactory.buildPostRequest(url,
-          new JsonHttpContent(jsonFactory, content));
+      HttpContent httpContent = content != null ? new JsonHttpContent(jsonFactory, content) : null;
+      HttpRequest request = requestFactory.buildRequest(method, url, httpContent);
       request.setParser(new JsonObjectParser(jsonFactory));
       request.getHeaders().set(CLIENT_VERSION_HEADER, clientVersion);
       request.setResponseInterceptor(interceptor);
diff --git a/src/main/java/com/google/firebase/messaging/ApsAlert.java b/src/main/java/com/google/firebase/messaging/ApsAlert.java
index 6f7e249..2efa944 100644
--- a/src/main/java/com/google/firebase/messaging/ApsAlert.java
+++ b/src/main/java/com/google/firebase/messaging/ApsAlert.java
@@ -34,6 +34,9 @@ public class ApsAlert {
   @Key("title")
   private final String title;
 
+  @Key("subtitle")
+  private final String subtitle;
+
   @Key("body")
   private final String body;
 
@@ -49,6 +52,12 @@ public class ApsAlert {
   @Key("title-loc-args")
   private final List<String> titleLocArgs;
 
+  @Key("subtitle-loc-key")
+  private final String subtitleLocKey;
+
+  @Key("subtitle-loc-args")
+  private final List<String> subtitleLocArgs;
+
   @Key("action-loc-key")
   private final String actionLocKey;
 
@@ -57,6 +66,7 @@ public class ApsAlert {
 
   private ApsAlert(Builder builder) {
     this.title = builder.title;
+    this.subtitle = builder.subtitle;
     this.body = builder.body;
     this.actionLocKey = builder.actionLocKey;
     this.locKey = builder.locKey;
@@ -76,6 +86,14 @@ public class ApsAlert {
     } else {
       this.titleLocArgs = null;
     }
+    this.subtitleLocKey = builder.subtitleLocKey;
+    if (!builder.subtitleLocArgs.isEmpty()) {
+      checkArgument(!Strings.isNullOrEmpty(builder.subtitleLocKey),
+          "subtitleLocKey is required when specifying subtitleLocArgs");
+      this.subtitleLocArgs = ImmutableList.copyOf(builder.subtitleLocArgs);
+    } else {
+      this.subtitleLocArgs = null;
+    }
     this.launchImage = builder.launchImage;
   }
 
@@ -91,11 +109,14 @@ public class ApsAlert {
   public static class Builder {
 
     private String title;
+    private String subtitle;
     private String body;
     private String locKey;
     private List<String> locArgs = new ArrayList<>();
     private String titleLocKey;
     private List<String> titleLocArgs = new ArrayList<>();
+    private String subtitleLocKey;
+    private List<String> subtitleLocArgs = new ArrayList<>();
     private String actionLocKey;
     private String launchImage;
 
@@ -113,6 +134,17 @@ public class ApsAlert {
       return this;
     }
 
+    /**
+     * Sets the subtitle of the alert.
+     *
+     * @param subtitle Subtitle of the notification.
+     * @return This builder.
+     */
+    public Builder setSubtitle(String subtitle) {
+      this.subtitle = subtitle;
+      return this;
+    }
+
     /**
      * Sets the body of the alert. When provided, overrides the body sent
      * via {@link Notification}.
@@ -209,6 +241,42 @@ public class ApsAlert {
       return this;
     }
 
+    /**
+     * Sets the key of the subtitle string in the app's string resources to use to localize 
+     * the subtitle text.
+     *
+     * @param subtitleLocKey Resource key string.
+     * @return This builder.
+     */
+    public Builder setSubtitleLocalizationKey(String subtitleLocKey) {
+      this.subtitleLocKey = subtitleLocKey;
+      return this;
+    }
+
+    /**
+     * Adds a resource key string that will be used in place of the format specifiers in
+     * {@code subtitleLocKey}.
+     *
+     * @param arg Resource key string.
+     * @return This builder.
+     */
+    public Builder addSubtitleLocalizationArg(@NonNull String arg) {
+      this.subtitleLocArgs.add(arg);
+      return this;
+    }
+
+    /**
+     * Adds a list of resource keys that will be used in place of the format specifiers in
+     * {@code subtitleLocKey}.
+     *
+     * @param args List of resource key strings.
+     * @return This builder.
+     */
+    public Builder addAllSubtitleLocArgs(@NonNull List<String> args) {
+      this.subtitleLocArgs.addAll(args);
+      return this;
+    }
+
     /**
      * Sets the launch image for the notification action.
      *
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/auth/FirebaseAuthTest.java b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
index db8a030..a4c2dc5 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
@@ -36,7 +36,6 @@ import com.google.auth.oauth2.ServiceAccountCredentials;
 import com.google.auth.oauth2.UserCredentials;
 import com.google.common.base.Defaults;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.ImplFirebaseTrampolines;
@@ -98,7 +97,10 @@ public class FirebaseAuthTest {
             /* isCertCredential */ true
           },
           {
-            new FirebaseOptions.Builder().setCredentials(createRefreshTokenCredential()).build(),
+            new FirebaseOptions.Builder()
+                .setCredentials(createRefreshTokenCredential())
+                .setProjectId("test-project-id")
+                .build(),
             /* isCertCredential */ false
           },
           {
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index df5a34e..fe6f000 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -24,6 +24,7 @@ import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
 import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.GenericUrl;
 import com.google.api.client.http.HttpHeaders;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponseException;
@@ -67,6 +68,18 @@ public class FirebaseUserManagerTest {
     TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
   }
 
+  @Test
+  public void testProjectIdRequired() {
+    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+            .setCredentials(credentials)
+            .build());
+    try {
+      FirebaseAuth.getInstance();
+      fail("No error thrown for missing project ID");
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
   @Test
   public void testGetUser() throws Exception {
     TestResponseInterceptor interceptor = initializeAppForUserManagement(
@@ -149,12 +162,9 @@ public class FirebaseUserManagerTest {
     assertEquals("", page.getNextPageToken());
     checkRequestHeaders(interceptor);
 
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    interceptor.getResponse().getRequest().getContent().writeTo(out);
-    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
-    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
-    assertEquals(new BigDecimal(999), parsed.get("maxResults"));
-    assertNull(parsed.get("nextPageToken"));
+    GenericUrl url = interceptor.getResponse().getRequest().getUrl();
+    assertEquals(999, url.getFirst("maxResults"));
+    assertNull(url.getFirst("nextPageToken"));
   }
 
   @Test
@@ -171,12 +181,9 @@ public class FirebaseUserManagerTest {
     assertEquals("", page.getNextPageToken());
     checkRequestHeaders(interceptor);
 
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    interceptor.getResponse().getRequest().getContent().writeTo(out);
-    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
-    GenericJson parsed = jsonFactory.fromString(new String(out.toByteArray()), GenericJson.class);
-    assertEquals(new BigDecimal(999), parsed.get("maxResults"));
-    assertEquals("token", parsed.get("nextPageToken"));
+    GenericUrl url = interceptor.getResponse().getRequest().getUrl();
+    assertEquals(999, url.getFirst("maxResults"));
+    assertEquals("token", url.getFirst("nextPageToken"));
   }
 
   @Test
@@ -427,9 +434,7 @@ public class FirebaseUserManagerTest {
 
   @Test
   public void testCreateSessionCookieInvalidArguments() {
-    FirebaseApp.initializeApp(new FirebaseOptions.Builder()
-        .setCredentials(credentials)
-        .build());
+    initializeAppForUserManagement();
     SessionCookieOptions options = SessionCookieOptions.builder()
         .setExpiresIn(TimeUnit.HOURS.toMillis(1))
         .build();
@@ -532,6 +537,7 @@ public class FirebaseUserManagerTest {
         .build();
     FirebaseApp.initializeApp(new FirebaseOptions.Builder()
         .setCredentials(credentials)
+        .setProjectId("test-project-id")
         .setHttpTransport(transport)
         .build());
 
@@ -596,6 +602,7 @@ public class FirebaseUserManagerTest {
         .build();
     FirebaseApp.initializeApp(new FirebaseOptions.Builder()
         .setCredentials(credentials)
+        .setProjectId("test-project-id")
         .setHttpTransport(transport)
         .build());
     try {
@@ -617,6 +624,7 @@ public class FirebaseUserManagerTest {
         new MockLowLevelHttpResponse().setContent(TestUtils.loadResource("getUser.json"))));
     FirebaseApp.initializeApp(new FirebaseOptions.Builder()
         .setCredentials(credentials)
+        .setProjectId("test-project-id")
         .setHttpTransport(transport)
         .setConnectTimeout(30000)
         .setReadTimeout(60000)
@@ -989,6 +997,7 @@ public class FirebaseUserManagerTest {
     FirebaseApp.initializeApp(new FirebaseOptions.Builder()
         .setCredentials(credentials)
         .setHttpTransport(transport)
+        .setProjectId("test-project-id")
         .build());
     FirebaseAuth auth = FirebaseAuth.getInstance();
     FirebaseUserManager userManager = auth.getUserManager();
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
index 4b51513..1ad74ac 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -644,6 +644,7 @@ public class FirebaseMessagingTest {
                     .setBadge(42)
                     .setAlert(ApsAlert.builder()
                         .setTitle("test-title")
+                        .setSubtitle("test-subtitle")
                         .setBody("test-body")
                         .build())
                     .build())
@@ -657,7 +658,8 @@ public class FirebaseMessagingTest {
                 "payload", ImmutableMap.of("k1", "v1", "k2", true,
                     "aps", ImmutableMap.<String, Object>of("badge", new BigDecimal(42),
                         "alert", ImmutableMap.<String, Object>of(
-                            "title", "test-title", "body", "test-body"))))
+                            "title", "test-title", "subtitle", "test-subtitle", 
+                            "body", "test-body"))))
         ));
 
     // Webpush message (no notification)
diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java
index 3313f2b..b6025c0 100644
--- a/src/test/java/com/google/firebase/messaging/MessageTest.java
+++ b/src/test/java/com/google/firebase/messaging/MessageTest.java
@@ -393,14 +393,18 @@ public class MessageTest {
             .setAps(Aps.builder()
                 .setAlert(ApsAlert.builder()
                     .setTitle("test-title")
+                    .setSubtitle("test-subtitle")
                     .setBody("test-body")
                     .setLocalizationKey("test-loc-key")
                     .setActionLocalizationKey("test-action-loc-key")
                     .setTitleLocalizationKey("test-title-loc-key")
+                    .setSubtitleLocalizationKey("test-subtitle-loc-key")
                     .addLocalizationArg("arg1")
                     .addAllLocalizationArgs(ImmutableList.of("arg2", "arg3"))
                     .addTitleLocalizationArg("arg4")
                     .addAllTitleLocArgs(ImmutableList.of("arg5", "arg6"))
+                    .addSubtitleLocalizationArg("arg7")
+                    .addAllSubtitleLocArgs(ImmutableList.of("arg8", "arg9"))
                     .setLaunchImage("test-image")
                     .build())
                 .setCategory("test-category")
@@ -417,12 +421,15 @@ public class MessageTest {
         "aps", ImmutableMap.<String, Object>builder()
             .put("alert", ImmutableMap.<String, Object>builder()
                 .put("title", "test-title")
+                .put("subtitle", "test-subtitle")
                 .put("body", "test-body")
                 .put("loc-key", "test-loc-key")
                 .put("action-loc-key", "test-action-loc-key")
                 .put("title-loc-key", "test-title-loc-key")
+                .put("subtitle-loc-key", "test-subtitle-loc-key")
                 .put("loc-args", ImmutableList.of("arg1", "arg2", "arg3"))
                 .put("title-loc-args", ImmutableList.of("arg4", "arg5", "arg6"))
+                .put("subtitle-loc-args", ImmutableList.of("arg7", "arg8", "arg9"))
                 .put("launch-image", "test-image")
                 .build())
             .put("category", "test-category")
@@ -474,7 +481,8 @@ public class MessageTest {
 
     List<ApsAlert.Builder> notificationBuilders = ImmutableList.of(
         ApsAlert.builder().addLocalizationArg("foo"),
-        ApsAlert.builder().addTitleLocalizationArg("foo")
+        ApsAlert.builder().addTitleLocalizationArg("foo"),
+        ApsAlert.builder().addSubtitleLocalizationArg("foo")
     );
     for (int i = 0; i < notificationBuilders.size(); i++) {
       try {
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");
+  }
+}