Retire `RetryingApnsClient`
This commit is contained in:
parent
6f0faae4ce
commit
9e9333424f
|
@ -4,27 +4,28 @@
|
||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.push;
|
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.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 io.dropwizard.lifecycle.Managed;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import javax.annotation.Nullable;
|
|
||||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.RetryingApnsClient.ApnResult;
|
|
||||||
|
|
||||||
public class APNSender implements Managed, PushNotificationSender {
|
public class APNSender implements Managed, PushNotificationSender {
|
||||||
|
|
||||||
private final ExecutorService executor;
|
private final ExecutorService executor;
|
||||||
private final String bundleId;
|
private final String bundleId;
|
||||||
private final boolean sandbox;
|
private final ApnsClient apnsClient;
|
||||||
private final RetryingApnsClient apnsClient;
|
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final String APN_VOIP_NOTIFICATION_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}}";
|
static final String APN_VOIP_NOTIFICATION_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"alert\":{\"loc-key\":\"APN_Message\"}}}";
|
||||||
|
@ -41,23 +42,25 @@ public class APNSender implements Managed, PushNotificationSender {
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final Instant MAX_EXPIRATION = Instant.ofEpochMilli(Integer.MAX_VALUE * 1000L);
|
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)
|
public APNSender(ExecutorService executor, ApnConfiguration configuration)
|
||||||
throws IOException, NoSuchAlgorithmException, InvalidKeyException
|
throws IOException, NoSuchAlgorithmException, InvalidKeyException
|
||||||
{
|
{
|
||||||
this.executor = executor;
|
this.executor = executor;
|
||||||
this.bundleId = configuration.getBundleId();
|
this.bundleId = configuration.getBundleId();
|
||||||
this.sandbox = configuration.isSandboxEnabled();
|
this.apnsClient = new ApnsClientBuilder().setSigningKey(
|
||||||
this.apnsClient = new RetryingApnsClient(configuration.getSigningKey(),
|
ApnsSigningKey.loadFromInputStream(new ByteArrayInputStream(configuration.getSigningKey().getBytes()),
|
||||||
configuration.getTeamId(),
|
configuration.getTeamId(), configuration.getKeyId()))
|
||||||
configuration.getKeyId(),
|
.setTrustedServerCertificateChain(getClass().getResourceAsStream(APNS_CA_FILENAME))
|
||||||
sandbox);
|
.setApnsServer(configuration.isSandboxEnabled() ? ApnsClientBuilder.DEVELOPMENT_APNS_HOST : ApnsClientBuilder.PRODUCTION_APNS_HOST)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public APNSender(ExecutorService executor, RetryingApnsClient apnsClient, String bundleId, boolean sandbox) {
|
public APNSender(ExecutorService executor, ApnsClient apnsClient, String bundleId) {
|
||||||
this.executor = executor;
|
this.executor = executor;
|
||||||
this.apnsClient = apnsClient;
|
this.apnsClient = apnsClient;
|
||||||
this.sandbox = sandbox;
|
|
||||||
this.bundleId = bundleId;
|
this.bundleId = bundleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,37 +84,30 @@ public class APNSender implements Managed, PushNotificationSender {
|
||||||
(notification.notificationType() == PushNotification.NotificationType.NOTIFICATION && !isVoip)
|
(notification.notificationType() == PushNotification.NotificationType.NOTIFICATION && !isVoip)
|
||||||
? "incoming-message" : null;
|
? "incoming-message" : null;
|
||||||
|
|
||||||
final ListenableFuture<ApnResult> sendFuture = apnsClient.send(notification.deviceToken(),
|
return apnsClient.sendNotification(new SimpleApnsPushNotification(notification.deviceToken(),
|
||||||
topic,
|
topic,
|
||||||
payload,
|
payload,
|
||||||
MAX_EXPIRATION,
|
MAX_EXPIRATION,
|
||||||
isVoip,
|
DeliveryPriority.IMMEDIATE,
|
||||||
collapseId);
|
isVoip ? PushType.VOIP : PushType.ALERT,
|
||||||
|
collapseId))
|
||||||
|
.thenApplyAsync(response -> {
|
||||||
|
final boolean accepted;
|
||||||
|
final String rejectionReason;
|
||||||
|
final boolean unregistered;
|
||||||
|
|
||||||
final CompletableFuture<SendPushNotificationResult> completableSendFuture = new CompletableFuture<>();
|
if (response.isAccepted()) {
|
||||||
|
accepted = true;
|
||||||
Futures.addCallback(sendFuture, new FutureCallback<>() {
|
rejectionReason = null;
|
||||||
@Override
|
unregistered = false;
|
||||||
public void onSuccess(@Nullable ApnResult result) {
|
|
||||||
if (result == null) {
|
|
||||||
// This should never happen
|
|
||||||
completableSendFuture.completeExceptionally(new NullPointerException("apnResult was null"));
|
|
||||||
} else {
|
} else {
|
||||||
completableSendFuture.complete(switch (result.getStatus()) {
|
accepted = false;
|
||||||
case SUCCESS -> new SendPushNotificationResult(true, null, false);
|
rejectionReason = response.getRejectionReason().orElse("unknown");
|
||||||
case NO_SUCH_USER -> new SendPushNotificationResult(false, result.getReason(), true);
|
unregistered = ("Unregistered".equals(rejectionReason) || "BadDeviceToken".equals(rejectionReason));
|
||||||
case GENERIC_FAILURE -> new SendPushNotificationResult(false, result.getReason(), false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
return new SendPushNotificationResult(accepted, rejectionReason, unregistered);
|
||||||
public void onFailure(@Nullable Throwable t) {
|
|
||||||
completableSendFuture.completeExceptionally(t);
|
|
||||||
}
|
|
||||||
}, executor);
|
}, executor);
|
||||||
|
|
||||||
return completableSendFuture;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -120,6 +116,6 @@ public class APNSender implements Managed, PushNotificationSender {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
this.apnsClient.disconnect();
|
this.apnsClient.close().join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<String, Metric> 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<ApnResult> send(final String apnId, final String topic, final String payload, final Instant expiration, final boolean isVoip, final String collapseId) {
|
|
||||||
SettableFuture<ApnResult> 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<PushNotificationResponse<SimpleApnsPushNotification>, Throwable> {
|
|
||||||
|
|
||||||
private final SettableFuture<ApnResult> future;
|
|
||||||
|
|
||||||
private ResponseHandler(SettableFuture<ApnResult> future) {
|
|
||||||
this.future = future;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void accept(final PushNotificationResponse<SimpleApnsPushNotification> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -59,9 +59,8 @@ class APNSenderTest {
|
||||||
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
||||||
.thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));
|
.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);
|
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();
|
final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join();
|
||||||
|
|
||||||
|
@ -89,9 +88,8 @@ class APNSenderTest {
|
||||||
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
||||||
.thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));
|
.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);
|
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();
|
final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join();
|
||||||
|
|
||||||
|
@ -121,9 +119,8 @@ class APNSenderTest {
|
||||||
.thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));
|
.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);
|
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.getApnId()).thenReturn(DESTINATION_APN_ID);
|
||||||
when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11));
|
when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11));
|
||||||
|
@ -154,9 +151,8 @@ class APNSenderTest {
|
||||||
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
||||||
.thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));
|
.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);
|
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();
|
final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join();
|
||||||
|
|
||||||
|
@ -181,9 +177,8 @@ class APNSenderTest {
|
||||||
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))
|
||||||
.thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), new IOException("lost connection")));
|
.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);
|
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())
|
assertThatThrownBy(() -> apnSender.sendNotification(pushNotification).join())
|
||||||
.isInstanceOf(CompletionException.class)
|
.isInstanceOf(CompletionException.class)
|
||||||
|
|
Loading…
Reference in New Issue