Introduce common push notification interfaces/pathways

This commit is contained in:
Jon Chambers 2022-08-03 10:07:53 -04:00 committed by GitHub
parent 0d24828539
commit 6f0faae4ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 843 additions and 1011 deletions

View File

@ -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<WhisperServerConfiguration
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty());
PubSubManager pubSubManager = new PubSubManager(pubsubClient, dispatchManager);
APNSender apnSender = new APNSender(apnSenderExecutor, accountsManager, config.getApnConfiguration());
FcmSender fcmSender = new FcmSender(gcmSenderExecutor, accountsManager, config.getFcmConfiguration().credentials());
APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration());
FcmSender fcmSender = new FcmSender(gcmSenderExecutor, config.getFcmConfiguration().credentials());
ApnFallbackManager apnFallbackManager = new ApnFallbackManager(pushSchedulerCluster, apnSender, accountsManager);
PushNotificationManager pushNotificationManager = new PushNotificationManager(accountsManager, apnSender, fcmSender, apnFallbackManager);
RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), rateLimitersCluster);
DynamicRateLimiters dynamicRateLimiters = new DynamicRateLimiters(rateLimitersCluster, dynamicConfigurationManager);
ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager);
@ -470,17 +473,16 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
ApnFallbackManager apnFallbackManager = new ApnFallbackManager(pushSchedulerCluster, apnSender, accountsManager);
TwilioSmsSender twilioSmsSender = new TwilioSmsSender(config.getTwilioConfiguration(), dynamicConfigurationManager);
SmsSender smsSender = new SmsSender(twilioSmsSender);
MessageSender messageSender = new MessageSender(apnFallbackManager, clientPresenceManager, messagesManager, fcmSender, apnSender, pushLatencyManager);
MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, pushNotificationManager, pushLatencyManager);
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
RecaptchaClient recaptchaClient = new RecaptchaClient(
config.getRecaptchaConfiguration().getProjectPath(),
config.getRecaptchaConfiguration().getCredentialConfigurationJson(),
dynamicConfigurationManager);
PushChallengeManager pushChallengeManager = new PushChallengeManager(apnSender, fcmSender, pushChallengeDynamoDb);
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
recaptchaClient, dynamicRateLimiters);
RateLimitChallengeOptionManager rateLimitChallengeOptionManager =
@ -548,10 +550,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
FtxClient ftxClient = new FtxClient(currencyClient);
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, ftxClient, config.getPaymentsServiceConfiguration().getPaymentCurrencies());
apnSender.setApnFallbackManager(apnFallbackManager);
environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(apnFallbackManager);
environment.lifecycle().manage(pubSubManager);
environment.lifecycle().manage(messageSender);
environment.lifecycle().manage(accountDatabaseCrawler);
environment.lifecycle().manage(directoryReconciliationAccountDatabaseCrawler);
environment.lifecycle().manage(accountCleanerAccountDatabaseCrawler);
@ -607,7 +608,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getWebSocketConfiguration(), 90000);
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
webSocketEnvironment.setConnectListener(
new AuthenticatedConnectListener(receiptSender, messagesManager, messageSender, apnFallbackManager,
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager, apnFallbackManager,
clientPresenceManager, websocketScheduledExecutor));
webSocketEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
@ -619,7 +620,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
smsSender, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
recaptchaClient, fcmSender, apnSender, verifyExperimentEnrollmentManager,
recaptchaClient, pushNotificationManager, verifyExperimentEnrollmentManager,
changeNumberManager, backupCredentialsGenerator));
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));

View File

@ -32,6 +32,7 @@ import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.GET;
import javax.ws.rs.HEAD;
@ -74,10 +75,8 @@ import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
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;
@ -137,8 +136,7 @@ public class AccountController {
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> 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<String, Integer> 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<Boolean> 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();
}

View File

@ -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;

View File

@ -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<ApnResult> sendMessage(final ApnMessage message) {
String topic = bundleId;
@Override
public CompletableFuture<SendPushNotificationResult> 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<ApnResult> 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<ApnResult> sendFuture = apnsClient.send(notification.deviceToken(),
topic,
payload,
MAX_EXPIRATION,
isVoip,
collapseId);
final CompletableFuture<SendPushNotificationResult> 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> account = accountsManager.getByAccountIdentifier(uuid);
if (account.isEmpty()) {
logger.info("No account found: {}", uuid);
unregisteredEventStale.mark();
return;
}
Optional<Device> 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();
}
}
}

View File

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

View File

@ -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<String> challengeData;
@Nullable
private final UUID uuid;
public ApnMessage(String apnId, @Nullable UUID uuid, long deviceId, boolean isVoip, Type type, Optional<String> 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<String> getChallengeData() {
return challengeData;
}
public long getExpirationTime() {
return MAX_EXPIRATION;
}
public Optional<UUID> getUuid() {
return Optional.ofNullable(uuid);
}
public long getDeviceId() {
return deviceId;
}
}

View File

@ -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<SendPushNotificationResult> 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<String> sendFuture = firebaseMessagingClient.sendAsync(builder.build());
final CompletableFuture<SendPushNotificationResult> 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> account = getAccountForEvent(message);
if (account.isPresent()) {
//noinspection OptionalGetWithoutIsPresent
Device device = account.get().getDevice(message.getDeviceId()).get();
if (device.getUninstalledFeedbackTimestamp() == 0) {
accountsManager.updateDevice(account.get(), message.getDeviceId(), d ->
d.setUninstalledFeedbackTimestamp(Util.todayInMillis()));
}
}
}
private Optional<Account> getAccountForEvent(GcmMessage message) {
Optional<Account> account = message.getUuid().flatMap(accountsManager::getByAccountIdentifier);
if (account.isPresent()) {
Optional<Device> device = account.get().getDevice(message.getDeviceId());
if (device.isPresent()) {
if (message.getGcmId().equals(device.get().getGcmId())) {
if (device.get().getPushTimestamp() == 0 || System.currentTimeMillis() > (device.get().getPushTimestamp() + TimeUnit.SECONDS.toMillis(10))) {
return account;
}
}
}
}
return Optional.empty();
return completableSendFuture;
}
}

View File

@ -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<String> data;
@Nullable
private final UUID uuid;
public GcmMessage(String gcmId, @Nullable UUID uuid, int deviceId, Type type, Optional<String> data) {
this.gcmId = gcmId;
this.uuid = uuid;
this.deviceId = deviceId;
this.type = type;
this.data = data;
}
public String getGcmId() {
return gcmId;
}
public Optional<UUID> getUuid() {
return Optional.ofNullable(uuid);
}
public Type getType() {
return type;
}
public int getDeviceId() {
return deviceId;
}
public Optional<String> getData() {
return data;
}
}

View File

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

View File

@ -9,12 +9,4 @@ public class NotPushRegisteredException extends Exception {
public NotPushRegisteredException() {
super();
}
public NotPushRegisteredException(String s) {
super(s);
}
public NotPushRegisteredException(Exception e) {
super(e);
}
}

View File

@ -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,
}
}

View File

@ -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<String, PushNotification.TokenType> 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<String, PushNotification.TokenType> tokenAndType = getToken(device);
sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(),
PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, destination, device));
}
@VisibleForTesting
Pair<String, PushNotification.TokenType> getToken(final Device device) throws NotPushRegisteredException {
final Pair<String, PushNotification.TokenType> 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));
}
}
}

View File

@ -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<SendPushNotificationResult> sendNotification(PushNotification notification);
}

View File

@ -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) {
}

View File

@ -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) {
}
}
});
}

View File

@ -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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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 <P extends ApnsPushNotification, V> extends PushNotificationFuture<P, V> {
MockPushNotificationFuture(final P pushNotification, final V response) {
super(pushNotification);
complete(response);
}
MockPushNotificationFuture(final P pushNotification, final Exception exception) {
super(pushNotification);
completeExceptionally(exception);
}
}
}

View File

@ -100,14 +100,14 @@ class ApnFallbackManagerTest {
assertEquals(1, worker.processNextSlot());
final ArgumentCaptor<ApnMessage> messageCaptor = ArgumentCaptor.forClass(ApnMessage.class);
verify(apnSender).sendMessage(messageCaptor.capture());
final ArgumentCaptor<PushNotification> 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());
}

View File

@ -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<String> 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<String> 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<String> 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);
}
}

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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<GcmMessage> captor = ArgumentCaptor.forClass(GcmMessage.class);
final ArgumentCaptor<String> 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<GcmMessage> captor = ArgumentCaptor.forClass(GcmMessage.class);
final ArgumentCaptor<String> 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<ApnMessage> captor = ArgumentCaptor.forClass(ApnMessage.class);
final ArgumentCaptor<String> 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<ApnMessage> captor = ArgumentCaptor.forClass(ApnMessage.class);
final ArgumentCaptor<String> 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<ApnMessage> captor = ArgumentCaptor.forClass(ApnMessage.class);
final ArgumentCaptor<String> 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

View File

@ -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<SimpleApnsPushNotification> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
ApnResult apnResult = sendFuture.get();
ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
ApnResult apnResult = sendFuture.get();
ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
ApnResult apnResult = sendFuture.get();
Thread.sleep(1000); // =(
ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);
// when(response.isAccepted()).thenReturn(false);
// when(response.getRejectionReason()).thenReturn("Unregistered");
//
// DefaultPromise<PushNotificationResponse<SimpleApnsPushNotification>> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
// ApnResult apnResult = sendFuture.get();
//
// Thread.sleep(1000); // =(
//
// ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
ApnResult apnResult = sendFuture.get();
Thread.sleep(1000); // =(
ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);
// when(response.isAccepted()).thenReturn(false);
// when(response.getRejectionReason()).thenReturn("Unregistered");
//
// DefaultPromise<PushNotificationResponse<SimpleApnsPushNotification>> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
// ApnResult apnResult = sendFuture.get();
//
// Thread.sleep(1000); // =(
//
// ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
ApnResult apnResult = sendFuture.get();
ArgumentCaptor<SimpleApnsPushNotification> 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<SimpleApnsPushNotification> 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<ApnResult> sendFuture = apnSender.sendMessage(message);
try {
sendFuture.get();
throw new AssertionError();
} catch (InterruptedException e) {
throw new AssertionError(e);
} catch (ExecutionException e) {
// good
}
ArgumentCaptor<SimpleApnsPushNotification> 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 <P extends ApnsPushNotification, V> extends PushNotificationFuture<P, V> {
MockPushNotificationFuture(final P pushNotification, final V response) {
super(pushNotification);
complete(response);
}
MockPushNotificationFuture(final P pushNotification, final Exception exception) {
super(pushNotification);
completeExceptionally(exception);
}
}
}

View File

@ -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);