From 6f0faae4ceecf03d3dd6f16831db4203e937ed50 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Wed, 3 Aug 2022 10:07:53 -0400 Subject: [PATCH] Introduce common push notification interfaces/pathways --- .../textsecuregcm/WhisperServerService.java | 19 +- .../controllers/AccountController.java | 39 +- .../limits/PushChallengeManager.java | 29 +- .../textsecuregcm/push/APNSender.java | 180 +++----- .../push/ApnFallbackManager.java | 3 +- .../textsecuregcm/push/ApnMessage.java | 93 ---- .../textsecuregcm/push/FcmSender.java | 95 +--- .../textsecuregcm/push/GcmMessage.java | 56 --- .../textsecuregcm/push/MessageSender.java | 109 ++--- .../push/NotPushRegisteredException.java | 8 - .../textsecuregcm/push/PushNotification.java | 28 ++ .../push/PushNotificationManager.java | 137 ++++++ .../push/PushNotificationSender.java | 13 + .../push/SendPushNotificationResult.java | 11 + .../AuthenticatedConnectListener.java | 15 +- .../textsecuregcm/push/APNSenderTest.java | 210 +++++++++ .../push/ApnFallbackManagerTest.java | 12 +- .../textsecuregcm/push/FcmSenderTest.java | 80 ++-- .../textsecuregcm/push/MessageSenderTest.java | 35 +- .../push/PushNotificationManagerTest.java | 186 ++++++++ .../controllers/AccountControllerTest.java | 77 ++-- .../tests/push/APNSenderTest.java | 415 ------------------ .../websocket/WebSocketConnectionTest.java | 4 +- 23 files changed, 843 insertions(+), 1011 deletions(-) delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/GcmMessage.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java delete mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index dd1fca001..84f27b6b0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -147,6 +147,7 @@ import org.whispersystems.textsecuregcm.push.ClientPresenceManager; import org.whispersystems.textsecuregcm.push.FcmSender; import org.whispersystems.textsecuregcm.push.MessageSender; import org.whispersystems.textsecuregcm.push.ProvisioningManager; +import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.push.ReceiptSender; import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger; @@ -446,8 +447,10 @@ public class WhisperServerService extends Application testDevices; private final RecaptchaClient recaptchaClient; - private final FcmSender fcmSender; - private final APNSender apnSender; + private final PushNotificationManager pushNotificationManager; private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator; private final TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager; @@ -153,8 +151,7 @@ public class AccountController { TurnTokenGenerator turnTokenGenerator, Map testDevices, RecaptchaClient recaptchaClient, - FcmSender fcmSender, - APNSender apnSender, + PushNotificationManager pushNotificationManager, TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager, ChangeNumberManager changeNumberManager, ExternalServiceCredentialGenerator backupServiceCredentialGenerator) @@ -168,8 +165,7 @@ public class AccountController { this.testDevices = testDevices; this.turnTokenGenerator = turnTokenGenerator; this.recaptchaClient = recaptchaClient; - this.fcmSender = fcmSender; - this.apnSender = apnSender; + this.pushNotificationManager = pushNotificationManager; this.verifyExperimentEnrollmentManager = verifyExperimentEnrollmentManager; this.backupServiceCredentialGenerator = backupServiceCredentialGenerator; this.changeNumberManager = changeNumberManager; @@ -179,15 +175,17 @@ public class AccountController { @GET @Path("/{type}/preauth/{token}/{number}") @Produces(MediaType.APPLICATION_JSON) - public Response getPreAuth(@PathParam("type") String pushType, - @PathParam("token") String pushToken, + public Response getPreAuth(@PathParam("type") String pushType, + @PathParam("token") String pushToken, @PathParam("number") String number, - @QueryParam("voip") Optional useVoip) + @QueryParam("voip") @DefaultValue("true") boolean useVoip) throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException { - if (!"apn".equals(pushType) && !"fcm".equals(pushType)) { - return Response.status(400).build(); - } + final PushNotification.TokenType tokenType = switch(pushType) { + case "apn" -> useVoip ? PushNotification.TokenType.APN_VOIP : PushNotification.TokenType.APN; + case "fcm" -> PushNotification.TokenType.FCM; + default -> throw new BadRequestException(); + }; Util.requireNormalizedNumber(number); @@ -198,14 +196,7 @@ public class AccountController { null); pendingAccounts.store(number, storedVerificationCode); - - if ("fcm".equals(pushType)) { - fcmSender.sendMessage(new GcmMessage(pushToken, null, 0, GcmMessage.Type.CHALLENGE, Optional.of(storedVerificationCode.getPushCode()))); - } else if ("apn".equals(pushType)) { - apnSender.sendMessage(new ApnMessage(pushToken, null, 0, useVoip.orElse(true), ApnMessage.Type.CHALLENGE, Optional.of(storedVerificationCode.getPushCode()))); - } else { - throw new AssertionError(); - } + pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.getPushCode()); return Response.ok().build(); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java index 548d85240..0c43610ae 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java @@ -10,16 +10,11 @@ import static com.codahale.metrics.MetricRegistry.name; import io.micrometer.core.instrument.Metrics; import java.security.SecureRandom; import java.time.Duration; -import java.util.Optional; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Hex; import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.push.APNSender; -import org.whispersystems.textsecuregcm.push.ApnMessage; -import org.whispersystems.textsecuregcm.push.ApnMessage.Type; -import org.whispersystems.textsecuregcm.push.FcmSender; -import org.whispersystems.textsecuregcm.push.GcmMessage; import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; +import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb; @@ -27,9 +22,7 @@ import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; public class PushChallengeManager { - private final APNSender apnSender; - private final FcmSender fcmSender; - + private final PushNotificationManager pushNotificationManager; private final PushChallengeDynamoDb pushChallengeDynamoDb; private final SecureRandom random = new SecureRandom(); @@ -45,21 +38,16 @@ public class PushChallengeManager { private static final String SUCCESS_TAG_NAME = "success"; private static final String SOURCE_COUNTRY_TAG_NAME = "sourceCountry"; - public PushChallengeManager(final APNSender apnSender, final FcmSender fcmSender, + public PushChallengeManager(final PushNotificationManager pushNotificationManager, final PushChallengeDynamoDb pushChallengeDynamoDb) { - this.apnSender = apnSender; - this.fcmSender = fcmSender; + this.pushNotificationManager = pushNotificationManager; this.pushChallengeDynamoDb = pushChallengeDynamoDb; } public void sendChallenge(final Account account) throws NotPushRegisteredException { final Device masterDevice = account.getMasterDevice().orElseThrow(NotPushRegisteredException::new); - if (StringUtils.isAllBlank(masterDevice.getGcmId(), masterDevice.getApnId())) { - throw new NotPushRegisteredException(); - } - final byte[] token = new byte[CHALLENGE_TOKEN_LENGTH]; random.nextBytes(token); @@ -67,17 +55,18 @@ public class PushChallengeManager { final String platform; if (pushChallengeDynamoDb.add(account.getUuid(), token, CHALLENGE_TTL)) { - final String tokenHex = Hex.encodeHexString(token); + pushNotificationManager.sendRateLimitChallengeNotification(account, Hex.encodeHexString(token)); + sent = true; if (StringUtils.isNotBlank(masterDevice.getGcmId())) { - fcmSender.sendMessage(new GcmMessage(masterDevice.getGcmId(), account.getUuid(), 0, GcmMessage.Type.RATE_LIMIT_CHALLENGE, Optional.of(tokenHex))); platform = ClientPlatform.ANDROID.name().toLowerCase(); } else if (StringUtils.isNotBlank(masterDevice.getApnId())) { - apnSender.sendMessage(new ApnMessage(masterDevice.getApnId(), account.getUuid(), 0, false, Type.RATE_LIMIT_CHALLENGE, Optional.of(tokenHex))); platform = ClientPlatform.IOS.name().toLowerCase(); } else { - throw new AssertionError(); + // This should never happen; if the account has neither an APN nor FCM token, sending the challenge will result + // in a `NotPushRegisteredException` + platform = "unrecognized"; } } else { sent = false; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java index 24761b77b..a34eaf1f4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java @@ -4,57 +4,47 @@ */ package org.whispersystems.textsecuregcm.push; -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; -import org.whispersystems.textsecuregcm.push.RetryingApnsClient.ApnResult; -import org.whispersystems.textsecuregcm.redis.RedisOperation; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Constants; - -import javax.annotation.Nullable; +import io.dropwizard.lifecycle.Managed; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; -import java.util.Optional; -import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; +import org.whispersystems.textsecuregcm.push.RetryingApnsClient.ApnResult; -import static com.codahale.metrics.MetricRegistry.name; -import io.dropwizard.lifecycle.Managed; - -public class APNSender implements Managed { - - private final Logger logger = LoggerFactory.getLogger(APNSender.class); - - private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private static final Meter unregisteredEventStale = metricRegistry.meter(name(APNSender.class, "unregistered_event_stale")); - private static final Meter unregisteredEventFresh = metricRegistry.meter(name(APNSender.class, "unregistered_event_fresh")); - - private ApnFallbackManager fallbackManager; +public class APNSender implements Managed, PushNotificationSender { private final ExecutorService executor; - private final AccountsManager accountsManager; private final String bundleId; private final boolean sandbox; private final RetryingApnsClient apnsClient; - public APNSender(ExecutorService executor, AccountsManager accountsManager, ApnConfiguration configuration) + @VisibleForTesting + static final String APN_VOIP_NOTIFICATION_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}}"; + + @VisibleForTesting + static final String APN_NSE_NOTIFICATION_PAYLOAD = "{\"aps\":{\"mutable-content\":1,\"alert\":{\"loc-key\":\"APN_Message\"}}}"; + + @VisibleForTesting + static final String APN_CHALLENGE_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}, \"challenge\" : \"%s\"}"; + + @VisibleForTesting + static final String APN_RATE_LIMIT_CHALLENGE_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}, \"rateLimitChallenge\" : \"%s\"}"; + + @VisibleForTesting + static final Instant MAX_EXPIRATION = Instant.ofEpochMilli(Integer.MAX_VALUE * 1000L); + + public APNSender(ExecutorService executor, ApnConfiguration configuration) throws IOException, NoSuchAlgorithmException, InvalidKeyException { this.executor = executor; - this.accountsManager = accountsManager; this.bundleId = configuration.getBundleId(); this.sandbox = configuration.isSandboxEnabled(); this.apnsClient = new RetryingApnsClient(configuration.getSigningKey(), @@ -64,50 +54,64 @@ public class APNSender implements Managed { } @VisibleForTesting - public APNSender(ExecutorService executor, AccountsManager accountsManager, RetryingApnsClient apnsClient, String bundleId, boolean sandbox) { + public APNSender(ExecutorService executor, RetryingApnsClient apnsClient, String bundleId, boolean sandbox) { this.executor = executor; - this.accountsManager = accountsManager; this.apnsClient = apnsClient; this.sandbox = sandbox; this.bundleId = bundleId; } - public ListenableFuture sendMessage(final ApnMessage message) { - String topic = bundleId; + @Override + public CompletableFuture sendNotification(final PushNotification notification) { + final String topic = switch (notification.tokenType()) { + case APN -> bundleId; + case APN_VOIP -> bundleId + ".voip"; + default -> throw new IllegalArgumentException("Unsupported token type: " + notification.tokenType()); + }; - if (message.isVoip()) { - topic = topic + ".voip"; - } - - ListenableFuture future = apnsClient.send(message.getApnId(), topic, - message.getMessage(), - Instant.ofEpochMilli(message.getExpirationTime()), - message.isVoip(), - message.getCollapseId()); + final boolean isVoip = notification.tokenType() == PushNotification.TokenType.APN_VOIP; - Futures.addCallback(future, new FutureCallback<>() { + final String payload = switch (notification.notificationType()) { + case NOTIFICATION -> isVoip ? APN_VOIP_NOTIFICATION_PAYLOAD : APN_NSE_NOTIFICATION_PAYLOAD; + case CHALLENGE -> String.format(APN_CHALLENGE_PAYLOAD, notification.data()); + case RATE_LIMIT_CHALLENGE -> String.format(APN_RATE_LIMIT_CHALLENGE_PAYLOAD, notification.data()); + }; + + final String collapseId = + (notification.notificationType() == PushNotification.NotificationType.NOTIFICATION && !isVoip) + ? "incoming-message" : null; + + final ListenableFuture sendFuture = apnsClient.send(notification.deviceToken(), + topic, + payload, + MAX_EXPIRATION, + isVoip, + collapseId); + + final CompletableFuture completableSendFuture = new CompletableFuture<>(); + + Futures.addCallback(sendFuture, new FutureCallback<>() { @Override public void onSuccess(@Nullable ApnResult result) { - if (message.getChallengeData().isPresent()) { - return; - } - if (result == null) { - logger.warn("*** RECEIVED NULL APN RESULT ***"); - } else if (result.getStatus() == ApnResult.Status.NO_SUCH_USER) { - message.getUuid().ifPresent(uuid -> handleUnregisteredUser(message.getApnId(), uuid, message.getDeviceId())); - } else if (result.getStatus() == ApnResult.Status.GENERIC_FAILURE) { - logger.warn("*** Got APN generic failure: " + result.getReason() + ", " + message.getUuid()); + // This should never happen + completableSendFuture.completeExceptionally(new NullPointerException("apnResult was null")); + } else { + completableSendFuture.complete(switch (result.getStatus()) { + case SUCCESS -> new SendPushNotificationResult(true, null, false); + case NO_SUCH_USER -> new SendPushNotificationResult(false, result.getReason(), true); + case GENERIC_FAILURE -> new SendPushNotificationResult(false, result.getReason(), false); + }); } } @Override public void onFailure(@Nullable Throwable t) { - logger.warn("Got fatal APNS exception", t); + completableSendFuture.completeExceptionally(t); } }, executor); - return future; + return completableSendFuture; } @Override @@ -118,66 +122,4 @@ public class APNSender implements Managed { public void stop() { this.apnsClient.disconnect(); } - - public void setApnFallbackManager(ApnFallbackManager fallbackManager) { - this.fallbackManager = fallbackManager; - } - - private void handleUnregisteredUser(String registrationId, UUID uuid, long deviceId) { -// logger.info("Got APN Unregistered: " + number + "," + deviceId); - - Optional account = accountsManager.getByAccountIdentifier(uuid); - - if (account.isEmpty()) { - logger.info("No account found: {}", uuid); - unregisteredEventStale.mark(); - return; - } - - Optional device = account.get().getDevice(deviceId); - - if (device.isEmpty()) { - logger.info("No device found: {}", uuid); - unregisteredEventStale.mark(); - return; - } - - if (!registrationId.equals(device.get().getApnId()) && - !registrationId.equals(device.get().getVoipApnId())) - { - logger.info("Registration ID does not match: " + registrationId + ", " + device.get().getApnId() + ", " + device.get().getVoipApnId()); - unregisteredEventStale.mark(); - return; - } - -// if (registrationId.equals(device.get().getApnId())) { -// logger.info("APN Unregister APN ID matches! " + number + ", " + deviceId); -// } else if (registrationId.equals(device.get().getVoipApnId())) { -// logger.info("APN Unregister VoIP ID matches! " + number + ", " + deviceId); -// } - - long tokenTimestamp = device.get().getPushTimestamp(); - - if (tokenTimestamp != 0 && System.currentTimeMillis() < tokenTimestamp + TimeUnit.SECONDS.toMillis(10)) - { - logger.info("APN Unregister push timestamp is more recent: {}, {}", tokenTimestamp, uuid); - unregisteredEventStale.mark(); - return; - } - -// logger.info("APN Unregister timestamp matches: " + device.get().getApnId() + ", " + device.get().getVoipApnId()); -// device.get().setApnId(null); -// device.get().setVoipApnId(null); -// device.get().setFetchesMessages(false); -// accountsManager.update(account.get()); - -// if (fallbackManager != null) { -// fallbackManager.cancel(new WebsocketAddress(number, deviceId)); -// } - - if (fallbackManager != null) { - RedisOperation.unchecked(() -> fallbackManager.cancel(account.get(), device.get())); - unregisteredEventFresh.mark(); - } - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnFallbackManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnFallbackManager.java index 55ba4065f..9b6c291e1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnFallbackManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnFallbackManager.java @@ -15,7 +15,6 @@ import io.lettuce.core.ScriptOutputType; import io.lettuce.core.cluster.SlotHash; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.push.ApnMessage.Type; import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; import org.whispersystems.textsecuregcm.storage.Account; @@ -184,7 +183,7 @@ public class ApnFallbackManager implements Managed { return; } - apnSender.sendMessage(new ApnMessage(apnId, account.getUuid(), device.getId(), true, Type.NOTIFICATION, Optional.empty())); + apnSender.sendNotification(new PushNotification(apnId, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device)); retry.mark(); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java deleted file mode 100644 index 1c69b0921..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import com.google.common.annotations.VisibleForTesting; - -import javax.annotation.Nullable; -import java.util.Optional; -import java.util.UUID; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class ApnMessage { - - public enum Type { - NOTIFICATION, CHALLENGE, RATE_LIMIT_CHALLENGE - } - - public static final String APN_VOIP_NOTIFICATION_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}}"; - public static final String APN_NSE_NOTIFICATION_PAYLOAD = "{\"aps\":{\"mutable-content\":1,\"alert\":{\"loc-key\":\"APN_Message\"}}}"; - public static final String APN_CHALLENGE_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}, \"challenge\" : \"%s\"}"; - public static final String APN_RATE_LIMIT_CHALLENGE_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}, \"rateLimitChallenge\" : \"%s\"}"; - public static final long MAX_EXPIRATION = Integer.MAX_VALUE * 1000L; - - private final String apnId; - private final long deviceId; - private final boolean isVoip; - private final Type type; - private final Optional challengeData; - - @Nullable - private final UUID uuid; - - public ApnMessage(String apnId, @Nullable UUID uuid, long deviceId, boolean isVoip, Type type, Optional challengeData) { - this.apnId = apnId; - this.uuid = uuid; - this.deviceId = deviceId; - this.isVoip = isVoip; - this.type = type; - this.challengeData = challengeData; - } - - public boolean isVoip() { - return isVoip; - } - - public String getApnId() { - return apnId; - } - - public String getMessage() { - switch (type) { - case NOTIFICATION: - return this.isVoip() ? APN_VOIP_NOTIFICATION_PAYLOAD : APN_NSE_NOTIFICATION_PAYLOAD; - - case CHALLENGE: - return String.format(APN_CHALLENGE_PAYLOAD, challengeData.orElseThrow(AssertionError::new)); - - case RATE_LIMIT_CHALLENGE: - return String.format(APN_RATE_LIMIT_CHALLENGE_PAYLOAD, challengeData.orElseThrow(AssertionError::new)); - - default: - throw new AssertionError(); - } - } - - @Nullable - public String getCollapseId() { - if (type == Type.NOTIFICATION && !isVoip) { - return "incoming-message"; - } - return null; - } - - @VisibleForTesting - public Optional getChallengeData() { - return challengeData; - } - - public long getExpirationTime() { - return MAX_EXPIRATION; - } - - public Optional getUuid() { - return Optional.ofNullable(uuid); - } - - public long getDeviceId() { - return deviceId; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java index 00e81fb51..376963a1e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java @@ -5,8 +5,6 @@ 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; @@ -16,33 +14,24 @@ 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 com.google.firebase.messaging.MessagingErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Optional; +import java.util.concurrent.CompletableFuture; 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 { +public class FcmSender implements PushNotificationSender { - 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"); + private static final Logger logger = LoggerFactory.getLogger(FcmSender.class); - public FcmSender(ExecutorService executor, AccountsManager accountsManager, String credentials) throws IOException { + public FcmSender(ExecutorService executor, String credentials) throws IOException { try (final ByteArrayInputStream credentialInputStream = new ByteArrayInputStream(credentials.getBytes(StandardCharsets.UTF_8))) { FirebaseOptions options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(credentialInputStream)) @@ -52,102 +41,62 @@ public class FcmSender { } this.executor = executor; - this.accountsManager = accountsManager; this.firebaseMessagingClient = FirebaseMessaging.getInstance(); } @VisibleForTesting - public FcmSender(ExecutorService executor, AccountsManager accountsManager, FirebaseMessaging firebaseMessagingClient) { - this.accountsManager = accountsManager; + public FcmSender(ExecutorService executor, FirebaseMessaging firebaseMessagingClient) { this.executor = executor; this.firebaseMessagingClient = firebaseMessagingClient; } - public void sendMessage(GcmMessage message) { + @Override + public CompletableFuture sendNotification(PushNotification pushNotification) { Message.Builder builder = Message.builder() - .setToken(message.getGcmId()) + .setToken(pushNotification.deviceToken()) .setAndroidConfig(AndroidConfig.builder() .setPriority(AndroidConfig.Priority.HIGH) .build()); - final String key = switch (message.getType()) { + final String key = switch (pushNotification.notificationType()) { case NOTIFICATION -> "notification"; case CHALLENGE -> "challenge"; case RATE_LIMIT_CHALLENGE -> "rateLimitChallenge"; }; - builder.putData(key, message.getData().orElse("")); + builder.putData(key, pushNotification.data() != null ? pushNotification.data() : ""); final ApiFuture sendFuture = firebaseMessagingClient.sendAsync(builder.build()); + final CompletableFuture completableSendFuture = new CompletableFuture<>(); sendFuture.addListener(() -> { - Tags tags = Tags.of("type", key); - try { sendFuture.get(); + completableSendFuture.complete(new SendPushNotificationResult(true, null, false)); } catch (ExecutionException e) { if (e.getCause() instanceof final FirebaseMessagingException firebaseMessagingException) { final String errorCode; if (firebaseMessagingException.getMessagingErrorCode() != null) { - errorCode = firebaseMessagingException.getMessagingErrorCode().name().toLowerCase(); + errorCode = firebaseMessagingException.getMessagingErrorCode().name(); } else { logger.warn("Received an FCM exception with no error code", firebaseMessagingException); errorCode = "unknown"; } - tags = tags.and("errorCode", errorCode); - - 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()); - } + completableSendFuture.complete(new SendPushNotificationResult(false, + errorCode, + firebaseMessagingException.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED)); } else { - throw new RuntimeException("Failed to send message", e); + completableSendFuture.completeExceptionally(e.getCause()); } } 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(); + completableSendFuture.completeExceptionally(e); } }, 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(); + return completableSendFuture; } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/GcmMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/GcmMessage.java deleted file mode 100644 index d5b86e4ed..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/GcmMessage.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - - -import javax.annotation.Nullable; -import java.util.Optional; -import java.util.UUID; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class GcmMessage { - - public enum Type { - NOTIFICATION, CHALLENGE, RATE_LIMIT_CHALLENGE - } - - private final String gcmId; - private final int deviceId; - private final Type type; - private final Optional data; - - @Nullable - private final UUID uuid; - - public GcmMessage(String gcmId, @Nullable UUID uuid, int deviceId, Type type, Optional data) { - this.gcmId = gcmId; - this.uuid = uuid; - this.deviceId = deviceId; - this.type = type; - this.data = data; - } - - public String getGcmId() { - return gcmId; - } - - public Optional getUuid() { - return Optional.ofNullable(uuid); - } - - public Type getType() { - return type; - } - - public int getDeviceId() { - return deviceId; - } - - public Optional getData() { - return data; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java index 7e2118712..42fa4c7b9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java @@ -7,18 +7,15 @@ package org.whispersystems.textsecuregcm.push; import static com.codahale.metrics.MetricRegistry.name; import static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import io.dropwizard.lifecycle.Managed; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import java.util.List; -import java.util.Optional; +import org.apache.commons.lang3.StringUtils; import org.whispersystems.textsecuregcm.metrics.PushLatencyManager; -import org.whispersystems.textsecuregcm.push.ApnMessage.Type; import org.whispersystems.textsecuregcm.redis.RedisOperation; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.util.Util; /** * A MessageSender sends Signal messages to destination devices. Messages may be "normal" user-to-user messages, @@ -33,41 +30,30 @@ import org.whispersystems.textsecuregcm.util.Util; * @see org.whispersystems.textsecuregcm.storage.MessageAvailabilityListener * @see ReceiptSender */ -public class MessageSender implements Managed { +public class MessageSender { - private final ApnFallbackManager apnFallbackManager; - private final ClientPresenceManager clientPresenceManager; - private final MessagesManager messagesManager; - private final FcmSender fcmSender; - private final APNSender apnSender; - private final PushLatencyManager pushLatencyManager; + private final ClientPresenceManager clientPresenceManager; + private final MessagesManager messagesManager; + private final PushNotificationManager pushNotificationManager; + private final PushLatencyManager pushLatencyManager; - private static final String SEND_COUNTER_NAME = name(MessageSender.class, "sendMessage"); - private static final String CHANNEL_TAG_NAME = "channel"; - private static final String EPHEMERAL_TAG_NAME = "ephemeral"; + private static final String SEND_COUNTER_NAME = name(MessageSender.class, "sendMessage"); + private static final String CHANNEL_TAG_NAME = "channel"; + private static final String EPHEMERAL_TAG_NAME = "ephemeral"; private static final String CLIENT_ONLINE_TAG_NAME = "clientOnline"; - public MessageSender(ApnFallbackManager apnFallbackManager, - ClientPresenceManager clientPresenceManager, - MessagesManager messagesManager, - FcmSender fcmSender, - APNSender apnSender, - PushLatencyManager pushLatencyManager) - { - this.apnFallbackManager = apnFallbackManager; + public MessageSender(ClientPresenceManager clientPresenceManager, + MessagesManager messagesManager, + PushNotificationManager pushNotificationManager, + PushLatencyManager pushLatencyManager) { this.clientPresenceManager = clientPresenceManager; - this.messagesManager = messagesManager; - this.fcmSender = fcmSender; - this.apnSender = apnSender; - this.pushLatencyManager = pushLatencyManager; + this.messagesManager = messagesManager; + this.pushNotificationManager = pushNotificationManager; + this.pushLatencyManager = pushLatencyManager; } public void sendMessage(final Account account, final Device device, final Envelope message, boolean online) - throws NotPushRegisteredException - { - if (device.getGcmId() == null && device.getApnId() == null && !device.getFetchesMessages()) { - throw new NotPushRegisteredException("No delivery possible!"); - } + throws NotPushRegisteredException { final String channel; @@ -98,59 +84,24 @@ public class MessageSender implements Managed { clientPresent = clientPresenceManager.isPresent(account.getUuid(), device.getId()); if (!clientPresent) { - sendNewMessageNotification(account, device); + try { + pushNotificationManager.sendNewMessageNotification(account, device.getId()); + + final boolean useVoip = StringUtils.isNotBlank(device.getVoipApnId()); + RedisOperation.unchecked(() -> pushLatencyManager.recordPushSent(account.getUuid(), device.getId(), useVoip)); + } catch (final NotPushRegisteredException e) { + if (!device.getFetchesMessages()) { + throw e; + } + } } } final List tags = List.of( - Tag.of(CHANNEL_TAG_NAME, channel), - Tag.of(EPHEMERAL_TAG_NAME, String.valueOf(online)), - Tag.of(CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent))); + Tag.of(CHANNEL_TAG_NAME, channel), + Tag.of(EPHEMERAL_TAG_NAME, String.valueOf(online)), + Tag.of(CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent))); Metrics.counter(SEND_COUNTER_NAME, tags).increment(); } - - public void sendNewMessageNotification(final Account account, final Device device) { - if (!Util.isEmpty(device.getGcmId())) { - sendGcmNotification(account, device); - } else if (!Util.isEmpty(device.getApnId()) || !Util.isEmpty(device.getVoipApnId())) { - sendApnNotification(account, device); - } - } - - private void sendGcmNotification(Account account, Device device) { - GcmMessage gcmMessage = new GcmMessage(device.getGcmId(), account.getUuid(), - (int)device.getId(), GcmMessage.Type.NOTIFICATION, Optional.empty()); - - fcmSender.sendMessage(gcmMessage); - - RedisOperation.unchecked(() -> pushLatencyManager.recordPushSent(account.getUuid(), device.getId(), false)); - } - - private void sendApnNotification(Account account, Device device) { - ApnMessage apnMessage; - - final boolean useVoip = !Util.isEmpty(device.getVoipApnId()); - - if (useVoip) { - apnMessage = new ApnMessage(device.getVoipApnId(), account.getUuid(), device.getId(), useVoip, Type.NOTIFICATION, Optional.empty()); - RedisOperation.unchecked(() -> apnFallbackManager.schedule(account, device)); - } else { - apnMessage = new ApnMessage(device.getApnId(), account.getUuid(), device.getId(), useVoip, Type.NOTIFICATION, Optional.empty()); - } - - apnSender.sendMessage(apnMessage); - - RedisOperation.unchecked(() -> pushLatencyManager.recordPushSent(account.getUuid(), device.getId(), useVoip)); - } - - @Override - public void start() { - apnSender.start(); - } - - @Override - public void stop() { - apnSender.stop(); - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java index fcac92ad6..c5dc14281 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java @@ -9,12 +9,4 @@ public class NotPushRegisteredException extends Exception { public NotPushRegisteredException() { super(); } - - public NotPushRegisteredException(String s) { - super(s); - } - - public NotPushRegisteredException(Exception e) { - super(e); - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java new file mode 100644 index 000000000..dcbaea7b1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java @@ -0,0 +1,28 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.push; + +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import javax.annotation.Nullable; + +public record PushNotification(String deviceToken, + TokenType tokenType, + NotificationType notificationType, + @Nullable String data, + @Nullable Account destination, + @Nullable Device destinationDevice) { + + public enum NotificationType { + NOTIFICATION, CHALLENGE, RATE_LIMIT_CHALLENGE + } + + public enum TokenType { + FCM, + APN, + APN_VOIP, + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java new file mode 100644 index 000000000..1864f9f7e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java @@ -0,0 +1,137 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.push; + +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tags; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.redis.RedisOperation; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.util.Pair; +import org.whispersystems.textsecuregcm.util.Util; + +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + +public class PushNotificationManager { + + private final AccountsManager accountsManager; + private final APNSender apnSender; + private final FcmSender fcmSender; + private final ApnFallbackManager fallbackManager; + + private static final String SENT_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "sentPushNotification"); + private static final String FAILED_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "failedPushNotification"); + + private final Logger logger = LoggerFactory.getLogger(PushNotificationManager.class); + + public PushNotificationManager(final AccountsManager accountsManager, + final APNSender apnSender, + final FcmSender fcmSender, + final ApnFallbackManager fallbackManager) { + + this.accountsManager = accountsManager; + this.apnSender = apnSender; + this.fcmSender = fcmSender; + this.fallbackManager = fallbackManager; + } + + public void sendNewMessageNotification(final Account destination, final long destinationDeviceId) throws NotPushRegisteredException { + final Device device = destination.getDevice(destinationDeviceId).orElseThrow(NotPushRegisteredException::new); + final Pair tokenAndType = getToken(device); + + sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), + PushNotification.NotificationType.NOTIFICATION, null, destination, device)); + } + + public void sendRegistrationChallengeNotification(final String deviceToken, final PushNotification.TokenType tokenType, final String challengeToken) { + sendNotification(new PushNotification(deviceToken, tokenType, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null)); + } + + public void sendRateLimitChallengeNotification(final Account destination, final String challengeToken) + throws NotPushRegisteredException { + + final Device device = destination.getDevice(Device.MASTER_ID).orElseThrow(NotPushRegisteredException::new); + final Pair tokenAndType = getToken(device); + + sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), + PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, destination, device)); + } + + @VisibleForTesting + Pair getToken(final Device device) throws NotPushRegisteredException { + final Pair tokenAndType; + + if (StringUtils.isNotBlank(device.getGcmId())) { + tokenAndType = new Pair<>(device.getGcmId(), PushNotification.TokenType.FCM); + } else if (StringUtils.isNotBlank(device.getVoipApnId())) { + tokenAndType = new Pair<>(device.getVoipApnId(), PushNotification.TokenType.APN_VOIP); + } else if (StringUtils.isNotBlank(device.getApnId())) { + tokenAndType = new Pair<>(device.getApnId(), PushNotification.TokenType.APN); + } else { + throw new NotPushRegisteredException(); + } + + return tokenAndType; + } + + @VisibleForTesting + void sendNotification(final PushNotification pushNotification) { + final PushNotificationSender sender = switch (pushNotification.tokenType()) { + case FCM -> fcmSender; + case APN, APN_VOIP -> apnSender; + }; + + sender.sendNotification(pushNotification).whenComplete((result, throwable) -> { + if (throwable == null) { + Tags tags = Tags.of("tokenType", pushNotification.tokenType().name(), + "notificationType", pushNotification.notificationType().name(), + "accepted", String.valueOf(result.accepted()), + "unregistered", String.valueOf(result.unregistered())); + + if (StringUtils.isNotBlank(result.errorCode())) { + tags = tags.and("errorCode", result.errorCode()); + } + + Metrics.counter(SENT_NOTIFICATION_COUNTER_NAME, tags).increment(); + + if (result.unregistered() && pushNotification.destination() != null && pushNotification.destinationDevice() != null) { + handleDeviceUnregistered(pushNotification.destination(), pushNotification.destinationDevice()); + } + + if (result.accepted() && + pushNotification.tokenType() == PushNotification.TokenType.APN_VOIP && + pushNotification.notificationType() == PushNotification.NotificationType.NOTIFICATION && + pushNotification.destination() != null && + pushNotification.destinationDevice() != null) { + + RedisOperation.unchecked(() -> fallbackManager.schedule(pushNotification.destination(), + pushNotification.destinationDevice())); + } + } else { + logger.debug("Failed to deliver {} push notification to {} ({})", + pushNotification.notificationType(), pushNotification.deviceToken(), pushNotification.tokenType(), throwable); + + Metrics.counter(FAILED_NOTIFICATION_COUNTER_NAME, "cause", throwable.getClass().getSimpleName()).increment(); + } + }); + } + + private void handleDeviceUnregistered(final Account account, final Device device) { + if (StringUtils.isNotBlank(device.getGcmId())) { + if (device.getUninstalledFeedbackTimestamp() == 0) { + accountsManager.updateDevice(account, device.getId(), d -> + d.setUninstalledFeedbackTimestamp(Util.todayInMillis())); + } + } else { + RedisOperation.unchecked(() -> fallbackManager.cancel(account, device)); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java new file mode 100644 index 000000000..2631efda2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java @@ -0,0 +1,13 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.push; + +import java.util.concurrent.CompletableFuture; + +public interface PushNotificationSender { + + CompletableFuture sendNotification(PushNotification notification); +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java new file mode 100644 index 000000000..fc7abdd85 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java @@ -0,0 +1,11 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.push; + +import javax.annotation.Nullable; + +public record SendPushNotificationResult(boolean accepted, @Nullable String errorCode, boolean unregistered) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java index 8d9fff065..0bcf56435 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java @@ -20,7 +20,8 @@ import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.push.ApnFallbackManager; import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.push.MessageSender; +import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; +import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.push.ReceiptSender; import org.whispersystems.textsecuregcm.redis.RedisOperation; import org.whispersystems.textsecuregcm.storage.Device; @@ -42,20 +43,21 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener { private final ReceiptSender receiptSender; private final MessagesManager messagesManager; - private final MessageSender messageSender; + private final PushNotificationManager pushNotificationManager; private final ApnFallbackManager apnFallbackManager; private final ClientPresenceManager clientPresenceManager; private final ScheduledExecutorService scheduledExecutorService; public AuthenticatedConnectListener(ReceiptSender receiptSender, MessagesManager messagesManager, - final MessageSender messageSender, ApnFallbackManager apnFallbackManager, + PushNotificationManager pushNotificationManager, + ApnFallbackManager apnFallbackManager, ClientPresenceManager clientPresenceManager, ScheduledExecutorService scheduledExecutorService) { this.receiptSender = receiptSender; this.messagesManager = messagesManager; - this.messageSender = messageSender; + this.pushNotificationManager = pushNotificationManager; this.apnFallbackManager = apnFallbackManager; this.clientPresenceManager = clientPresenceManager; this.scheduledExecutorService = scheduledExecutorService; @@ -97,7 +99,10 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener { messagesManager.removeMessageAvailabilityListener(connection); if (messagesManager.hasCachedMessages(auth.getAccount().getUuid(), device.getId())) { - messageSender.sendNewMessageNotification(auth.getAccount(), device); + try { + pushNotificationManager.sendNewMessageNotification(auth.getAccount(), device.getId()); + } catch (NotPushRegisteredException ignored) { + } } }); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java new file mode 100644 index 000000000..7d48cfb09 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.push; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.eatthepath.pushy.apns.ApnsClient; +import com.eatthepath.pushy.apns.ApnsPushNotification; +import com.eatthepath.pushy.apns.DeliveryPriority; +import com.eatthepath.pushy.apns.PushNotificationResponse; +import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification; +import com.eatthepath.pushy.apns.util.concurrent.PushNotificationFuture; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService; + +class APNSenderTest { + + private static final String DESTINATION_APN_ID = "foo"; + + private Account destinationAccount; + private Device destinationDevice; + + private ApnsClient apnsClient; + + @BeforeEach + void setup() { + destinationAccount = mock(Account.class); + destinationDevice = mock(Device.class); + + apnsClient = mock(ApnsClient.class); + + when(destinationAccount.getDevice(1)).thenReturn(Optional.of(destinationDevice)); + when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID); + } + + @Test + void testSendVoip() { + PushNotificationResponse response = mock(PushNotificationResponse.class); + when(response.isAccepted()).thenReturn(true); + + when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) + .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); + + RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); + PushNotification pushNotification = new PushNotification(DESTINATION_APN_ID, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), retryingApnsClient, "foo", false); + + final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); + + ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); + verify(apnsClient).sendNotification(notification.capture()); + + assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); + assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); + assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_VOIP_NOTIFICATION_PAYLOAD); + assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); + assertThat(notification.getValue().getTopic()).isEqualTo("foo.voip"); + + assertThat(result.accepted()).isTrue(); + assertThat(result.errorCode()).isNull(); + assertThat(result.unregistered()).isFalse(); + + verifyNoMoreInteractions(apnsClient); + } + + @Test + void testSendApns() { + PushNotificationResponse response = mock(PushNotificationResponse.class); + when(response.isAccepted()).thenReturn(true); + + when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) + .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); + + RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); + PushNotification pushNotification = new PushNotification(DESTINATION_APN_ID, PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), retryingApnsClient, "foo", false); + + final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); + + ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); + verify(apnsClient).sendNotification(notification.capture()); + + assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); + assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); + assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_NSE_NOTIFICATION_PAYLOAD); + assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); + assertThat(notification.getValue().getTopic()).isEqualTo("foo"); + + assertThat(result.accepted()).isTrue(); + assertThat(result.errorCode()).isNull(); + assertThat(result.unregistered()).isFalse(); + + verifyNoMoreInteractions(apnsClient); + } + + @Test + void testUnregisteredUser() throws Exception { + PushNotificationResponse response = mock(PushNotificationResponse.class); + when(response.isAccepted()).thenReturn(false); + when(response.getRejectionReason()).thenReturn(Optional.of("Unregistered")); + + when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) + .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); + + + RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); + PushNotification pushNotification = new PushNotification(DESTINATION_APN_ID, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), retryingApnsClient, "foo", false); + + when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID); + when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11)); + + final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); + + ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); + verify(apnsClient).sendNotification(notification.capture()); + + assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); + assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); + assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_VOIP_NOTIFICATION_PAYLOAD); + assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); + + assertThat(result.accepted()).isFalse(); + assertThat(result.errorCode()).isEqualTo("Unregistered"); + assertThat(result.unregistered()).isTrue(); + } + + @Test + void testGenericFailure() { + ApnsClient apnsClient = mock(ApnsClient.class); + + PushNotificationResponse response = mock(PushNotificationResponse.class); + when(response.isAccepted()).thenReturn(false); + when(response.getRejectionReason()).thenReturn(Optional.of("BadTopic")); + + when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) + .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); + + RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); + PushNotification pushNotification = new PushNotification(DESTINATION_APN_ID, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), retryingApnsClient, "foo", false); + + final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); + + ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); + verify(apnsClient).sendNotification(notification.capture()); + + assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); + assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); + assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_VOIP_NOTIFICATION_PAYLOAD); + assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); + + assertThat(result.accepted()).isFalse(); + assertThat(result.errorCode()).isEqualTo("BadTopic"); + assertThat(result.unregistered()).isFalse(); + } + + @Test + void testFailure() { + PushNotificationResponse response = mock(PushNotificationResponse.class); + when(response.isAccepted()).thenReturn(true); + + when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) + .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), new IOException("lost connection"))); + + RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); + PushNotification pushNotification = new PushNotification(DESTINATION_APN_ID, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), retryingApnsClient, "foo", false); + + assertThatThrownBy(() -> apnSender.sendNotification(pushNotification).join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(IOException.class); + + verify(apnsClient).sendNotification(any()); + + verifyNoMoreInteractions(apnsClient); + } + + private static class MockPushNotificationFuture

extends PushNotificationFuture { + + MockPushNotificationFuture(final P pushNotification, final V response) { + super(pushNotification); + complete(response); + } + + MockPushNotificationFuture(final P pushNotification, final Exception exception) { + super(pushNotification); + completeExceptionally(exception); + } + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnFallbackManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnFallbackManagerTest.java index 357b749f2..464484947 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnFallbackManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnFallbackManagerTest.java @@ -100,14 +100,14 @@ class ApnFallbackManagerTest { assertEquals(1, worker.processNextSlot()); - final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ApnMessage.class); - verify(apnSender).sendMessage(messageCaptor.capture()); + final ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); + verify(apnSender).sendNotification(notificationCaptor.capture()); - final ApnMessage message = messageCaptor.getValue(); + final PushNotification pushNotification = notificationCaptor.getValue(); - assertEquals(VOIP_APN_ID, message.getApnId()); - assertEquals(Optional.of(ACCOUNT_UUID), message.getUuid()); - assertEquals(DEVICE_ID, message.getDeviceId()); + assertEquals(VOIP_APN_ID, pushNotification.deviceToken()); + assertEquals(account, pushNotification.destination()); + assertEquals(device, pushNotification.destinationDevice()); assertEquals(0, worker.processNextSlot()); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java index 72a5bcd2d..4e174a3a5 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java @@ -5,8 +5,12 @@ package org.whispersystems.textsecuregcm.push; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; 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; @@ -16,24 +20,18 @@ 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.io.IOException; +import java.util.concurrent.CompletionException; 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; @@ -41,10 +39,9 @@ class FcmSenderTest { @BeforeEach void setUp() { executorService = new SynchronousExecutorService(); - accountsManager = mock(AccountsManager.class); firebaseMessaging = mock(FirebaseMessaging.class); - fcmSender = new FcmSender(executorService, accountsManager, firebaseMessaging); + fcmSender = new FcmSender(executorService, firebaseMessaging); } @AfterEach @@ -57,35 +54,44 @@ class FcmSenderTest { @Test void testSendMessage() { - AccountsHelper.setupMockUpdate(accountsManager); - - final GcmMessage message = new GcmMessage("foo", UUID.randomUUID(), 1, GcmMessage.Type.NOTIFICATION, Optional.empty()); + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null); final SettableApiFuture sendFuture = SettableApiFuture.create(); sendFuture.set("message-id"); when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - fcmSender.sendMessage(message); + final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join(); verify(firebaseMessaging).sendAsync(any(Message.class)); + assertTrue(result.accepted()); + assertNull(result.errorCode()); + assertFalse(result.unregistered()); } @Test - void testSendUninstalled() { - final UUID destinationUuid = UUID.randomUUID(); - final String gcmId = "foo"; + void testSendMessageRejected() { + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null); - final Account destinationAccount = mock(Account.class); - final Device destinationDevice = mock(Device.class ); + final FirebaseMessagingException invalidArgumentException = mock(FirebaseMessagingException.class); + when(invalidArgumentException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.INVALID_ARGUMENT); - AccountsHelper.setupMockUpdate(accountsManager); + final SettableApiFuture sendFuture = SettableApiFuture.create(); + sendFuture.setException(invalidArgumentException); - when(destinationAccount.getDevice(1)).thenReturn(Optional.of(destinationDevice)); - when(accountsManager.getByAccountIdentifier(destinationUuid)).thenReturn(Optional.of(destinationAccount)); - when(destinationDevice.getGcmId()).thenReturn(gcmId); + when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - final GcmMessage message = new GcmMessage(gcmId, destinationUuid, 1, GcmMessage.Type.NOTIFICATION, Optional.empty()); + final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join(); + + verify(firebaseMessaging).sendAsync(any(Message.class)); + assertFalse(result.accepted()); + assertEquals("INVALID_ARGUMENT", result.errorCode()); + assertFalse(result.unregistered()); + } + + @Test + void testSendMessageUnregistered() { + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null); final FirebaseMessagingException unregisteredException = mock(FirebaseMessagingException.class); when(unregisteredException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED); @@ -95,11 +101,27 @@ class FcmSenderTest { when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - fcmSender.sendMessage(message); + final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join(); verify(firebaseMessaging).sendAsync(any(Message.class)); - verify(accountsManager).getByAccountIdentifier(destinationUuid); - verify(accountsManager).updateDevice(eq(destinationAccount), eq(1L), any()); - verify(destinationDevice).setUninstalledFeedbackTimestamp(Util.todayInMillis()); + assertFalse(result.accepted()); + assertEquals("UNREGISTERED", result.errorCode()); + assertTrue(result.unregistered()); + } + + @Test + void testSendMessageException() { + final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null); + + final SettableApiFuture sendFuture = SettableApiFuture.create(); + sendFuture.setException(new IOException()); + + when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); + + final CompletionException completionException = + assertThrows(CompletionException.class, () -> fcmSender.sendNotification(pushNotification).join()); + + verify(firebaseMessaging).sendAsync(any(Message.class)); + assertTrue(completionException.getCause() instanceof IOException); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java index 4d0ae7353..4bfc454d1 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java @@ -5,12 +5,14 @@ package org.whispersystems.textsecuregcm.push; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -37,8 +39,7 @@ class MessageSenderTest { private ClientPresenceManager clientPresenceManager; private MessagesManager messagesManager; - private FcmSender fcmSender; - private APNSender apnSender; + private PushNotificationManager pushNotificationManager; private MessageSender messageSender; private static final UUID ACCOUNT_UUID = UUID.randomUUID(); @@ -53,13 +54,10 @@ class MessageSenderTest { clientPresenceManager = mock(ClientPresenceManager.class); messagesManager = mock(MessagesManager.class); - fcmSender = mock(FcmSender.class); - apnSender = mock(APNSender.class); - messageSender = new MessageSender(mock(ApnFallbackManager.class), - clientPresenceManager, + pushNotificationManager = mock(PushNotificationManager.class); + messageSender = new MessageSender(clientPresenceManager, messagesManager, - fcmSender, - apnSender, + pushNotificationManager, mock(PushLatencyManager.class)); when(account.getUuid()).thenReturn(ACCOUNT_UUID); @@ -80,8 +78,7 @@ class MessageSenderTest { assertTrue(envelopeArgumentCaptor.getValue().getEphemeral()); - verifyNoInteractions(fcmSender); - verifyNoInteractions(apnSender); + verifyNoInteractions(pushNotificationManager); } @Test @@ -92,8 +89,7 @@ class MessageSenderTest { messageSender.sendMessage(account, device, message, true); verify(messagesManager, never()).insert(any(), anyLong(), any()); - verifyNoInteractions(fcmSender); - verifyNoInteractions(apnSender); + verifyNoInteractions(pushNotificationManager); } @Test @@ -110,8 +106,7 @@ class MessageSenderTest { assertFalse(envelopeArgumentCaptor.getValue().getEphemeral()); assertEquals(message, envelopeArgumentCaptor.getValue()); - verifyNoInteractions(fcmSender); - verifyNoInteractions(apnSender); + verifyNoInteractions(pushNotificationManager); } @Test @@ -122,8 +117,7 @@ class MessageSenderTest { messageSender.sendMessage(account, device, message, false); verify(messagesManager).insert(ACCOUNT_UUID, DEVICE_ID, message); - verify(fcmSender).sendMessage(any()); - verifyNoInteractions(apnSender); + verify(pushNotificationManager).sendNewMessageNotification(account, device.getId()); } @Test @@ -134,8 +128,7 @@ class MessageSenderTest { messageSender.sendMessage(account, device, message, false); verify(messagesManager).insert(ACCOUNT_UUID, DEVICE_ID, message); - verifyNoInteractions(fcmSender); - verify(apnSender).sendMessage(any()); + verify(pushNotificationManager).sendNewMessageNotification(account, device.getId()); } @Test @@ -143,11 +136,11 @@ class MessageSenderTest { when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(false); when(device.getFetchesMessages()).thenReturn(true); - messageSender.sendMessage(account, device, message, false); + doThrow(NotPushRegisteredException.class) + .when(pushNotificationManager).sendNewMessageNotification(account, DEVICE_ID); + assertDoesNotThrow(() -> messageSender.sendMessage(account, device, message, false)); verify(messagesManager).insert(ACCOUNT_UUID, DEVICE_ID, message); - verifyNoInteractions(fcmSender); - verifyNoInteractions(apnSender); } private MessageProtos.Envelope generateRandomMessage() { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java new file mode 100644 index 000000000..1d6ed2550 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java @@ -0,0 +1,186 @@ +/* + * 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.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +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.util.Util; + +class PushNotificationManagerTest { + + private AccountsManager accountsManager; + private APNSender apnSender; + private FcmSender fcmSender; + private ApnFallbackManager apnFallbackManager; + + private PushNotificationManager pushNotificationManager; + + @BeforeEach + void setUp() { + accountsManager = mock(AccountsManager.class); + apnSender = mock(APNSender.class); + fcmSender = mock(FcmSender.class); + apnFallbackManager = mock(ApnFallbackManager.class); + + AccountsHelper.setupMockUpdate(accountsManager); + + pushNotificationManager = new PushNotificationManager(accountsManager, apnSender, fcmSender, apnFallbackManager); + } + + @Test + void sendNewMessageNotification() throws NotPushRegisteredException { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + final String deviceToken = "token"; + + when(device.getId()).thenReturn(Device.MASTER_ID); + when(device.getGcmId()).thenReturn(deviceToken); + when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); + + when(fcmSender.sendNotification(any())) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); + + pushNotificationManager.sendNewMessageNotification(account, Device.MASTER_ID); + verify(fcmSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device)); + } + + @Test + void sendRegistrationChallengeNotification() { + final String deviceToken = "token"; + final String challengeToken = "challenge"; + + when(apnSender.sendNotification(any())) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); + + pushNotificationManager.sendRegistrationChallengeNotification(deviceToken, PushNotification.TokenType.APN_VOIP, challengeToken); + verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null)); + } + + @Test + void sendRateLimitChallengeNotification() throws NotPushRegisteredException { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + final String deviceToken = "token"; + final String challengeToken = "challenge"; + + when(device.getId()).thenReturn(Device.MASTER_ID); + when(device.getApnId()).thenReturn(deviceToken); + when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); + + when(apnSender.sendNotification(any())) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); + + pushNotificationManager.sendRateLimitChallengeNotification(account, challengeToken); + verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, account, device)); + } + + @Test + void testSendNotification() { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + when(device.getId()).thenReturn(Device.MASTER_ID); + when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); + + final PushNotification pushNotification = new PushNotification( + "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device); + + when(fcmSender.sendNotification(pushNotification)) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); + + pushNotificationManager.sendNotification(pushNotification); + + verify(fcmSender).sendNotification(pushNotification); + verifyNoInteractions(apnSender); + verify(accountsManager, never()).updateDevice(eq(account), eq(Device.MASTER_ID), any()); + verify(device, never()).setUninstalledFeedbackTimestamp(Util.todayInMillis()); + verifyNoInteractions(apnFallbackManager); + } + + @Test + void testSendNotificationApnVoip() { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + when(device.getId()).thenReturn(Device.MASTER_ID); + when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); + + final PushNotification pushNotification = new PushNotification( + "token", PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device); + + when(apnSender.sendNotification(pushNotification)) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); + + pushNotificationManager.sendNotification(pushNotification); + + verify(apnSender).sendNotification(pushNotification); + verifyNoInteractions(fcmSender); + verify(accountsManager, never()).updateDevice(eq(account), eq(Device.MASTER_ID), any()); + verify(device, never()).setUninstalledFeedbackTimestamp(Util.todayInMillis()); + verify(apnFallbackManager).schedule(account, device); + } + + @Test + void testSendNotificationUnregisteredFcm() { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + when(device.getId()).thenReturn(Device.MASTER_ID); + when(device.getGcmId()).thenReturn("token"); + when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); + + final PushNotification pushNotification = new PushNotification( + "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device); + + when(fcmSender.sendNotification(pushNotification)) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, null, true))); + + pushNotificationManager.sendNotification(pushNotification); + + verify(accountsManager).updateDevice(eq(account), eq(Device.MASTER_ID), any()); + verify(device).setUninstalledFeedbackTimestamp(Util.todayInMillis()); + verifyNoInteractions(apnSender); + verifyNoInteractions(apnFallbackManager); + } + + @Test + void testSendNotificationUnregisteredApn() { + final Account account = mock(Account.class); + final Device device = mock(Device.class); + + when(device.getId()).thenReturn(Device.MASTER_ID); + when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); + + final PushNotification pushNotification = new PushNotification( + "token", PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device); + + when(apnSender.sendNotification(pushNotification)) + .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, null, true))); + + pushNotificationManager.sendNotification(pushNotification); + + verifyNoInteractions(fcmSender); + verify(accountsManager, never()).updateDevice(eq(account), eq(Device.MASTER_ID), any()); + verify(device, never()).setUninstalledFeedbackTimestamp(Util.todayInMillis()); + verify(apnFallbackManager).cancel(account, device); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java index 9875be1de..ebffde14f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java @@ -80,10 +80,8 @@ import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMa import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberResponse; import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.push.APNSender; -import org.whispersystems.textsecuregcm.push.ApnMessage; -import org.whispersystems.textsecuregcm.push.FcmSender; -import org.whispersystems.textsecuregcm.push.GcmMessage; +import org.whispersystems.textsecuregcm.push.PushNotification; +import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.TwilioVerifyExperimentEnrollmentManager; @@ -147,8 +145,7 @@ class AccountControllerTest { private static Account senderHasStorage = mock(Account.class); private static Account senderTransfer = mock(Account.class); private static RecaptchaClient recaptchaClient = mock(RecaptchaClient.class); - private static FcmSender fcmSender = mock(FcmSender.class); - private static APNSender apnSender = mock(APNSender.class); + private static PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); private static ChangeNumberManager changeNumberManager = mock(ChangeNumberManager.class); private static DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); @@ -179,8 +176,7 @@ class AccountControllerTest { turnTokenGenerator, Map.of(TEST_NUMBER, TEST_VERIFICATION_CODE), recaptchaClient, - fcmSender, - apnSender, + pushNotificationManager, verifyExperimentEnrollmentManager, changeNumberManager, storageCredentialGenerator)) @@ -322,8 +318,7 @@ class AccountControllerTest { senderHasStorage, senderTransfer, recaptchaClient, - fcmSender, - apnSender, + pushNotificationManager, verifyExperimentEnrollmentManager, changeNumberManager); @@ -339,14 +334,12 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(200); - ArgumentCaptor captor = ArgumentCaptor.forClass(GcmMessage.class); + final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - verify(fcmSender, times(1)).sendMessage(captor.capture()); - assertThat(captor.getValue().getGcmId()).isEqualTo("mytoken"); - assertThat(captor.getValue().getData().isPresent()).isTrue(); - assertThat(captor.getValue().getData().get().length()).isEqualTo(32); + verify(pushNotificationManager).sendRegistrationChallengeNotification( + eq("mytoken"), eq(PushNotification.TokenType.FCM), challengeTokenCaptor.capture()); - verifyNoMoreInteractions(apnSender); + assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); } @Test @@ -358,14 +351,12 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(200); - ArgumentCaptor captor = ArgumentCaptor.forClass(GcmMessage.class); + final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - verify(fcmSender, times(1)).sendMessage(captor.capture()); - assertThat(captor.getValue().getGcmId()).isEqualTo("mytoken"); - assertThat(captor.getValue().getData().isPresent()).isTrue(); - assertThat(captor.getValue().getData().get().length()).isEqualTo(32); + verify(pushNotificationManager).sendRegistrationChallengeNotification( + eq("mytoken"), eq(PushNotification.TokenType.FCM), challengeTokenCaptor.capture()); - verifyNoMoreInteractions(apnSender); + assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); } @Test @@ -377,16 +368,12 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(200); - ArgumentCaptor captor = ArgumentCaptor.forClass(ApnMessage.class); + final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - verify(apnSender, times(1)).sendMessage(captor.capture()); - assertThat(captor.getValue().getApnId()).isEqualTo("mytoken"); - assertThat(captor.getValue().getChallengeData().isPresent()).isTrue(); - assertThat(captor.getValue().getChallengeData().get().length()).isEqualTo(32); - assertThat(captor.getValue().getMessage()).contains("\"challenge\" : \"" + captor.getValue().getChallengeData().get() + "\""); - assertThat(captor.getValue().isVoip()).isTrue(); + verify(pushNotificationManager).sendRegistrationChallengeNotification( + eq("mytoken"), eq(PushNotification.TokenType.APN_VOIP), challengeTokenCaptor.capture()); - verifyNoMoreInteractions(fcmSender); + assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); } @Test @@ -399,16 +386,12 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(200); - ArgumentCaptor captor = ArgumentCaptor.forClass(ApnMessage.class); + final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - verify(apnSender, times(1)).sendMessage(captor.capture()); - assertThat(captor.getValue().getApnId()).isEqualTo("mytoken"); - assertThat(captor.getValue().getChallengeData().isPresent()).isTrue(); - assertThat(captor.getValue().getChallengeData().get().length()).isEqualTo(32); - assertThat(captor.getValue().getMessage()).contains("\"challenge\" : \"" + captor.getValue().getChallengeData().get() + "\""); - assertThat(captor.getValue().isVoip()).isTrue(); + verify(pushNotificationManager).sendRegistrationChallengeNotification( + eq("mytoken"), eq(PushNotification.TokenType.APN_VOIP), challengeTokenCaptor.capture()); - verifyNoMoreInteractions(fcmSender); + assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); } @Test @@ -421,16 +404,12 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(200); - ArgumentCaptor captor = ArgumentCaptor.forClass(ApnMessage.class); + final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - verify(apnSender, times(1)).sendMessage(captor.capture()); - assertThat(captor.getValue().getApnId()).isEqualTo("mytoken"); - assertThat(captor.getValue().getChallengeData().isPresent()).isTrue(); - assertThat(captor.getValue().getChallengeData().get().length()).isEqualTo(32); - assertThat(captor.getValue().getMessage()).contains("\"challenge\" : \"" + captor.getValue().getChallengeData().get() + "\""); - assertThat(captor.getValue().isVoip()).isFalse(); + verify(pushNotificationManager).sendRegistrationChallengeNotification( + eq("mytoken"), eq(PushNotification.TokenType.APN), challengeTokenCaptor.capture()); - verifyNoMoreInteractions(fcmSender); + assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); } @Test @@ -443,8 +422,7 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(400); assertThat(response.readEntity(String.class)).isBlank(); - verifyNoMoreInteractions(fcmSender); - verifyNoMoreInteractions(apnSender); + verifyNoMoreInteractions(pushNotificationManager); } @Test @@ -462,8 +440,7 @@ class AccountControllerTest { assertThat(responseEntity.getOriginalNumber()).isEqualTo(number); assertThat(responseEntity.getNormalizedNumber()).isEqualTo("+447700900111"); - verifyNoMoreInteractions(fcmSender); - verifyNoMoreInteractions(apnSender); + verifyNoMoreInteractions(pushNotificationManager); } @ParameterizedTest diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java deleted file mode 100644 index 91151974e..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java +++ /dev/null @@ -1,415 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.push; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.eatthepath.pushy.apns.ApnsClient; -import com.eatthepath.pushy.apns.ApnsPushNotification; -import com.eatthepath.pushy.apns.DeliveryPriority; -import com.eatthepath.pushy.apns.PushNotificationResponse; -import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification; -import com.eatthepath.pushy.apns.util.concurrent.PushNotificationFuture; -import com.google.common.util.concurrent.ListenableFuture; -import java.time.Instant; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.push.APNSender; -import org.whispersystems.textsecuregcm.push.ApnFallbackManager; -import org.whispersystems.textsecuregcm.push.ApnMessage; -import org.whispersystems.textsecuregcm.push.ApnMessage.Type; -import org.whispersystems.textsecuregcm.push.RetryingApnsClient; -import org.whispersystems.textsecuregcm.push.RetryingApnsClient.ApnResult; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService; - -class APNSenderTest { - - private static final UUID DESTINATION_UUID = UUID.randomUUID(); - private static final String DESTINATION_APN_ID = "foo"; - - private final AccountsManager accountsManager = mock(AccountsManager.class); - - private final Account destinationAccount = mock(Account.class); - private final Device destinationDevice = mock(Device.class); - private final ApnFallbackManager fallbackManager = mock(ApnFallbackManager.class); - - @BeforeEach - void setup() { - when(destinationAccount.getDevice(1)).thenReturn(Optional.of(destinationDevice)); - when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID); - when(accountsManager.getByAccountIdentifier(DESTINATION_UUID)).thenReturn(Optional.of(destinationAccount)); - } - - @Test - void testSendVoip() throws Exception { - ApnsClient apnsClient = mock(ApnsClient.class); - - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(true); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); - ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_UUID, 1, true, Type.NOTIFICATION, Optional.empty()); - APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); - - apnSender.setApnFallbackManager(fallbackManager); - ListenableFuture sendFuture = apnSender.sendMessage(message); - ApnResult apnResult = sendFuture.get(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient, times(1)).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); - assertThat(notification.getValue().getExpiration()).isEqualTo(Instant.ofEpochMilli(ApnMessage.MAX_EXPIRATION)); - assertThat(notification.getValue().getPayload()).isEqualTo(ApnMessage.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - assertThat(notification.getValue().getTopic()).isEqualTo("foo.voip"); - - assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.SUCCESS); - - verifyNoMoreInteractions(apnsClient); - verifyNoMoreInteractions(accountsManager); - verifyNoMoreInteractions(fallbackManager); - } - - @Test - void testSendApns() throws Exception { - ApnsClient apnsClient = mock(ApnsClient.class); - - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(true); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); - ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_UUID, 1, false, Type.NOTIFICATION, Optional.empty()); - APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); - apnSender.setApnFallbackManager(fallbackManager); - - ListenableFuture sendFuture = apnSender.sendMessage(message); - ApnResult apnResult = sendFuture.get(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient, times(1)).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); - assertThat(notification.getValue().getExpiration()).isEqualTo(Instant.ofEpochMilli(ApnMessage.MAX_EXPIRATION)); - assertThat(notification.getValue().getPayload()).isEqualTo(ApnMessage.APN_NSE_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - assertThat(notification.getValue().getTopic()).isEqualTo("foo"); - - assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.SUCCESS); - - verifyNoMoreInteractions(apnsClient); - verifyNoMoreInteractions(accountsManager); - verifyNoMoreInteractions(fallbackManager); - } - - @Test - void testUnregisteredUser() throws Exception { - ApnsClient apnsClient = mock(ApnsClient.class); - - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(false); - when(response.getRejectionReason()).thenReturn(Optional.of("Unregistered")); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - - RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); - ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_UUID, 1, true, Type.NOTIFICATION, Optional.empty()); - APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); - apnSender.setApnFallbackManager(fallbackManager); - - when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID); - when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11)); - - ListenableFuture sendFuture = apnSender.sendMessage(message); - ApnResult apnResult = sendFuture.get(); - - Thread.sleep(1000); // =( - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient, times(1)).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); - assertThat(notification.getValue().getExpiration()).isEqualTo(Instant.ofEpochMilli(ApnMessage.MAX_EXPIRATION)); - assertThat(notification.getValue().getPayload()).isEqualTo(ApnMessage.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - - assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.NO_SUCH_USER); - - verifyNoMoreInteractions(apnsClient); - verify(accountsManager, times(1)).getByAccountIdentifier(eq(DESTINATION_UUID)); - verify(destinationAccount, times(1)).getDevice(1); - verify(destinationDevice, times(1)).getApnId(); - verify(destinationDevice, times(1)).getPushTimestamp(); -// verify(destinationDevice, times(1)).setApnId(eq((String)null)); -// verify(destinationDevice, times(1)).setVoipApnId(eq((String)null)); -// verify(destinationDevice, times(1)).setFetchesMessages(eq(false)); -// verify(accountsManager, times(1)).update(eq(destinationAccount)); - verify(fallbackManager, times(1)).cancel(eq(destinationAccount), eq(destinationDevice)); - - verifyNoMoreInteractions(accountsManager); - verifyNoMoreInteractions(fallbackManager); - } - -// @Test -// public void testVoipUnregisteredUser() throws Exception { -// ApnsClient apnsClient = mock(ApnsClient.class); -// -// PushNotificationResponse response = mock(PushNotificationResponse.class); -// when(response.isAccepted()).thenReturn(false); -// when(response.getRejectionReason()).thenReturn("Unregistered"); -// -// DefaultPromise> result = new DefaultPromise<>(executor); -// result.setSuccess(response); -// -// when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) -// .thenReturn(result); -// -// RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient, 10); -// ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", true, 30); -// APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); -// apnSender.setApnFallbackManager(fallbackManager); -// -// when(destinationDevice.getApnId()).thenReturn("baz"); -// when(destinationDevice.getVoipApnId()).thenReturn(DESTINATION_APN_ID); -// when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11)); -// -// ListenableFuture sendFuture = apnSender.sendMessage(message); -// ApnResult apnResult = sendFuture.get(); -// -// Thread.sleep(1000); // =( -// -// ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); -// verify(apnsClient, times(1)).sendNotification(notification.capture()); -// -// assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); -// assertThat(notification.getValue().getExpiration()).isEqualTo(new Date(30)); -// assertThat(notification.getValue().getPayload()).isEqualTo("message"); -// assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); -// -// assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.NO_SUCH_USER); -// -// verifyNoMoreInteractions(apnsClient); -// verify(accountsManager, times(1)).get(eq(DESTINATION_NUMBER)); -// verify(destinationAccount, times(1)).getDevice(1); -// verify(destinationDevice, times(1)).getApnId(); -// verify(destinationDevice, times(1)).getVoipApnId(); -// verify(destinationDevice, times(1)).getPushTimestamp(); -// verify(destinationDevice, times(1)).setApnId(eq((String)null)); -// verify(destinationDevice, times(1)).setVoipApnId(eq((String)null)); -// verify(destinationDevice, times(1)).setFetchesMessages(eq(false)); -// verify(accountsManager, times(1)).update(eq(destinationAccount)); -// verify(fallbackManager, times(1)).cancel(eq(new WebsocketAddress(DESTINATION_NUMBER, 1))); -// -// verifyNoMoreInteractions(accountsManager); -// verifyNoMoreInteractions(fallbackManager); -// } - - @Test - void testRecentUnregisteredUser() throws Exception { - ApnsClient apnsClient = mock(ApnsClient.class); - - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(false); - when(response.getRejectionReason()).thenReturn(Optional.of("Unregistered")); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); - ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_UUID, 1, true, Type.NOTIFICATION, Optional.empty()); - APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); - apnSender.setApnFallbackManager(fallbackManager); - - when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID); - when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis()); - - ListenableFuture sendFuture = apnSender.sendMessage(message); - ApnResult apnResult = sendFuture.get(); - - Thread.sleep(1000); // =( - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient, times(1)).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); - assertThat(notification.getValue().getExpiration()).isEqualTo(Instant.ofEpochMilli(ApnMessage.MAX_EXPIRATION)); - assertThat(notification.getValue().getPayload()).isEqualTo(ApnMessage.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - - assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.NO_SUCH_USER); - - verifyNoMoreInteractions(apnsClient); - verify(accountsManager, times(1)).getByAccountIdentifier(eq(DESTINATION_UUID)); - verify(destinationAccount, times(1)).getDevice(1); - verify(destinationDevice, times(1)).getApnId(); - verify(destinationDevice, times(1)).getPushTimestamp(); - - verifyNoMoreInteractions(destinationDevice); - verifyNoMoreInteractions(destinationAccount); - verifyNoMoreInteractions(accountsManager); - verifyNoMoreInteractions(fallbackManager); - } - -// @Test -// public void testUnregisteredUserOldApnId() throws Exception { -// ApnsClient apnsClient = mock(ApnsClient.class); -// -// PushNotificationResponse response = mock(PushNotificationResponse.class); -// when(response.isAccepted()).thenReturn(false); -// when(response.getRejectionReason()).thenReturn("Unregistered"); -// -// DefaultPromise> result = new DefaultPromise<>(executor); -// result.setSuccess(response); -// -// when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) -// .thenReturn(result); -// -// RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient, 10); -// ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", true, 30); -// APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); -// apnSender.setApnFallbackManager(fallbackManager); -// -// when(destinationDevice.getApnId()).thenReturn("baz"); -// when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(12)); -// -// ListenableFuture sendFuture = apnSender.sendMessage(message); -// ApnResult apnResult = sendFuture.get(); -// -// Thread.sleep(1000); // =( -// -// ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); -// verify(apnsClient, times(1)).sendNotification(notification.capture()); -// -// assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); -// assertThat(notification.getValue().getExpiration()).isEqualTo(new Date(30)); -// assertThat(notification.getValue().getPayload()).isEqualTo("message"); -// assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); -// -// assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.NO_SUCH_USER); -// -// verifyNoMoreInteractions(apnsClient); -// verify(accountsManager, times(1)).get(eq(DESTINATION_NUMBER)); -// verify(destinationAccount, times(1)).getDevice(1); -// verify(destinationDevice, times(2)).getApnId(); -// verify(destinationDevice, times(2)).getVoipApnId(); -// -// verifyNoMoreInteractions(destinationDevice); -// verifyNoMoreInteractions(destinationAccount); -// verifyNoMoreInteractions(accountsManager); -// verifyNoMoreInteractions(fallbackManager); -// } - - @Test - void testGenericFailure() throws Exception { - ApnsClient apnsClient = mock(ApnsClient.class); - - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(false); - when(response.getRejectionReason()).thenReturn(Optional.of("BadTopic")); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); - ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_UUID, 1, true, Type.NOTIFICATION, Optional.empty()); - APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); - apnSender.setApnFallbackManager(fallbackManager); - - ListenableFuture sendFuture = apnSender.sendMessage(message); - ApnResult apnResult = sendFuture.get(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient, times(1)).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); - assertThat(notification.getValue().getExpiration()).isEqualTo(Instant.ofEpochMilli(ApnMessage.MAX_EXPIRATION)); - assertThat(notification.getValue().getPayload()).isEqualTo(ApnMessage.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - - assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.GENERIC_FAILURE); - - verifyNoMoreInteractions(apnsClient); - verifyNoMoreInteractions(accountsManager); - verifyNoMoreInteractions(fallbackManager); - } - - @Test - void testFailure() throws Exception { - ApnsClient apnsClient = mock(ApnsClient.class); - - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(true); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), new Exception("lost connection"))); - - RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient); - ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_UUID, 1, true, Type.NOTIFICATION, Optional.empty()); - APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false); - apnSender.setApnFallbackManager(fallbackManager); - - ListenableFuture sendFuture = apnSender.sendMessage(message); - - try { - sendFuture.get(); - throw new AssertionError(); - } catch (InterruptedException e) { - throw new AssertionError(e); - } catch (ExecutionException e) { - // good - } - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient, times(1)).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID); - assertThat(notification.getValue().getExpiration()).isEqualTo(Instant.ofEpochMilli(ApnMessage.MAX_EXPIRATION)); - assertThat(notification.getValue().getPayload()).isEqualTo(ApnMessage.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - - verifyNoMoreInteractions(apnsClient); - verifyNoMoreInteractions(accountsManager); - verifyNoMoreInteractions(fallbackManager); - } - - private static class MockPushNotificationFuture

extends PushNotificationFuture { - - MockPushNotificationFuture(final P pushNotification, final V response) { - super(pushNotification); - complete(response); - } - - MockPushNotificationFuture(final P pushNotification, final Exception exception) { - super(pushNotification); - completeExceptionally(exception); - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java index 8df10d2b3..6632ee367 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java @@ -54,7 +54,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList; import org.whispersystems.textsecuregcm.push.ApnFallbackManager; import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.push.MessageSender; +import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.push.ReceiptSender; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; @@ -104,7 +104,7 @@ class WebSocketConnectionTest { MessagesManager storedMessages = mock(MessagesManager.class); WebSocketAccountAuthenticator webSocketAuthenticator = new WebSocketAccountAuthenticator(accountAuthenticator); AuthenticatedConnectListener connectListener = new AuthenticatedConnectListener(receiptSender, storedMessages, - mock(MessageSender.class), apnFallbackManager, mock(ClientPresenceManager.class), + mock(PushNotificationManager.class), apnFallbackManager, mock(ClientPresenceManager.class), retrySchedulingExecutor); WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class);