diff --git a/service/config/sample.yml b/service/config/sample.yml
index 26b0f34fc..e1109e885 100644
--- a/service/config/sample.yml
+++ b/service/config/sample.yml
@@ -206,6 +206,10 @@ gcm: # GCM Configuration
senderId: 123456789
apiKey: unset
+fcm: # FCM configuration
+ credentials: |
+ { "json": true }
+
cdn:
accessKey: test # AWS Access Key ID
accessSecret: test # AWS Access Secret
diff --git a/service/pom.xml b/service/pom.xml
index 27400d9b6..2f6561cb6 100644
--- a/service/pom.xml
+++ b/service/pom.xml
@@ -195,6 +195,39 @@
commons-csv
+
+ com.google.firebase
+ firebase-admin
+ 9.0.0
+
+
+
+
+ com.google.api-client
+ google-api-client
+
+
+
+ com.google.oauth-client
+ google-oauth-client
+
+
+
+
+
+ com.google.api-client
+ google-api-client
+ 1.35.1
+
+
+
+ com.google.oauth-client
+ google-oauth-client
+ 1.34.1
+
+
com.google.code.findbugs
jsr305
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
index 3d76a4f99..a4a6fedb6 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
@@ -27,6 +27,7 @@ import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
+import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration;
@@ -168,6 +169,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private GcmConfiguration gcm;
+ @Valid
+ @NotNull
+ @JsonProperty
+ private FcmConfiguration fcm;
+
@Valid
@NotNull
@JsonProperty
@@ -340,6 +346,10 @@ public class WhisperServerConfiguration extends Configuration {
return gcm;
}
+ public FcmConfiguration getFcmConfiguration() {
+ return fcm;
+ }
+
public ApnConfiguration getApnConfiguration() {
return apn;
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java
new file mode 100644
index 000000000..494d23532
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java
@@ -0,0 +1,11 @@
+/*
+ * Copyright 2013-2022 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.configuration;
+
+import javax.validation.constraints.NotBlank;
+
+public record FcmConfiguration(@NotBlank String credentials) {
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java
new file mode 100644
index 000000000..1d6c92ae1
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2013-2022 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.push;
+
+import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
+
+import com.google.api.core.ApiFuture;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.messaging.AndroidConfig;
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.Message;
+import io.micrometer.core.instrument.Metrics;
+import io.micrometer.core.instrument.Tags;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.storage.Device;
+import org.whispersystems.textsecuregcm.util.Util;
+
+public class FcmSender {
+
+ private final Logger logger = LoggerFactory.getLogger(FcmSender.class);
+
+ private final AccountsManager accountsManager;
+ private final ExecutorService executor;
+ private final FirebaseMessaging firebaseMessagingClient;
+
+ private static final String SENT_MESSAGE_COUNTER_NAME = name(FcmSender.class, "sentMessage");
+
+ public FcmSender(ExecutorService executor, AccountsManager accountsManager, String credentials) throws IOException {
+ try (final ByteArrayInputStream credentialInputStream = new ByteArrayInputStream(credentials.getBytes(StandardCharsets.UTF_8))) {
+ FirebaseOptions options = FirebaseOptions.builder()
+ .setCredentials(GoogleCredentials.fromStream(credentialInputStream))
+ .build();
+
+ FirebaseApp.initializeApp(options);
+ }
+
+ this.executor = executor;
+ this.accountsManager = accountsManager;
+ this.firebaseMessagingClient = FirebaseMessaging.getInstance();
+ }
+
+ @VisibleForTesting
+ public FcmSender(ExecutorService executor, AccountsManager accountsManager, FirebaseMessaging firebaseMessagingClient) {
+ this.accountsManager = accountsManager;
+ this.executor = executor;
+ this.firebaseMessagingClient = firebaseMessagingClient;
+ }
+
+ public void sendMessage(GcmMessage message) {
+ Message.Builder builder = Message.builder()
+ .setToken(message.getGcmId())
+ .setAndroidConfig(AndroidConfig.builder()
+ .setPriority(AndroidConfig.Priority.HIGH)
+ .build());
+
+ final String key = switch (message.getType()) {
+ case NOTIFICATION -> "notification";
+ case CHALLENGE -> "challenge";
+ case RATE_LIMIT_CHALLENGE -> "rateLimitChallenge";
+ };
+
+ builder.putData(key, message.getData().orElse(""));
+
+ final ApiFuture sendFuture = firebaseMessagingClient.sendAsync(builder.build());
+
+ sendFuture.addListener(() -> {
+ Tags tags = Tags.of("type", key);
+
+ try {
+ sendFuture.get();
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof final FirebaseMessagingException firebaseMessagingException) {
+ tags = tags.and("errorCode", firebaseMessagingException.getMessagingErrorCode().name().toLowerCase());
+
+ switch (firebaseMessagingException.getMessagingErrorCode()) {
+
+ case UNREGISTERED -> handleBadRegistration(message);
+ case THIRD_PARTY_AUTH_ERROR, INVALID_ARGUMENT, INTERNAL, QUOTA_EXCEEDED, SENDER_ID_MISMATCH, UNAVAILABLE ->
+ logger.debug("Unrecoverable Error ::: (error={}}), (gcm_id={}}), (destination={}}), (device_id={}})",
+ firebaseMessagingException.getMessagingErrorCode(), message.getGcmId(), message.getUuid(), message.getDeviceId());
+ }
+ } else {
+ throw new RuntimeException("Failed to send message", e);
+ }
+ } catch (InterruptedException e) {
+ // This should never happen; by definition, if we're in the future's listener, the future is done, and so
+ // `get()` should return immediately.
+ throw new IllegalStateException("Interrupted while getting send future result", e);
+ } finally {
+ Metrics.counter(SENT_MESSAGE_COUNTER_NAME, tags).increment();
+ }
+ }, executor);
+ }
+
+ private void handleBadRegistration(GcmMessage message) {
+ Optional account = getAccountForEvent(message);
+
+ if (account.isPresent()) {
+ //noinspection OptionalGetWithoutIsPresent
+ Device device = account.get().getDevice(message.getDeviceId()).get();
+
+ if (device.getUninstalledFeedbackTimestamp() == 0) {
+ accountsManager.updateDevice(account.get(), message.getDeviceId(), d ->
+ d.setUninstalledFeedbackTimestamp(Util.todayInMillis()));
+ }
+ }
+ }
+
+ private Optional getAccountForEvent(GcmMessage message) {
+ Optional account = message.getUuid().flatMap(accountsManager::getByAccountIdentifier);
+
+ if (account.isPresent()) {
+ Optional device = account.get().getDevice(message.getDeviceId());
+
+ if (device.isPresent()) {
+ if (message.getGcmId().equals(device.get().getGcmId())) {
+
+ if (device.get().getPushTimestamp() == 0 || System.currentTimeMillis() > (device.get().getPushTimestamp() + TimeUnit.SECONDS.toMillis(10))) {
+ return account;
+ }
+ }
+ }
+ }
+
+ return Optional.empty();
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java
new file mode 100644
index 000000000..72a5bcd2d
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2013-2022 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.push;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.api.core.SettableApiFuture;
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MessagingErrorCode;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.storage.Device;
+import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
+import org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService;
+import org.whispersystems.textsecuregcm.util.Util;
+
+class FcmSenderTest {
+
+ private ExecutorService executorService;
+ private AccountsManager accountsManager;
+ private FirebaseMessaging firebaseMessaging;
+
+ private FcmSender fcmSender;
+
+ @BeforeEach
+ void setUp() {
+ executorService = new SynchronousExecutorService();
+ accountsManager = mock(AccountsManager.class);
+ firebaseMessaging = mock(FirebaseMessaging.class);
+
+ fcmSender = new FcmSender(executorService, accountsManager, firebaseMessaging);
+ }
+
+ @AfterEach
+ void tearDown() throws InterruptedException {
+ executorService.shutdown();
+
+ //noinspection ResultOfMethodCallIgnored
+ executorService.awaitTermination(1, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void testSendMessage() {
+ AccountsHelper.setupMockUpdate(accountsManager);
+
+ final GcmMessage message = new GcmMessage("foo", UUID.randomUUID(), 1, GcmMessage.Type.NOTIFICATION, Optional.empty());
+
+ final SettableApiFuture sendFuture = SettableApiFuture.create();
+ sendFuture.set("message-id");
+
+ when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture);
+
+ fcmSender.sendMessage(message);
+
+ verify(firebaseMessaging).sendAsync(any(Message.class));
+ }
+
+ @Test
+ void testSendUninstalled() {
+ final UUID destinationUuid = UUID.randomUUID();
+ final String gcmId = "foo";
+
+ final Account destinationAccount = mock(Account.class);
+ final Device destinationDevice = mock(Device.class );
+
+ AccountsHelper.setupMockUpdate(accountsManager);
+
+ when(destinationAccount.getDevice(1)).thenReturn(Optional.of(destinationDevice));
+ when(accountsManager.getByAccountIdentifier(destinationUuid)).thenReturn(Optional.of(destinationAccount));
+ when(destinationDevice.getGcmId()).thenReturn(gcmId);
+
+ final GcmMessage message = new GcmMessage(gcmId, destinationUuid, 1, GcmMessage.Type.NOTIFICATION, Optional.empty());
+
+ final FirebaseMessagingException unregisteredException = mock(FirebaseMessagingException.class);
+ when(unregisteredException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED);
+
+ final SettableApiFuture sendFuture = SettableApiFuture.create();
+ sendFuture.setException(unregisteredException);
+
+ when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture);
+
+ fcmSender.sendMessage(message);
+
+ verify(firebaseMessaging).sendAsync(any(Message.class));
+ verify(accountsManager).getByAccountIdentifier(destinationUuid);
+ verify(accountsManager).updateDevice(eq(destinationAccount), eq(1L), any());
+ verify(destinationDevice).setUninstalledFeedbackTimestamp(Util.todayInMillis());
+ }
+}