diff --git a/pom.xml b/pom.xml
index bdec77fa3..d77b7b4a9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -105,9 +105,9 @@
- com.notnoop.apns
- apns
- 0.2.3
+ com.relayrides
+ pushy
+ 0.9.3
org.whispersystems
diff --git a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
index 80a70f368..e3efc2a08 100644
--- a/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
+++ b/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
@@ -172,7 +172,7 @@ public class WhisperServerService extends Applicationof(deadLetterHandler));
PubSubManager pubSubManager = new PubSubManager(cacheClient, dispatchManager);
- APNSender apnSender = new APNSender(accountsManager, cacheClient, config.getApnConfiguration());
+ APNSender apnSender = new APNSender(accountsManager, config.getApnConfiguration());
GCMSender gcmSender = new GCMSender(accountsManager, config.getGcmConfiguration().getApiKey());
WebsocketSender websocketSender = new WebsocketSender(messagesManager, pubSubManager);
AccountAuthenticator deviceAuthenticator = new AccountAuthenticator(accountsManager );
diff --git a/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java b/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java
index 31f08a636..82a8ebf93 100644
--- a/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java
+++ b/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java
@@ -32,14 +32,7 @@ public class ApnConfiguration {
@NotEmpty
@JsonProperty
- private String voipCertificate;
-
- @NotEmpty
- @JsonProperty
- private String voipKey;
-
- @JsonProperty
- private boolean feedback = true;
+ private String bundleId;
@JsonProperty
private boolean sandbox = false;
@@ -52,16 +45,8 @@ public class ApnConfiguration {
return pushKey;
}
- public String getVoipCertificate() {
- return voipCertificate;
- }
-
- public String getVoipKey() {
- return voipKey;
- }
-
- public boolean isFeedbackEnabled() {
- return feedback;
+ public String getBundleId() {
+ return bundleId;
}
public boolean isSandboxEnabled() {
diff --git a/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java b/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java
index c53028f22..2c6c7da4d 100644
--- a/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java
+++ b/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java
@@ -18,234 +18,131 @@ package org.whispersystems.textsecuregcm.push;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
-import com.notnoop.apns.APNS;
-import com.notnoop.apns.ApnsService;
-import com.notnoop.apns.ApnsServiceBuilder;
-import com.notnoop.exceptions.NetworkIOException;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.relayrides.pushy.apns.ApnsClient;
+import com.relayrides.pushy.apns.ApnsClientBuilder;
import org.bouncycastle.openssl.PEMReader;
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.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
+import javax.annotation.Nullable;
import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.KeyPair;
-import java.security.KeyStore;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateException;
+import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Date;
-import java.util.Map;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import io.dropwizard.lifecycle.Managed;
-import redis.clients.jedis.Jedis;
-import redis.clients.jedis.JedisPool;
public class APNSender implements Managed {
private final Logger logger = LoggerFactory.getLogger(APNSender.class);
- private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+ private ExecutorService executor;
- private final AccountsManager accountsManager;
- private final JedisPool jedisPool;
+ private final AccountsManager accountsManager;
+ private final String bundleId;
+ private final boolean sandbox;
+ private final RetryingApnsClient apnsClient;
- private final String pushCertificate;
- private final String pushKey;
-
- private final String voipCertificate;
- private final String voipKey;
-
- private final boolean feedbackEnabled;
- private final boolean sandbox;
-
- private ApnsService pushApnService;
- private ApnsService voipApnService;
-
- public APNSender(AccountsManager accountsManager,
- JedisPool jedisPool,
- ApnConfiguration configuration)
+ public APNSender(AccountsManager accountsManager, ApnConfiguration configuration)
+ throws IOException
{
this.accountsManager = accountsManager;
- this.jedisPool = jedisPool;
- this.pushCertificate = configuration.getPushCertificate();
- this.pushKey = configuration.getPushKey();
- this.voipCertificate = configuration.getVoipCertificate();
- this.voipKey = configuration.getVoipKey();
- this.feedbackEnabled = configuration.isFeedbackEnabled();
+ this.bundleId = configuration.getBundleId();
this.sandbox = configuration.isSandboxEnabled();
+ this.apnsClient = new RetryingApnsClient(configuration.getPushCertificate(),
+ configuration.getPushKey(),
+ 10);
}
@VisibleForTesting
- public APNSender(AccountsManager accountsManager, JedisPool jedisPool,
- ApnsService pushApnService, ApnsService voipApnService,
- boolean feedbackEnabled, boolean sandbox)
- {
+ public APNSender(ExecutorService executor, AccountsManager accountsManager, RetryingApnsClient apnsClient, String bundleId, boolean sandbox) {
+ this.executor = executor;
this.accountsManager = accountsManager;
- this.jedisPool = jedisPool;
- this.pushApnService = pushApnService;
- this.voipApnService = voipApnService;
- this.feedbackEnabled = feedbackEnabled;
+ this.apnsClient = apnsClient;
this.sandbox = sandbox;
- this.pushCertificate = null;
- this.pushKey = null;
- this.voipCertificate = null;
- this.voipKey = null;
+ this.bundleId = bundleId;
}
- public void sendMessage(ApnMessage message)
+ public ListenableFuture sendMessage(final ApnMessage message)
throws TransientPushFailureException
{
- try {
- redisSet(message.getApnId(), message.getNumber(), message.getDeviceId());
+ String topic = bundleId;
- if (message.isVoip()) {
- voipApnService.push(message.getApnId(), message.getMessage(), new Date(message.getExpirationTime()));
- } else {
- pushApnService.push(message.getApnId(), message.getMessage(), new Date(message.getExpirationTime()));
- }
- } catch (NetworkIOException nioe) {
- logger.warn("Network Error", nioe);
- throw new TransientPushFailureException(nioe);
+ if (message.isVoip()) {
+ topic = topic + ".voip";
}
- }
- private static byte[] initializeKeyStore(String pemCertificate, String pemKey)
- throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException
- {
- PEMReader reader = new PEMReader(new InputStreamReader(new ByteArrayInputStream(pemCertificate.getBytes())));
- X509Certificate certificate = (X509Certificate) reader.readObject();
- Certificate[] certificateChain = {certificate};
- reader = new PEMReader(new InputStreamReader(new ByteArrayInputStream(pemKey.getBytes())));
- KeyPair keyPair = (KeyPair) reader.readObject();
+ ListenableFuture future = apnsClient.send(message.getApnId(), topic,
+ message.getMessage(),
+ new Date(message.getExpirationTime()));
- KeyStore keyStore = KeyStore.getInstance("pkcs12");
- keyStore.load(null);
- keyStore.setEntry("apn",
- new KeyStore.PrivateKeyEntry(keyPair.getPrivate(), certificateChain),
- new KeyStore.PasswordProtection("insecure".toCharArray()));
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(@Nullable ApnResult result) {
+ if (result == null) {
+ logger.warn("*** RECEIVED NULL APN RESULT ***");
+ } else if (result.getStatus() == ApnResult.Status.NO_SUCH_USER) {
+ handleUnregisteredUser(message.getApnId(), message.getNumber(), message.getDeviceId());
+ } else if (result.getStatus() == ApnResult.Status.GENERIC_FAILURE) {
+ logger.warn("*** Got APN generic failure: " + result.getReason());
+ }
+ }
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- keyStore.store(baos, "insecure".toCharArray());
+ @Override
+ public void onFailure(@Nullable Throwable t) {
+ logger.warn("Got fatal APNS exception", t);
+ }
+ }, executor);
- return baos.toByteArray();
+ return future;
}
@Override
public void start() throws Exception {
- byte[] pushKeyStore = initializeKeyStore(pushCertificate, pushKey);
- byte[] voipKeyStore = initializeKeyStore(voipCertificate, voipKey);
-
- ApnsServiceBuilder pushApnServiceBuilder = APNS.newService()
- .withCert(new ByteArrayInputStream(pushKeyStore), "insecure")
- .asQueued();
-
-
- ApnsServiceBuilder voipApnServiceBuilder = APNS.newService()
- .withCert(new ByteArrayInputStream(voipKeyStore), "insecure")
- .asQueued();
-
-
- if (sandbox) {
- this.pushApnService = pushApnServiceBuilder.withSandboxDestination().build();
- this.voipApnService = voipApnServiceBuilder.withSandboxDestination().build();
- } else {
- this.pushApnService = pushApnServiceBuilder.withProductionDestination().build();
- this.voipApnService = voipApnServiceBuilder.withProductionDestination().build();
- }
-
- if (feedbackEnabled) {
- this.executor.scheduleAtFixedRate(new FeedbackRunnable(), 0, 1, TimeUnit.HOURS);
- }
+ this.executor = Executors.newSingleThreadExecutor();
+ this.apnsClient.connect(sandbox);
}
@Override
public void stop() throws Exception {
- pushApnService.stop();
- voipApnService.stop();
+ this.executor.shutdown();
+ this.apnsClient.disconnect();
}
- private void redisSet(String registrationId, String number, int deviceId) {
- try (Jedis jedis = jedisPool.getResource()) {
- jedis.set("APN-" + registrationId.toLowerCase(), number + "." + deviceId);
- jedis.expire("APN-" + registrationId.toLowerCase(), (int) TimeUnit.HOURS.toSeconds(1));
- }
- }
+ private void handleUnregisteredUser(String registrationId, String number, int deviceId) {
+ logger.info("Got APN Unregistered: " + number + "," + deviceId);
- private Optional redisGet(String registrationId) {
- try (Jedis jedis = jedisPool.getResource()) {
- String number = jedis.get("APN-" + registrationId.toLowerCase());
- return Optional.fromNullable(number);
- }
- }
+ Optional account = accountsManager.get(number);
- @VisibleForTesting
- public void checkFeedback() {
- new FeedbackRunnable().run();
- }
+ if (account.isPresent()) {
+ Optional device = account.get().getDevice(deviceId);
- private class FeedbackRunnable implements Runnable {
-
- @Override
- public void run() {
- try {
- Map inactiveDevices = pushApnService.getInactiveDevices();
- inactiveDevices.putAll(voipApnService.getInactiveDevices());
-
- for (String registrationId : inactiveDevices.keySet()) {
- Optional device = redisGet(registrationId);
-
- if (device.isPresent()) {
- logger.warn("Got APN unregistered notice!");
- String[] parts = device.get().split("\\.", 2);
-
- if (parts.length == 2) {
- String number = parts[0];
- int deviceId = Integer.parseInt(parts[1]);
- long timestamp = inactiveDevices.get(registrationId).getTime();
-
- handleApnUnregistered(registrationId, number, deviceId, timestamp);
- } else {
- logger.warn("APN unregister event for device with no parts: " + device.get());
- }
- } else {
- logger.warn("APN unregister event received for uncached ID: " + registrationId);
- }
- }
- } catch (Throwable t) {
- logger.warn("Exception during feedback", t);
- }
- }
-
- private void handleApnUnregistered(String registrationId, String number, int deviceId, long timestamp) {
- logger.info("Got APN Unregistered: " + number + "," + deviceId);
-
- Optional account = accountsManager.get(number);
-
- if (account.isPresent()) {
- Optional device = account.get().getDevice(deviceId);
-
- if (device.isPresent()) {
- if (registrationId.equals(device.get().getApnId())) {
- logger.info("APN Unregister APN ID matches!");
- if (device.get().getPushTimestamp() == 0 ||
- timestamp > device.get().getPushTimestamp())
- {
- logger.info("APN Unregister timestamp matches!");
- device.get().setApnId(null);
- accountsManager.update(account.get());
- }
+ if (device.isPresent()) {
+ if (registrationId.equals(device.get().getApnId())) {
+ logger.info("APN Unregister APN ID matches!");
+ if (device.get().getPushTimestamp() == 0 ||
+ System.currentTimeMillis() > device.get().getPushTimestamp() + TimeUnit.SECONDS.toMillis(10))
+ {
+ logger.info("APN Unregister timestamp matches!");
+ device.get().setApnId(null);
+ device.get().setFetchesMessages(false);
+ accountsManager.update(account.get());
}
}
}
diff --git a/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java b/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java
index 6110ea979..972e5ff62 100644
--- a/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java
+++ b/src/main/java/org/whispersystems/textsecuregcm/push/ApnMessage.java
@@ -8,35 +8,35 @@ public class ApnMessage {
private final String number;
private final int deviceId;
private final String message;
- private final boolean voip;
+ private final boolean isVoip;
private final long expirationTime;
- public ApnMessage(String apnId, String number, int deviceId, String message, boolean voip, long expirationTime) {
+ public ApnMessage(String apnId, String number, int deviceId, String message, boolean isVoip, long expirationTime) {
this.apnId = apnId;
this.number = number;
this.deviceId = deviceId;
this.message = message;
- this.voip = voip;
+ this.isVoip = isVoip;
this.expirationTime = expirationTime;
}
- public ApnMessage(ApnMessage copy, String apnId, boolean voip, long expirationTime) {
+ public ApnMessage(ApnMessage copy, String apnId, boolean isVoip, long expirationTime) {
this.apnId = apnId;
this.number = copy.number;
this.deviceId = copy.deviceId;
this.message = copy.message;
- this.voip = voip;
+ this.isVoip = isVoip;
this.expirationTime = expirationTime;
}
+ public boolean isVoip() {
+ return isVoip;
+ }
+
public String getApnId() {
return apnId;
}
- public boolean isVoip() {
- return voip;
- }
-
public String getMessage() {
return message;
}
diff --git a/src/main/java/org/whispersystems/textsecuregcm/push/PushSender.java b/src/main/java/org/whispersystems/textsecuregcm/push/PushSender.java
index 55ee86612..83c8ac8f1 100644
--- a/src/main/java/org/whispersystems/textsecuregcm/push/PushSender.java
+++ b/src/main/java/org/whispersystems/textsecuregcm/push/PushSender.java
@@ -136,8 +136,8 @@ public class PushSender implements Managed {
if (!Util.isEmpty(device.getVoipApnId())) {
apnMessage = new ApnMessage(device.getVoipApnId(), account.getNumber(), (int)device.getId(),
- String.format(APN_PAYLOAD, messageQueueDepth),
- true, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(ApnFallbackManager.FALLBACK_DURATION));
+ String.format(APN_PAYLOAD, messageQueueDepth), true,
+ System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(ApnFallbackManager.FALLBACK_DURATION));
if (fallback) {
apnFallbackManager.schedule(new WebsocketAddress(account.getNumber(), device.getId()),
diff --git a/src/main/java/org/whispersystems/textsecuregcm/push/RetryingApnsClient.java b/src/main/java/org/whispersystems/textsecuregcm/push/RetryingApnsClient.java
new file mode 100644
index 000000000..5b1930dee
--- /dev/null
+++ b/src/main/java/org/whispersystems/textsecuregcm/push/RetryingApnsClient.java
@@ -0,0 +1,164 @@
+package org.whispersystems.textsecuregcm.push;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import com.nurkiewicz.asyncretry.AsyncRetryExecutor;
+import com.nurkiewicz.asyncretry.RetryContext;
+import com.nurkiewicz.asyncretry.RetryExecutor;
+import com.nurkiewicz.asyncretry.function.RetryCallable;
+import com.relayrides.pushy.apns.ApnsClient;
+import com.relayrides.pushy.apns.ApnsClientBuilder;
+import com.relayrides.pushy.apns.ApnsServerException;
+import com.relayrides.pushy.apns.ClientNotConnectedException;
+import com.relayrides.pushy.apns.DeliveryPriority;
+import com.relayrides.pushy.apns.PushNotificationResponse;
+import com.relayrides.pushy.apns.util.SimpleApnsPushNotification;
+
+import org.bouncycastle.openssl.PEMReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+import io.netty.util.concurrent.Future;
+import io.netty.util.concurrent.GenericFutureListener;
+
+public class RetryingApnsClient {
+
+ private static final Logger logger = LoggerFactory.getLogger(RetryingApnsClient.class);
+
+ private final ApnsClient apnsClient;
+ private final RetryExecutor retryExecutor;
+
+ RetryingApnsClient(String apnCertificate, String apnKey, int retryCount)
+ throws IOException
+ {
+ this(new ApnsClientBuilder().setClientCredentials(initializeCertificate(apnCertificate),
+ initializePrivateKey(apnKey), null)
+ .build(),
+ retryCount);
+ }
+
+ @VisibleForTesting
+ public RetryingApnsClient(ApnsClient apnsClient, int retryCount) {
+ ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
+
+ this.apnsClient = apnsClient;
+ this.retryExecutor = new AsyncRetryExecutor(executorService).retryOn(ClientNotConnectedException.class)
+ .retryOn(InterruptedException.class)
+ .retryOn(ApnsServerException.class)
+ .withExponentialBackoff(100, 2.0)
+ .withUniformJitter()
+ .withMaxDelay(4000)
+ .withMaxRetries(retryCount);
+ }
+
+ ListenableFuture send(final String apnId, final String topic, final String payload, final Date expiration) {
+ return this.retryExecutor.getFutureWithRetry(new RetryCallable>() {
+ @Override
+ public ListenableFuture call(RetryContext context) throws Exception {
+ SettableFuture result = SettableFuture.create();
+ SimpleApnsPushNotification notification = new SimpleApnsPushNotification(apnId, topic, payload, expiration, DeliveryPriority.IMMEDIATE);
+
+ apnsClient.sendNotification(notification).addListener(new ResponseHandler(apnsClient, result));
+
+ return result;
+ }
+ });
+ }
+
+ void connect(boolean sandbox) {
+ apnsClient.connect(sandbox ? ApnsClient.DEVELOPMENT_APNS_HOST : ApnsClient.PRODUCTION_APNS_HOST).awaitUninterruptibly();
+ }
+
+ void disconnect() {
+ apnsClient.disconnect();
+ }
+
+ private static X509Certificate initializeCertificate(String pemCertificate) throws IOException {
+ PEMReader reader = new PEMReader(new InputStreamReader(new ByteArrayInputStream(pemCertificate.getBytes())));
+ return (X509Certificate) reader.readObject();
+ }
+
+ private static PrivateKey initializePrivateKey(String pemKey) throws IOException {
+ PEMReader reader = new PEMReader(new InputStreamReader(new ByteArrayInputStream(pemKey.getBytes())));
+ return ((KeyPair) reader.readObject()).getPrivate();
+ }
+
+ private static final class ResponseHandler implements GenericFutureListener>> {
+
+ private final ApnsClient client;
+ private final SettableFuture future;
+
+ private ResponseHandler(ApnsClient client, SettableFuture future) {
+ this.client = client;
+ this.future = future;
+ }
+
+ @Override
+ public void operationComplete(io.netty.util.concurrent.Future> result) {
+ try {
+ PushNotificationResponse response = result.get();
+
+ if (response.isAccepted()) {
+ future.set(new ApnResult(ApnResult.Status.SUCCESS, null));
+ } else if ("Unregistered".equals(response.getRejectionReason())) {
+ future.set(new ApnResult(ApnResult.Status.NO_SUCH_USER, response.getRejectionReason()));
+ } else {
+ logger.warn("Got APN failure: " + response.getRejectionReason());
+ future.set(new ApnResult(ApnResult.Status.GENERIC_FAILURE, response.getRejectionReason()));
+ }
+
+ } catch (InterruptedException e) {
+ future.setException(e);
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof ClientNotConnectedException) setDisconnected(e.getCause());
+ else future.setException(e.getCause());
+ }
+ }
+
+ private void setDisconnected(final Throwable t) {
+ logger.warn("Client disconnected, waiting for reconnect...", t);
+ client.getReconnectionFuture().addListener(new GenericFutureListener>() {
+ @Override
+ public void operationComplete(Future complete) {
+ logger.warn("Client reconnected...");
+ future.setException(t);
+ }
+ });
+ }
+ }
+
+ public static class ApnResult {
+ public enum Status {
+ SUCCESS, NO_SUCH_USER, GENERIC_FAILURE
+ }
+
+ private final Status status;
+ private final String reason;
+
+ ApnResult(Status status, String reason) {
+ this.status = status;
+ this.reason = reason;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+ }
+
+}
diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java b/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java
index 47ec9731a..461081d09 100644
--- a/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java
+++ b/src/test/java/org/whispersystems/textsecuregcm/tests/push/APNSenderTest.java
@@ -1,23 +1,31 @@
package org.whispersystems.textsecuregcm.tests.push;
import com.google.common.base.Optional;
-import com.notnoop.apns.ApnsService;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.relayrides.pushy.apns.ApnsClient;
+import com.relayrides.pushy.apns.ApnsServerException;
+import com.relayrides.pushy.apns.ClientNotConnectedException;
+import com.relayrides.pushy.apns.DeliveryPriority;
+import com.relayrides.pushy.apns.PushNotificationResponse;
+import com.relayrides.pushy.apns.util.SimpleApnsPushNotification;
import org.junit.Before;
import org.junit.Test;
+import org.mockito.ArgumentCaptor;
import org.whispersystems.textsecuregcm.push.APNSender;
import org.whispersystems.textsecuregcm.push.ApnMessage;
-import org.whispersystems.textsecuregcm.push.TransientPushFailureException;
+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;
import java.util.Date;
-import java.util.HashMap;
-import static org.mockito.Mockito.mock;
+import io.netty.util.concurrent.DefaultEventExecutor;
+import io.netty.util.concurrent.DefaultPromise;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
-import redis.clients.jedis.Jedis;
-import redis.clients.jedis.JedisPool;
public class APNSenderTest {
@@ -25,63 +33,255 @@ public class APNSenderTest {
private static final String DESTINATION_APN_ID = "foo";
private final AccountsManager accountsManager = mock(AccountsManager.class);
- private final JedisPool jedisPool = mock(JedisPool.class);
- private final Jedis jedis = mock(Jedis.class);
- private final ApnsService voipService = mock(ApnsService.class);
- private final ApnsService apnsService = mock(ApnsService.class);
private final Account destinationAccount = mock(Account.class);
private final Device destinationDevice = mock(Device.class );
+ private final DefaultEventExecutor executor = new DefaultEventExecutor();
+
@Before
public void setup() {
when(destinationAccount.getDevice(1)).thenReturn(Optional.of(destinationDevice));
when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID);
when(accountsManager.get(DESTINATION_NUMBER)).thenReturn(Optional.of(destinationAccount));
-
- when(jedisPool.getResource()).thenReturn(jedis);
- when(jedis.get("APN-" + DESTINATION_APN_ID)).thenReturn(DESTINATION_NUMBER + "." + 1);
-
- when(voipService.getInactiveDevices()).thenReturn(new HashMap() {{
- put(DESTINATION_APN_ID, new Date(System.currentTimeMillis()));
- }});
- when(apnsService.getInactiveDevices()).thenReturn(new HashMap());
}
@Test
- public void testSendVoip() throws TransientPushFailureException {
- APNSender apnSender = new APNSender(accountsManager, jedisPool, apnsService, voipService, false, false);
+ public void testSendVoip() throws Exception {
+ ApnsClient apnsClient = mock(ApnsClient.class);
- ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", true, 30);
- apnSender.sendMessage(message);
+ PushNotificationResponse response = mock(PushNotificationResponse.class);
+ when(response.isAccepted()).thenReturn(true);
+ DefaultPromise> result = new DefaultPromise<>(executor);
+ result.setSuccess(response);
- verify(jedis, times(1)).set(eq("APN-" + DESTINATION_APN_ID.toLowerCase()), eq(DESTINATION_NUMBER + "." + 1));
- verify(voipService, times(1)).push(eq(DESTINATION_APN_ID), eq(message.getMessage()), eq(new Date(30)));
- verifyNoMoreInteractions(apnsService);
+ 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);
+
+ ListenableFuture sendFuture = apnSender.sendMessage(message);
+ ApnResult apnResult = sendFuture.get();
+
+ ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+ verify(apnsClient, times(1)).sendNotification(notification.capture());
+
+ assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID);
+ assertThat(notification.getValue().getExpiration()).isEqualTo(new Date(30));
+ assertThat(notification.getValue().getPayload()).isEqualTo("message");
+ 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);
}
@Test
- public void testSendApns() throws TransientPushFailureException {
- APNSender apnSender = new APNSender(accountsManager, jedisPool, apnsService, voipService, false, false);
+ public void testSendApns() throws Exception {
+ ApnsClient apnsClient = mock(ApnsClient.class);
+ PushNotificationResponse response = mock(PushNotificationResponse.class);
+ when(response.isAccepted()).thenReturn(true);
+
+ DefaultPromise> result = new DefaultPromise<>(executor);
+ result.setSuccess(response);
+
+ when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
+ .thenReturn(result);
+
+ RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient, 10);
ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", false, 30);
- apnSender.sendMessage(message);
+ APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false);
- verify(jedis, times(1)).set(eq("APN-" + DESTINATION_APN_ID.toLowerCase()), eq(DESTINATION_NUMBER + "." + 1));
- verify(apnsService, times(1)).push(eq(DESTINATION_APN_ID), eq(message.getMessage()), eq(new Date(30)));
- verifyNoMoreInteractions(voipService);
+ ListenableFuture sendFuture = apnSender.sendMessage(message);
+ ApnResult apnResult = sendFuture.get();
+
+ ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+ verify(apnsClient, times(1)).sendNotification(notification.capture());
+
+ assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID);
+ assertThat(notification.getValue().getExpiration()).isEqualTo(new Date(30));
+ assertThat(notification.getValue().getPayload()).isEqualTo("message");
+ assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE);
+ assertThat(notification.getValue().getTopic()).isEqualTo("foo");
+
+ assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.SUCCESS);
+
+ verifyNoMoreInteractions(apnsClient);
+ verifyNoMoreInteractions(accountsManager);
}
@Test
- public void testFeedbackUnregistered() {
- APNSender apnSender = new APNSender(accountsManager, jedisPool, apnsService, voipService, false, false);
- apnSender.checkFeedback();
+ public void testUnregisteredUser() throws Exception {
+ ApnsClient apnsClient = mock(ApnsClient.class);
- verify(jedis, times(1)).get(eq("APN-" +DESTINATION_APN_ID));
+ PushNotificationResponse response = mock(PushNotificationResponse.class);
+ when(response.isAccepted()).thenReturn(false);
+ when(response.getRejectionReason()).thenReturn("Unregistered");
+
+ DefaultPromise> result = new DefaultPromise<>(executor);
+ result.setSuccess(response);
+
+ when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
+ .thenReturn(result);
+
+ RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient, 10);
+ ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", true, 30);
+ APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false);
+
+ ListenableFuture sendFuture = apnSender.sendMessage(message);
+ ApnResult apnResult = sendFuture.get();
+
+ Thread.sleep(1000); // =(
+
+ ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+ verify(apnsClient, times(1)).sendNotification(notification.capture());
+
+ assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID);
+ assertThat(notification.getValue().getExpiration()).isEqualTo(new Date(30));
+ assertThat(notification.getValue().getPayload()).isEqualTo("message");
+ assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE);
+
+ assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.NO_SUCH_USER);
+
+ verifyNoMoreInteractions(apnsClient);
verify(accountsManager, times(1)).get(eq(DESTINATION_NUMBER));
+ verify(destinationDevice, times(1)).getApnId();
verify(destinationDevice, times(1)).setApnId(eq((String)null));
verify(accountsManager, times(1)).update(eq(destinationAccount));
+
+ verifyNoMoreInteractions(accountsManager);
+ }
+
+ @Test
+ public void testGenericFailure() throws Exception {
+ ApnsClient apnsClient = mock(ApnsClient.class);
+
+ PushNotificationResponse response = mock(PushNotificationResponse.class);
+ when(response.isAccepted()).thenReturn(false);
+ when(response.getRejectionReason()).thenReturn("BadTopic");
+
+ DefaultPromise> result = new DefaultPromise<>(executor);
+ result.setSuccess(response);
+
+ when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
+ .thenReturn(result);
+
+ RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient, 10);
+ ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", true, 30);
+ APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false);
+
+ ListenableFuture sendFuture = apnSender.sendMessage(message);
+ ApnResult apnResult = sendFuture.get();
+
+ ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+ verify(apnsClient, times(1)).sendNotification(notification.capture());
+
+ assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_APN_ID);
+ assertThat(notification.getValue().getExpiration()).isEqualTo(new Date(30));
+ assertThat(notification.getValue().getPayload()).isEqualTo("message");
+ assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE);
+
+ assertThat(apnResult.getStatus()).isEqualTo(ApnResult.Status.GENERIC_FAILURE);
+
+ verifyNoMoreInteractions(apnsClient);
+ verifyNoMoreInteractions(accountsManager);
+ }
+
+ @Test
+ public void testTransientFailure() throws Exception {
+ ApnsClient apnsClient = mock(ApnsClient.class);
+
+ PushNotificationResponse response = mock(PushNotificationResponse.class);
+ when(response.isAccepted()).thenReturn(true);
+
+ DefaultPromise> result = new DefaultPromise<>(executor);
+ result.setFailure(new ClientNotConnectedException("lost connection"));
+
+ DefaultPromise connectedResult = new DefaultPromise<>(executor);
+
+ when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
+ .thenReturn(result);
+
+ when(apnsClient.getReconnectionFuture())
+ .thenReturn(connectedResult);
+
+ 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);
+
+ ListenableFuture sendFuture = apnSender.sendMessage(message);
+
+ Thread.sleep(1000);
+
+ assertThat(sendFuture.isDone()).isFalse();
+
+ DefaultPromise> updatedResult = new DefaultPromise<>(executor);
+ updatedResult.setSuccess(response);
+
+ when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
+ .thenReturn(updatedResult);
+
+ connectedResult.setSuccess(null);
+
+ ApnResult apnResult = sendFuture.get();
+
+ ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+ verify(apnsClient, times(2)).sendNotification(notification.capture());
+ verify(apnsClient, times(1)).getReconnectionFuture();
+
+ 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.SUCCESS);
+
+ verifyNoMoreInteractions(apnsClient);
+ verifyNoMoreInteractions(accountsManager);
+ }
+
+ @Test
+ public void testPersistentTransientFailure() throws Exception {
+ ApnsClient apnsClient = mock(ApnsClient.class);
+
+ PushNotificationResponse response = mock(PushNotificationResponse.class);
+ when(response.isAccepted()).thenReturn(true);
+
+ DefaultPromise> result = new DefaultPromise<>(executor);
+ result.setFailure(new ApnsServerException("apn servers suck again"));
+
+ when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
+ .thenReturn(result);
+
+ RetryingApnsClient retryingApnsClient = new RetryingApnsClient(apnsClient, 3);
+ ApnMessage message = new ApnMessage(DESTINATION_APN_ID, DESTINATION_NUMBER, 1, "message", true, 30);
+ APNSender apnSender = new APNSender(new SynchronousExecutorService(), accountsManager, retryingApnsClient, "foo", false);
+
+ ListenableFuture sendFuture = apnSender.sendMessage(message);
+
+ try {
+ sendFuture.get();
+ throw new AssertionError("future did not throw exception");
+ } catch (Exception e) {
+ // good
+ }
+
+ ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+ verify(apnsClient, times(4)).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);
+
+ verifyNoMoreInteractions(apnsClient);
+ verifyNoMoreInteractions(accountsManager);
}
}
diff --git a/src/test/java/org/whispersystems/textsecuregcm/tests/push/ApnFallbackManagerTest.java b/src/test/java/org/whispersystems/textsecuregcm/tests/push/ApnFallbackManagerTest.java
index 01b2ce89b..ad91daf2a 100644
--- a/src/test/java/org/whispersystems/textsecuregcm/tests/push/ApnFallbackManagerTest.java
+++ b/src/test/java/org/whispersystems/textsecuregcm/tests/push/ApnFallbackManagerTest.java
@@ -43,12 +43,10 @@ public class ApnFallbackManagerTest {
assertEquals(arguments.get(0).getMessage(), message.getMessage());
assertEquals(arguments.get(0).getApnId(), task.getVoipApnId());
- assertTrue(arguments.get(0).isVoip());
// assertEquals(arguments.get(0).getExpirationTime(), Integer.MAX_VALUE * 1000L);
assertEquals(arguments.get(1).getMessage(), message.getMessage());
assertEquals(arguments.get(1).getApnId(), task.getApnId());
- assertFalse(arguments.get(1).isVoip());
assertEquals(arguments.get(1).getExpirationTime(), Integer.MAX_VALUE * 1000L);
}