diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java index 0e9831588..b38e8d827 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java @@ -537,7 +537,11 @@ FirebaseMessagingClient getMessagingClient() { */ public TopicManagementResponse subscribeToTopic(@NonNull List registrationTokens, @NonNull String topic) throws FirebaseMessagingException { - return subscribeOp(registrationTokens, topic).call(); + try { + return subscribeToTopicAsync(registrationTokens, topic).get(); + } catch (InterruptedException | ExecutionException e) { + throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID); + } } /** @@ -550,10 +554,33 @@ public TopicManagementResponse subscribeToTopic(@NonNull List registrati */ public ApiFuture subscribeToTopicAsync( @NonNull List registrationTokens, @NonNull String topic) { - return subscribeOp(registrationTokens, topic).callAsync(app); + return manageTopicAsync(registrationTokens, topic, true); + } + + /** + * Subscribes a list of registration tokens to a topic using the legacy Instance ID API. + * + * @deprecated Use {@link #subscribeToTopic(List, String)} instead. + */ + @Deprecated + public TopicManagementResponse subscribeToTopicLegacy(@NonNull List registrationTokens, + @NonNull String topic) throws FirebaseMessagingException { + return subscribeLegacyOp(registrationTokens, topic).call(); + } + + /** + * Similar to {@link #subscribeToTopicLegacy(List, String)} but performs the operation + * asynchronously. + * + * @deprecated Use {@link #subscribeToTopicAsync(List, String)} instead. + */ + @Deprecated + public ApiFuture subscribeToTopicLegacyAsync( + @NonNull List registrationTokens, @NonNull String topic) { + return subscribeLegacyOp(registrationTokens, topic).callAsync(app); } - private CallableOperation subscribeOp( + private CallableOperation subscribeLegacyOp( final List registrationTokens, final String topic) { checkRegistrationTokens(registrationTokens); checkTopic(topic); @@ -576,7 +603,11 @@ protected TopicManagementResponse execute() throws FirebaseMessagingException { */ public TopicManagementResponse unsubscribeFromTopic(@NonNull List registrationTokens, @NonNull String topic) throws FirebaseMessagingException { - return unsubscribeOp(registrationTokens, topic).call(); + try { + return unsubscribeFromTopicAsync(registrationTokens, topic).get(); + } catch (InterruptedException | ExecutionException e) { + throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID); + } } /** @@ -590,11 +621,155 @@ public TopicManagementResponse unsubscribeFromTopic(@NonNull List regist */ public ApiFuture unsubscribeFromTopicAsync( @NonNull List registrationTokens, @NonNull String topic) { - return unsubscribeOp(registrationTokens, topic).callAsync(app); + return manageTopicAsync(registrationTokens, topic, false); } - private CallableOperation unsubscribeOp( - final List registrationTokens, final String topic) { + private ApiFuture manageTopicAsync( + final List registrationTokens, final String topic, final boolean isSubscribe) { + checkRegistrationTokens(registrationTokens); + checkTopic(topic); + final String cleanTopic = topic.startsWith("/topics/") + ? topic.substring("/topics/".length()) : topic; + final List immutableTokens = ImmutableList.copyOf(registrationTokens); + + List> futures = new ArrayList<>(immutableTokens.size()); + for (int i = 0; i < immutableTokens.size(); i++) { + futures.add( + manageTopicSingleOp(immutableTokens.get(i), cleanTopic, isSubscribe, i) + .callAsync(app)); + } + + ApiFuture> resultsFuture = ApiFutures.allAsList(futures); + return ApiFutures.transform( + resultsFuture, + (results) -> { + int successCount = 0; + List errors = new ArrayList<>(); + for (TopicResult result : results) { + if (result.isSuccess()) { + successCount++; + } else { + errors.add(new TopicManagementResponse.Error( + result.getIndex(), result.getReason())); + } + } + return new TopicManagementResponse(successCount, errors); + }, + MoreExecutors.directExecutor()); + } + + private CallableOperation manageTopicSingleOp( + final String token, final String topic, final boolean isSubscribe, final int index) { + final FirebaseMessagingClient messagingClient = getMessagingClient(); + return new CallableOperation() { + @Override + protected TopicResult execute() { + try { + if (isSubscribe) { + messagingClient.subscribeToTopic(topic, token); + } else { + messagingClient.unsubscribeFromTopic(topic, token); + } + return TopicResult.success(index); + } catch (FirebaseMessagingException e) { + return TopicResult.error(index, extractReason(e)); + } catch (Exception e) { + return TopicResult.error(index, "UNKNOWN_ERROR"); + } + } + }; + } + + @VisibleForTesting + static String extractReason(FirebaseMessagingException e) { + if (e.getMessagingErrorCode() != null) { + return e.getMessagingErrorCode().name(); + } + if (e.getErrorCode() != null && e.getErrorCode() != ErrorCode.UNKNOWN) { + return e.getErrorCode().name(); + } + if (e.getHttpResponse() != null) { + switch (e.getHttpResponse().getStatusCode()) { + case 400: + return "INVALID_ARGUMENT"; + case 401: + case 403: + return "PERMISSION_DENIED"; + case 404: + return "NOT_FOUND"; + case 429: + return "RESOURCE_EXHAUSTED"; + case 500: + return "INTERNAL"; + case 503: + return "UNAVAILABLE"; + case 504: + return "DEADLINE_EXCEEDED"; + default: + return "UNKNOWN_ERROR"; + } + } + return "UNKNOWN_ERROR"; + } + + private static class TopicResult { + private final int index; + private final boolean success; + private final String reason; + + private TopicResult(int index, boolean success, String reason) { + this.index = index; + this.success = success; + this.reason = reason; + } + + static TopicResult success(int index) { + return new TopicResult(index, true, null); + } + + static TopicResult error(int index, String reason) { + return new TopicResult(index, false, reason); + } + + int getIndex() { + return index; + } + + boolean isSuccess() { + return success; + } + + String getReason() { + return reason; + } + } + + /** + * Unsubscribes a list of registration tokens from a topic using the legacy Instance ID API. + * + * @deprecated Use {@link #unsubscribeFromTopic(List, String)} instead. + */ + @Deprecated + public TopicManagementResponse unsubscribeFromTopicLegacy( + @NonNull List registrationTokens, + @NonNull String topic) throws FirebaseMessagingException { + return unsubscribeLegacyOp(registrationTokens, topic).call(); + } + + /** + * Similar to {@link #unsubscribeFromTopicLegacy(List, String)} but performs the operation + * asynchronously. + * + * @deprecated Use {@link #unsubscribeFromTopicAsync(List, String)} instead. + */ + @Deprecated + public ApiFuture unsubscribeFromTopicLegacyAsync( + @NonNull List registrationTokens, @NonNull String topic) { + return unsubscribeLegacyOp(registrationTokens, topic).callAsync(app); + } + + private CallableOperation + unsubscribeLegacyOp(final List registrationTokens, final String topic) { checkRegistrationTokens(registrationTokens); checkTopic(topic); final InstanceIdClient instanceIdClient = getInstanceIdClient(); diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java index da049565d..a4e436a61 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClient.java @@ -29,4 +29,23 @@ interface FirebaseMessagingClient { */ BatchResponse sendAll(List messages, boolean dryRun) throws FirebaseMessagingException; + /** + * Subscribes a registration token to a topic via the FCM v1 API. + * + * @param topic Name of the topic. + * @param registrationToken A registration token. + * @throws FirebaseMessagingException If an error occurs. + */ + void subscribeToTopic( + String topic, String registrationToken) throws FirebaseMessagingException; + + /** + * Unsubscribes a registration token from a topic via the FCM v1 API. + * + * @param topic Name of the topic. + * @param registrationToken A registration token. + * @throws FirebaseMessagingException If an error occurs. + */ + void unsubscribeFromTopic( + String topic, String registrationToken) throws FirebaseMessagingException; } diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java index 6049b4f4d..9b38ac769 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java @@ -52,6 +52,8 @@ import com.google.firebase.messaging.internal.MessagingServiceErrorResponse; import com.google.firebase.messaging.internal.MessagingServiceResponse; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -60,13 +62,16 @@ */ final class FirebaseMessagingClientImpl implements FirebaseMessagingClient { - private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send"; + private static final String DEFAULT_FCM_HOST = "https://fcm.googleapis.com"; + private static final String FCM_URL = "%s/v1/projects/%s/messages:send"; private static final Map COMMON_HEADERS = ImmutableMap.of( "X-GOOG-API-FORMAT-VERSION", "2", "X-Firebase-Client", "fire-admin-java/" + SdkUtils.getVersion()); + private final String projectId; + private final String fcmHost; private final String fcmSendUrl; private final HttpRequestFactory requestFactory; private final HttpRequestFactory childRequestFactory; @@ -78,7 +83,13 @@ final class FirebaseMessagingClientImpl implements FirebaseMessagingClient { private FirebaseMessagingClientImpl(Builder builder) { checkArgument(!Strings.isNullOrEmpty(builder.projectId)); - this.fcmSendUrl = String.format(FCM_URL, builder.projectId); + this.projectId = builder.projectId; + String host = Strings.isNullOrEmpty(builder.fcmHost) ? DEFAULT_FCM_HOST : builder.fcmHost; + while (host.endsWith("/")) { + host = host.substring(0, host.length() - 1); + } + this.fcmHost = host; + this.fcmSendUrl = String.format(FCM_URL, this.fcmHost, builder.projectId); this.requestFactory = checkNotNull(builder.requestFactory); this.childRequestFactory = checkNotNull(builder.childRequestFactory); this.jsonFactory = checkNotNull(builder.jsonFactory); @@ -182,6 +193,61 @@ public void initialize(HttpRequest request) throws IOException { }; } + @Override + public void subscribeToTopic( + String topic, String registrationToken) throws FirebaseMessagingException { + sendSingleTopicRequest(registrationToken, topic, true); + } + + @Override + public void unsubscribeFromTopic( + String topic, String registrationToken) throws FirebaseMessagingException { + sendSingleTopicRequest(registrationToken, topic, false); + } + + private void sendSingleTopicRequest( + String token, String topic, boolean isSubscribe) throws FirebaseMessagingException { + try { + String topicName = topic.startsWith("/topics/") + ? topic.substring("/topics/".length()) : topic; + String encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()); + String encodedTopic = URLEncoder.encode(topicName, StandardCharsets.UTF_8.name()); + HttpRequestInfo requestInfo; + if (isSubscribe) { + String url = String.format( + "%s/v1/projects/%s/registrations/%s/topicSubscriptions?topic_name=%s", + fcmHost, projectId, encodedToken, encodedTopic); + requestInfo = HttpRequestInfo.buildJsonPostRequest(url, ImmutableMap.of()) + .addAllHeaders(COMMON_HEADERS); + } else { + String url = String.format( + "%s/v1/projects/%s/registrations/%s/topicSubscriptions/%s?allow_missing=true", + fcmHost, projectId, encodedToken, encodedTopic); + requestInfo = HttpRequestInfo.buildDeleteRequest(url) + .addAllHeaders(COMMON_HEADERS); + } + + httpClient.send(requestInfo); + } catch (FirebaseMessagingException e) { + if (isSubscribe && isAlreadyExists(e)) { + return; + } + throw e; + } catch (IOException e) { + throw errorHandler.handleIOException(e); + } + } + + private boolean isAlreadyExists(FirebaseMessagingException e) { + if (e.getHttpResponse() != null && e.getHttpResponse().getStatusCode() == 409) { + return true; + } + if (e.getErrorCode() == ErrorCode.ALREADY_EXISTS || e.getErrorCode() == ErrorCode.CONFLICT) { + return true; + } + return false; + } + static FirebaseMessagingClientImpl fromApp(FirebaseApp app) { String projectId = ImplFirebaseTrampolines.getProjectId(app); checkArgument(!Strings.isNullOrEmpty(projectId), @@ -203,6 +269,7 @@ static Builder builder() { static final class Builder { private String projectId; + private String fcmHost = DEFAULT_FCM_HOST; private HttpRequestFactory requestFactory; private HttpRequestFactory childRequestFactory; private JsonFactory jsonFactory; @@ -215,6 +282,11 @@ Builder setProjectId(String projectId) { return this; } + Builder setFcmHost(String fcmHost) { + this.fcmHost = fcmHost; + return this; + } + Builder setRequestFactory(HttpRequestFactory requestFactory) { this.requestFactory = requestFactory; return this; diff --git a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java index f02590f74..28664efce 100644 --- a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java +++ b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java @@ -61,6 +61,11 @@ public class TopicManagementResponse { this.errors = errors.build(); } + TopicManagementResponse(int successCount, List errors) { + this.successCount = successCount; + this.errors = ImmutableList.copyOf(errors); + } + /** * Gets the number of registration tokens that were successfully subscribed or unsubscribed. * @@ -97,7 +102,7 @@ public static class Error { private final int index; private final String reason; - private Error(int index, String reason) { + Error(int index, String reason) { this.index = index; if (reason == null || reason.trim().isEmpty()) { this.reason = UNKNOWN_ERROR; diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java index 03bfc4327..8052beb36 100644 --- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java +++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingClientImplTest.java @@ -651,4 +651,108 @@ private static Map> buildTestMessages() { return builder.build(); } + + @Test + public void testSubscribeToTopic() throws Exception { + response.setContent("{}"); + client.subscribeToTopic("test-topic", "id1"); + + HttpRequest request = interceptor.getLastRequest(); + assertEquals("POST", request.getRequestMethod()); + assertEquals( + "https://fcm.googleapis.com/v1/projects/test-project/registrations/id1" + + "/topicSubscriptions?topic_name=test-topic", + request.getUrl().toString()); + HttpHeaders headers = request.getHeaders(); + assertEquals("2", headers.get("X-GOOG-API-FORMAT-VERSION")); + assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client")); + } + + @Test + public void testSubscribeToTopic409() throws Exception { + response.setStatusCode(409).setContent("{\"error\": {\"status\": \"ALREADY_EXISTS\"}}"); + client.subscribeToTopic("test-topic", "id1"); + } + + @Test + public void testUnsubscribeFromTopic() throws Exception { + response.setContent("{}"); + client.unsubscribeFromTopic("test-topic", "id1"); + + HttpRequest request = interceptor.getLastRequest(); + assertEquals("DELETE", request.getRequestMethod()); + assertEquals( + "https://fcm.googleapis.com/v1/projects/test-project/registrations/id1" + + "/topicSubscriptions/test-topic?allow_missing=true", + request.getUrl().toString()); + } + + @Test + public void testUnsubscribeFromTopic404() { + response.setStatusCode(404).setContent("{\"error\": {\"status\": \"NOT_FOUND\"}}"); + try { + client.unsubscribeFromTopic("test-topic", "id1"); + fail("No error thrown"); + } catch (FirebaseMessagingException e) { + assertEquals(ErrorCode.NOT_FOUND, e.getErrorCode()); + } + } + + @Test + public void testTopicManagementFcmErrorDetails() { + response.setStatusCode(404).setContent("{\n" + + " \"error\": {\n" + + " \"status\": \"NOT_FOUND\",\n" + + " \"details\": [\n" + + " {\n" + + " \"@type\": \"type.googleapis.com/google.firebase.fcm.v1.FcmError\",\n" + + " \"errorCode\": \"UNREGISTERED\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"); + try { + client.subscribeToTopic("test-topic", "id1"); + fail("No error thrown"); + } catch (FirebaseMessagingException e) { + assertEquals(MessagingErrorCode.UNREGISTERED, e.getMessagingErrorCode()); + } + } + + @Test + public void testTopicManagement500Error() { + response.setStatusCode(500).setContent("{}"); + try { + client.subscribeToTopic("test-topic", "id1"); + fail("No error thrown"); + } catch (FirebaseMessagingException e) { + assertEquals(ErrorCode.INTERNAL, e.getErrorCode()); + } + } + + @Test + public void testFcmHostTrailingSlash() throws Exception { + TestResponseInterceptor testInterceptor = new TestResponseInterceptor(); + MockHttpTransport transport = new MockHttpTransport.Builder() + .setLowLevelHttpResponse(new MockLowLevelHttpResponse().setContent("{}")) + .build(); + FirebaseMessagingClientImpl clientWithSlash = FirebaseMessagingClientImpl.builder() + .setProjectId("test-project") + .setFcmHost("https://custom.fcm.host///") + .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) + .setRequestFactory(transport.createRequestFactory()) + .setChildRequestFactory(ApiClientUtils.getDefaultTransport().createRequestFactory()) + .setResponseInterceptor(testInterceptor) + .build(); + + clientWithSlash.subscribeToTopic("test-topic", "id1"); + HttpRequest request = testInterceptor.getLastRequest(); + assertEquals( + "https://custom.fcm.host/v1/projects/test-project/registrations/id1" + + "/topicSubscriptions?topic_name=test-topic", + request.getUrl().toString()); + assertEquals( + "https://custom.fcm.host/v1/projects/test-project/messages:send", + clientWithSlash.getFcmSendUrl()); + } } diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java index 42a499b75..02e05fdd6 100644 --- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java +++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java @@ -26,23 +26,28 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; 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.common.collect.ImmutableSet; import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; import com.google.firebase.FirebaseOptions; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.OutgoingHttpRequest; import com.google.firebase.TestOnlyImplFirebaseTrampolines; import com.google.firebase.auth.MockGoogleCredentials; import com.google.firebase.internal.Nullable; - +import java.util.ArrayList; 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; @@ -547,9 +552,9 @@ public void testSendEachForMulticastAsyncFailure() throws Exception { @Test public void testInvalidSubscribe() throws FirebaseMessagingException { - MockInstanceIdClient client = MockInstanceIdClient.fromResponse(null); + MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromResponse(null); FirebaseMessaging messaging = getMessagingForTopicManagement( - Suppliers.ofInstance(client)); + Suppliers.ofInstance(client)); for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) { try { @@ -565,55 +570,93 @@ public void testInvalidSubscribe() throws FirebaseMessagingException { @Test public void testSubscribeToTopic() throws FirebaseMessagingException { - MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + MockFirebaseMessagingClient client = + MockFirebaseMessagingClient.fromResponse(TOPIC_MGT_RESPONSE); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); TopicManagementResponse got = messaging.subscribeToTopic( ImmutableList.of("id1", "id2"), "test-topic"); - assertSame(TOPIC_MGT_RESPONSE, got); + assertEquals(2, got.getSuccessCount()); + assertEquals(0, got.getFailureCount()); + assertTrue(got.getErrors().isEmpty()); + assertEquals("test-topic", client.lastTopic); + assertEquals(ImmutableSet.of("id1", "id2"), ImmutableSet.copyOf(client.lastBatch)); } @Test - public void testSubscribeToTopicFailure() { - MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION); + public void testSubscribeToTopicFailure() throws FirebaseMessagingException { + MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); - try { - messaging.subscribeToTopic(ImmutableList.of("id1", "id2"), "test-topic"); - } catch (FirebaseMessagingException e) { - assertSame(TEST_EXCEPTION, e); - } + TopicManagementResponse got = messaging.subscribeToTopic( + ImmutableList.of("id1", "id2"), "test-topic"); + + assertEquals(0, got.getSuccessCount()); + assertEquals(2, got.getFailureCount()); + assertEquals(2, got.getErrors().size()); + assertEquals(0, got.getErrors().get(0).getIndex()); + assertEquals(1, got.getErrors().get(1).getIndex()); } @Test public void testSubscribeToTopicAsync() throws Exception { - MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + MockFirebaseMessagingClient client = + MockFirebaseMessagingClient.fromResponse(TOPIC_MGT_RESPONSE); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); TopicManagementResponse got = messaging.subscribeToTopicAsync( ImmutableList.of("id1", "id2"), "test-topic").get(); - assertSame(TOPIC_MGT_RESPONSE, got); + assertEquals(2, got.getSuccessCount()); + assertEquals(0, got.getFailureCount()); + assertTrue(got.getErrors().isEmpty()); + assertEquals("test-topic", client.lastTopic); + assertEquals(ImmutableSet.of("id1", "id2"), ImmutableSet.copyOf(client.lastBatch)); } @Test - public void testSubscribeToTopicAsyncFailure() throws InterruptedException { - MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION); + public void testSubscribeToTopicAsyncFailure() throws Exception { + MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); - try { - messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get(); - } catch (ExecutionException e) { - assertSame(TEST_EXCEPTION, e.getCause()); - } + TopicManagementResponse got = messaging.subscribeToTopicAsync( + ImmutableList.of("id1", "id2"), "test-topic").get(); + + assertEquals(0, got.getSuccessCount()); + assertEquals(2, got.getFailureCount()); + assertEquals(2, got.getErrors().size()); + } + + @Test + public void testSubscribeToTopicLegacy() throws FirebaseMessagingException { + MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + FirebaseMessaging messaging = + getMessagingForLegacyTopicManagement(Suppliers.ofInstance(client)); + + TopicManagementResponse got = messaging.subscribeToTopicLegacy( + ImmutableList.of("id1", "id2"), "test-topic"); + + assertSame(TOPIC_MGT_RESPONSE, got); + } + + @Test + public void testSubscribeToTopicLegacyAsync() throws Exception { + MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + FirebaseMessaging messaging = + getMessagingForLegacyTopicManagement(Suppliers.ofInstance(client)); + + TopicManagementResponse got = messaging.subscribeToTopicLegacyAsync( + ImmutableList.of("id1", "id2"), "test-topic").get(); + + assertSame(TOPIC_MGT_RESPONSE, got); } @Test public void testInvalidUnsubscribe() throws FirebaseMessagingException { - MockInstanceIdClient client = MockInstanceIdClient.fromResponse(null); + MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromResponse(null); FirebaseMessaging messaging = getMessagingForTopicManagement( - Suppliers.ofInstance(client)); + Suppliers.ofInstance(client)); for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) { try { @@ -629,48 +672,121 @@ public void testInvalidUnsubscribe() throws FirebaseMessagingException { @Test public void testUnsubscribeFromTopic() throws FirebaseMessagingException { - MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + MockFirebaseMessagingClient client = + MockFirebaseMessagingClient.fromResponse(TOPIC_MGT_RESPONSE); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); TopicManagementResponse got = messaging.unsubscribeFromTopic( ImmutableList.of("id1", "id2"), "test-topic"); - assertSame(TOPIC_MGT_RESPONSE, got); + assertEquals(2, got.getSuccessCount()); + assertEquals(0, got.getFailureCount()); + assertTrue(got.getErrors().isEmpty()); + assertEquals("test-topic", client.lastTopic); + assertEquals(ImmutableSet.of("id1", "id2"), ImmutableSet.copyOf(client.lastBatch)); } @Test - public void testUnsubscribeFromTopicFailure() { - MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION); + public void testUnsubscribeFromTopicFailure() throws FirebaseMessagingException { + MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); - try { - messaging.unsubscribeFromTopic(ImmutableList.of("id1", "id2"), "test-topic"); - } catch (FirebaseMessagingException e) { - assertSame(TEST_EXCEPTION, e); - } + TopicManagementResponse got = messaging.unsubscribeFromTopic( + ImmutableList.of("id1", "id2"), "test-topic"); + + assertEquals(0, got.getSuccessCount()); + assertEquals(2, got.getFailureCount()); + assertEquals(2, got.getErrors().size()); + assertEquals(0, got.getErrors().get(0).getIndex()); + assertEquals(1, got.getErrors().get(1).getIndex()); } @Test public void testUnsubscribeFromTopicAsync() throws Exception { - MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + MockFirebaseMessagingClient client = + MockFirebaseMessagingClient.fromResponse(TOPIC_MGT_RESPONSE); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); TopicManagementResponse got = messaging.unsubscribeFromTopicAsync( ImmutableList.of("id1", "id2"), "test-topic").get(); - assertSame(TOPIC_MGT_RESPONSE, got); + assertEquals(2, got.getSuccessCount()); + assertEquals(0, got.getFailureCount()); + assertTrue(got.getErrors().isEmpty()); + assertEquals("test-topic", client.lastTopic); + assertEquals(ImmutableSet.of("id1", "id2"), ImmutableSet.copyOf(client.lastBatch)); } @Test - public void testUnsubscribeFromTopicAsyncFailure() throws InterruptedException { - MockInstanceIdClient client = MockInstanceIdClient.fromException(TEST_EXCEPTION); + public void testUnsubscribeFromTopicAsyncFailure() throws Exception { + MockFirebaseMessagingClient client = MockFirebaseMessagingClient.fromException(TEST_EXCEPTION); FirebaseMessaging messaging = getMessagingForTopicManagement(Suppliers.ofInstance(client)); - try { - messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get(); - } catch (ExecutionException e) { - assertSame(TEST_EXCEPTION, e.getCause()); - } + TopicManagementResponse got = messaging.unsubscribeFromTopicAsync( + ImmutableList.of("id1", "id2"), "test-topic").get(); + + assertEquals(0, got.getSuccessCount()); + assertEquals(2, got.getFailureCount()); + assertEquals(2, got.getErrors().size()); + } + + @Test + public void testExtractReason() { + FirebaseMessagingException messagingError = + FirebaseMessagingException.withMessagingErrorCode( + new FirebaseException(ErrorCode.INVALID_ARGUMENT, "bad arg", null), + MessagingErrorCode.UNREGISTERED); + assertEquals("UNREGISTERED", FirebaseMessaging.extractReason(messagingError)); + + FirebaseMessagingException platformError = + new FirebaseMessagingException(ErrorCode.PERMISSION_DENIED, "permission denied"); + assertEquals("PERMISSION_DENIED", FirebaseMessaging.extractReason(platformError)); + + IncomingHttpResponse resp503 = new IncomingHttpResponse( + new HttpResponseException.Builder(503, "Unavailable", new HttpHeaders()).build(), + new OutgoingHttpRequest("GET", "https://example.com")); + FirebaseMessagingException unavailableError = + FirebaseMessagingException.withMessagingErrorCode( + new FirebaseException(ErrorCode.UNKNOWN, "unavailable", null, resp503), + null); + assertEquals("UNAVAILABLE", FirebaseMessaging.extractReason(unavailableError)); + + IncomingHttpResponse resp504 = new IncomingHttpResponse( + new HttpResponseException.Builder(504, "Gateway Timeout", new HttpHeaders()).build(), + new OutgoingHttpRequest("GET", "https://example.com")); + FirebaseMessagingException timeoutError = + FirebaseMessagingException.withMessagingErrorCode( + new FirebaseException(ErrorCode.UNKNOWN, "timeout", null, resp504), + null); + assertEquals("DEADLINE_EXCEEDED", FirebaseMessaging.extractReason(timeoutError)); + + FirebaseMessagingException unknownError = + new FirebaseMessagingException(ErrorCode.UNKNOWN, "something unknown"); + assertEquals("UNKNOWN_ERROR", FirebaseMessaging.extractReason(unknownError)); + } + + @Test + public void testUnsubscribeFromTopicLegacy() throws FirebaseMessagingException { + MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + FirebaseMessaging messaging = + getMessagingForLegacyTopicManagement(Suppliers.ofInstance(client)); + + TopicManagementResponse got = messaging.unsubscribeFromTopicLegacy( + ImmutableList.of("id1", "id2"), "test-topic"); + + assertSame(TOPIC_MGT_RESPONSE, got); + } + + @Test + public void testUnsubscribeFromTopicLegacyAsync() throws Exception { + MockInstanceIdClient client = MockInstanceIdClient.fromResponse(TOPIC_MGT_RESPONSE); + FirebaseMessaging messaging = + getMessagingForLegacyTopicManagement(Suppliers.ofInstance(client)); + + TopicManagementResponse got = messaging.unsubscribeFromTopicLegacyAsync( + ImmutableList.of("id1", "id2"), "test-topic").get(); + + assertSame(TOPIC_MGT_RESPONSE, got); } private FirebaseMessaging getMessagingForSend( @@ -684,6 +800,16 @@ private FirebaseMessaging getMessagingForSend( } private FirebaseMessaging getMessagingForTopicManagement( + Supplier supplier) { + FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS); + return FirebaseMessaging.builder() + .setFirebaseApp(app) + .setMessagingClient(supplier) + .setInstanceIdClient(Suppliers.ofInstance(null)) + .build(); + } + + private FirebaseMessaging getMessagingForLegacyTopicManagement( Supplier supplier) { FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS); return FirebaseMessaging.builder() @@ -697,11 +823,14 @@ private static class MockFirebaseMessagingClient implements FirebaseMessagingCli private String messageId; private BatchResponse batchResponse; + private TopicManagementResponse topicManagementResponse; private FirebaseMessagingException exception; private Message lastMessage; private boolean isLastDryRun; private ImmutableMap messageMap; + private String lastTopic; + private List lastBatch; private MockFirebaseMessagingClient( String messageId, BatchResponse batchResponse, FirebaseMessagingException exception) { @@ -710,6 +839,12 @@ private MockFirebaseMessagingClient( this.exception = exception; } + private MockFirebaseMessagingClient( + TopicManagementResponse topicManagementResponse, FirebaseMessagingException exception) { + this.topicManagementResponse = topicManagementResponse; + this.exception = exception; + } + private MockFirebaseMessagingClient( Map messageMap, FirebaseMessagingException exception) { this.messageMap = ImmutableMap.copyOf(messageMap); @@ -720,6 +855,10 @@ static MockFirebaseMessagingClient fromMessageId(String messageId) { return new MockFirebaseMessagingClient(messageId, null, null); } + static MockFirebaseMessagingClient fromResponse(TopicManagementResponse response) { + return new MockFirebaseMessagingClient(response, null); + } + static MockFirebaseMessagingClient fromMessageMap(Map messageMap) { return new MockFirebaseMessagingClient(messageMap, null); } @@ -753,6 +892,32 @@ public BatchResponse sendAll( List messages, boolean dryRun) throws FirebaseMessagingException { return batchResponse; } + + @Override + public synchronized void subscribeToTopic( + String topic, String registrationToken) throws FirebaseMessagingException { + this.lastTopic = topic; + if (this.lastBatch == null) { + this.lastBatch = new ArrayList<>(); + } + this.lastBatch.add(registrationToken); + if (exception != null) { + throw exception; + } + } + + @Override + public synchronized void unsubscribeFromTopic( + String topic, String registrationToken) throws FirebaseMessagingException { + this.lastTopic = topic; + if (this.lastBatch == null) { + this.lastBatch = new ArrayList<>(); + } + this.lastBatch.add(registrationToken); + if (exception != null) { + throw exception; + } + } } private static class MockInstanceIdClient implements InstanceIdClient {