getResponseFuture() {
+ return responseFuture;
+ }
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelPool.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelPool.java
index f1c3ebd09..b0bd01f76 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelPool.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelPool.java
@@ -36,7 +36,7 @@
import java.util.Set;
/**
- * A pool of channels connected to an APNs server. Channel pools use a {@link ApnsChannelFactory} to create
+ *
A pool of channels connected to an APNs server. Channel pools use a {@link ApnsNotificationChannelFactory} to create
* connections (up to a given maximum capacity) on demand.
*
* Callers acquire channels from the pool via the {@link ApnsChannelPool#acquire()} method, and must return them to
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClient.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClient.java
index 6e119448c..eec7a3492 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClient.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClient.java
@@ -31,6 +31,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -58,10 +59,12 @@
* result, clients do not need to be "started" explicitly, and are ready to begin sending notifications as soon
* as they're constructed.
*
- * Notifications sent by a client to an APNs server are sent asynchronously. A
- * {@link CompletableFuture} is returned immediately when a notification is sent, but will not complete until the
- * attempt to send the notification has failed, the notification has been accepted by the APNs server, or the
- * notification has been rejected by the APNs server.
+ * Notifications sent by a client to an APNs server are sent asynchronously. A {@link CompletableFuture} is returned
+ * immediately when a notification is sent, but will not complete until the attempt to send the notification has failed,
+ * the notification has been accepted by the APNs server, or the notification has been rejected by the APNs server.
+ *
+ * APNs clients also allow for management of broadcast push notification channels. Channel management requests use
+ * the same credentials and client resources as requests to send push notifications.
*
* APNs clients are intended to be long-lived, persistent resources. They are also inherently thread-safe and can be
* shared across many threads in a complex application. Callers must shut them down via the {@link ApnsClient#close()}
@@ -79,6 +82,8 @@ public class ApnsClient {
private final ApnsChannelPool channelPool;
+ private final ApnsChannelManagementClient channelManagementClient;
+
private final ApnsClientMetricsListener metricsListener;
private final AtomicBoolean isClosed = new AtomicBoolean(false);
@@ -90,7 +95,6 @@ public class ApnsClient {
private static class NoopApnsClientMetricsListener implements ApnsClientMetricsListener {
-
@Override
public void handleWriteFailure(final String topic) {
}
@@ -129,7 +133,7 @@ public void handleConnectionCreationFailed() {
this.metricsListener = clientConfiguration.getMetricsListener()
.orElseGet(NoopApnsClientMetricsListener::new);
- final ApnsChannelFactory channelFactory = new ApnsChannelFactory(clientConfiguration, this.clientResources);
+ final ApnsNotificationChannelFactory channelFactory = new ApnsNotificationChannelFactory(clientConfiguration, this.clientResources);
final ApnsChannelPoolMetricsListener channelPoolMetricsListener = new ApnsChannelPoolMetricsListener() {
@@ -153,6 +157,8 @@ public void handleConnectionCreationFailed() {
clientConfiguration.getConcurrentConnections(),
this.clientResources.getEventLoopGroup().next(),
channelPoolMetricsListener);
+
+ this.channelManagementClient = new ApnsChannelManagementClient(clientConfiguration, this.clientResources);
}
/**
@@ -216,6 +222,92 @@ public PushNotificationFutureIn order to support several simultaneous events, you can maintain up to 10,000 channels for your app
+ * in the development and production environment, respectively. Once the Live Activity event is complete, and you no
+ * longer plan to use the channel for any subsequent updates, delete the channel to avoid going over the allocated
+ * channel limit.
+ *
+ * @param bundleId the bundle ID for the app for which to create a new channel
+ * @param messageStoragePolicy the message storage policy for the new channel
+ * @param apnsRequestId a unique identifier for this request; may be {@code null}, in which case the remote server
+ * will generate a unique ID for this request
+ *
+ * @return a future that completes when the channel has been created
+ *
+ * @see Sending channel management requests to APN
+ *
+ * @since 0.16
+ */
+ public CompletableFuture createChannel(final String bundleId,
+ final MessageStoragePolicy messageStoragePolicy,
+ final UUID apnsRequestId) {
+
+ return channelManagementClient.createChannel(bundleId, messageStoragePolicy, apnsRequestId);
+ }
+
+ /**
+ * Retrieves the configuration for a given broadcast notification channel.
+ *
+ * @param bundleId the bundle ID for the app with which the channel is associated
+ * @param channelId the channel ID for which to retrieve configuration details
+ * @param apnsRequestId a unique identifier for this request; may be {@code null}, in which case the remote server
+ * will generate a unique ID for this request
+ *
+ * @return a future that yields configuration details for the given channel
+ *
+ * @see Sending channel management requests to APN
+ *
+ * @since 0.16
+ */
+ public CompletableFuture getChannelConfiguration(final String bundleId,
+ final String channelId,
+ final UUID apnsRequestId) {
+
+ return channelManagementClient.getChannelConfiguration(bundleId, channelId, apnsRequestId);
+ }
+
+ /**
+ * Deletes a broadcast notification channel.
+ *
+ * @param bundleId the bundle ID for the app with which the channel is associated
+ * @param channelId the ID of the channel to delete
+ * @param apnsRequestId a unique identifier for this request; may be {@code null}, in which case the remote server
+ * will generate a unique ID for this request
+ *
+ * @return a future that completes when the given channel has been deleted
+ *
+ * @see Sending channel management requests to APN
+ *
+ * @since 0.16
+ */
+ public CompletableFuture deleteChannel(final String bundleId,
+ final String channelId,
+ final UUID apnsRequestId) {
+
+ return channelManagementClient.deleteChannel(bundleId, channelId, apnsRequestId);
+ }
+
+ /**
+ * Retrieves a list of all broadcast notification channel IDs associated with the given bundle ID.
+ *
+ * @param bundleId the bundle ID for the app for which to retrieve a list of broadcast notification channel IDs
+ * @param apnsRequestId a unique identifier for this request; may be {@code null}, in which case the remote server
+ * will generate a unique ID for this request
+ *
+ * @return a future that yields a list of all broadcast notification channel IDs for the given bundle ID
+ *
+ * @see Sending channel management requests to APN
+ *
+ * @since 0.16
+ */
+ public CompletableFuture getChannelIds(final String bundleId, final UUID apnsRequestId) {
+ return channelManagementClient.getChannelIds(bundleId, apnsRequestId);
+ }
+
/**
* Gracefully shuts down the client, closing all connections and releasing all persistent resources. The
* disconnection process will wait until notifications that have been sent to the APNs server have been either
@@ -242,13 +334,14 @@ public CompletableFuture close() {
if (this.isClosed.compareAndSet(false, true)) {
closeFuture = new CompletableFuture<>();
- this.channelPool.close().addListener((GenericFutureListener>) closePoolFuture -> {
- if (ApnsClient.this.shouldShutDownClientResources) {
- ApnsClient.this.clientResources.shutdownGracefully().addListener(future -> closeFuture.complete(null));
- } else {
- closeFuture.complete(null);
- }
- });
+ this.channelManagementClient.close().addListener(closeChannelManagementClientFuture ->
+ this.channelPool.close().addListener((GenericFutureListener>) closePoolFuture -> {
+ if (ApnsClient.this.shouldShutDownClientResources) {
+ ApnsClient.this.clientResources.shutdownGracefully().addListener(future -> closeFuture.complete(null));
+ } else {
+ closeFuture.complete(null);
+ }
+ }));
} else {
closeFuture = CompletableFuture.completedFuture(null);
}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
index 2b8377094..06f2dd1a8 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
@@ -576,26 +576,14 @@ public ApnsClientBuilder setUseAlpn(boolean useAlpn) {
return this;
}
- /**
- * Constructs a new {@link ApnsClient} with the previously-set configuration.
- *
- * @return a new ApnsClient instance with the previously-set configuration
- *
- * @throws SSLException if an SSL context could not be created for the new client for any reason
- * @throws IllegalStateException if this method is called without specifying an APNs server address, if this method
- * is called without providing TLS credentials or a signing key, or if this method is called with both TLS
- * credentials and a signing key
- *
- * @since 0.8
- */
- public ApnsClient build() throws SSLException {
+ ApnsClientConfiguration buildClientConfiguration() throws SSLException {
if (this.apnsServerAddress == null) {
throw new IllegalStateException("No APNs server address specified.");
}
if (this.clientCertificate == null && this.privateKey == null && this.signingKey == null) {
throw new IllegalStateException("No client credentials specified; either TLS credentials (a " +
- "certificate/private key) or an APNs signing key must be provided before building a client.");
+ "certificate/private key) or an APNs signing key must be provided before building a client.");
} else if ((this.clientCertificate != null || this.privateKey != null) && this.signingKey != null) {
throw new IllegalStateException("Clients may not have both a signing key and TLS credentials.");
}
@@ -613,18 +601,18 @@ public ApnsClient build() throws SSLException {
}
final SslContextBuilder sslContextBuilder = SslContextBuilder.forClient()
- .sslProvider(sslProvider)
- .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE);
+ .sslProvider(sslProvider)
+ .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE);
if (useAlpn) {
sslContextBuilder.applicationProtocolConfig(
- new ApplicationProtocolConfig(
- ApplicationProtocolConfig.Protocol.ALPN,
- // NO_ADVERTISE is currently the only mode supported by both OpenSsl and JDK providers.
- ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
- // ACCEPT is currently the only mode supported by both OpenSsl and JDK providers.
- ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
- ApplicationProtocolNames.HTTP_2));
+ new ApplicationProtocolConfig(
+ ApplicationProtocolConfig.Protocol.ALPN,
+ // NO_ADVERTISE is currently the only mode supported by both OpenSsl and JDK providers.
+ ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
+ // ACCEPT is currently the only mode supported by both OpenSsl and JDK providers.
+ ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
+ ApplicationProtocolNames.HTTP_2));
}
if (this.clientCertificate != null && this.privateKey != null) {
@@ -643,25 +631,38 @@ public ApnsClient build() throws SSLException {
}
try {
- final ApnsClientConfiguration clientConfiguration =
- new ApnsClientConfiguration(this.apnsServerAddress,
- sslContext,
- this.enableHostnameVerification,
- this.signingKey,
- this.tokenExpiration,
- this.proxyHandlerFactory,
- this.connectionTimeout,
- this.closeAfterIdleDuration,
- this.gracefulShutdownTimeout,
- this.concurrentConnections,
- this.metricsListener,
- this.frameLogger);
-
- return new ApnsClient(clientConfiguration, this.apnsClientResources);
+ return new ApnsClientConfiguration(this.apnsServerAddress,
+ sslContext,
+ this.enableHostnameVerification,
+ this.signingKey,
+ this.tokenExpiration,
+ this.proxyHandlerFactory,
+ this.connectionTimeout,
+ this.closeAfterIdleDuration,
+ this.gracefulShutdownTimeout,
+ this.concurrentConnections,
+ this.metricsListener,
+ this.frameLogger);
} finally {
if (sslContext instanceof ReferenceCounted) {
((ReferenceCounted) sslContext).release();
}
}
}
+
+ /**
+ * Constructs a new {@link ApnsClient} with the previously-set configuration.
+ *
+ * @return a new ApnsClient instance with the previously-set configuration
+ *
+ * @throws SSLException if an SSL context could not be created for the new client for any reason
+ * @throws IllegalStateException if this method is called without specifying an APNs server address, if this method
+ * is called without providing TLS credentials or a signing key, or if this method is called with both TLS
+ * credentials and a signing key
+ *
+ * @since 0.8
+ */
+ public ApnsClient build() throws SSLException {
+ return new ApnsClient(buildClientConfiguration(), this.apnsClientResources);
+ }
}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
index 790221521..59fd3062b 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
@@ -490,6 +490,6 @@ public void exceptionCaught(final ChannelHandlerContext context, final Throwable
}
private Promise getChannelReadyPromise(final Channel channel) {
- return channel.attr(ApnsChannelFactory.CHANNEL_READY_PROMISE_ATTRIBUTE_KEY).get();
+ return channel.attr(ApnsNotificationChannelFactory.CHANNEL_READY_PROMISE_ATTRIBUTE_KEY).get();
}
}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsNotificationChannelFactory.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsNotificationChannelFactory.java
new file mode 100644
index 000000000..4c095244a
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsNotificationChannelFactory.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2020 Jon Chambers
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package com.eatthepath.pushy.apns;
+
+import io.netty.channel.ChannelPipeline;
+import io.netty.handler.flush.FlushConsolidationHandler;
+import io.netty.handler.ssl.SslHandler;
+import io.netty.handler.timeout.IdleStateHandler;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * An APNs channel factory creates new channels connected to an APNs server. Channels constructed by this factory are
+ * intended for use in an {@link ApnsChannelPool}.
+ */
+class ApnsNotificationChannelFactory extends AbstractApnsChannelFactory {
+
+ private final ApnsClientConfiguration clientConfiguration;
+
+ ApnsNotificationChannelFactory(final ApnsClientConfiguration clientConfiguration,
+ final ApnsClientResources clientResources) {
+
+ super(clientConfiguration.getApnsServerAddress(),
+ clientConfiguration.getSslContext(),
+ clientConfiguration.getProxyHandlerFactory().orElse(null),
+ clientConfiguration.isHostnameVerificationEnabled(),
+ clientConfiguration.getConnectionTimeout().orElse(null),
+ clientResources);
+
+ this.clientConfiguration = clientConfiguration;
+ }
+
+ protected void constructPipeline(final SslHandler sslHandler, final ChannelPipeline pipeline) {
+ final String authority = clientConfiguration.getApnsServerAddress().getHostName();
+
+ final ApnsClientHandler apnsClientHandler;
+ {
+ final ApnsClientHandler.ApnsClientHandlerBuilder clientHandlerBuilder;
+
+ if (clientConfiguration.getSigningKey().isPresent()) {
+ clientHandlerBuilder = new TokenAuthenticationApnsClientHandler.TokenAuthenticationApnsClientHandlerBuilder()
+ .signingKey(clientConfiguration.getSigningKey().get())
+ .tokenExpiration(clientConfiguration.getTokenExpiration())
+ .authority(authority);
+ } else {
+ clientHandlerBuilder = new ApnsClientHandler.ApnsClientHandlerBuilder()
+ .authority(authority);
+ }
+
+ clientConfiguration.getFrameLogger().ifPresent(clientHandlerBuilder::frameLogger);
+
+ apnsClientHandler = clientHandlerBuilder.build();
+
+ clientConfiguration.getGracefulShutdownTimeout().ifPresent(timeout ->
+ apnsClientHandler.gracefulShutdownTimeoutMillis(timeout.toMillis()));
+ }
+
+ clientConfiguration.getProxyHandlerFactory().ifPresent(proxyHandlerFactory ->
+ pipeline.addFirst(proxyHandlerFactory.createProxyHandler()));
+
+ pipeline.addLast(sslHandler);
+ pipeline.addLast(new FlushConsolidationHandler(FlushConsolidationHandler.DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, true));
+ pipeline.addLast(new IdleStateHandler(clientConfiguration.getCloseAfterIdleDuration().toMillis(), 0, 0, TimeUnit.MILLISECONDS));
+ pipeline.addLast(apnsClientHandler);
+ }
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ChannelManagementException.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ChannelManagementException.java
new file mode 100644
index 000000000..00fa044e7
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ChannelManagementException.java
@@ -0,0 +1,56 @@
+package com.eatthepath.pushy.apns;
+
+import java.util.UUID;
+
+/**
+ * A channel management exception indicates that a request to interact with a broadcast push notification channel was
+ * received, acknowledged, and ultimately rejected by the APNs server.
+ *
+ * @see Handling error responses from Apple Push Notification service
+ */
+public class ChannelManagementException extends RuntimeException {
+
+ private final int status;
+ private final UUID apnsRequestId;
+ private final String reason;
+
+ /**
+ * Constructs a new channel management exception with the given status code, request ID, and rejection reason.
+ *
+ * @param status the HTTP status code returned by the APNs server
+ * @param apnsRequestId the unique identifier for the request that was rejected
+ * @param reason an APNs-specific rejection reason provided by the APNs server
+ */
+ public ChannelManagementException(final int status, final UUID apnsRequestId, final String reason) {
+ this.status = status;
+ this.apnsRequestId = apnsRequestId;
+ this.reason = reason;
+ }
+
+ /**
+ * Returns the HTTP status code returned by the APNs server
+ *
+ * @return the HTTP status code returned by the APNs server
+ */
+ public int getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns a unique identifier for the request that was rejected.
+ *
+ * @return a unique identifier for the request that was rejected
+ */
+ public UUID getApnsRequestId() {
+ return apnsRequestId;
+ }
+
+ /**
+ * Returns an APNs-specific rejection reason provided by the APNs server.
+ *
+ * @return an APNs-specific rejection reason provided by the APNs server
+ */
+ public String getReason() {
+ return reason;
+ }
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ChannelManagementResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ChannelManagementResponse.java
new file mode 100644
index 000000000..6dbd760e3
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ChannelManagementResponse.java
@@ -0,0 +1,26 @@
+package com.eatthepath.pushy.apns;
+
+import java.util.UUID;
+
+/**
+ * A response from the APNs broadcast channel management system.
+ *
+ * @since 0.16
+ */
+public interface ChannelManagementResponse {
+
+ /**
+ * Returns the unique identifier (which may be assigned by the caller or by the server if the caller does not provide
+ * a unique identifier) for the original request.
+ *
+ * @return the unique identifier for the original request
+ */
+ UUID getRequestId();
+
+ /**
+ * Returns the HTTP status code returned by the APNs server.
+ *
+ * @return the HTTP status code returned by the APNs server
+ */
+ int getStatus();
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/CreateChannelResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/CreateChannelResponse.java
new file mode 100644
index 000000000..4ea0ffa5a
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/CreateChannelResponse.java
@@ -0,0 +1,16 @@
+package com.eatthepath.pushy.apns;
+
+/**
+ * A response to a "create channel" request sent to the APNs broadcast channel management system.
+ *
+ * @since 0.16
+ */
+public interface CreateChannelResponse extends ChannelManagementResponse {
+
+ /**
+ * Returns the base64-encoded representation of the newly-created channel ID.
+ *
+ * @return the base64-encoded representation of the newly-created channel ID
+ */
+ String getChannelId();
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/DeleteChannelResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/DeleteChannelResponse.java
new file mode 100644
index 000000000..587312df2
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/DeleteChannelResponse.java
@@ -0,0 +1,9 @@
+package com.eatthepath.pushy.apns;
+
+/**
+ * A response to a "delete channel" request sent to the APNs broadcast notification channel management system.
+ *
+ * @since 0.16
+ */
+public interface DeleteChannelResponse extends ChannelManagementResponse {
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/GetChannelConfigurationResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/GetChannelConfigurationResponse.java
new file mode 100644
index 000000000..cdc40bc66
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/GetChannelConfigurationResponse.java
@@ -0,0 +1,17 @@
+package com.eatthepath.pushy.apns;
+
+/**
+ * A response to a "get channel configuration" request sent to the APNs broadcast notification channel management
+ * system.
+ *
+ * @since 0.16
+ */
+public interface GetChannelConfigurationResponse extends ChannelManagementResponse {
+
+ /**
+ * Returns the message storage policy for the channel named in the original request.
+ *
+ * @return the message storage policy for the channel named in the original request
+ */
+ MessageStoragePolicy getMessageStoragePolicy();
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/GetChannelIdsResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/GetChannelIdsResponse.java
new file mode 100644
index 000000000..f94f63ec3
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/GetChannelIdsResponse.java
@@ -0,0 +1,19 @@
+package com.eatthepath.pushy.apns;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * A response to a "list all channel IDs" request sent to the APNS broadcast notification channel management system.
+ *
+ * @since 0.16
+ */
+public interface GetChannelIdsResponse extends ChannelManagementResponse {
+
+ /**
+ * Returns a list of all active channel IDs for the bundle ID named in the original request.
+ *
+ * @return a list of all active channel IDs for the bundle ID named in the original request
+ */
+ List getChannelIds();
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/Http2Response.java b/pushy/src/main/java/com/eatthepath/pushy/apns/Http2Response.java
new file mode 100644
index 000000000..a306ddc1a
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/Http2Response.java
@@ -0,0 +1,21 @@
+package com.eatthepath.pushy.apns;
+
+import io.netty.handler.codec.http2.Http2Headers;
+
+class Http2Response {
+ private final Http2Headers headers;
+ private final byte[] data;
+
+ public Http2Response(final Http2Headers headers, final byte[] data) {
+ this.headers = headers;
+ this.data = data;
+ }
+
+ public Http2Headers getHeaders() {
+ return headers;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+}
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/MessageStoragePolicy.java b/pushy/src/main/java/com/eatthepath/pushy/apns/MessageStoragePolicy.java
new file mode 100644
index 000000000..5edae4772
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/MessageStoragePolicy.java
@@ -0,0 +1,58 @@
+package com.eatthepath.pushy.apns;
+
+import java.util.UUID;
+
+/**
+ * An enumeration of message storage policies for APNs broadcast notification channels.
+ *
+ * @see Sending broadcast push notification requests to APNs
+ * @see ApnsClient#createChannel(String, MessageStoragePolicy, UUID)
+ * @see ApnsClient#getChannelConfiguration(String, String, UUID)
+ * @see ApnsPushNotification#getExpiration()
+ *
+ * @since 0.16
+ */
+public enum MessageStoragePolicy {
+
+ /**
+ * Indicates that a broadcast notification channel should not store and forward notifications if they cannot be
+ * delivered immediately. As the documentation notes:
+ *
+ * Providing a nonzero expiration for a channel created with the No Message Stored storage policy results
+ * in message rejection.
+ */
+ NO_MESSAGE_STORED(0),
+
+ /**
+ * Indicates that a broadcast notification channel may attempt to store and forward notifications if they cannot be
+ * delivered immediately. As the broadcast notification documentation explains:
+ *
+ * As a best-effort service, APNs may reorder notifications you send on the same channel. If APNs can’t
+ * deliver a notification immediately, it may store the notification based on the channel’s message storage policy
+ * specified during channel creation. Notifications with Medium and Low apns-priority might get grouped and delivered
+ * in bursts to the person’s device. APNs may also throttle your notifications and, in some cases, not deliver them.
+ * The exact behavior is determined by the way the person interacts with your application and the power state of the
+ * device.
+ */
+ MOST_RECENT_MESSAGE_STORED(1);
+
+ private final int code;
+
+ MessageStoragePolicy(final int code) {
+ this.code = code;
+ }
+
+ int getCode() {
+ return this.code;
+ }
+
+ static MessageStoragePolicy getFromCode(final int code) {
+ for (final MessageStoragePolicy policy : MessageStoragePolicy.values()) {
+ if (policy.getCode() == code) {
+ return policy;
+ }
+ }
+
+ throw new IllegalArgumentException(String.format("No message storage policy found with code %d", code));
+ }
+}
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsChannelManagementClientTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsChannelManagementClientTest.java
new file mode 100644
index 000000000..5b3874a58
--- /dev/null
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsChannelManagementClientTest.java
@@ -0,0 +1,320 @@
+package com.eatthepath.pushy.apns;
+
+import com.eatthepath.json.JsonSerializer;
+import com.eatthepath.pushy.apns.auth.ApnsSigningKey;
+import com.eatthepath.pushy.apns.auth.KeyPairUtil;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder;
+import io.netty.channel.nio.NioEventLoopGroup;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.net.ssl.SSLException;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.ECPrivateKey;
+import java.util.*;
+import java.util.concurrent.CompletionException;
+import java.util.stream.Stream;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ApnsChannelManagementClientTest {
+
+ private ApnsChannelManagementClient channelManagementClient;
+
+ private static ApnsClientResources CLIENT_RESOURCES;
+
+ @RegisterExtension
+ static WireMockExtension wireMockExtension = WireMockExtension.newInstance()
+ .options(wireMockConfig()
+ .keystoreType("PKCS12")
+ .keystorePath("server.p12")
+ .keystorePassword("pushy-test")
+ .keyManagerPassword("pushy-test")
+ .dynamicHttpsPort())
+ .build();
+
+ protected static final String TEAM_ID = "team-id";
+ protected static final String KEY_ID = "key-id";
+
+ private static final String CA_CERTIFICATE_FILENAME = "/ca.pem";
+
+ @BeforeAll
+ public static void setUpBeforeClass() {
+ CLIENT_RESOURCES = new ApnsClientResources(new NioEventLoopGroup(1));
+ }
+
+ @BeforeEach
+ void setUp() throws NoSuchAlgorithmException, InvalidKeyException, SSLException {
+ final KeyPair keyPair = KeyPairUtil.generateKeyPair();
+ final ApnsSigningKey signingKey = new ApnsSigningKey(KEY_ID, TEAM_ID, (ECPrivateKey) keyPair.getPrivate());
+
+ final ApnsClientBuilder clientBuilder = new ApnsClientBuilder()
+ .setApnsServer("localhost", wireMockExtension.getRuntimeInfo().getHttpsPort())
+ .setTrustedServerCertificateChain(getClass().getResourceAsStream(CA_CERTIFICATE_FILENAME))
+ .setSigningKey(signingKey)
+ .setApnsClientResources(CLIENT_RESOURCES)
+ .setUseAlpn(true);
+
+ channelManagementClient =
+ new ApnsChannelManagementClient(clientBuilder.buildClientConfiguration(), CLIENT_RESOURCES);
+ }
+
+ @AfterEach
+ void tearDown() throws InterruptedException {
+ channelManagementClient.close().await();
+ }
+
+ @AfterAll
+ public static void tearDownAfterAll() throws Exception {
+ CLIENT_RESOURCES.shutdownGracefully().await();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void createChannel(final boolean specifyApnsRequestId) {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+ final String channelId = Base64.getEncoder().encodeToString("channel-id".getBytes(StandardCharsets.UTF_8));
+
+ stubFor(post("/1/apps/com.example.Test/channels")
+ .willReturn(status(201)
+ .withHeader("apns-channel-id", channelId)
+ .withHeader("apns-request-id", apnsRequestId.toString())));
+
+ final CreateChannelResponse createChannelResponse = channelManagementClient.createChannel(bundleId,
+ MessageStoragePolicy.MOST_RECENT_MESSAGE_STORED,
+ specifyApnsRequestId ? apnsRequestId : null)
+ .join();
+
+ assertEquals(201, createChannelResponse.getStatus());
+ assertEquals(apnsRequestId, createChannelResponse.getRequestId());
+ assertEquals(channelId, createChannelResponse.getChannelId());
+
+ RequestPatternBuilder requestPatternBuilder =
+ postRequestedFor(urlEqualTo(String.format("/1/apps/%s/channels", bundleId)))
+ .withHeader("authorization", matching("bearer .+"))
+ .withRequestBody(equalToJson("{\"message-storage-policy\":1, \"push-type\":\"LiveActivity\"}"));
+
+ if (specifyApnsRequestId) {
+ requestPatternBuilder = requestPatternBuilder.withHeader("apns-request-id", equalTo(apnsRequestId.toString()));
+ }
+
+ verify(requestPatternBuilder);
+ }
+
+ @Test
+ void createChannelError() {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+
+ stubFor(post("/1/apps/com.example.Test/channels")
+ .willReturn(badRequest()
+ .withHeader("apns-request-id", apnsRequestId.toString())
+ .withBody("{\"reason\":\"BadChannelId\"}")));
+
+ final CompletionException completionException = assertThrows(CompletionException.class, () ->
+ channelManagementClient.createChannel(bundleId, MessageStoragePolicy.MOST_RECENT_MESSAGE_STORED, apnsRequestId).join());
+
+ final ChannelManagementException channelManagementException =
+ assertInstanceOf(ChannelManagementException.class, completionException.getCause());
+
+ assertEquals(400, channelManagementException.getStatus());
+ assertEquals(apnsRequestId, channelManagementException.getApnsRequestId());
+ assertEquals("BadChannelId", channelManagementException.getReason());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void getChannelConfiguration(final boolean specifyApnsRequestId) {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+ final String channelId = Base64.getEncoder().encodeToString("channel-id".getBytes(StandardCharsets.UTF_8));
+
+ stubFor(get("/1/apps/com.example.Test/channels")
+ .willReturn(ok("{\"message-storage-policy\":1, \"push-type\":\"LiveActivity\"}")
+ .withHeader("apns-request-id", apnsRequestId.toString())));
+
+ final GetChannelConfigurationResponse getChannelConfigurationResponse =
+ channelManagementClient.getChannelConfiguration(
+ bundleId,
+ channelId,
+ specifyApnsRequestId ? apnsRequestId : null)
+ .join();
+
+ assertEquals(200, getChannelConfigurationResponse.getStatus());
+ assertEquals(apnsRequestId, getChannelConfigurationResponse.getRequestId());
+ assertEquals(MessageStoragePolicy.MOST_RECENT_MESSAGE_STORED, getChannelConfigurationResponse.getMessageStoragePolicy());
+
+ RequestPatternBuilder requestPatternBuilder =
+ getRequestedFor(urlEqualTo(String.format("/1/apps/%s/channels", bundleId)))
+ .withHeader("authorization", matching("bearer .+"))
+ .withHeader("apns-channel-id", equalTo(channelId));
+
+ if (specifyApnsRequestId) {
+ requestPatternBuilder = requestPatternBuilder.withHeader("apns-request-id", equalTo(apnsRequestId.toString()));
+ }
+
+ verify(requestPatternBuilder);
+ }
+
+ @Test
+ void getChannelConfigurationError() {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+ final String channelId = Base64.getEncoder().encodeToString("channel-id".getBytes(StandardCharsets.UTF_8));
+
+ stubFor(get("/1/apps/com.example.Test/channels")
+ .willReturn(badRequest()
+ .withHeader("apns-request-id", apnsRequestId.toString())
+ .withBody("{\"reason\":\"BadChannelId\"}")));
+
+ final CompletionException completionException = assertThrows(CompletionException.class, () ->
+ channelManagementClient.getChannelConfiguration(bundleId, channelId, apnsRequestId).join());
+
+ final ChannelManagementException channelManagementException =
+ assertInstanceOf(ChannelManagementException.class, completionException.getCause());
+
+ assertEquals(400, channelManagementException.getStatus());
+ assertEquals(apnsRequestId, channelManagementException.getApnsRequestId());
+ assertEquals("BadChannelId", channelManagementException.getReason());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void deleteChannel(final boolean specifyApnsRequestId) {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+ final String channelId = Base64.getEncoder().encodeToString("channel-id".getBytes(StandardCharsets.UTF_8));
+
+ stubFor(delete("/1/apps/com.example.Test/channels")
+ .willReturn(noContent()
+ .withHeader("apns-request-id", apnsRequestId.toString())));
+
+ final DeleteChannelResponse deleteChannelResponse = channelManagementClient.deleteChannel(bundleId,
+ channelId,
+ specifyApnsRequestId ? apnsRequestId : null)
+ .join();
+
+ assertEquals(204, deleteChannelResponse.getStatus());
+ assertEquals(apnsRequestId, deleteChannelResponse.getRequestId());
+
+ RequestPatternBuilder requestPatternBuilder =
+ deleteRequestedFor(urlEqualTo(String.format("/1/apps/%s/channels", bundleId)))
+ .withHeader("authorization", matching("bearer .+"))
+ .withHeader("apns-channel-id", equalTo(channelId));
+
+ if (specifyApnsRequestId) {
+ requestPatternBuilder = requestPatternBuilder.withHeader("apns-request-id", equalTo(apnsRequestId.toString()));
+ }
+
+ verify(requestPatternBuilder);
+ }
+
+ @Test
+ void deleteChannelError() {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+ final String channelId = Base64.getEncoder().encodeToString("channel-id".getBytes(StandardCharsets.UTF_8));
+
+ stubFor(delete("/1/apps/com.example.Test/channels")
+ .willReturn(badRequest()
+ .withHeader("apns-request-id", apnsRequestId.toString())
+ .withBody("{\"reason\":\"BadChannelId\"}")));
+
+ final CompletionException completionException = assertThrows(CompletionException.class, () ->
+ channelManagementClient.deleteChannel(bundleId, channelId, apnsRequestId).join());
+
+ final ChannelManagementException channelManagementException =
+ assertInstanceOf(ChannelManagementException.class, completionException.getCause());
+
+ assertEquals(400, channelManagementException.getStatus());
+ assertEquals(apnsRequestId, channelManagementException.getApnsRequestId());
+ assertEquals("BadChannelId", channelManagementException.getReason());
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void getChannelIds(final boolean specifyApnsRequestId, final List channelIds) {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+
+ final Map> channelResponse = new HashMap<>();
+ channelResponse.put("channels", channelIds);
+
+ stubFor(get("/1/apps/com.example.Test/all-channels")
+ .willReturn(ok(JsonSerializer.writeJsonTextAsString(channelResponse))
+ .withHeader("apns-request-id", apnsRequestId.toString())));
+
+ final GetChannelIdsResponse getChannelIdsResponse = channelManagementClient.getChannelIds(bundleId,
+ specifyApnsRequestId ? apnsRequestId : null)
+ .join();
+
+ assertEquals(200, getChannelIdsResponse.getStatus());
+ assertEquals(apnsRequestId, getChannelIdsResponse.getRequestId());
+ assertEquals(channelIds, getChannelIdsResponse.getChannelIds());
+
+ RequestPatternBuilder requestPatternBuilder =
+ getRequestedFor(urlEqualTo(String.format("/1/apps/%s/all-channels", bundleId)))
+ .withHeader("authorization", matching("bearer .+"));
+
+ if (specifyApnsRequestId) {
+ requestPatternBuilder = requestPatternBuilder.withHeader("apns-request-id", equalTo(apnsRequestId.toString()));
+ }
+
+ verify(requestPatternBuilder);
+ }
+
+ private static Stream getChannelIds() {
+ return Stream.of(
+ Arguments.argumentSet("Single channel, specified request ID",
+ true, generateChannelIds(1)),
+
+ Arguments.argumentSet("Single channel, unspecified request ID",
+ false, generateChannelIds(1)),
+
+ // The upstream service has a limit of 10,000 active channels
+ Arguments.argumentSet("Large channel batch, specified request ID",
+ true, generateChannelIds(10_000)));
+ }
+
+ private static List generateChannelIds(final int channelIdCount) {
+ final List channelIds = new ArrayList<>(channelIdCount);
+
+ for (int i = 0; i < channelIdCount; i++) {
+ channelIds.add(Base64.getEncoder().encodeToString(("channel-id-" + i).getBytes(StandardCharsets.UTF_8)));
+ }
+
+ return channelIds;
+ }
+
+ @Test
+ void getChannelIdsError() {
+ final String bundleId = "com.example.Test";
+ final UUID apnsRequestId = UUID.randomUUID();
+
+ stubFor(get("/1/apps/com.example.Test/all-channels")
+ .willReturn(badRequest()
+ .withHeader("apns-request-id", apnsRequestId.toString())
+ .withBody("{\"reason\":\"BadChannelId\"}")));
+
+ final CompletionException completionException = assertThrows(CompletionException.class, () ->
+ channelManagementClient.getChannelIds(bundleId, apnsRequestId).join());
+
+ final ChannelManagementException channelManagementException =
+ assertInstanceOf(ChannelManagementException.class, completionException.getCause());
+
+ assertEquals(400, channelManagementException.getStatus());
+ assertEquals(apnsRequestId, channelManagementException.getApnsRequestId());
+ assertEquals("BadChannelId", channelManagementException.getReason());
+ }
+}