Introduce an `FcmSender`
This commit is contained in:
parent
9c03f2e468
commit
421d594507
|
@ -206,6 +206,10 @@ gcm: # GCM Configuration
|
||||||
senderId: 123456789
|
senderId: 123456789
|
||||||
apiKey: unset
|
apiKey: unset
|
||||||
|
|
||||||
|
fcm: # FCM configuration
|
||||||
|
credentials: |
|
||||||
|
{ "json": true }
|
||||||
|
|
||||||
cdn:
|
cdn:
|
||||||
accessKey: test # AWS Access Key ID
|
accessKey: test # AWS Access Key ID
|
||||||
accessSecret: test # AWS Access Secret
|
accessSecret: test # AWS Access Secret
|
||||||
|
|
|
@ -195,6 +195,39 @@
|
||||||
<artifactId>commons-csv</artifactId>
|
<artifactId>commons-csv</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.firebase</groupId>
|
||||||
|
<artifactId>firebase-admin</artifactId>
|
||||||
|
<version>9.0.0</version>
|
||||||
|
|
||||||
|
<!-- firebase-admin has conflicting versions of these artifacts in its dependency tree; for firebase-admin
|
||||||
|
9.0.0, we'll need to depend directly on com.google.api-client:google-api-client:1.35.1 and
|
||||||
|
com.google.oauth-client:google-oauth-client:1.34.1 -->
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>com.google.api-client</groupId>
|
||||||
|
<artifactId>google-api-client</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
|
||||||
|
<exclusion>
|
||||||
|
<groupId>com.google.oauth-client</groupId>
|
||||||
|
<artifactId>google-oauth-client</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.api-client</groupId>
|
||||||
|
<artifactId>google-api-client</artifactId>
|
||||||
|
<version>1.35.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.oauth-client</groupId>
|
||||||
|
<artifactId>google-oauth-client</artifactId>
|
||||||
|
<version>1.34.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.code.findbugs</groupId>
|
<groupId>com.google.code.findbugs</groupId>
|
||||||
<artifactId>jsr305</artifactId>
|
<artifactId>jsr305</artifactId>
|
||||||
|
|
|
@ -27,6 +27,7 @@ import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.GcmConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.GcmConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration;
|
||||||
|
@ -168,6 +169,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private GcmConfiguration gcm;
|
private GcmConfiguration gcm;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
@JsonProperty
|
||||||
|
private FcmConfiguration fcm;
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@NotNull
|
@NotNull
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
|
@ -340,6 +346,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
return gcm;
|
return gcm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public FcmConfiguration getFcmConfiguration() {
|
||||||
|
return fcm;
|
||||||
|
}
|
||||||
|
|
||||||
public ApnConfiguration getApnConfiguration() {
|
public ApnConfiguration getApnConfiguration() {
|
||||||
return apn;
|
return apn;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
}
|
|
@ -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<String> 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> 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<Account> getAccountForEvent(GcmMessage message) {
|
||||||
|
Optional<Account> account = message.getUuid().flatMap(accountsManager::getByAccountIdentifier);
|
||||||
|
|
||||||
|
if (account.isPresent()) {
|
||||||
|
Optional<Device> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String> 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<String> 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());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue