Repository: firebase/firebase-admin-java
Pull request #815 (2023-06-02 17:32:58)

feat(fcm): Implement `sendEach`, `sendEachAsync`, `sendEachForMulticast` and `sendEachForMulticastAsync` (#785)
* Add org.hamcrest as dependency for unit tests

* Implement sendEach, sendEachAsync, sendEachForMulticast and sendEachForMulticastAsync

`sendEach` vs `sendAll`
1. `sendEach` sends one HTTP request to V1 Send endpoint for each message in the array. `sendAll` sends only one HTTP request to V1 Batch Send endpoint to send all messages in the array.
2. `sendEach` calls `messagingClient.send` to send each message and constructs a `SendResponse` with the returned `messageId`. If `messagingClient.send` throws out an exception, `sendEach` will catch the exception and also turn it into a `SendResponse` with the exception in it. `sendEach` calls `ApiFutures.allAsList().get()` to execute all `messagingClient.send` calls asynchronously and wait for all of them to complete and construct a `BatchResponse` with all `SendResponse`s. Therefore, unlike `sendAll`, `sendEach` does not always throw an error for a total failure. It can also return a `BatchResponse` with only errors in it.

`sendEachForMulticast` calls `sendEach` under the hood. `sendEachAsync` is the async version of `sendEach`. `sendEachForMulticastAsync` is the async version of `sendEachForMulticast`.

* Add integration tests for batch-send re-implementation: testSendEach(), testSendFiveHundredWithSendEach(), testSendEachForMulticast()



Patch:
diff --git a/pom.xml b/pom.xml
index dfd425e..0a67f6c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -482,5 +482,11 @@
             <version>2.1.1</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest</artifactId>
+            <version>2.2</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
index 95366ea..882fec8 100644
--- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
@@ -20,18 +20,22 @@ import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
+import com.google.firebase.ErrorCode;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.internal.CallableOperation;
 import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
 
+import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.ExecutionException;
 
 /**
  * This class is the entry point for all server-side Firebase Cloud Messaging actions.
@@ -91,7 +95,7 @@ public class FirebaseMessaging {
    *
    * <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. The {@code dryRun}
-   * option is useful for determining whether an FCM registration has been deleted. However, it 
+   * option is useful for determining whether an FCM registration has been deleted. However, it
    * cannot be used to validate APNs tokens.
    *
    * @param message A non-null {@link Message} to be sent.
@@ -139,6 +143,191 @@ public class FirebaseMessaging {
     };
   }
 
+  /**
+   * Sends each message in the given list via Firebase Cloud Messaging.
+   * Unlike {@link #sendAll(List)}, this method makes an HTTP call for each message in the
+   * given array.
+   *
+   * <p>The list of responses obtained by calling {@link BatchResponse#getResponses()} on the return
+   * value is in the same order as the input list.
+   *
+   * @param messages A non-null, non-empty list containing up to 500 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 or a {@link BatchResponse} with all failures indicates a total
+   *     failure, meaning that none of the messages in the list could be sent. Partial failures or
+   *     no failures are only indicated by a {@link BatchResponse}.
+   */
+  public BatchResponse sendEach(@NonNull List<Message> messages) throws FirebaseMessagingException {
+    return sendEachOp(messages, false).call();
+  }
+
+
+  /**
+   * Sends each message in the given list via Firebase Cloud Messaging.
+   * Unlike {@link #sendAll(List)}, this method makes an HTTP call for each message in the
+   * given array.
+   *
+   * <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. The {@code dryRun}
+   * option is useful for determining whether an FCM registration has been deleted. But it cannot be
+   * used to validate APNs tokens.
+   *
+   * <p>The list of responses obtained by calling {@link BatchResponse#getResponses()} on the return
+   * value is in the same order as the input list.
+   *
+   * @param messages A non-null, non-empty list containing up to 500 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 or a {@link BatchResponse} with all failures indicates a total
+   *     failure, meaning that none of the messages in the list could be sent. Partial failures or
+   *     no failures are only indicated by a {@link BatchResponse}.
+   */
+  public BatchResponse sendEach(
+      @NonNull List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
+    return sendEachOp(messages, dryRun).call();
+  }
+
+  /**
+   * Similar to {@link #sendEach(List)} but performs the operation asynchronously.
+   *
+   * @param messages A non-null, non-empty list containing up to 500 messages.
+   * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
+   *     the messages have been sent.
+   */
+  public ApiFuture<BatchResponse> sendEachAsync(@NonNull List<Message> messages) {
+    return sendEachOp(messages, false).callAsync(app);
+  }
+
+  /**
+   * Similar to {@link #sendEach(List, boolean)} but performs the operation asynchronously.
+   *
+   * @param messages A non-null, non-empty list containing up to 500 messages.
+   * @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> sendEachAsync(@NonNull List<Message> messages, boolean dryRun) {
+    return sendEachOp(messages, dryRun).callAsync(app);
+  }
+
+  private CallableOperation<BatchResponse, FirebaseMessagingException> sendEachOp(
+      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() <= 500,
+        "messages list must not contain more than 500 elements");
+
+    return new CallableOperation<BatchResponse, FirebaseMessagingException>() {
+      @Override
+      protected BatchResponse execute() throws FirebaseMessagingException {
+        List<ApiFuture<SendResponse>> list = new ArrayList<>();
+        for (Message message : immutableMessages) {
+          ApiFuture<SendResponse> messageId = sendOpForSendResponse(message, dryRun).callAsync(app);
+          list.add(messageId);
+        }
+        try {
+          List<SendResponse> responses = ApiFutures.allAsList(list).get();
+          return new BatchResponseImpl(responses);
+        } catch (InterruptedException | ExecutionException e) {
+          throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID);
+        }
+      }
+    };
+  }
+
+  private CallableOperation<SendResponse, FirebaseMessagingException> sendOpForSendResponse(
+      final Message message, final boolean dryRun) {
+    checkNotNull(message, "message must not be null");
+    final FirebaseMessagingClient messagingClient = getMessagingClient();
+    return new CallableOperation<SendResponse, FirebaseMessagingException>() {
+      @Override
+      protected SendResponse execute() {
+        try {
+          String messageId = messagingClient.send(message, dryRun);
+          return SendResponse.fromMessageId(messageId);
+        } catch (FirebaseMessagingException e) {
+          return SendResponse.fromException(e);
+        }
+      }
+    };
+  }
+
+  /**
+   * Sends the given multicast message to all the FCM registration tokens specified in it.
+   *
+   * <p>This method uses the {@link #sendEach(List)} API under the hood to send the given
+   * message to all the target recipients. The list of responses obtained by calling
+   * {@link BatchResponse#getResponses()} on the return value is in the same order as the
+   * 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 or a {@link BatchResponse} with all failures indicates a total
+   *     failure, meaning that none of the messages in the list could be sent. Partial failures or
+   *     no failures are only indicated by a {@link BatchResponse}.
+   */
+  public BatchResponse sendEachForMulticast(
+      @NonNull MulticastMessage message) throws FirebaseMessagingException {
+    return sendEachForMulticast(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. The {@code dryRun}
+   * option is useful for determining whether an FCM registration has been deleted. But it cannot be
+   * used to validate APNs tokens.
+   *
+   * <p>This method uses the {@link #sendEach(List)} API under the hood to send the given
+   * message to all the target recipients. The list of responses obtained by calling
+   * {@link BatchResponse#getResponses()} on the return value is in the same order as the
+   * 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 or a {@link BatchResponse} with all failures indicates a total
+   *     failure, meaning that none of the messages in the list could be sent. Partial failures or
+   *     no failures are only indicated by a {@link BatchResponse}.
+   */
+  public BatchResponse sendEachForMulticast(@NonNull MulticastMessage message, boolean dryRun)
+    throws FirebaseMessagingException {
+    checkNotNull(message, "multicast message must not be null");
+    return sendEach(message.getMessageList(), dryRun);
+  }
+
+  /**
+   * Similar to {@link #sendEachForMulticast(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> sendEachForMulticastAsync(@NonNull MulticastMessage message) {
+    return sendEachForMulticastAsync(message, false);
+  }
+
+  /**
+   * Similar to {@link #sendEachForMulticast(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> sendEachForMulticastAsync(
+      @NonNull MulticastMessage message, boolean dryRun) {
+    checkNotNull(message, "multicast message must not be null");
+    return sendEachAsync(message.getMessageList(), 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
@@ -150,8 +339,10 @@ public class FirebaseMessaging {
    * @param messages A non-null, non-empty list containing up to 500 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.
+   *     delivery. An exception here indicates a total failure, meaning that none of the messages in
+   *     the list could be sent. Partial failures are indicated by a {@link BatchResponse} return
+   *     value.
+   * @deprecated Use {@link #sendEach(List)} instead.
    */
   public BatchResponse sendAll(
       @NonNull List<Message> messages) throws FirebaseMessagingException {
@@ -175,8 +366,10 @@ public class FirebaseMessaging {
    * @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.
+   *     delivery. An exception here indicates a total failure, meaning that none of the messages in
+   *     the list could be sent. Partial failures are indicated by a {@link BatchResponse} return
+   *     value.
+   * @deprecated Use {@link #sendEach(List, boolean)} instead.
    */
   public BatchResponse sendAll(
       @NonNull List<Message> messages, boolean dryRun) throws FirebaseMessagingException {
@@ -187,8 +380,9 @@ public class FirebaseMessaging {
    * Similar to {@link #sendAll(List)} but performs the operation asynchronously.
    *
    * @param messages A non-null, non-empty list containing up to 500 messages.
-   * @return @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
+   * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
    *     the messages have been sent.
+   * @deprecated Use {@link #sendEachAsync(List)} instead.
    */
   public ApiFuture<BatchResponse> sendAllAsync(@NonNull List<Message> messages) {
     return sendAllAsync(messages, false);
@@ -199,8 +393,9 @@ public class FirebaseMessaging {
    *
    * @param messages A non-null, non-empty list containing up to 500 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
+   * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
    *     the messages have been sent, or when the emulation has finished.
+   * @deprecated Use {@link #sendEachAsync(List, boolean)} instead.
    */
   public ApiFuture<BatchResponse> sendAllAsync(
       @NonNull List<Message> messages, boolean dryRun) {
@@ -218,9 +413,10 @@ public class FirebaseMessaging {
    * @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}
+   *     delivery. An exception here indicates a total failure, meaning that the messages could not
+   *     be delivered to any recipient. Partial failures are indicated by a {@link BatchResponse}
    *     return value.
+   * @deprecated Use {@link #sendEachForMulticast(MulticastMessage)} instead.
    */
   public BatchResponse sendMulticast(
       @NonNull MulticastMessage message) throws FirebaseMessagingException {
@@ -244,9 +440,10 @@ public class FirebaseMessaging {
    * @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}
+   *     delivery. An exception here indicates a total failure, meaning that the messages could not
+   *     be delivered to any recipient. Partial failures are indicated by a {@link BatchResponse}
    *     return value.
+   * @deprecated Use {@link #sendEachForMulticast(MulticastMessage, boolean)} instead.
    */
   public BatchResponse sendMulticast(
       @NonNull MulticastMessage message, boolean dryRun) throws FirebaseMessagingException {
@@ -261,6 +458,7 @@ public class FirebaseMessaging {
    * @param message A non-null {@link MulticastMessage}.
    * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when
    *     the messages have been sent.
+   * @deprecated Use {@link #sendEachForMulticastAsync(MulticastMessage)} instead.
    */
   public ApiFuture<BatchResponse> sendMulticastAsync(@NonNull MulticastMessage message) {
     return sendMulticastAsync(message, false);
@@ -274,6 +472,7 @@ public class FirebaseMessaging {
    * @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.
+   * @deprecated Use {@link #sendEachForMulticastAsync(MulticastMessage, boolean)} instead.
    */
   public ApiFuture<BatchResponse> sendMulticastAsync(
       @NonNull MulticastMessage message, boolean dryRun) {


Test patch:
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
index 9ae7bba..bebae6e 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
@@ -96,6 +96,103 @@ public class FirebaseMessagingIT {
     }
   }
 
+  @Test
+  public void testSendEach() throws Exception {
+    List<Message> messages = new ArrayList<>();
+    messages.add(
+        Message.builder()
+          .setNotification(Notification.builder()
+              .setTitle("Title")
+              .setBody("Body")
+              .build())
+          .setTopic("foo-bar")
+          .build());
+    messages.add(
+        Message.builder()
+          .setNotification(Notification.builder()
+              .setTitle("Title")
+              .setBody("Body")
+              .build())
+          .setTopic("foo-bar")
+          .build());
+    messages.add(
+        Message.builder()
+          .setNotification(Notification.builder()
+              .setTitle("Title")
+              .setBody("Body")
+              .build())
+          .setToken("not-a-token")
+          .build());
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendEach(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(ErrorCode.INVALID_ARGUMENT, exception.getErrorCode());
+  }
+
+  @Test
+  public void testSendFiveHundredWithSendEach() throws Exception {
+    List<Message> messages = new ArrayList<>();
+    for (int i = 0; i < 500; i++) {
+      messages.add(Message.builder().setTopic("foo-bar-" + (i % 10)).build());
+    }
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendEach(messages, true);
+
+    assertEquals(500, response.getResponses().size());
+    assertEquals(500, 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 testSendEachForMulticast() throws Exception {
+    MulticastMessage multicastMessage = MulticastMessage.builder()
+        .setNotification(Notification.builder()
+            .setTitle("Title")
+            .setBody("Body")
+            .build())
+        .addToken("not-a-token")
+        .addToken("also-not-a-token")
+        .build();
+
+    BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(
+        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 testSendAll() throws Exception {
     List<Message> messages = new ArrayList<>();
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
index 8e8e79e..dc07ce0 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -16,6 +16,9 @@
 
 package com.google.firebase.messaging;
 
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.is;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
@@ -27,13 +30,19 @@ import com.google.api.client.json.GenericJson;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.firebase.ErrorCode;
 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.Nullable;
+
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
+
 import org.junit.After;
 import org.junit.Test;
 
@@ -46,6 +55,9 @@ public class FirebaseMessagingTest {
   private static final Message EMPTY_MESSAGE = Message.builder()
       .setTopic("test-topic")
       .build();
+  private static final Message EMPTY_MESSAGE_2 = Message.builder()
+      .setTopic("test-topic2")
+      .build();
   private static final MulticastMessage TEST_MULTICAST_MESSAGE = MulticastMessage.builder()
       .addToken("test-fcm-token1")
       .addToken("test-fcm-token2")
@@ -262,6 +274,277 @@ public class FirebaseMessagingTest {
     assertFalse(client.isLastDryRun);
   }
 
+  @Test
+  public void testSendEachWithNull() throws  FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    try {
+      messaging.sendEach(null);
+      fail("No error thrown for null message list");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+
+    assertNull(client.lastMessage);
+  }
+
+  @Test
+  public void testSendEachWithEmptyList() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    try {
+      messaging.sendEach(ImmutableList.<Message>of());
+      fail("No error thrown for empty message list");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    assertNull(client.lastMessage);
+  }
+
+  @Test
+  public void testSendEachWithTooManyMessages() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+    ImmutableList.Builder<Message> listBuilder = ImmutableList.builder();
+    for (int i = 0; i < 501; i++) {
+      listBuilder.add(Message.builder().setTopic("topic").build());
+    }
+
+    try {
+      messaging.sendEach(listBuilder.build(), false);
+      fail("No error thrown for too many messages in the list");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    assertNull(client.lastMessage);
+  }
+
+  @Test
+  public void testSendEach() throws FirebaseMessagingException {
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE_2);
+    ImmutableList<String> messageIds = ImmutableList.of("test1", "test2");
+    Map<Message, SendResponse> messageMap = new HashMap<>();
+    for (int i = 0; i < 2; i++) {
+      messageMap.put(messages.get(i), SendResponse.fromMessageId(messageIds.get(i)));
+    }
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageMap(messageMap);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEach(messages);
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals(messageIds.get(i), response.getResponses().get(i).getMessageId());
+    }
+    assertThat(client.lastMessage, anyOf(is(EMPTY_MESSAGE), is(EMPTY_MESSAGE_2)));
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachDryRun() throws FirebaseMessagingException {
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE_2);
+    ImmutableList<String> messageIds = ImmutableList.of("test1", "test2");
+    Map<Message, SendResponse> messageMap = new HashMap<>();
+    for (int i = 0; i < 2; i++) {
+      messageMap.put(messages.get(i), SendResponse.fromMessageId(messageIds.get(i)));
+    }
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageMap(messageMap);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEach(messages, true);
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals(messageIds.get(i), response.getResponses().get(i).getMessageId());
+    }
+    assertThat(client.lastMessage, anyOf(is(EMPTY_MESSAGE), is(EMPTY_MESSAGE_2)));
+    assertTrue(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachFailure() throws FirebaseMessagingException {
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE_2);
+    Map<Message, SendResponse> messageMap = new HashMap<>();
+    messageMap.put(messages.get(0), SendResponse.fromMessageId("test"));
+    messageMap.put(messages.get(1), SendResponse.fromException(TEST_EXCEPTION));
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageMap(messageMap);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response =  messaging.sendEach(messages);
+
+    assertEquals(1, response.getFailureCount());
+    assertEquals(1, response.getSuccessCount());
+    assertEquals("test", response.getResponses().get(0).getMessageId());
+    assertEquals(TEST_EXCEPTION, response.getResponses().get(1).getException());
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachAsync() throws Exception {
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE_2);
+    ImmutableList<String> messageIds = ImmutableList.of("test1", "test2");
+    Map<Message, SendResponse> messageMap = new HashMap<>();
+    for (int i = 0; i < 2; i++) {
+      messageMap.put(messages.get(i), SendResponse.fromMessageId(messageIds.get(i)));
+    }
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageMap(messageMap);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEachAsync(messages).get();
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals(messageIds.get(i), response.getResponses().get(i).getMessageId());
+    }
+    assertThat(client.lastMessage, anyOf(is(EMPTY_MESSAGE), is(EMPTY_MESSAGE_2)));
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachAsyncDryRun() throws Exception {
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE_2);
+    ImmutableList<String> messageIds = ImmutableList.of("test1", "test2");
+    Map<Message, SendResponse> messageMap = new HashMap<>();
+    for (int i = 0; i < 2; i++) {
+      messageMap.put(messages.get(i), SendResponse.fromMessageId(messageIds.get(i)));
+    }
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageMap(messageMap);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEachAsync(messages, true).get();
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals(messageIds.get(i), response.getResponses().get(i).getMessageId());
+    }
+    assertThat(client.lastMessage, anyOf(is(EMPTY_MESSAGE), is(EMPTY_MESSAGE_2)));
+    assertTrue(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachAsyncFailure() throws Exception {
+    ImmutableList<Message> messages = ImmutableList.of(EMPTY_MESSAGE, EMPTY_MESSAGE_2);
+    Map<Message, SendResponse> messageMap = new HashMap<>();
+    messageMap.put(messages.get(0), SendResponse.fromMessageId("test"));
+    messageMap.put(messages.get(1), SendResponse.fromException(TEST_EXCEPTION));
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageMap(messageMap);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response =  messaging.sendEachAsync(messages).get();
+
+    assertEquals(1, response.getFailureCount());
+    assertEquals(1, response.getSuccessCount());
+    assertEquals("test", response.getResponses().get(0).getMessageId());
+    assertEquals(TEST_EXCEPTION, response.getResponses().get(1).getException());
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachForMulticastWithNull() throws  FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    try {
+      messaging.sendEachForMulticast(null);
+      fail("No error thrown for null multicast message");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+
+    assertNull(client.lastMessage);
+  }
+
+  @Test
+  public void testSendEachForMulticast() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEachForMulticast(TEST_MULTICAST_MESSAGE);
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals("test", response.getResponses().get(i).getMessageId());
+    }
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachForMulticastDryRun() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEachForMulticast(TEST_MULTICAST_MESSAGE, true);
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals("test", response.getResponses().get(i).getMessageId());
+    }
+    assertTrue(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachForMulticastFailure() throws FirebaseMessagingException {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response =  messaging.sendEachForMulticast(TEST_MULTICAST_MESSAGE);
+
+    assertEquals(2, response.getFailureCount());
+    assertEquals(0, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals(TEST_EXCEPTION, response.getResponses().get(i).getException());
+    }
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachForMulticastAsync() throws Exception {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEachForMulticastAsync(TEST_MULTICAST_MESSAGE).get();
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals("test", response.getResponses().get(i).getMessageId());
+    }
+    assertFalse(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachForMulticastAsyncDryRun() throws Exception {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId("test");
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response = messaging.sendEachForMulticastAsync(
+        TEST_MULTICAST_MESSAGE, true).get();
+
+    assertEquals(2, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals("test", response.getResponses().get(i).getMessageId());
+    }
+    assertTrue(client.isLastDryRun);
+  }
+
+  @Test
+  public void testSendEachForMulticastAsyncFailure() throws Exception {
+    MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION);
+    FirebaseMessaging messaging = getMessagingForSend(Suppliers.ofInstance(client));
+
+    BatchResponse response =  messaging.sendEachForMulticastAsync(TEST_MULTICAST_MESSAGE).get();
+
+    assertEquals(2, response.getFailureCount());
+    assertEquals(0, response.getSuccessCount());
+    for (int i = 0; i < 2; i++) {
+      assertEquals(TEST_EXCEPTION, response.getResponses().get(i).getException());
+    }
+    assertFalse(client.isLastDryRun);
+  }
+
   @Test
   public void testSendAllWithNull() throws  FirebaseMessagingException {
     MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromMessageId(null);
@@ -681,6 +964,7 @@ public class FirebaseMessagingTest {
     private Message lastMessage;
     private List<Message> lastBatch;
     private boolean isLastDryRun;
+    private ImmutableMap<Message, SendResponse> messageMap;
 
     private MockFirebaseMessagingClient(
         String messageId, BatchResponse batchResponse, FirebaseMessagingException exception) {
@@ -689,10 +973,20 @@ public class FirebaseMessagingTest {
       this.exception = exception;
     }
 
+    private MockFirebaseMessagingClient(
+        Map<Message, SendResponse> messageMap, FirebaseMessagingException exception) {
+      this.messageMap = ImmutableMap.copyOf(messageMap);
+      this.exception = exception;
+    }
+
     static MockFirebaseMessagingClient fromMessageId(String messageId) {
       return new MockFirebaseMessagingClient(messageId, null, null);
     }
 
+    static MockFirebaseMessagingClient fromMessageMap(Map<Message, SendResponse> messageMap) {
+      return new MockFirebaseMessagingClient(messageMap, null);
+    }
+
     static MockFirebaseMessagingClient fromBatchResponse(BatchResponse batchResponse) {
       return new MockFirebaseMessagingClient(null, batchResponse, null);
     }
@@ -702,13 +996,23 @@ public class FirebaseMessagingTest {
     }
 
     @Override
+    @Nullable
     public String send(Message message, boolean dryRun) throws FirebaseMessagingException {
       lastMessage = message;
       isLastDryRun = dryRun;
       if (exception != null) {
         throw exception;
       }
-      return messageId;
+      if (messageMap == null) {
+        return messageId;
+      }
+      if (!messageMap.containsKey(message)) {
+        return null;
+      }
+      if (messageMap.get(message).getException() != null) {
+        throw messageMap.get(message).getException();
+      }
+      return messageMap.get(message).getMessageId();
     }
 
     @Override