diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java index a34eaf1f4..34711da1a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java @@ -4,27 +4,28 @@ */ package org.whispersystems.textsecuregcm.push; +import com.eatthepath.pushy.apns.ApnsClient; +import com.eatthepath.pushy.apns.ApnsClientBuilder; +import com.eatthepath.pushy.apns.DeliveryPriority; +import com.eatthepath.pushy.apns.PushType; +import com.eatthepath.pushy.apns.auth.ApnsSigningKey; +import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification; 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 io.dropwizard.lifecycle.Managed; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; -import javax.annotation.Nullable; import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; -import org.whispersystems.textsecuregcm.push.RetryingApnsClient.ApnResult; public class APNSender implements Managed, PushNotificationSender { - private final ExecutorService executor; - private final String bundleId; - private final boolean sandbox; - private final RetryingApnsClient apnsClient; + private final ExecutorService executor; + private final String bundleId; + private final ApnsClient apnsClient; @VisibleForTesting static final String APN_VOIP_NOTIFICATION_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}}"; @@ -41,24 +42,26 @@ public class APNSender implements Managed, PushNotificationSender { @VisibleForTesting static final Instant MAX_EXPIRATION = Instant.ofEpochMilli(Integer.MAX_VALUE * 1000L); + private static final String APNS_CA_FILENAME = "apns-certificates.pem"; + public APNSender(ExecutorService executor, ApnConfiguration configuration) throws IOException, NoSuchAlgorithmException, InvalidKeyException { - this.executor = executor; - this.bundleId = configuration.getBundleId(); - this.sandbox = configuration.isSandboxEnabled(); - this.apnsClient = new RetryingApnsClient(configuration.getSigningKey(), - configuration.getTeamId(), - configuration.getKeyId(), - sandbox); + this.executor = executor; + this.bundleId = configuration.getBundleId(); + this.apnsClient = new ApnsClientBuilder().setSigningKey( + ApnsSigningKey.loadFromInputStream(new ByteArrayInputStream(configuration.getSigningKey().getBytes()), + configuration.getTeamId(), configuration.getKeyId())) + .setTrustedServerCertificateChain(getClass().getResourceAsStream(APNS_CA_FILENAME)) + .setApnsServer(configuration.isSandboxEnabled() ? ApnsClientBuilder.DEVELOPMENT_APNS_HOST : ApnsClientBuilder.PRODUCTION_APNS_HOST) + .build(); } @VisibleForTesting - public APNSender(ExecutorService executor, RetryingApnsClient apnsClient, String bundleId, boolean sandbox) { - this.executor = executor; - this.apnsClient = apnsClient; - this.sandbox = sandbox; - this.bundleId = bundleId; + public APNSender(ExecutorService executor, ApnsClient apnsClient, String bundleId) { + this.executor = executor; + this.apnsClient = apnsClient; + this.bundleId = bundleId; } @Override @@ -81,37 +84,30 @@ public class APNSender implements Managed, PushNotificationSender { (notification.notificationType() == PushNotification.NotificationType.NOTIFICATION && !isVoip) ? "incoming-message" : null; - final ListenableFuture sendFuture = apnsClient.send(notification.deviceToken(), + return apnsClient.sendNotification(new SimpleApnsPushNotification(notification.deviceToken(), topic, payload, MAX_EXPIRATION, - isVoip, - collapseId); + DeliveryPriority.IMMEDIATE, + isVoip ? PushType.VOIP : PushType.ALERT, + collapseId)) + .thenApplyAsync(response -> { + final boolean accepted; + final String rejectionReason; + final boolean unregistered; - final CompletableFuture completableSendFuture = new CompletableFuture<>(); + if (response.isAccepted()) { + accepted = true; + rejectionReason = null; + unregistered = false; + } else { + accepted = false; + rejectionReason = response.getRejectionReason().orElse("unknown"); + unregistered = ("Unregistered".equals(rejectionReason) || "BadDeviceToken".equals(rejectionReason)); + } - Futures.addCallback(sendFuture, new FutureCallback<>() { - @Override - public void onSuccess(@Nullable ApnResult result) { - if (result == null) { - // 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) { - completableSendFuture.completeExceptionally(t); - } - }, executor); - - return completableSendFuture; + return new SendPushNotificationResult(accepted, rejectionReason, unregistered); + }, executor); } @Override @@ -120,6 +116,6 @@ public class APNSender implements Managed, PushNotificationSender { @Override public void stop() { - this.apnsClient.disconnect(); + this.apnsClient.close().join(); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/RetryingApnsClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/RetryingApnsClient.java deleted file mode 100644 index 119d9c1d2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/RetryingApnsClient.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import com.codahale.metrics.Metric; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.eatthepath.pushy.apns.ApnsClient; -import com.eatthepath.pushy.apns.ApnsClientBuilder; -import com.eatthepath.pushy.apns.DeliveryPriority; -import com.eatthepath.pushy.apns.PushNotificationResponse; -import com.eatthepath.pushy.apns.PushType; -import com.eatthepath.pushy.apns.auth.ApnsSigningKey; -import com.eatthepath.pushy.apns.metrics.dropwizard.DropwizardApnsClientMetricsListener; -import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; -import io.micrometer.core.instrument.Metrics; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Constants; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.Map; -import java.util.function.BiConsumer; - -import static com.codahale.metrics.MetricRegistry.name; - -public class RetryingApnsClient { - - private static final String APNS_CA_FILENAME = "apns-certificates.pem"; - private static final Logger logger = LoggerFactory.getLogger(RetryingApnsClient.class); - - private static final String RESPONSE_COUNTER_NAME = name(RetryingApnsClient.class, "response"); - private static final String ACCEPTED_TAG_NAME = "accepted"; - private static final String REJECTION_REASON_TAG_NAME = "rejectionReason"; - - private final ApnsClient apnsClient; - - RetryingApnsClient(String apnSigningKey, String teamId, String keyId, boolean sandbox) - throws IOException, InvalidKeyException, NoSuchAlgorithmException - { - MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - DropwizardApnsClientMetricsListener metricsListener = new DropwizardApnsClientMetricsListener(); - - for (Map.Entry entry : metricsListener.getMetrics().entrySet()) { - metricRegistry.register(name(getClass(), entry.getKey()), entry.getValue()); - } - - this.apnsClient = new ApnsClientBuilder().setSigningKey(ApnsSigningKey.loadFromInputStream(new ByteArrayInputStream(apnSigningKey.getBytes()), teamId, keyId)) - .setMetricsListener(metricsListener) - .setTrustedServerCertificateChain(getClass().getResourceAsStream(APNS_CA_FILENAME)) - .setApnsServer(sandbox ? ApnsClientBuilder.DEVELOPMENT_APNS_HOST : ApnsClientBuilder.PRODUCTION_APNS_HOST) - .build(); - } - - @VisibleForTesting - public RetryingApnsClient(ApnsClient apnsClient) { - this.apnsClient = apnsClient; - } - - ListenableFuture send(final String apnId, final String topic, final String payload, final Instant expiration, final boolean isVoip, final String collapseId) { - SettableFuture result = SettableFuture.create(); - SimpleApnsPushNotification notification = new SimpleApnsPushNotification(apnId, topic, payload, expiration, DeliveryPriority.IMMEDIATE, isVoip ? PushType.VOIP : PushType.ALERT, collapseId); - - apnsClient.sendNotification(notification).whenComplete(new ResponseHandler(result)); - - return result; - } - - void disconnect() { - apnsClient.close(); - } - - private static final class ResponseHandler implements BiConsumer, Throwable> { - - private final SettableFuture future; - - private ResponseHandler(SettableFuture future) { - this.future = future; - } - - @Override - public void accept(final PushNotificationResponse response, final Throwable cause) { - if (response != null) { - if (response.isAccepted()) { - future.set(new ApnResult(ApnResult.Status.SUCCESS, null)); - - Metrics.counter(RESPONSE_COUNTER_NAME, ACCEPTED_TAG_NAME, "true").increment(); - } else { - final String rejectionReason = response.getRejectionReason().orElse(null); - - Metrics.counter(RESPONSE_COUNTER_NAME, - ACCEPTED_TAG_NAME, "false", - REJECTION_REASON_TAG_NAME, rejectionReason).increment(); - - if ("Unregistered".equals(rejectionReason) || "BadDeviceToken".equals(rejectionReason)) { - future.set(new ApnResult(ApnResult.Status.NO_SUCH_USER, rejectionReason)); - } else { - logger.warn("Got APN failure: {}", rejectionReason); - future.set(new ApnResult(ApnResult.Status.GENERIC_FAILURE, rejectionReason)); - } - } - } else { - logger.warn("Execution exception", cause); - future.setException(cause); - } - } - } - - 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/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java index 7d48cfb09..b3d479c7c 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java @@ -59,9 +59,8 @@ class APNSenderTest { 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); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, "foo"); final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); @@ -89,9 +88,8 @@ class APNSenderTest { 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); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, "foo"); final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); @@ -121,9 +119,8 @@ class APNSenderTest { .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); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, "foo"); when(destinationDevice.getApnId()).thenReturn(DESTINATION_APN_ID); when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11)); @@ -154,9 +151,8 @@ class APNSenderTest { 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); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, "foo"); final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); @@ -181,9 +177,8 @@ class APNSenderTest { 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); + APNSender apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, "foo"); assertThatThrownBy(() -> apnSender.sendNotification(pushNotification).join()) .isInstanceOf(CompletionException.class)