Repository: firebase/firebase-admin-java
Pull request #247 (2019-02-06 19:15:56)

Implementing FCM sendAll and sendMulticast APIs
Implementing the new APIs for sending messages in batches:

```
BatchResponse sendAll(List<Message>)
BatchResponse sendAll(List<Message>, boolean)
BatchResponse sendMulticast(MulticastMessage)
BatchResponse sendMulticast(MulticastMessage, boolean)
```

go/fcm-multicast for the full proposal.

Also implementing the following refactorings for clarity and modularity:
* Reducing the size of and responsibilities of `FirebaseMessaging` by introducing 2 new classes - `FirebaseMessagingClient` and `InstanceIdClient`.
* All the rpc logic is now implemented in the new classes, and `FirebaseMessaging` is just the public API for them.
* Moved some of the common API client logic to a new `ApiClientUtils` class, so we can share it across packages in the future.


Patch:
diff --git a/.gitignore b/.gitignore
index 49d03c7..a869aa6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@ target/
 .classpath
 .project
 .checkstyle
+.factorypath
+.vscode/
 release.properties
 integration_cert.json
 integration_apikey.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e92219..e669a28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,9 @@
 # Unreleased
 
+- [added] Implemented new `sendAll()` and `sendMulticast()` APIs in
+  `FirebaseMessaging`.
 - [changed] Removed org.json dependency and replaced with com.google.code.gson.
 - [changed] Upgraded Mockito dependency, and fixed the build on Java 11.
-- [fixed] UploadAccountResponse now has the correct error message/reason.
 
 # v6.7.0
 
