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()); + } +}