diff --git a/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java b/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java
index 544617c..abf2853 100644
--- a/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java
+++ b/src/main/java/com/google/firebase/auth/internal/UploadAccountResponse.java
@@ -35,7 +35,7 @@ public class UploadAccountResponse {
     @Key("index")
     private int index;
 
-    @Key("errorMessage")
+    @Key("message")
     private String message;
 
     public int getIndex() {
diff --git a/src/main/java/com/google/firebase/internal/ApiClientUtils.java b/src/main/java/com/google/firebase/internal/ApiClientUtils.java
new file mode 100644
index 0000000..62e4320
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2019 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.internal;
+
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
+import com.google.firebase.FirebaseApp;
+
+import java.io.IOException;
+
+/**
+ * A set of shared utilities for using the Google API client.
+ */
+public class ApiClientUtils {
+
+  public static HttpRequestFactory newAuthorizedRequestFactory(FirebaseApp app) {
+    HttpTransport transport = app.getOptions().getHttpTransport();
+    return transport.createRequestFactory(new FirebaseRequestInitializer(app));
+  }
+
+  public static HttpRequestFactory newUnauthorizedRequestFactory(FirebaseApp app) {
+    HttpTransport transport = app.getOptions().getHttpTransport();
+    return transport.createRequestFactory();
+  }
+
+  public static void disconnectQuietly(HttpResponse response) {
+    if (response != null) {
+      try {
+        response.disconnect();
+      } catch (IOException ignored) {
+        // ignored
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/BatchResponse.java b/src/main/java/com/google/firebase/messaging/BatchResponse.java
new file mode 100644
index 0000000..bd5069f
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/BatchResponse.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright  2019 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.messaging;
+
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.internal.NonNull;
+import java.util.List;
+
+/**
+ * Response from an operation that sends FCM messages to multiple recipients.
+ * See {@link FirebaseMessaging#sendAll(List)} and {@link
+ * FirebaseMessaging#sendMulticast(MulticastMessage)}.
+ */
+public final class BatchResponse {
+
+  private final List<SendResponse> responses;
+  private final int successCount;
+
+  BatchResponse(List<SendResponse> responses) {
+    this.responses = ImmutableList.copyOf(responses);
+    int successCount = 0;
+    for (SendResponse response : this.responses) {
+      if (response.isSuccessful()) {
+        successCount++;
+      }
+    }
+    this.successCount = successCount;
+  }
+
+  @NonNull
+  public List<SendResponse> getResponses() {
+    return responses;
+  }
+
+  public int getSuccessCount() {
+    return successCount;
+  }
+
+  public int getFailureCount() {
+    return responses.size() - successCount;
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
index 34ed364..49b85b2 100644
--- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
@@ -18,33 +18,20 @@ package com.google.firebase.messaging;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
 
-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.HttpTransport;
-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.api.client.json.JsonParser;
-import com.google.api.client.util.Key;
 import com.google.api.core.ApiFuture;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.internal.CallableOperation;
-import com.google.firebase.internal.FirebaseRequestInitializer;
 import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
-import java.io.IOException;
+import com.google.firebase.internal.Nullable;
+
 import java.util.List;
-import java.util.Map;
 
 /**
  * This class is the entry point for all server-side Firebase Cloud Messaging actions.
@@ -54,60 +41,23 @@ import java.util.Map;
  */
 public class FirebaseMessaging {
 
-  private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
-  private static final String FCM_ERROR_TYPE =
-      "type.googleapis.com/google.firebase.fcm.v1.FcmError";
-
-  private static final String INTERNAL_ERROR = "internal-error";
-  private static final String UNKNOWN_ERROR = "unknown-error";
-  private static final Map<String, String> FCM_ERROR_CODES =
-      ImmutableMap.<String, String>builder()
-        // FCM v1 canonical error codes
-        .put("NOT_FOUND", "registration-token-not-registered")
-        .put("PERMISSION_DENIED", "mismatched-credential")
-        .put("RESOURCE_EXHAUSTED", "message-rate-exceeded")
-        .put("UNAUTHENTICATED", "invalid-apns-credentials")
-
-        // FCM v1 new error codes
-        .put("APNS_AUTH_ERROR", "invalid-apns-credentials")
-        .put("INTERNAL", INTERNAL_ERROR)
-        .put("INVALID_ARGUMENT", "invalid-argument")
-        .put("QUOTA_EXCEEDED", "message-rate-exceeded")
-        .put("SENDER_ID_MISMATCH", "mismatched-credential")
-        .put("UNAVAILABLE", "server-unavailable")
-        .put("UNREGISTERED", "registration-token-not-registered")
-        .build();
-  static final Map<Integer, String> IID_ERROR_CODES =
-      ImmutableMap.<Integer, String>builder()
-        .put(400, "invalid-argument")
-        .put(401, "authentication-error")
-        .put(403, "authentication-error")
-        .put(500, INTERNAL_ERROR)
-        .put(503, "server-unavailable")
-        .build();
-
-  private static final String IID_HOST = "https://iid.googleapis.com";
-  private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd";
-  private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove";
+  static final String INTERNAL_ERROR = "internal-error";
 
-  private final FirebaseApp app;
-  private final HttpRequestFactory requestFactory;
-  private final JsonFactory jsonFactory;
-  private final String url;
+  static final String UNKNOWN_ERROR = "unknown-error";
 
-  private HttpResponseInterceptor interceptor;
+  private final FirebaseApp app;
+  private final FirebaseMessagingClient messagingClient;
+  private final InstanceIdClient instanceIdClient;
 
   private FirebaseMessaging(FirebaseApp app) {
-    HttpTransport httpTransport = app.getOptions().getHttpTransport();
-    this.app = app;
-    this.requestFactory = httpTransport.createRequestFactory(new FirebaseRequestInitializer(app));
-    this.jsonFactory = app.getOptions().getJsonFactory();
-    String projectId = ImplFirebaseTrampolines.getProjectId(app);
-    checkArgument(!Strings.isNullOrEmpty(projectId),
-        "Project ID is required to access messaging 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.url = String.format(FCM_URL, projectId);
+    this(app, null);
+  }
+
+  @VisibleForTesting
+  FirebaseMessaging(FirebaseApp app, @Nullable HttpResponseInterceptor responseInterceptor) {
+    this.app = checkNotNull(app, "app must not be null");
+    this.messagingClient = new FirebaseMessagingClient(app, responseInterceptor);
+    this.instanceIdClient = new InstanceIdClient(app, responseInterceptor);
   }
 
   /**
@@ -138,6 +88,8 @@ public class FirebaseMessaging {
    *
    * @param message A non-null {@link Message} to be sent.
    * @return A message ID string.
+   * @throws FirebaseMessagingException If an error occurs while handing the message off to FCM for
+   *     delivery.
    */
   public String send(@NonNull Message message) throws FirebaseMessagingException {
     return send(message, false);
@@ -152,6 +104,8 @@ public class FirebaseMessaging {
    * @param message A non-null {@link Message} to be sent.
    * @param dryRun a boolean indicating whether to perform a dry run (validation only) of the send.
    * @return A message ID string.
+   * @throws FirebaseMessagingException If an error occurs while handing the message off to FCM for
+   *     delivery.
    */
   public String send(@NonNull Message message, boolean dryRun) throws FirebaseMessagingException {
     return sendOp(message, dryRun).call();
@@ -180,6 +134,170 @@ public class FirebaseMessaging {
     return sendOp(message, dryRun).callAsync(app);
   }
 
+  private CallableOperation<String, FirebaseMessagingException> sendOp(
+      final Message message, final boolean dryRun) {
+    checkNotNull(message, "message must not be null");
+    return new CallableOperation<String, FirebaseMessagingException>() {
+      @Override
+      protected String execute() throws FirebaseMessagingException {
+        return messagingClient.send(message, dryRun);
+      }
+    };
+  }
+
+  /**
+   * Sends all the messages in the given list via Firebase Cloud Messaging. Employs batching to
+   * send the entire list as a single RPC call. Compared to the {@link #send(Message)} method, this
+   * is a significantly more efficient way to send multiple messages.
+   *
+   * <p>The responses list obtained by calling {@link BatchResponse#getResponses()} on the return
+   * value corresponds to the order of input messages.
+   *
+   * @param messages A non-null, non-empty list containing up to 100 messages.
+   * @return A {@link BatchResponse} indicating the result of the operation.
+   * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for
+   *     delivery. An exception here indicates a total failure -- i.e. none of the messages in the
+   *     list could be sent. Partial failures are indicated by a {@link BatchResponse} return value.
+   */
+  public BatchResponse sendAll(
+      @NonNull List<Message> messages) throws FirebaseMessagingException {
+    return sendAll(messages, false);
+  }
+
+  /**
+   * Sends all the messages in the given list via Firebase Cloud Messaging. Employs batching to
+   * send the entire list as a single RPC call. Compared to the {@link #send(Message)} method, this
+   * is a significantly more efficient way to send multiple messages.
+   *
+   * <p>If the {@code dryRun} option is set to true, the messages will not be actually sent. Instead
+   * FCM performs all the necessary validations, and emulates the send operation.
+   *
+   * <p>The responses list obtained by calling {@link BatchResponse#getResponses()} on the return
+   * value corresponds to the order of input messages.
+   *
+   * @param messages A non-null, non-empty list containing up to 100 messages.
+   * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return A {@link BatchResponse} indicating the result of the operation.
+   * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for
+   *     delivery. An exception here indicates a total failure -- i.e. none of the messages in the
+   *     list could be sent. Partial failures are indicated by a {@link BatchResponse} return value.
+   */
+  public BatchResponse sendAll(
+      @NonNull List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
+    return sendAllOp(messages, dryRun).call();
+  }
+
+  /**
+   * Similar to {@link #sendAll(List)} but performs the operation asynchronously.
+   *
+   * @param messages A non-null, non-empty list containing up to 100 messages.
+   * @return @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
+   *     the messages have been sent.
+   */
+  public ApiFuture<BatchResponse> sendAllAsync(@NonNull List<Message> messages) {
+    return sendAllAsync(messages, false);
+  }
+
+  /**
+   * Similar to {@link #sendAll(List, boolean)} but performs the operation asynchronously.
+   *
+   * @param messages A non-null, non-empty list containing up to 100 messages.
+   * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
+   *     the messages have been sent, or when the emulation has finished.
+   */
+  public ApiFuture<BatchResponse> sendAllAsync(
+      @NonNull List<Message> messages, boolean dryRun) {
+    return sendAllOp(messages, dryRun).callAsync(app);
+  }
+
+  /**
+   * Sends the given multicast message to all the FCM registration tokens specified in it.
+   *
+   * <p>This method uses the {@link #sendAll(List)} API under the hood to send the given
+   * message to all the target recipients. The responses list obtained by calling
+   * {@link BatchResponse#getResponses()} on the return value corresponds to the order of tokens
+   * in the {@link MulticastMessage}.
+   *
+   * @param message A non-null {@link MulticastMessage}
+   * @return A {@link BatchResponse} indicating the result of the operation.
+   * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for
+   *     delivery. An exception here indicates a total failure -- i.e. the messages could not be
+   *     delivered to any recipient. Partial failures are indicated by a {@link BatchResponse}
+   *     return value.
+   */
+  public BatchResponse sendMulticast(
+      @NonNull MulticastMessage message) throws FirebaseMessagingException {
+    return sendMulticast(message, false);
+  }
+
+  /**
+   * Sends the given multicast message to all the FCM registration tokens specified in it.
+   *
+   * <p>If the {@code dryRun} option is set to true, the message will not be actually sent. Instead
+   * FCM performs all the necessary validations, and emulates the send operation.
+   *
+   * <p>This method uses the {@link #sendAll(List)} API under the hood to send the given
+   * message to all the target recipients. The responses list obtained by calling
+   * {@link BatchResponse#getResponses()} on the return value corresponds to the order of tokens
+   * in the {@link MulticastMessage}.
+   *
+   * @param message A non-null {@link MulticastMessage}.
+   * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return A {@link BatchResponse} indicating the result of the operation.
+   * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for
+   *     delivery. An exception here indicates a total failure -- i.e. the messages could not be
+   *     delivered to any recipient. Partial failures are indicated by a {@link BatchResponse}
+   *     return value.
+   */
+  public BatchResponse sendMulticast(
+      @NonNull MulticastMessage message, boolean dryRun) throws FirebaseMessagingException {
+    checkNotNull(message, "multicast message must not be null");
+    return sendAll(message.getMessageList(), dryRun);
+  }
+
+  /**
+   * Similar to {@link #sendMulticast(MulticastMessage)} but performs the operation
+   * asynchronously.
+   *
+   * @param message A non-null {@link MulticastMessage}.
+   * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
+   *     the messages have been sent.
+   */
+  public ApiFuture<BatchResponse> sendMulticastAsync(@NonNull MulticastMessage message) {
+    return sendMulticastAsync(message, false);
+  }
+
+  /**
+   * Similar to {@link #sendMulticast(MulticastMessage, boolean)} but performs the operation
+   * asynchronously.
+   *
+   * @param message A non-null {@link MulticastMessage}.
+   * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
+   *     the messages have been sent.
+   */
+  public ApiFuture<BatchResponse> sendMulticastAsync(
+      @NonNull MulticastMessage message, boolean dryRun) {
+    checkNotNull(message, "multicast message must not be null");
+    return sendAllAsync(message.getMessageList(), dryRun);
+  }
+
+  private CallableOperation<BatchResponse, FirebaseMessagingException> sendAllOp(
+      final List<Message> messages, final boolean dryRun) {
+
+    final List<Message> immutableMessages = ImmutableList.copyOf(messages);
+    checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty");
+    checkArgument(immutableMessages.size() <= 100,
+        "messages list must not contain more than 100 elements");
+    return new CallableOperation<BatchResponse,FirebaseMessagingException>() {
+      @Override
+      protected BatchResponse execute() throws FirebaseMessagingException {
+        return messagingClient.sendAll(messages, dryRun);
+      }
+    };
+  }
+
   /**
    * Subscribes a list of registration tokens to a topic.
    *
@@ -190,7 +308,7 @@ public class FirebaseMessaging {
    */
   public TopicManagementResponse subscribeToTopic(@NonNull List<String> registrationTokens,
       @NonNull String topic) throws FirebaseMessagingException {
-    return manageTopicOp(registrationTokens, topic, IID_SUBSCRIBE_PATH).call();
+    return subscribeOp(registrationTokens, topic).call();
   }
 
   /**
@@ -203,11 +321,23 @@ public class FirebaseMessaging {
    */
   public ApiFuture<TopicManagementResponse> subscribeToTopicAsync(
       @NonNull List<String> registrationTokens, @NonNull String topic) {
-    return manageTopicOp(registrationTokens, topic, IID_SUBSCRIBE_PATH).callAsync(app);
+    return subscribeOp(registrationTokens, topic).callAsync(app);
+  }
+
+  private CallableOperation<TopicManagementResponse, FirebaseMessagingException> subscribeOp(
+      final List<String> registrationTokens, final String topic) {
+    checkRegistrationTokens(registrationTokens);
+    checkTopic(topic);
+    return new CallableOperation<TopicManagementResponse, FirebaseMessagingException>() {
+      @Override
+      protected TopicManagementResponse execute() throws FirebaseMessagingException {
+        return instanceIdClient.subscribeToTopic(topic, registrationTokens);
+      }
+    };
   }
 
   /**
-   * Unubscribes a list of registration tokens from a topic.
+   * Unsubscribes a list of registration tokens from a topic.
    *
    * @param registrationTokens A non-null, non-empty list of device registration tokens, with at
    *     most 1000 entries.
@@ -216,7 +346,7 @@ public class FirebaseMessaging {
    */
   public TopicManagementResponse unsubscribeFromTopic(@NonNull List<String> registrationTokens,
       @NonNull String topic) throws FirebaseMessagingException {
-    return manageTopicOp(registrationTokens, topic, IID_UNSUBSCRIBE_PATH).call();
+    return unsubscribeOp(registrationTokens, topic).call();
   }
 
   /**
@@ -230,146 +360,21 @@ public class FirebaseMessaging {
    */
   public ApiFuture<TopicManagementResponse> unsubscribeFromTopicAsync(
       @NonNull List<String> registrationTokens, @NonNull String topic) {
-    return manageTopicOp(registrationTokens, topic, IID_UNSUBSCRIBE_PATH)
-        .callAsync(app);
-  }
-
-  private CallableOperation<String, FirebaseMessagingException> sendOp(
-      final Message message, final boolean dryRun) {
-    checkNotNull(message, "message must not be null");
-    return new CallableOperation<String, FirebaseMessagingException>() {
-      @Override
-      protected String execute() throws FirebaseMessagingException {
-        ImmutableMap.Builder<String, Object> payload = ImmutableMap.<String, Object>builder()
-            .put("message", message);
-        if (dryRun) {
-          payload.put("validate_only", true);
-        }
-        HttpResponse response = null;
-        try {
-          HttpRequest request = requestFactory.buildPostRequest(
-              new GenericUrl(url), new JsonHttpContent(jsonFactory, payload.build()));
-          request.getHeaders().set("X-GOOG-API-FORMAT-VERSION", "2");
-          request.setParser(new JsonObjectParser(jsonFactory));
-          request.setResponseInterceptor(interceptor);
-          response = request.execute();
-          MessagingServiceResponse parsed = new MessagingServiceResponse();
-          jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
-          return parsed.name;
-        } catch (HttpResponseException e) {
-          handleSendHttpError(e);
-          return null;
-        } catch (IOException e) {
-          throw new FirebaseMessagingException(
-              INTERNAL_ERROR, "Error while calling FCM backend service", e);
-        } finally {
-          disconnectQuietly(response);
-        }
-      }
-    };
+    return unsubscribeOp(registrationTokens, topic).callAsync(app);
   }
 
-  private void handleSendHttpError(HttpResponseException e) throws FirebaseMessagingException {
-    MessagingServiceErrorResponse response = new MessagingServiceErrorResponse();
-    if (e.getContent() != null) {
-      try {
-        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
-        parser.parseAndClose(response);
-      } catch (IOException ignored) {
-        // ignored
-      }
-    }
-    String code = FCM_ERROR_CODES.get(response.getErrorCode());
-    if (code == null) {
-      code = UNKNOWN_ERROR;
-    }
-    String msg = response.getErrorMessage();
-    if (Strings.isNullOrEmpty(msg)) {
-      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
-          e.getStatusCode(), e.getContent());
-    }
-    throw new FirebaseMessagingException(code, msg, e);
-  }
-
-  private CallableOperation<TopicManagementResponse, FirebaseMessagingException>
-      manageTopicOp(
-          final List<String> registrationTokens, final String topic, final String path) {
+  private CallableOperation<TopicManagementResponse, FirebaseMessagingException> unsubscribeOp(
+      final List<String> registrationTokens, final String topic) {
     checkRegistrationTokens(registrationTokens);
     checkTopic(topic);
     return new CallableOperation<TopicManagementResponse, FirebaseMessagingException>() {
       @Override
       protected TopicManagementResponse execute() throws FirebaseMessagingException {
-        final String prefixedTopic;
-        if (topic.startsWith("/topics/")) {
-          prefixedTopic = topic;
-        } else {
-          prefixedTopic = "/topics/" + topic;
-        }
-        Map<String, Object> payload = ImmutableMap.of(
-            "to", prefixedTopic,
-            "registration_tokens", registrationTokens
-        );
-
-        final String url = String.format("%s/%s", IID_HOST, path);
-        HttpResponse response = null;
-        try {
-          HttpRequest request = requestFactory.buildPostRequest(
-              new GenericUrl(url), new JsonHttpContent(jsonFactory, payload));
-          request.getHeaders().set("access_token_auth", "true");
-          request.setParser(new JsonObjectParser(jsonFactory));
-          request.setResponseInterceptor(interceptor);
-          response = request.execute();
-          InstanceIdServiceResponse parsed = new InstanceIdServiceResponse();
-          jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
-          checkState(parsed.results != null && !parsed.results.isEmpty(),
-              "unexpected response from topic management service");
-          return new TopicManagementResponse(parsed.results);
-        } catch (HttpResponseException e) {
-          handleTopicManagementHttpError(e);
-          return null;
-        } catch (IOException e) {
-          throw new FirebaseMessagingException(
-              INTERNAL_ERROR, "Error while calling IID backend service", e);
-        } finally {
-          disconnectQuietly(response);
-        }
+        return instanceIdClient.unsubscribeFromTopic(topic, registrationTokens);
       }
     };
   }
 
-  private void handleTopicManagementHttpError(
-      HttpResponseException e) throws FirebaseMessagingException {
-    InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse();
-    try {
-      JsonParser parser = jsonFactory.createJsonParser(e.getContent());
-      parser.parseAndClose(response);
-    } catch (IOException ignored) {
-      // ignored
-    }
-
-    // Infer error code from HTTP status
-    String code = IID_ERROR_CODES.get(e.getStatusCode());
-    if (code == null) {
-      code = UNKNOWN_ERROR;
-    }
-    String msg = response.error;
-    if (Strings.isNullOrEmpty(msg)) {
-      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
-          e.getStatusCode(), e.getContent());
-    }
-    throw new FirebaseMessagingException(code, msg, e);
-  }
-
-  private static void disconnectQuietly(HttpResponse response) {
-    if (response != null) {
-      try {
-        response.disconnect();
-      } catch (IOException ignored) {
-        // ignored
-      }
-    }
-  }
-
   private static void checkRegistrationTokens(List<String> registrationTokens) {
     checkArgument(registrationTokens != null && !registrationTokens.isEmpty(),
         "registrationTokens list must not be null or empty");
@@ -386,11 +391,6 @@ public class FirebaseMessaging {
     checkArgument(topic.matches("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$"), "invalid topic name");
   }
 
-  @VisibleForTesting
-  void setInterceptor(HttpResponseInterceptor interceptor) {
-    this.interceptor = interceptor;
-  }
-
   private static final String SERVICE_ID = FirebaseMessaging.class.getName();
 
   private static class FirebaseMessagingService extends FirebaseService<FirebaseMessaging> {
@@ -406,50 +406,4 @@ public class FirebaseMessaging {
       // which will throw once the app is deleted.
     }
   }
-
-  private static class MessagingServiceResponse {
-    @Key("name")
-    private String name;
-  }
-
-  private static class MessagingServiceErrorResponse {
-    @Key("error")
-    private Map<String, Object> error;
-
-
-    String getErrorCode() {
-      if (error == null) {
-        return null;
-      }
-      Object details = error.get("details");
-      if (details != null && details instanceof List) {
-        for (Object detail : (List) details) {
-          if (detail instanceof Map) {
-            Map detailMap = (Map) detail;
-            if (FCM_ERROR_TYPE.equals(detailMap.get("@type"))) {
-              return (String) detailMap.get("errorCode");
-            }
-          }
-        }
-      }
-      return (String) error.get("status");
-    }
-
-    String getErrorMessage() {
-      if (error != null) {
-        return (String) error.get("message");
-      }
-      return null;
-    }
-  }
-
-  private static class InstanceIdServiceResponse {
-    @Key("results")
-    private List<Map<String, Object>> results;
-  }
-
-  private static class InstanceIdServiceErrorResponse {
-    @Key("error")
-    private String error;
-  }
 }
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java
new file mode 100644
index 0000000..5e694ca
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2019 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.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.client.googleapis.batch.BatchCallback;
+import com.google.api.client.googleapis.batch.BatchRequest;
+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.HttpRequestFactory;
+import com.google.api.client.http.HttpRequestInitializer;
+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.api.client.json.JsonParser;
+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.ApiClientUtils;
+import com.google.firebase.internal.Nullable;
+import com.google.firebase.internal.SdkUtils;
+import com.google.firebase.messaging.internal.MessagingServiceErrorResponse;
+import com.google.firebase.messaging.internal.MessagingServiceResponse;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A helper class for interacting with Firebase Cloud Messaging service.
+ */
+final class FirebaseMessagingClient {
+
+  private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
+
+  private static final String FCM_BATCH_URL = "https://fcm.googleapis.com/batch";
+
+  private static final Map<String, String> FCM_ERROR_CODES =
+      ImmutableMap.<String, String>builder()
+          // FCM v1 canonical error codes
+          .put("NOT_FOUND", "registration-token-not-registered")
+          .put("PERMISSION_DENIED", "mismatched-credential")
+          .put("RESOURCE_EXHAUSTED", "message-rate-exceeded")
+          .put("UNAUTHENTICATED", "invalid-apns-credentials")
+
+          // FCM v1 new error codes
+          .put("APNS_AUTH_ERROR", "invalid-apns-credentials")
+          .put("INTERNAL", FirebaseMessaging.INTERNAL_ERROR)
+          .put("INVALID_ARGUMENT", "invalid-argument")
+          .put("QUOTA_EXCEEDED", "message-rate-exceeded")
+          .put("SENDER_ID_MISMATCH", "mismatched-credential")
+          .put("UNAVAILABLE", "server-unavailable")
+          .put("UNREGISTERED", "registration-token-not-registered")
+          .build();
+
+  private final String fcmSendUrl;
+  private final HttpRequestFactory requestFactory;
+  private final HttpRequestFactory childRequestFactory;
+  private final JsonFactory jsonFactory;
+  private final HttpResponseInterceptor responseInterceptor;
+  private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion();
+
+  FirebaseMessagingClient(FirebaseApp app, @Nullable HttpResponseInterceptor responseInterceptor) {
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkArgument(!Strings.isNullOrEmpty(projectId),
+        "Project ID is required to access messaging 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.fcmSendUrl = String.format(FCM_URL, projectId);
+    this.requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app);
+    this.childRequestFactory = ApiClientUtils.newUnauthorizedRequestFactory(app);
+    this.jsonFactory = app.getOptions().getJsonFactory();
+    this.responseInterceptor = responseInterceptor;
+  }
+
+  String send(Message message, boolean dryRun) throws FirebaseMessagingException {
+    try {
+      return sendSingleRequest(message, dryRun);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e);
+    }
+  }
+
+  BatchResponse sendAll(
+      List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
+    try {
+      return sendBatchRequest(messages, dryRun);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e);
+    }
+  }
+
+  private String sendSingleRequest(Message message, boolean dryRun) throws IOException {
+    HttpRequest request = requestFactory.buildPostRequest(
+        new GenericUrl(fcmSendUrl),
+        new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun)));
+    setCommonFcmHeaders(request.getHeaders());
+    request.setParser(new JsonObjectParser(jsonFactory));
+    request.setResponseInterceptor(responseInterceptor);
+    HttpResponse response = request.execute();
+    try {
+      MessagingServiceResponse parsed = new MessagingServiceResponse();
+      jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
+      return parsed.getMessageId();
+    } finally {
+      ApiClientUtils.disconnectQuietly(response);
+    }
+  }
+
+  private BatchResponse sendBatchRequest(
+      List<Message> messages, boolean dryRun) throws IOException {
+
+    MessagingBatchCallback callback = new MessagingBatchCallback();
+    BatchRequest batch = newBatchRequest(messages, dryRun, callback);
+    batch.execute();
+    return new BatchResponse(callback.getResponses());
+  }
+
+  private BatchRequest newBatchRequest(
+      List<Message> messages, boolean dryRun, MessagingBatchCallback callback) throws IOException {
+
+    BatchRequest batch = new BatchRequest(
+        requestFactory.getTransport(), getBatchRequestInitializer());
+    batch.setBatchUrl(new GenericUrl(FCM_BATCH_URL));
+
+    final JsonObjectParser jsonParser = new JsonObjectParser(this.jsonFactory);
+    final GenericUrl sendUrl = new GenericUrl(fcmSendUrl);
+    for (Message message : messages) {
+      // Using a separate request factory without authorization is faster for large batches.
+      // A simple performance test showed a 400-500ms speed up for batches of 1000 messages.
+      HttpRequest request = childRequestFactory.buildPostRequest(
+          sendUrl,
+          new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun)));
+      request.setParser(jsonParser);
+      setCommonFcmHeaders(request.getHeaders());
+      batch.queue(
+          request, MessagingServiceResponse.class, MessagingServiceErrorResponse.class, callback);
+    }
+
+    return batch;
+  }
+
+  private void setCommonFcmHeaders(HttpHeaders headers) {
+    headers.set("X-GOOG-API-FORMAT-VERSION", "2");
+    headers.set("X-Client-Version", clientVersion);
+  }
+
+  private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) {
+    MessagingServiceErrorResponse response = new MessagingServiceErrorResponse();
+    if (e.getContent() != null) {
+      try {
+        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
+        parser.parseAndClose(response);
+      } catch (IOException ignored) {
+        // ignored
+      }
+    }
+
+    return newException(response, e);
+  }
+
+  private HttpRequestInitializer getBatchRequestInitializer() {
+    return new HttpRequestInitializer(){
+      @Override
+      public void initialize(HttpRequest request) throws IOException {
+        requestFactory.getInitializer().initialize(request);
+        request.setResponseInterceptor(responseInterceptor);
+      }
+    };
+  }
+
+  private static FirebaseMessagingException newException(MessagingServiceErrorResponse response) {
+    return newException(response, null);
+  }
+
+  private static FirebaseMessagingException newException(
+      MessagingServiceErrorResponse response, @Nullable HttpResponseException e) {
+    String code = FCM_ERROR_CODES.get(response.getErrorCode());
+    if (code == null) {
+      code = FirebaseMessaging.UNKNOWN_ERROR;
+    }
+
+    String msg = response.getErrorMessage();
+    if (Strings.isNullOrEmpty(msg)) {
+      if (e != null) {
+        msg = String.format("Unexpected HTTP response with status: %d; body: %s",
+            e.getStatusCode(), e.getContent());
+      } else {
+        msg = String.format("Unexpected HTTP response: %s", response.toString());
+      }
+    }
+
+    return new FirebaseMessagingException(code, msg, e);
+  }
+
+  private static class MessagingBatchCallback
+      implements BatchCallback<MessagingServiceResponse, MessagingServiceErrorResponse> {
+
+    private final ImmutableList.Builder<SendResponse> responses = ImmutableList.builder();
+
+    @Override
+    public void onSuccess(
+        MessagingServiceResponse response, HttpHeaders responseHeaders) {
+      responses.add(SendResponse.fromMessageId(response.getMessageId()));
+    }
+
+    @Override
+    public void onFailure(
+        MessagingServiceErrorResponse error, HttpHeaders responseHeaders) {
+      responses.add(SendResponse.fromException(newException(error)));
+    }
+
+    List<SendResponse> getResponses() {
+      return this.responses.build();
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/InstanceIdClient.java b/src/main/java/com/google/firebase/messaging/InstanceIdClient.java
new file mode 100644
index 0000000..0170290
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/InstanceIdClient.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2019 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.messaging;
+
+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.GenericJson;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.util.Key;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.internal.ApiClientUtils;
+import com.google.firebase.internal.Nullable;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A helper class for interacting with the Firebase Instance ID service. Implements the FCM
+ * topic management functionality.
+ */
+final class InstanceIdClient {
+
+  private static final String IID_HOST = "https://iid.googleapis.com";
+
+  private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd";
+
+  private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove";
+
+  static final Map<Integer, String> IID_ERROR_CODES =
+      ImmutableMap.<Integer, String>builder()
+          .put(400, "invalid-argument")
+          .put(401, "authentication-error")
+          .put(403, "authentication-error")
+          .put(500, FirebaseMessaging.INTERNAL_ERROR)
+          .put(503, "server-unavailable")
+          .build();
+
+  private final HttpRequestFactory requestFactory;
+  private final JsonFactory jsonFactory;
+  private final HttpResponseInterceptor responseInterceptor;
+
+  InstanceIdClient(FirebaseApp app, @Nullable HttpResponseInterceptor responseInterceptor) {
+    this.requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app);
+    this.jsonFactory = app.getOptions().getJsonFactory();
+    this.responseInterceptor = responseInterceptor;
+  }
+
+  TopicManagementResponse subscribeToTopic(
+      String topic, List<String> registrationTokens) throws FirebaseMessagingException {
+    try {
+      return sendInstanceIdRequest(topic, registrationTokens, IID_SUBSCRIBE_PATH);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e);
+    }
+  }
+
+  TopicManagementResponse unsubscribeFromTopic(
+      String topic, List<String> registrationTokens) throws FirebaseMessagingException {
+    try {
+      return sendInstanceIdRequest(topic, registrationTokens, IID_UNSUBSCRIBE_PATH);
+    } catch (HttpResponseException e) {
+      throw createExceptionFromResponse(e);
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e);
+    }
+  }
+
+  private TopicManagementResponse sendInstanceIdRequest(
+      String topic, List<String> registrationTokens, String path) throws IOException {
+    String url = String.format("%s/%s", IID_HOST, path);
+    Map<String, Object> payload = ImmutableMap.of(
+        "to", getPrefixedTopic(topic),
+        "registration_tokens", registrationTokens
+    );
+    HttpResponse response = null;
+    try {
+      HttpRequest request = requestFactory.buildPostRequest(
+          new GenericUrl(url), new JsonHttpContent(jsonFactory, payload));
+      request.getHeaders().set("access_token_auth", "true");
+      request.setParser(new JsonObjectParser(jsonFactory));
+      request.setResponseInterceptor(responseInterceptor);
+      response = request.execute();
+
+      JsonParser parser = jsonFactory.createJsonParser(response.getContent());
+      InstanceIdServiceResponse parsedResponse = new InstanceIdServiceResponse();
+      parser.parse(parsedResponse);
+      return new TopicManagementResponse(parsedResponse.results);
+    } finally {
+      ApiClientUtils.disconnectQuietly(response);
+    }
+  }
+
+  private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) {
+    InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse();
+    if (e.getContent() != null) {
+      try {
+        JsonParser parser = jsonFactory.createJsonParser(e.getContent());
+        parser.parseAndClose(response);
+      } catch (IOException ignored) {
+        // ignored
+      }
+    }
+    return newException(response, e);
+  }
+
+  private String getPrefixedTopic(String topic) {
+    if (topic.startsWith("/topics/")) {
+      return topic;
+    } else {
+      return "/topics/" + topic;
+    }
+  }
+
+  private static FirebaseMessagingException newException(
+      InstanceIdServiceErrorResponse response, HttpResponseException e) {
+    // Infer error code from HTTP status
+    String code = IID_ERROR_CODES.get(e.getStatusCode());
+    if (code == null) {
+      code = FirebaseMessaging.UNKNOWN_ERROR;
+    }
+    String msg = response.error;
+    if (Strings.isNullOrEmpty(msg)) {
+      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
+          e.getStatusCode(), e.getContent());
+    }
+    return new FirebaseMessagingException(code, msg, e);
+  }
+
+  private static class InstanceIdServiceResponse {
+    @Key("results")
+    private List<GenericJson> results;
+  }
+
+  private static class InstanceIdServiceErrorResponse {
+    @Key("error")
+    private String error;
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/Message.java b/src/main/java/com/google/firebase/messaging/Message.java
index 77cce7a..89a4a5c 100644
--- a/src/main/java/com/google/firebase/messaging/Message.java
+++ b/src/main/java/com/google/firebase/messaging/Message.java
@@ -19,6 +19,7 @@ package com.google.firebase.messaging;
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.api.client.util.Key;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.primitives.Booleans;
@@ -79,6 +80,55 @@ public class Message {
     this.condition = builder.condition;
   }
 
+  @VisibleForTesting
+  Map<String, String> getData() {
+    return data;
+  }
+
+  @VisibleForTesting
+  Notification getNotification() {
+    return notification;
+  }
+
+  @VisibleForTesting
+  AndroidConfig getAndroidConfig() {
+    return androidConfig;
+  }
+
+  @VisibleForTesting
+  WebpushConfig getWebpushConfig() {
+    return webpushConfig;
+  }
+
+  @VisibleForTesting
+  ApnsConfig getApnsConfig() {
+    return apnsConfig;
+  }
+
+  @VisibleForTesting
+  String getToken() {
+    return token;
+  }
+
+  @VisibleForTesting
+  String getTopic() {
+    return topic;
+  }
+
+  @VisibleForTesting
+  String getCondition() {
+    return condition;
+  }
+
+  Map<String, Object> wrapForTransport(boolean dryRun) {
+    ImmutableMap.Builder<String, Object> payload = ImmutableMap.<String, Object>builder()
+        .put("message", this);
+    if (dryRun) {
+      payload.put("validate_only", true);
+    }
+    return payload.build();
+  }
+
   private static String stripPrefix(String topic) {
     if (Strings.isNullOrEmpty(topic)) {
       return null;
@@ -226,5 +276,4 @@ public class Message {
       return new Message(this);
     }
   }
-
 }
diff --git a/src/main/java/com/google/firebase/messaging/MulticastMessage.java b/src/main/java/com/google/firebase/messaging/MulticastMessage.java
new file mode 100644
index 0000000..32c9365
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/MulticastMessage.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2019 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.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.client.util.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.NonNull;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a message that can be sent to multiple devices via Firebase Cloud Messaging (FCM).
+ * Contains payload information as well as the list of device registration tokens to which the
+ * message should be sent. A single {@code MulticastMessage} may contain up to 100 registration
+ * tokens.
+ *
+ * <p>Instances of this class are thread-safe and immutable. Use {@link MulticastMessage.Builder}
+ * to create new instances. See {@link FirebaseMessaging#sendMulticast(MulticastMessage)} for
+ * details on how to send the message to FCM for multicast delivery.
+ *
+ * <p>This class and the associated Builder retain the order of tokens. Therefore the order of
+ * the responses list obtained by calling {@link BatchResponse#getResponses()} on the return value
+ * of {@link FirebaseMessaging#sendMulticast(MulticastMessage)} corresponds to the order in which
+ * tokens were added to the {@link MulticastMessage.Builder}.
+ */
+public class MulticastMessage {
+
+  private final List<String> tokens;
+  private final Map<String, String> data;
+  private final Notification notification;
+  private final AndroidConfig androidConfig;
+  private final WebpushConfig webpushConfig;
+  private final ApnsConfig apnsConfig;
+
+  private MulticastMessage(Builder builder) {
+    this.tokens = builder.tokens.build();
+    checkArgument(!this.tokens.isEmpty(), "at least one token must be specified");
+    checkArgument(this.tokens.size() <= 100, "no more than 100 tokens can be specified");
+    for (String token : this.tokens) {
+      checkArgument(!Strings.isNullOrEmpty(token), "none of the tokens can be null or empty");
+    }
+    this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data);
+    this.notification = builder.notification;
+    this.androidConfig = builder.androidConfig;
+    this.webpushConfig = builder.webpushConfig;
+    this.apnsConfig = builder.apnsConfig;
+  }
+
+  List<Message> getMessageList() {
+    Message.Builder builder = Message.builder()
+        .setNotification(this.notification)
+        .setAndroidConfig(this.androidConfig)
+        .setApnsConfig(this.apnsConfig)
+        .setWebpushConfig(this.webpushConfig);
+    if (this.data != null) {
+      builder.putAllData(this.data);
+    }
+    ImmutableList.Builder<Message> messages = ImmutableList.builder();
+    for (String token : this.tokens) {
+      messages.add(builder.setToken(token).build());
+    }
+    return messages.build();
+  }
+
+  /**
+   * Creates a new {@link MulticastMessage.Builder}.
+   *
+   * @return A {@link MulticastMessage.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private final ImmutableList.Builder<String> tokens = ImmutableList.builder();
+    private final Map<String, String> data = new HashMap<>();
+    private Notification notification;
+    private AndroidConfig androidConfig;
+    private WebpushConfig webpushConfig;
+    private ApnsConfig apnsConfig;
+
+    private Builder() {}
+
+    /**
+     * Adds a token to which the message should be sent. Up to 100 tokens can be specified on
+     * a single instance of {@link MulticastMessage}.
+     *
+     * @param token A non-null, non-empty Firebase device registration token.
+     * @return This builder.
+     */
+    public Builder addToken(@NonNull String token) {
+      this.tokens.add(token);
+      return this;
+    }
+
+    /**
+     * Adds a list of tokens to which the message should be sent. Up to 100 tokens can be
+     * specified on a single instance of {@link MulticastMessage}.
+     *
+     * @param tokens List of Firebase device registration tokens.
+     * @return This builder.
+     */
+    public Builder addAllTokens(@NonNull List<String> tokens) {
+      this.tokens.addAll(tokens);
+      return this;
+    }
+
+    /**
+     * Sets the notification information to be included in the message.
+     *
+     * @param notification A {@link Notification} instance.
+     * @return This builder.
+     */
+    public Builder setNotification(Notification notification) {
+      this.notification = notification;
+      return this;
+    }
+
+    /**
+     * Sets the Android-specific information to be included in the message.
+     *
+     * @param androidConfig An {@link AndroidConfig} instance.
+     * @return This builder.
+     */
+    public Builder setAndroidConfig(AndroidConfig androidConfig) {
+      this.androidConfig = androidConfig;
+      return this;
+    }
+
+    /**
+     * Sets the Webpush-specific information to be included in the message.
+     *
+     * @param webpushConfig A {@link WebpushConfig} instance.
+     * @return This builder.
+     */
+    public Builder setWebpushConfig(WebpushConfig webpushConfig) {
+      this.webpushConfig = webpushConfig;
+      return this;
+    }
+
+    /**
+     * Sets the information specific to APNS (Apple Push Notification Service).
+     *
+     * @param apnsConfig An {@link ApnsConfig} instance.
+     * @return This builder.
+     */
+    public Builder setApnsConfig(ApnsConfig apnsConfig) {
+      this.apnsConfig = apnsConfig;
+      return this;
+    }
+
+    /**
+     * Adds the given key-value pair to the message as a data field. Key or the value may not be
+     * null.
+     *
+     * @param key Name of the data field. Must not be null.
+     * @param value Value of the data field. Must not be null.
+     * @return This builder.
+     */
+    public Builder putData(@NonNull String key, @NonNull String value) {
+      this.data.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map to the message as data fields. None of the
+     * keys or values may be null.
+     *
+     * @param map A non-null map of data fields. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllData(@NonNull Map<String, String> map) {
+      this.data.putAll(map);
+      return this;
+    }
+
+    /**
+     * Creates a new {@link MulticastMessage} instance from the parameters set on this builder.
+     *
+     * @return A new {@link MulticastMessage} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public MulticastMessage build() {
+      return new MulticastMessage(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/SendResponse.java b/src/main/java/com/google/firebase/messaging/SendResponse.java
new file mode 100644
index 0000000..b179df2
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/SendResponse.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2019 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.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+import com.google.firebase.internal.Nullable;
+
+/**
+ * The result of an individual send operation that was executed as part of a batch. See
+ * {@link BatchResponse} for more details.
+ */
+public final class SendResponse {
+
+  private final String messageId;
+  private final FirebaseMessagingException exception;
+
+  private SendResponse(String messageId, FirebaseMessagingException exception) {
+    this.messageId = messageId;
+    this.exception = exception;
+  }
+
+  /**
+   * Returns a message ID string if the send operation was successful. Otherwise returns null.
+   *
+   * @return A message ID string or null.
+   */
+  @Nullable
+  public String getMessageId() {
+    return this.messageId;
+  }
+
+  /**
+   * Returns an exception if the send operation failed. Otherwise returns null.
+   *
+   * @return A {@link FirebaseMessagingException} or null.
+   */
+  @Nullable
+  public FirebaseMessagingException getException() {
+    return this.exception;
+  }
+
+  /**
+   * Returns whether the send operation was successful or not. When this method returns true,
+   * {@link #getMessageId()} is guaranteed to return a non-null value. When this method returns
+   * false {@link #getException()} is guaranteed to return a non-null value.
+   *
+   * @return A boolean indicating success of the operation.
+   */
+  public boolean isSuccessful() {
+    return !Strings.isNullOrEmpty(this.messageId);
+  }
+
+  static SendResponse fromMessageId(String messageId) {
+    checkArgument(!Strings.isNullOrEmpty(messageId), "messageId must not be null or empty");
+    return new SendResponse(messageId, null);
+  }
+
+  static SendResponse fromException(FirebaseMessagingException exception) {
+    checkNotNull(exception, "exception must not be null");
+    return new SendResponse(null, exception);
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java
index 230c64d..b8f9257 100644
--- a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java
+++ b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java
@@ -16,6 +16,10 @@
 
 package com.google.firebase.messaging;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.api.client.json.GenericJson;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.firebase.internal.NonNull;
@@ -41,7 +45,9 @@ public class TopicManagementResponse {
   private final int successCount;
   private final List<Error> errors;
 
-  TopicManagementResponse(List<Map<String, Object>> results) {
+  TopicManagementResponse(List<GenericJson> results) {
+    checkArgument(results != null && !results.isEmpty(),
+        "unexpected response from topic management service");
     int successCount = 0;
     ImmutableList.Builder<Error> errors = ImmutableList.builder();
     for (int i = 0; i < results.size(); i++) {
diff --git a/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java
new file mode 100644
index 0000000..d63f3af
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java
@@ -0,0 +1,46 @@
+package com.google.firebase.messaging.internal;
+
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.util.Key;
+import com.google.firebase.internal.Nullable;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The DTO for parsing error responses from the FCM service.
+ */
+public class MessagingServiceErrorResponse extends GenericJson {
+
+  private static final String FCM_ERROR_TYPE =
+      "type.googleapis.com/google.firebase.fcm.v1.FcmError";
+
+  @Key("error")
+  private Map<String, Object> error;
+
+  @Nullable
+  public String getErrorCode() {
+    if (error == null) {
+      return null;
+    }
+    Object details = error.get("details");
+    if (details != null && details instanceof List) {
+      for (Object detail : (List<?>) details) {
+        if (detail instanceof Map) {
+          Map<?,?> detailMap = (Map<?,?>) detail;
+          if (FCM_ERROR_TYPE.equals(detailMap.get("@type"))) {
+            return (String) detailMap.get("errorCode");
+          }
+        }
+      }
+    }
+    return (String) error.get("status");
+  }
+
+  @Nullable
+  public String getErrorMessage() {
+    if (error != null) {
+      return (String) error.get("message");
+    }
+    return null;
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/internal/MessagingServiceResponse.java b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceResponse.java
new file mode 100644
index 0000000..b3105ec
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceResponse.java
@@ -0,0 +1,16 @@
+package com.google.firebase.messaging.internal;
+
+import com.google.api.client.util.Key;
+
+/**
+ * The DTO for parsing success responses from the FCM service.
+ */
+public class MessagingServiceResponse {
+
+  @Key("name")
+  private String messageId;
+
+  public String getMessageId() {
+    return this.messageId;
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/internal/package-info.java b/src/main/java/com/google/firebase/messaging/internal/package-info.java
new file mode 100644
index 0000000..5ef722b
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/internal/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2019 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.
+ */
+
+/**
+ * @hide
+ */
+package com.google.firebase.messaging.internal;
diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java
index 6ce7f35..72c570c 100644
--- a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java
+++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java
@@ -16,10 +16,12 @@
 package com.google.firebase.projectmanagement;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 
 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.client.util.Sleeper;
 import com.google.api.core.ApiAsyncFunction;
 import com.google.api.core.ApiFunction;
 import com.google.api.core.ApiFuture;
@@ -51,6 +53,8 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS
   private static final String IOS_NAMESPACE_PROPERTY = "bundle_id";
 
   private final FirebaseApp app;
+  private final Sleeper sleeper;
+  private final Scheduler scheduler;
   private final HttpHelper httpHelper;
 
   private final CreateAndroidAppFromAppIdFunction createAndroidAppFromAppIdFunction =
@@ -59,7 +63,13 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS
       new CreateIosAppFromAppIdFunction();
 
   FirebaseProjectManagementServiceImpl(FirebaseApp app) {
-    this.app = app;
+    this(app, Sleeper.DEFAULT, new FirebaseAppScheduler(app));
+  }
+
+  FirebaseProjectManagementServiceImpl(FirebaseApp app, Sleeper sleeper, Scheduler scheduler) {
+    this.app = checkNotNull(app);
+    this.sleeper = checkNotNull(sleeper);
+    this.scheduler = checkNotNull(scheduler);
     this.httpHelper = new HttpHelper(
         app.getOptions().getJsonFactory(),
         app.getOptions().getHttpTransport().createRequestFactory(
@@ -187,7 +197,7 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS
             projectId,
             platformResourceName,
             MAXIMUM_LIST_APPS_PAGE_SIZE);
-        ImmutableList.Builder<T> builder = ImmutableList.<T>builder();
+        ImmutableList.Builder<T> builder = ImmutableList.builder();
         ListAppsResponse parsedResponse;
         do {
           parsedResponse = new ListAppsResponse();
@@ -361,10 +371,9 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS
      * 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,
+    public ApiFuture<String> apply(String operationName) {
+      SettableApiFuture<String> settableFuture = SettableApiFuture.create();
+      scheduler.schedule(
           new WaitOperationRunnable(
               /* numberOfPreviousPolls= */ 0,
               operationName,
@@ -417,8 +426,7 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS
           long delayMillis = (long) (
               POLL_BASE_WAIT_TIME_MILLIS
                   * Math.pow(POLL_EXPONENTIAL_BACKOFF_FACTOR, numberOfPreviousPolls + 1));
-          ImplFirebaseTrampolines.schedule(
-              app,
+          scheduler.schedule(
               new WaitOperationRunnable(
                   numberOfPreviousPolls + 1,
                   operationName,
@@ -725,12 +733,26 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS
     private String certType;
   }
 
+  private static class FirebaseAppScheduler implements Scheduler {
+
+    private final FirebaseApp app;
+
+    FirebaseAppScheduler(FirebaseApp app) {
+      this.app = checkNotNull(app);
+    }
+
+    @Override
+    public void schedule(Runnable runnable, long delayMillis) {
+      ImplFirebaseTrampolines.schedule(app, runnable, delayMillis);
+    }
+  }
+
   /* Helper methods. */
 
   private void sleepOrThrow(String projectId, long delayMillis)
       throws FirebaseProjectManagementException {
     try {
-      Thread.sleep(delayMillis);
+      sleeper.sleep(delayMillis);
     } catch (InterruptedException e) {
       throw HttpHelper.createFirebaseProjectManagementException(
           projectId,
diff --git a/src/main/java/com/google/firebase/projectmanagement/Scheduler.java b/src/main/java/com/google/firebase/projectmanagement/Scheduler.java
new file mode 100644
index 0000000..3df12a9
--- /dev/null
+++ b/src/main/java/com/google/firebase/projectmanagement/Scheduler.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2019 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;
+
+/**
+ * Schedules a task to be executed after a specified delay.
+ */
+interface Scheduler {
+
+  void schedule(Runnable runnable, long delayMillis);
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/messaging/BatchResponseTest.java b/src/test/java/com/google/firebase/messaging/BatchResponseTest.java
new file mode 100644
index 0000000..cea0010
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/BatchResponseTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright  2019 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.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+public class BatchResponseTest {
+
+  @Test
+  public void testEmptyResponses() {
+    List<SendResponse> responses = new ArrayList<>();
+
+    BatchResponse batchResponse = new BatchResponse(responses);
+
+    assertEquals(0, batchResponse.getSuccessCount());
+    assertEquals(0, batchResponse.getFailureCount());
+    assertEquals(0, batchResponse.getResponses().size());
+  }
+
+  @Test
+  public void testSomeResponse() {
+    ImmutableList<SendResponse> responses = ImmutableList.of(
+        SendResponse.fromMessageId("message1"),
+        SendResponse.fromMessageId("message2"),
+        SendResponse.fromException(new FirebaseMessagingException("error-code",
+            "error-message", null))
+    );
+
+    BatchResponse batchResponse = new BatchResponse(responses);
+
+    assertEquals(2, batchResponse.getSuccessCount());
+    assertEquals(1, batchResponse.getFailureCount());
+    assertEquals(3, batchResponse.getResponses().size());
+    for (int i = 0; i < 3; i ++) {
+      assertSame(responses.get(i), batchResponse.getResponses().get(i));
+    }
+  }
+
+  @Test
+  public void testResponsesImmutable() {
+    List<SendResponse> responses = new ArrayList<>();
+    responses.add(SendResponse.fromMessageId("message1"));
+    BatchResponse batchResponse = new BatchResponse(responses);
+    SendResponse sendResponse = SendResponse.fromMessageId("message2");
+
+    try {
+      batchResponse.getResponses().add(sendResponse);
+      fail("No error thrown when modifying responses list");
+    } catch (UnsupportedOperationException expected) {
+      // expected
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testResponsesCannotBeNull() {
+    new BatchResponse(null);
+  }
+}
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
index f309889..9a2b3ff 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
@@ -17,10 +17,15 @@
 package com.google.firebase.messaging;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.ImmutableList;
 import com.google.firebase.testing.IntegrationTestUtils;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
@@ -31,7 +36,7 @@ public class FirebaseMessagingIT {
           + "PWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE";
 
   @BeforeClass
-  public static void setUpClass() throws Exception {
+  public static void setUpClass() {
     IntegrationTestUtils.ensureDefaultApp();
   }
 
@@ -61,6 +66,91 @@ public class FirebaseMessagingIT {
     assertTrue(id != null && id.matches("^projects/.*/messages/.*$"));
   }
 
+  @Test
+  public void testSendAll() throws Exception {
+    List<Message> messages = new ArrayList<>();
+    messages.add(
+        Message.builder()
+          .setNotification(new Notification("Title", "Body"))
+          .setTopic("foo-bar")
+          .build());
+    messages.add(
+        Message.builder()
+          .setNotification(new Notification("Title", "Body"))
+          .setTopic("foo-bar")
+          .build());
+    messages.add(
+        Message.builder()
+          .setNotification(new Notification("Title", "Body"))
+          .setToken("not-a-token")
+          .build());
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendAll(messages, true);
+
+    assertEquals(2, response.getSuccessCount());
+    assertEquals(1, response.getFailureCount());
+
+    List<SendResponse> responses = response.getResponses();
+    assertEquals(3, responses.size());
+    assertTrue(responses.get(0).isSuccessful());
+    String id = responses.get(0).getMessageId();
+    assertTrue(id != null && id.matches("^projects/.*/messages/.*$"));
+
+    assertTrue(responses.get(1).isSuccessful());
+    id = responses.get(1).getMessageId();
+    assertTrue(id != null && id.matches("^projects/.*/messages/.*$"));
+
+    assertFalse(responses.get(2).isSuccessful());
+    assertNull(responses.get(2).getMessageId());
+    FirebaseMessagingException exception = responses.get(2).getException();
+    assertNotNull(exception);
+    assertEquals("invalid-argument", exception.getErrorCode());
+  }
+
+  @Test
+  public void testSendHundred() throws Exception {
+    List<Message> messages = new ArrayList<>();
+    for (int i = 0; i < 100; i++) {
+      messages.add(Message.builder().setTopic("foo-bar-" + (i % 10)).build());
+    }
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendAll(messages, true);
+
+    assertEquals(100, response.getResponses().size());
+    assertEquals(100, response.getSuccessCount());
+    assertEquals(0, response.getFailureCount());
+    for (SendResponse sendResponse : response.getResponses()) {
+      if (!sendResponse.isSuccessful()) {
+        sendResponse.getException().printStackTrace();
+      }
+      assertTrue(sendResponse.isSuccessful());
+      String id = sendResponse.getMessageId();
+      assertTrue(id != null && id.matches("^projects/.*/messages/.*$"));
+      assertNull(sendResponse.getException());
+    }
+  }
+
+  @Test
+  public void testSendMulticast() throws Exception {
+    MulticastMessage multicastMessage = MulticastMessage.builder()
+        .setNotification(new Notification("Title", "Body"))
+        .addToken("not-a-token")
+        .addToken("also-not-a-token")
+        .build();
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(
+        multicastMessage, true);
+
+    assertEquals(0, response.getSuccessCount());
+    assertEquals(2, response.getFailureCount());
+    assertEquals(2, response.getResponses().size());
+    for (SendResponse sendResponse : response.getResponses()) {
+      assertFalse(sendResponse.isSuccessful());
+      assertNull(sendResponse.getMessageId());
+      assertNotNull(sendResponse.getException());
+    }
+  }
+
   @Test
   public void testSubscribe() throws Exception {
     FirebaseMessaging messaging = FirebaseMessaging.getInstance();
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
index d18e2be..3c9b7c4 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -17,6 +17,7 @@
 package com.google.firebase.messaging;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
@@ -24,8 +25,12 @@ 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.HttpHeaders;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.LowLevelHttpRequest;
 import com.google.api.client.json.JsonParser;
 import com.google.api.client.testing.http.MockHttpTransport;
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
@@ -35,10 +40,12 @@ 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.internal.SdkUtils;
 import com.google.firebase.messaging.WebpushNotification.Action;
 import com.google.firebase.messaging.WebpushNotification.Direction;
 import com.google.firebase.testing.GenericFunction;
 import com.google.firebase.testing.TestResponseInterceptor;
+import com.google.firebase.testing.TestUtils;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.math.BigDecimal;
@@ -54,18 +61,28 @@ public class FirebaseMessagingTest {
 
   private static final String TEST_FCM_URL =
       "https://fcm.googleapis.com/v1/projects/test-project/messages:send";
+
   private static final String TEST_IID_SUBSCRIBE_URL =
       "https://iid.googleapis.com/iid/v1:batchAdd";
+
   private static final String TEST_IID_UNSUBSCRIBE_URL =
       "https://iid.googleapis.com/iid/v1:batchRemove";
+
   private static final List<Integer> HTTP_ERRORS = ImmutableList.of(401, 404, 500);
+
   private static final String MOCK_RESPONSE = "{\"name\": \"mock-name\"}";
 
-  private static final ImmutableList.Builder<String> tooManyIds = ImmutableList.builder();
+  private static final String MOCK_BATCH_SUCCESS_RESPONSE = TestUtils.loadResource(
+      "fcm_batch_success.txt");
+
+  private static final String MOCK_BATCH_FAILURE_RESPONSE = TestUtils.loadResource(
+      "fcm_batch_failure.txt");
+
+  private static final ImmutableList.Builder<String> TOO_MANY_IDS = ImmutableList.builder();
 
   static {
     for (int i = 0; i < 1001; i++) {
-      tooManyIds.add("id" + i);
+      TOO_MANY_IDS.add("id" + i);
     }
   }
 
@@ -74,7 +91,7 @@ public class FirebaseMessagingTest {
       new TopicMgtArgs(null, "test-topic"),
       new TopicMgtArgs(ImmutableList.<String>of(), "test-topic"),
       new TopicMgtArgs(ImmutableList.of(""), "test-topic"),
-      new TopicMgtArgs(tooManyIds.build(), "test-topic"),
+      new TopicMgtArgs(TOO_MANY_IDS.build(), "test-topic"),
       new TopicMgtArgs(ImmutableList.of(""), null),
       new TopicMgtArgs(ImmutableList.of("id"), ""),
       new TopicMgtArgs(ImmutableList.of("id"), "foo*")
@@ -85,6 +102,46 @@ public class FirebaseMessagingTest {
     TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
   }
 
+  @Test
+  public void testGetInstance() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .build();
+    FirebaseApp.initializeApp(options);
+
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+    assertSame(messaging, FirebaseMessaging.getInstance());
+  }
+
+  @Test
+  public void testGetInstanceByApp() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options, "custom-app");
+
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance(app);
+    assertSame(messaging, FirebaseMessaging.getInstance(app));
+  }
+
+  @Test
+  public void testPostDeleteApp() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options, "custom-app");
+    app.delete();
+    try {
+      FirebaseMessaging.getInstance(app);
+      fail("No error thrown for deleted app");
+    } catch (IllegalStateException expected) {
+      // expected
+    }
+  }
+
   @Test
   public void testNoProjectId() {
     FirebaseOptions options = new FirebaseOptions.Builder()
@@ -100,10 +157,9 @@ public class FirebaseMessagingTest {
   }
 
   @Test
-  public void testNullMessage() {
-    FirebaseMessaging messaging = initDefaultMessaging();
+  public void testSendNullMessage() {
     TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    messaging.setInterceptor(interceptor);
+    FirebaseMessaging messaging = initDefaultMessaging(interceptor);
     try {
       messaging.sendAsync(null);
       fail("No error thrown for null message");
@@ -118,7 +174,8 @@ public class FirebaseMessagingTest {
   public void testSend() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
         .setContent(MOCK_RESPONSE);
-    final FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final FirebaseMessaging messaging = initMessaging(response, interceptor);
     Map<Message, Map<String, Object>> testMessages = buildTestMessages();
 
     List<GenericFunction<String>> functions = ImmutableList.of(
@@ -138,13 +195,12 @@ public class FirebaseMessagingTest {
     for (GenericFunction<String> fn : functions) {
       for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
         response.setContent(MOCK_RESPONSE);
-        TestResponseInterceptor interceptor = new TestResponseInterceptor();
-        messaging.setInterceptor(interceptor);
         String resp = fn.call(entry.getKey());
         assertEquals("mock-name", resp);
 
-        HttpRequest request = checkRequestHeader(interceptor);
-        checkRequest(request, ImmutableMap.<String, Object>of("message", entry.getValue()));
+        checkRequestHeader(interceptor.getLastRequest());
+        checkRequest(interceptor.getLastRequest(),
+            ImmutableMap.<String, Object>of("message", entry.getValue()));
       }
     }
   }
@@ -153,7 +209,8 @@ public class FirebaseMessagingTest {
   public void testSendDryRun() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
         .setContent(MOCK_RESPONSE);
-    final FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final FirebaseMessaging messaging = initMessaging(response, interceptor);
     Map<Message, Map<String, Object>> testMessages = buildTestMessages();
 
     List<GenericFunction<String>> functions = ImmutableList.of(
@@ -174,13 +231,12 @@ public class FirebaseMessagingTest {
     for (GenericFunction<String> fn : functions) {
       for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
         response.setContent(MOCK_RESPONSE);
-        TestResponseInterceptor interceptor = new TestResponseInterceptor();
-        messaging.setInterceptor(interceptor);
         String resp = fn.call(entry.getKey());
         assertEquals("mock-name", resp);
 
-        HttpRequest request = checkRequestHeader(interceptor);
-        checkRequest(request, ImmutableMap.of("message", entry.getValue(), "validate_only", true));
+        checkRequestHeader(interceptor.getLastRequest());
+        checkRequest(interceptor.getLastRequest(),
+            ImmutableMap.of("message", entry.getValue(), "validate_only", true));
       }
     }
   }
@@ -188,11 +244,10 @@ public class FirebaseMessagingTest {
   @Test
   public void testSendError() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int code : HTTP_ERRORS) {
       response.setStatusCode(code).setContent("{}");
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
         fail("No error thrown for HTTP error");
@@ -204,18 +259,17 @@ public class FirebaseMessagingTest {
             error.getMessage());
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
-      checkRequestHeader(interceptor);
+      checkRequestHeader(interceptor.getLastRequest());
     }
   }
 
   @Test
   public void testSendErrorWithZeroContentResponse() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int code : HTTP_ERRORS) {
       response.setStatusCode(code).setZeroContent();
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
         fail("No error thrown for HTTP error");
@@ -227,19 +281,18 @@ public class FirebaseMessagingTest {
             error.getMessage());
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
-      checkRequestHeader(interceptor);
+      checkRequestHeader(interceptor.getLastRequest());
     }
   }
 
   @Test
   public void testSendErrorWithDetails() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int code : HTTP_ERRORS) {
       response.setStatusCode(code).setContent(
           "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
         fail("No error thrown for HTTP error");
@@ -250,19 +303,18 @@ public class FirebaseMessagingTest {
         assertEquals("test error", error.getMessage());
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
-      checkRequestHeader(interceptor);
+      checkRequestHeader(interceptor.getLastRequest());
     }
   }
 
   @Test
   public void testSendErrorWithCanonicalCode() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int code : HTTP_ERRORS) {
       response.setStatusCode(code).setContent(
           "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}");
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
         fail("No error thrown for HTTP error");
@@ -273,21 +325,20 @@ public class FirebaseMessagingTest {
         assertEquals("test error", error.getMessage());
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
-      checkRequestHeader(interceptor);
+      checkRequestHeader(interceptor.getLastRequest());
     }
   }
 
   @Test
   public void testSendErrorWithFcmError() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int code : HTTP_ERRORS) {
       response.setStatusCode(code).setContent(
           "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
               + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
               + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
         fail("No error thrown for HTTP error");
@@ -298,15 +349,364 @@ public class FirebaseMessagingTest {
         assertEquals("test error", error.getMessage());
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
-      checkRequestHeader(interceptor);
+      checkRequestHeader(interceptor.getLastRequest());
     }
   }
 
   @Test
-  public void testInvalidSubscribe() {
+  public void testSendMulticastWithNull() {
     FirebaseMessaging messaging = initDefaultMessaging();
+    try {
+      messaging.sendMulticastAsync(null);
+      fail("No error thrown for null multicast message");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testSendMulticast() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
+    MulticastMessage multicast = MulticastMessage.builder()
+        .addToken("token1")
+        .addToken("token2")
+        .build();
+
+    BatchResponse responses = messaging.sendMulticast(multicast);
+
+    assertSendBatchSuccess(responses, interceptor);
+  }
+
+  @Test
+  public void testSendMulticastAsync() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
+    MulticastMessage multicast = MulticastMessage.builder()
+        .addToken("token1")
+        .addToken("token2")
+        .build();
+
+    BatchResponse responses = messaging.sendMulticastAsync(multicast).get();
+
+    assertSendBatchSuccess(responses, interceptor);
+  }
+
+  @Test
+  public void testSendMulticastFailure() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
+    MulticastMessage multicast = MulticastMessage.builder()
+        .addToken("token1")
+        .addToken("token2")
+        .addToken("token3")
+        .build();
+
+    BatchResponse responses = messaging.sendMulticast(multicast);
+
+    assertSendBatchFailure(responses, interceptor);
+  }
+
+  @Test
+  public void testSendMulticastAsyncFailure() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
+    MulticastMessage multicast = MulticastMessage.builder()
+        .addToken("token1")
+        .addToken("token2")
+        .addToken("token3")
+        .build();
+
+    BatchResponse responses = messaging.sendMulticastAsync(multicast).get();
+
+    assertSendBatchFailure(responses, interceptor);
+  }
+
+  @Test
+  public void testSendAllWithNull() {
+    FirebaseMessaging messaging = initDefaultMessaging();
+    try {
+      messaging.sendAllAsync(null);
+      fail("No error thrown for null message list");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testSendAllWithEmptyList() {
+    FirebaseMessaging messaging = initDefaultMessaging();
+    try {
+      messaging.sendAllAsync(ImmutableList.<Message>of());
+      fail("No error thrown for empty message list");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testSendAllWithTooManyMessages() {
+    FirebaseMessaging messaging = initDefaultMessaging();
+    ImmutableList.Builder<Message> listBuilder = ImmutableList.builder();
+    for (int i = 0; i < 101; i++) {
+      listBuilder.add(Message.builder().setTopic("topic").build());
+    }
+    try {
+      messaging.sendAllAsync(listBuilder.build());
+      fail("No error thrown for too many messages in the list");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testSendAll() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
+    List<Message> messages = ImmutableList.of(
+        Message.builder().setTopic("topic1").build(),
+        Message.builder().setTopic("topic2").build()
+    );
+
+    BatchResponse responses = messaging.sendAll(messages);
+
+    assertSendBatchSuccess(responses, interceptor);
+  }
+
+  @Test
+  public void testSendAllAsync() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_SUCCESS_RESPONSE, interceptor);
+    List<Message> messages = ImmutableList.of(
+        Message.builder().setTopic("topic1").build(),
+        Message.builder().setTopic("topic2").build()
+    );
+
+    BatchResponse responses = messaging.sendAllAsync(messages).get();
+
+    assertSendBatchSuccess(responses, interceptor);
+  }
+
+  @Test
+  public void testSendAllFailure() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
+    List<Message> messages = ImmutableList.of(
+        Message.builder().setTopic("topic1").build(),
+        Message.builder().setTopic("topic2").build(),
+        Message.builder().setTopic("topic3").build()
+    );
+
+    BatchResponse responses = messaging.sendAll(messages);
+
+    assertSendBatchFailure(responses, interceptor);
+  }
+
+  @Test
+  public void testSendAllAsyncFailure() throws Exception {
+    final TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = getMessagingForBatchRequest(
+        MOCK_BATCH_FAILURE_RESPONSE, interceptor);
+    List<Message> messages = ImmutableList.of(
+        Message.builder().setTopic("topic1").build(),
+        Message.builder().setTopic("topic2").build(),
+        Message.builder().setTopic("topic3").build()
+    );
+
+    BatchResponse responses = messaging.sendAllAsync(messages).get();
+
+    assertSendBatchFailure(responses, interceptor);
+  }
+
+  @Test
+  public void testSendAllHttpError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent("{}");
+      try {
+        messaging.sendAllAsync(messages).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("unknown-error", error.getErrorCode());
+        assertEquals("Unexpected HTTP response with status: " + code + "; body: {}",
+            error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllTransportError() throws Exception {
+    FirebaseMessaging messaging = initFaultyTransportMessaging();
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+
+    try {
+      messaging.sendAllAsync(messages).get();
+      fail("No error thrown for HTTP error");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseMessagingException);
+      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling FCM backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithEmptyResponse() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
     TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    messaging.setInterceptor(interceptor);
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        messaging.sendAllAsync(messages).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("unknown-error", error.getErrorCode());
+        assertEquals("Unexpected HTTP response with status: " + code + "; body: null",
+            error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithDetails() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        messaging.sendAllAsync(messages).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("invalid-argument", error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithCanonicalCode() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}");
+
+      try {
+        messaging.sendAllAsync(messages).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("registration-token-not-registered", error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithFcmError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
+              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
+              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
+
+      try {
+        messaging.sendAllAsync(messages).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("registration-token-not-registered", error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testSendAllErrorWithoutMessage() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    List<Message> messages = ImmutableList.of(Message.builder()
+        .setTopic("test-topic")
+        .build());
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
+              + ".v1.FcmError\", \"errorCode\": \"UNREGISTERED\"}]}}");
+
+      try {
+        messaging.sendAllAsync(messages).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("registration-token-not-registered", error.getErrorCode());
+        assertTrue(error.getMessage().startsWith("Unexpected HTTP response"));
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkBatchRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testInvalidSubscribe() {
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initDefaultMessaging(interceptor);
 
     for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) {
       try {
@@ -324,7 +724,8 @@ public class FirebaseMessagingTest {
   public void testSubscribe() throws Exception {
     final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    final FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final FirebaseMessaging messaging = initMessaging(response, interceptor);
 
     List<GenericFunction<TopicManagementResponse>> functions = ImmutableList.of(
         new GenericFunction<TopicManagementResponse>() {
@@ -350,23 +751,21 @@ public class FirebaseMessagingTest {
     );
 
     for (GenericFunction<TopicManagementResponse> fn : functions) {
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       response.setContent(responseString);
       TopicManagementResponse result = fn.call();
-      HttpRequest request = checkTopicManagementRequestHeader(interceptor, TEST_IID_SUBSCRIBE_URL);
-      checkTopicManagementRequest(request, result);
+      checkTopicManagementRequestHeader(
+          interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+      checkTopicManagementRequest(interceptor.getLastRequest(), result);
     }
   }
 
   @Test
   public void testSubscribeError() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int statusCode : HTTP_ERRORS) {
       response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
         fail("No error thrown for HTTP error");
@@ -378,15 +777,49 @@ public class FirebaseMessagingTest {
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
 
-      checkTopicManagementRequestHeader(interceptor, TEST_IID_SUBSCRIBE_URL);
+      checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+    }
+  }
+
+  @Test
+  public void testSubscribeUnknownError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    response.setStatusCode(500).setContent("{}");
+    try {
+      messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
+      fail("No error thrown for HTTP error");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseMessagingException);
+      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+      assertEquals(getTopicManagementErrorCode(500), error.getErrorCode());
+      assertEquals("Unexpected HTTP response with status: 500; body: {}", error.getMessage());
+      assertTrue(error.getCause() instanceof HttpResponseException);
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_SUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testSubscribeTransportError() throws Exception {
+    FirebaseMessaging messaging = initFaultyTransportMessaging();
+    try {
+      messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
+      fail("No error thrown for HTTP error");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseMessagingException);
+      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling IID backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
     }
   }
 
   @Test
   public void testInvalidUnsubscribe() {
-    FirebaseMessaging messaging = initDefaultMessaging();
     TestResponseInterceptor interceptor = new TestResponseInterceptor();
-    messaging.setInterceptor(interceptor);
+    FirebaseMessaging messaging = initDefaultMessaging(interceptor);
 
     for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) {
       try {
@@ -404,7 +837,8 @@ public class FirebaseMessagingTest {
   public void testUnsubscribe() throws Exception {
     final String responseString = "{\"results\": [{}, {\"error\": \"error_reason\"}]}";
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    final FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    final FirebaseMessaging messaging = initMessaging(response, interceptor);
 
     List<GenericFunction<TopicManagementResponse>> functions = ImmutableList.of(
         new GenericFunction<TopicManagementResponse>() {
@@ -430,24 +864,21 @@ public class FirebaseMessagingTest {
     );
 
     for (GenericFunction<TopicManagementResponse> fn : functions) {
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       response.setContent(responseString);
       TopicManagementResponse result = fn.call();
-      HttpRequest request = checkTopicManagementRequestHeader(
-          interceptor, TEST_IID_UNSUBSCRIBE_URL);
-      checkTopicManagementRequest(request, result);
+      checkTopicManagementRequestHeader(
+          interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+      checkTopicManagementRequest(interceptor.getLastRequest(), result);
     }
   }
 
   @Test
   public void testUnsubscribeError() throws Exception {
     MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
-    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
     for (int statusCode : HTTP_ERRORS) {
       response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
-      TestResponseInterceptor interceptor = new TestResponseInterceptor();
-      messaging.setInterceptor(interceptor);
       try {
         messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
         fail("No error thrown for HTTP error");
@@ -459,19 +890,47 @@ public class FirebaseMessagingTest {
         assertTrue(error.getCause() instanceof HttpResponseException);
       }
 
-      checkTopicManagementRequestHeader(interceptor, TEST_IID_UNSUBSCRIBE_URL);
+      checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
     }
   }
 
-  private static String getTopicManagementErrorCode(int statusCode) {
-    String code = FirebaseMessaging.IID_ERROR_CODES.get(statusCode);
-    if (code == null) {
-      code = "unknown-error";
+  @Test
+  public void testUnsubscribeUnknownError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    FirebaseMessaging messaging = initMessaging(response, interceptor);
+    response.setStatusCode(500).setContent("{}");
+    try {
+      messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
+      fail("No error thrown for HTTP error");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseMessagingException);
+      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+      assertEquals(getTopicManagementErrorCode(500), error.getErrorCode());
+      assertEquals("Unexpected HTTP response with status: 500; body: {}", error.getMessage());
+      assertTrue(error.getCause() instanceof HttpResponseException);
+    }
+
+    checkTopicManagementRequestHeader(interceptor.getLastRequest(), TEST_IID_UNSUBSCRIBE_URL);
+  }
+
+  @Test
+  public void testUnsubscribeTransportError() throws Exception {
+    FirebaseMessaging messaging = initFaultyTransportMessaging();
+    try {
+      messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
+      fail("No error thrown for HTTP error");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseMessagingException);
+      FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+      assertEquals("internal-error", error.getErrorCode());
+      assertEquals("Error while calling IID backend service", error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
     }
-    return code;
   }
 
-  private static FirebaseMessaging initMessaging(MockLowLevelHttpResponse mockResponse) {
+  private static FirebaseMessaging initMessaging(
+      MockLowLevelHttpResponse mockResponse, HttpResponseInterceptor interceptor) {
     MockHttpTransport transport = new MockHttpTransport.Builder()
         .setLowLevelHttpResponse(mockResponse)
         .build();
@@ -482,21 +941,33 @@ public class FirebaseMessagingTest {
         .build();
     FirebaseApp app = FirebaseApp.initializeApp(options);
 
-    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
-    assertSame(messaging, FirebaseMessaging.getInstance(app));
-    return messaging;
+    return new FirebaseMessaging(app, interceptor);
   }
 
   private static FirebaseMessaging initDefaultMessaging() {
+    return initDefaultMessaging(null);
+  }
+
+  private static FirebaseMessaging initDefaultMessaging(HttpResponseInterceptor interceptor) {
     FirebaseOptions options = new FirebaseOptions.Builder()
         .setCredentials(new MockGoogleCredentials("test-token"))
         .setProjectId("test-project")
         .build();
     FirebaseApp app = FirebaseApp.initializeApp(options);
-    return FirebaseMessaging.getInstance(app);
+    return new FirebaseMessaging(app, interceptor);
   }
 
-  private static void checkRequest(
+  private static FirebaseMessaging initFaultyTransportMessaging() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .setHttpTransport(new FailingHttpTransport())
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+    return new FirebaseMessaging(app, null);
+  }
+
+  private void checkRequest(
       HttpRequest request, Map<String, Object> expected) throws IOException {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     request.getContent().writeTo(out);
@@ -506,17 +977,108 @@ public class FirebaseMessagingTest {
     assertEquals(expected, parsed);
   }
 
-  private static HttpRequest checkRequestHeader(TestResponseInterceptor interceptor) {
-    assertNotNull(interceptor.getResponse());
-    HttpRequest request = interceptor.getResponse().getRequest();
+  private void checkRequestHeader(HttpRequest request) {
     assertEquals("POST", request.getRequestMethod());
     assertEquals(TEST_FCM_URL, request.getUrl().toString());
+    HttpHeaders headers = request.getHeaders();
+    assertEquals("Bearer test-token", headers.getAuthorization());
+    assertEquals("2", headers.get("X-GOOG-API-FORMAT-VERSION"));
+    assertEquals("Java/Admin/" + SdkUtils.getVersion(), headers.get("X-Client-Version"));
+  }
+
+  private FirebaseMessaging getMessagingForBatchRequest(
+      String responsePayload, TestResponseInterceptor interceptor) {
+    MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse()
+        .setContentType("multipart/mixed; boundary=test_boundary")
+        .setContent(responsePayload);
+    return initMessaging(httpResponse, interceptor);
+  }
+
+  private void assertSendBatchSuccess(
+      BatchResponse batchResponse, TestResponseInterceptor interceptor) throws IOException {
+
+    assertEquals(2, batchResponse.getSuccessCount());
+    assertEquals(0, batchResponse.getFailureCount());
+
+    List<SendResponse> responses = batchResponse.getResponses();
+    assertEquals(2, responses.size());
+    for (int i = 0; i < 2; i++) {
+      SendResponse sendResponse = responses.get(i);
+      assertTrue(sendResponse.isSuccessful());
+      assertEquals("projects/test-project/messages/" + (i + 1), sendResponse.getMessageId());
+      assertNull(sendResponse.getException());
+    }
+    checkBatchRequestHeader(interceptor.getLastRequest());
+    checkBatchRequest(interceptor.getLastRequest(), 2);
+  }
+
+  private void assertSendBatchFailure(
+      BatchResponse batchResponse, TestResponseInterceptor interceptor) throws IOException {
+
+    assertEquals(1, batchResponse.getSuccessCount());
+    assertEquals(2, batchResponse.getFailureCount());
+
+    List<SendResponse> responses = batchResponse.getResponses();
+    assertEquals(3, responses.size());
+    SendResponse firstResponse = responses.get(0);
+    assertTrue(firstResponse.isSuccessful());
+    assertEquals("projects/test-project/messages/1", firstResponse.getMessageId());
+    assertNull(firstResponse.getException());
+
+    SendResponse secondResponse = responses.get(1);
+    assertFalse(secondResponse.isSuccessful());
+    assertNull(secondResponse.getMessageId());
+    FirebaseMessagingException exception = secondResponse.getException();
+    assertNotNull(exception);
+    assertEquals("invalid-argument", exception.getErrorCode());
+
+    SendResponse thirdResponse = responses.get(2);
+    assertFalse(thirdResponse.isSuccessful());
+    assertNull(thirdResponse.getMessageId());
+    exception = thirdResponse.getException();
+    assertNotNull(exception);
+    assertEquals("invalid-argument", exception.getErrorCode());
+
+    checkBatchRequestHeader(interceptor.getLastRequest());
+    checkBatchRequest(interceptor.getLastRequest(), 3);
+  }
+
+  private void checkBatchRequestHeader(HttpRequest request) {
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals("https://fcm.googleapis.com/batch", request.getUrl().toString());
     assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
-    assertEquals("2", request.getHeaders().get("X-GOOG-API-FORMAT-VERSION"));
-    return request;
   }
 
-  private static void checkTopicManagementRequest(
+  private void checkBatchRequest(HttpRequest request, int expectedParts) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    String[] lines = out.toString().split("\n");
+    assertEquals(expectedParts, countLinesWithPrefix(lines, "POST " + TEST_FCM_URL));
+    assertEquals(expectedParts, countLinesWithPrefix(lines, "x-goog-api-format-version: 2"));
+    assertEquals(expectedParts, countLinesWithPrefix(
+        lines, "x-client-version: Java/Admin/" + SdkUtils.getVersion()));
+  }
+
+  private int countLinesWithPrefix(String[] lines, String prefix) {
+    int matchCount = 0;
+    for (String line : lines) {
+      if (line.trim().startsWith(prefix)) {
+        matchCount++;
+      }
+    }
+    return matchCount;
+  }
+
+  private static String getTopicManagementErrorCode(int statusCode) {
+    String code = InstanceIdClient.IID_ERROR_CODES.get(statusCode);
+    if (code == null) {
+      code = "unknown-error";
+    }
+    return code;
+  }
+
+
+  private void checkTopicManagementRequest(
       HttpRequest request, TopicManagementResponse result) throws IOException {
     assertEquals(1, result.getSuccessCount());
     assertEquals(1, result.getFailureCount());
@@ -534,14 +1096,11 @@ public class FirebaseMessagingTest {
     assertEquals(ImmutableList.of("id1", "id2"), parsed.get("registration_tokens"));
   }
 
-  private static HttpRequest checkTopicManagementRequestHeader(
-      TestResponseInterceptor interceptor, String expectedUrl) {
-    assertNotNull(interceptor.getResponse());
-    HttpRequest request = interceptor.getResponse().getRequest();
+  private void checkTopicManagementRequestHeader(
+      HttpRequest request, String expectedUrl) {
     assertEquals("POST", request.getRequestMethod());
     assertEquals(expectedUrl, request.getUrl().toString());
     assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
-    return request;
   }
 
   private static class TopicMgtArgs {
@@ -554,6 +1113,13 @@ public class FirebaseMessagingTest {
     }
   }
 
+  private static class FailingHttpTransport extends HttpTransport {
+    @Override
+    protected LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
+      throw new IOException("transport error");
+    }
+  }
+
   private static Map<Message, Map<String, Object>> buildTestMessages() {
     ImmutableMap.Builder<Message, Map<String, Object>> builder = ImmutableMap.builder();
 
@@ -660,7 +1226,7 @@ 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", "subtitle", "test-subtitle", 
+                            "title", "test-title", "subtitle", "test-subtitle",
                             "body", "test-body"))))
         ));
 
diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java
index 5b53b1a..788127d 100644
--- a/src/test/java/com/google/firebase/messaging/MessageTest.java
+++ b/src/test/java/com/google/firebase/messaging/MessageTest.java
@@ -17,6 +17,7 @@
 package com.google.firebase.messaging;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.fail;
 
 import com.google.api.client.googleapis.util.Utils;
@@ -561,6 +562,27 @@ public class MessageTest {
     }
   }
 
+  @Test
+  public void testWrapForTransportDryRun() {
+    Message message = Message.builder()
+        .setTopic("foo")
+        .build();
+    Map<String, Object> wrappedMessage = message.wrapForTransport(true);
+    assertEquals(2, wrappedMessage.size());
+    assertSame(message, wrappedMessage.get("message"));
+    assertEquals(Boolean.TRUE, wrappedMessage.get("validate_only"));
+  }
+
+  @Test
+  public void testWrapForTransport() {
+    Message message = Message.builder()
+        .setTopic("foo")
+        .build();
+    Map<String, Object> wrappedMessage = message.wrapForTransport(false);
+    assertEquals(1, wrappedMessage.size());
+    assertSame(message, wrappedMessage.get("message"));
+  }
+
   private static void assertJsonEquals(
       Map expected, Object actual) throws IOException {
     assertEquals(expected, toMap(actual));
@@ -574,5 +596,4 @@ public class MessageTest {
     parser.parse(map);
     return map;
   }
-
 }
diff --git a/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java b/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java
new file mode 100644
index 0000000..5cd0531
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2019 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.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.List;
+import org.junit.Test;
+
+public class MulticastMessageTest {
+
+  private static final AndroidConfig ANDROID = AndroidConfig.builder()
+      .setCollapseKey("collapseKey")
+      .build();
+  private static final ApnsConfig APNS = ApnsConfig.builder()
+      .setAps(Aps.builder()
+        .setBadge(42)
+        .build())
+      .build();
+  private static final WebpushConfig WEBPUSH = WebpushConfig.builder()
+      .putData("key", "value")
+      .build();
+  private static final Notification NOTIFICATION = new Notification("title", "body");
+
+  @Test
+  public void testMulticastMessage() {
+    MulticastMessage multicastMessage = MulticastMessage.builder()
+        .setAndroidConfig(ANDROID)
+        .setApnsConfig(APNS)
+        .setWebpushConfig(WEBPUSH)
+        .setNotification(NOTIFICATION)
+        .putData("key1", "value1")
+        .putAllData(ImmutableMap.of("key2", "value2"))
+        .addToken("token1")
+        .addAllTokens(ImmutableList.of("token2", "token3"))
+        .build();
+
+    List<Message> messages = multicastMessage.getMessageList();
+
+    assertEquals(3, messages.size());
+    for (int i = 0; i < 3; i++) {
+      Message message = messages.get(i);
+      assertMessage(message, "token" + (i + 1));
+    }
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testNoTokens() {
+    MulticastMessage.builder().build();
+  }
+
+  @Test
+  public void testTooManyTokens() {
+    MulticastMessage.Builder builder = MulticastMessage.builder();
+    for (int i = 0; i < 101; i++) {
+      builder.addToken("token" + i);
+    }
+    try {
+      builder.build();
+      fail("No error thrown for more than 100 tokens");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testNullToken() {
+    MulticastMessage.builder().addToken(null).build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testEmptyToken() {
+    MulticastMessage.builder().addToken("").build();
+  }
+
+  private void assertMessage(Message message, String expectedToken) {
+    assertSame(ANDROID, message.getAndroidConfig());
+    assertSame(APNS, message.getApnsConfig());
+    assertSame(WEBPUSH, message.getWebpushConfig());
+    assertSame(NOTIFICATION, message.getNotification());
+    assertEquals(ImmutableMap.of("key1", "value1", "key2", "value2"), message.getData());
+    assertEquals(expectedToken, message.getToken());
+  }
+}
\ No newline at end of file
diff --git a/src/test/java/com/google/firebase/messaging/SendResponseTest.java b/src/test/java/com/google/firebase/messaging/SendResponseTest.java
new file mode 100644
index 0000000..e9667cc
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/SendResponseTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2019 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.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class SendResponseTest {
+
+  @Test
+  public void testSuccessfulResponse() {
+    SendResponse response = SendResponse.fromMessageId("message-id");
+
+    assertEquals("message-id", response.getMessageId());
+    assertTrue(response.isSuccessful());
+    assertNull(response.getException());
+  }
+
+  @Test
+  public void testFailureResponse() {
+    FirebaseMessagingException exception = new FirebaseMessagingException("error-code",
+        "error-message", null);
+    SendResponse response = SendResponse.fromException(exception);
+
+    assertNull(response.getMessageId());
+    assertFalse(response.isSuccessful());
+    assertSame(exception, response.getException());
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testMessageIdCannotBeNull() {
+    SendResponse.fromMessageId(null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testMessageIdCannotBeEmpty() {
+    SendResponse.fromMessageId("");
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testExceptionCannotBeNull() {
+    SendResponse.fromException(null);
+  }
+}
diff --git a/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java
index edf620c..a2fe872 100644
--- a/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java
+++ b/src/test/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImplTest.java
@@ -34,6 +34,7 @@ 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.testing.util.MockSleeper;
 import com.google.api.client.util.Base64;
 import com.google.common.base.Charsets;
 import com.google.common.collect.ImmutableList;
@@ -341,7 +342,7 @@ public class FirebaseProjectManagementServiceImplTest {
     MockLowLevelHttpResponse secondRpcResponse = new MockLowLevelHttpResponse();
     secondRpcResponse.setContent(LIST_IOS_APPS_PAGE_2_RESPONSE);
     serviceImpl = initServiceImpl(
-        ImmutableList.<MockLowLevelHttpResponse>of(firstRpcResponse, secondRpcResponse),
+        ImmutableList.of(firstRpcResponse, secondRpcResponse),
         interceptor);
 
     List<IosApp> iosAppList = serviceImpl.listIosApps(PROJECT_ID);
@@ -931,7 +932,7 @@ public class FirebaseProjectManagementServiceImplTest {
         .build();
     FirebaseApp app = FirebaseApp.initializeApp(options);
     FirebaseProjectManagementServiceImpl serviceImpl =
-        new FirebaseProjectManagementServiceImpl(app);
+        new FirebaseProjectManagementServiceImpl(app, new MockSleeper(), new MockScheduler());
     serviceImpl.setInterceptor(interceptor);
     return serviceImpl;
   }
@@ -985,7 +986,7 @@ public class FirebaseProjectManagementServiceImplTest {
   /**
    * Can be used to intercept multiple HTTP requests and responses made by the SDK during tests.
    */
-  private class MultiRequestTestResponseInterceptor implements HttpResponseInterceptor {
+  private static class MultiRequestTestResponseInterceptor implements HttpResponseInterceptor {
     private final List<HttpResponse> responsesList = new ArrayList<>();
 
     @Override
@@ -1001,4 +1002,11 @@ public class FirebaseProjectManagementServiceImplTest {
       return responsesList.get(index);
     }
   }
+
+  private static class MockScheduler implements Scheduler {
+    @Override
+    public void schedule(Runnable runnable, long delayMillis) {
+      runnable.run();
+    }
+  }
 }
diff --git a/src/test/java/com/google/firebase/snippets/FirebaseMessagingSnippets.java b/src/test/java/com/google/firebase/snippets/FirebaseMessagingSnippets.java
index 316e206..7c46ad9 100644
--- a/src/test/java/com/google/firebase/snippets/FirebaseMessagingSnippets.java
+++ b/src/test/java/com/google/firebase/snippets/FirebaseMessagingSnippets.java
@@ -21,13 +21,17 @@ import com.google.firebase.messaging.AndroidNotification;
 import com.google.firebase.messaging.ApnsConfig;
 import com.google.firebase.messaging.Aps;
 import com.google.firebase.messaging.ApsAlert;
+import com.google.firebase.messaging.BatchResponse;
 import com.google.firebase.messaging.FirebaseMessaging;
 import com.google.firebase.messaging.FirebaseMessagingException;
 import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
 import com.google.firebase.messaging.Notification;
+import com.google.firebase.messaging.SendResponse;
 import com.google.firebase.messaging.TopicManagementResponse;
 import com.google.firebase.messaging.WebpushConfig;
 import com.google.firebase.messaging.WebpushNotification;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
@@ -113,6 +117,82 @@ public class FirebaseMessagingSnippets {
     // [END send_dry_run]
   }
 
+  public void sendAll() throws FirebaseMessagingException {
+    String registrationToken = "YOUR_REGISTRATION_TOKEN";
+
+    // [START send_all]
+    // Create a list containing up to 100 messages.
+    List<Message> messages = Arrays.asList(
+        Message.builder()
+            .setNotification(new Notification("Price drop", "5% off all electronics"))
+            .setToken(registrationToken)
+            .build(),
+        // ...
+        Message.builder()
+            .setNotification(new Notification("Price drop", "2% off all books"))
+            .setTopic("readers-club")
+            .build()
+    );
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendAll(messages);
+    // See the BatchResponse reference documentation
+    // for the contents of response.
+    System.out.println(response.getSuccessCount() + " messages were sent successfully");
+    // [END send_all]
+  }
+
+  public void sendMulticast() throws FirebaseMessagingException {
+    // [START send_multicast]
+    // Create a list containing up to 100 registration tokens.
+    // These registration tokens come from the client FCM SDKs.
+    List<String> registrationTokens = Arrays.asList(
+        "YOUR_REGISTRATION_TOKEN_1",
+        // ...
+        "YOUR_REGISTRATION_TOKEN_n"
+    );
+
+    MulticastMessage message = MulticastMessage.builder()
+        .putData("score", "850")
+        .putData("time", "2:45")
+        .addAllTokens(registrationTokens)
+        .build();
+    BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message);
+    // See the BatchResponse reference documentation
+    // for the contents of response.
+    System.out.println(response.getSuccessCount() + " messages were sent successfully");
+    // [END send_multicast]
+  }
+
+  public void sendMulticastAndHandleErrors() throws FirebaseMessagingException {
+    // [START send_multicast_error]
+    // These registration tokens come from the client FCM SDKs.
+    List<String> registrationTokens = Arrays.asList(
+        "YOUR_REGISTRATION_TOKEN_1",
+        // ...
+        "YOUR_REGISTRATION_TOKEN_n"
+    );
+
+    MulticastMessage message = MulticastMessage.builder()
+        .putData("score", "850")
+        .putData("time", "2:45")
+        .addAllTokens(registrationTokens)
+        .build();
+    BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message);
+    if (response.getFailureCount() > 0) {
+      List<SendResponse> responses = response.getResponses();
+      List<String> failedTokens = new ArrayList<>();
+      for (int i = 0; i < responses.size(); i++) {
+        if (!responses.get(i).isSuccessful()) {
+          // The order of responses corresponds to the order of the registration tokens.
+          failedTokens.add(registrationTokens.get(i));
+        }
+      }
+
+      System.out.println("List of tokens that caused failures: " + failedTokens);
+    }
+    // [END send_multicast_error]
+  }
+
   public Message androidMessage() {
     // [START android_message]
     Message message = Message.builder()
diff --git a/src/test/java/com/google/firebase/testing/TestResponseInterceptor.java b/src/test/java/com/google/firebase/testing/TestResponseInterceptor.java
index 5daa6c3..7bc3a7e 100644
--- a/src/test/java/com/google/firebase/testing/TestResponseInterceptor.java
+++ b/src/test/java/com/google/firebase/testing/TestResponseInterceptor.java
@@ -16,6 +16,9 @@
 
 package com.google.firebase.testing;
 
+import static org.junit.Assert.assertNotNull;
+
+import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponse;
 import com.google.api.client.http.HttpResponseInterceptor;
 import java.io.IOException;
@@ -28,11 +31,16 @@ public class TestResponseInterceptor implements HttpResponseInterceptor {
   private HttpResponse response;
 
   @Override
-  public void interceptResponse(HttpResponse response) throws IOException {
+  public void interceptResponse(HttpResponse response) {
     this.response = response;
   }
 
   public HttpResponse getResponse() {
     return response;
   }
+
+  public HttpRequest getLastRequest() {
+    assertNotNull(response);
+    return response.getRequest();
+  }
 }
diff --git a/src/test/resources/fcm_batch_failure.txt b/src/test/resources/fcm_batch_failure.txt
new file mode 100644
index 0000000..c4fb043
--- /dev/null
+++ b/src/test/resources/fcm_batch_failure.txt
@@ -0,0 +1,80 @@
+--test_boundary
+Content-Type: application/http
+Content-ID: response-1
+
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+
+{
+  "name": "projects/test-project/messages/1"
+}
+
+--test_boundary
+Content-Type: application/http
+Content-ID: response-2
+
+HTTP/1.1 400 Bad Request
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+Content-Type: application/json; charset=UTF-8
+
+{
+  "error": {
+    "code": 400,
+    "message": "The registration token is not a valid FCM registration token",
+    "status": "INVALID_ARGUMENT",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError",
+        "errorCode": "INVALID_ARGUMENT"
+      },
+      {
+        "@type": "type.googleapis.com/google.rpc.BadRequest",
+        "fieldViolations": [
+          {
+            "field": "message.token",
+            "description": "The registration token is not a valid FCM registration token"
+          }
+        ]
+      }
+    ]
+  }
+}
+
+--test_boundary
+Content-Type: application/http
+Content-ID: response-3
+
+HTTP/1.1 400 Bad Request
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+Content-Type: application/json; charset=UTF-8
+
+{
+  "error": {
+    "code": 400,
+    "status": "INVALID_ARGUMENT",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError",
+        "errorCode": "INVALID_ARGUMENT"
+      },
+      {
+        "@type": "type.googleapis.com/google.rpc.BadRequest",
+        "fieldViolations": [
+          {
+            "field": "message.token",
+            "description": "The registration token is not a valid FCM registration token"
+          }
+        ]
+      }
+    ]
+  }
+}
+
+--test_boundary--
\ No newline at end of file
diff --git a/src/test/resources/fcm_batch_success.txt b/src/test/resources/fcm_batch_success.txt
new file mode 100644
index 0000000..e318e97
--- /dev/null
+++ b/src/test/resources/fcm_batch_success.txt
@@ -0,0 +1,29 @@
+--test_boundary
+Content-Type: application/http
+Content-ID: response-1
+
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+
+{
+  "name": "projects/test-project/messages/1"
+}
+
+--test_boundary
+Content-Type: application/http
+Content-ID: response-2
+
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+
+{
+  "name": "projects/test-project/messages/2"
+}
+
+--test_boundary--
\ No newline at end of file
diff --git a/src/test/resources/importUsersError.json b/src/test/resources/importUsersError.json
index 6cffde2..9233f8a 100644
--- a/src/test/resources/importUsersError.json
+++ b/src/test/resources/importUsersError.json
@@ -1,6 +1,6 @@
 {
   "error": [
-    {"index": 0, "errorMessage": "Some error occurred in user1"},
-    {"index": 2, "errorMessage": "Another error occurred in user3"}
+    {"index": 0, "message": "Some error occurred in user1"},
+    {"index": 2, "message": "Another error occurred in user3"}
   ]
 }