Repository: relayrides/pushy
Pull request #310 (2016-06-07 04:56:48)

Builder pattern for ApnsClients
The number of options for `ApnsClient` instances was growing, and will probably continue to grow in the future. Many of them also only really made sense to set before starting a client, and making them all constructor arguments was going to get crazy really fast. The idea here is to introduce a builder pattern for `ApnsClient` instances. That should clarify which options should be set at client construction time (basically all of them) without resulting in huge constructors.

TODO:

- [x] Finish documenting `ApnsClientBuilder`
- [x] Update README (75f1ced)
- [x] Figure out certificate chain stuff for `setTrustedServerCertificate`


Patch:
diff --git a/README.md b/README.md
index daf14000..f02c7e59 100644
--- a/README.md
+++ b/README.md
@@ -38,8 +38,10 @@ Before you can get started with Pushy, you'll need to do some provisioning work
 Once you've registered your app and have the requisite certificates, the first thing you'll need to do to start sending push notifications with Pushy is to create an [`ApnsClient`](http://relayrides.github.io/pushy/apidocs/0.7/com/relayrides/pushy/apns/ApnsClient.html). Clients need a certificate and private key to authenticate with the APNs server. The most common way to store the certificate and key is in a password-protected PKCS#12 file (you'll wind up with a password-protected .p12 file if you follow Apple's instructions at the time of this writing):
 
 ```java
-final ApnsClient<SimpleApnsPushNotification> apnsClient = new ApnsClient<>(
-        new File("/path/to/certificate.p12"), "p12-file-password");
+final ApnsClient<SimpleApnsPushNotification> apnsClient =
+    new ApnsClientBuilder<SimpleApnsPushNotification>()
+        .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
+        .build();
 ```
 
 Once you've created a client, you can connect it to the APNs gateway. Note that this process is asynchronous; the client will return a Future right away, but you'll need to wait for it to complete before you can send any notifications. Note that this is a Netty [`Future`](http://netty.io/4.1/api/io/netty/util/concurrent/Future.html), which is an extension of the Java [`Future`](http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/Future.html) interface that allows callers to add listeners and adds methods for checking the status of the `Future`.
@@ -156,28 +158,31 @@ If you know exactly which version of Java you'll be running, you can just add th
 
 ## Metrics
 
-Pushy includes an interface for monitoring metrics that provide insight into clients' behavior and performance. You can write your own implementation of the `ApnsClientMetricsListener` interface to record and report metrics. To begin receiving metrics, use the client's `setMetricsListener` method:
+Pushy includes an interface for monitoring metrics that provide insight into clients' behavior and performance. You can write your own implementation of the `ApnsClientMetricsListener` interface to record and report metrics. To begin receiving metrics, set a listener when building a new client:
 
 ```java
-apnsClient.setMetricsListener(new MyCustomMetricsListener());
+final ApnsClient<SimpleApnsPushNotification> apnsClient =
+    new ApnsClientBuilder<SimpleApnsPushNotification>()
+        .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
+        .setMetricsListener(new MyCustomMetricsListener())
+        .build();
 ```
 
 Please note that the metric-handling methods in your listener implementation should *never* call blocking code. It's appropriate to increment counters directly in the handler methods, but calls to databases or remote monitoring endpoints should be dispatched to separate threads.
 
 ## Using a proxy
 
-If you need to use a proxy for outbound connections, you may specify a [`ProxyHandlerFactory`](http://relayrides.github.io/pushy/apidocs/0.7/com/relayrides/pushy/apns/proxy/ProxyHandlerFactory.html) before attempting to connect your `ApnsClient` instance. Concrete implementations of `ProxyHandlerFactory` are provided for HTTP, SOCKS4, and SOCKS5 proxies.
+If you need to use a proxy for outbound connections, you may specify a [`ProxyHandlerFactory`](http://relayrides.github.io/pushy/apidocs/0.7/com/relayrides/pushy/apns/proxy/ProxyHandlerFactory.html) when building your `ApnsClient` instance. Concrete implementations of `ProxyHandlerFactory` are provided for HTTP, SOCKS4, and SOCKS5 proxies.
 
 An example:
 
 ```java
 final ApnsClient<SimpleApnsPushNotification> apnsClient =
-        new ApnsClient<SimpleApnsPushNotification>(
-                new File("/path/to/certificate.p12"), "p12-file-password");
-
-apnsClient.setProxyHandlerFactory(
-        new Socks5ProxyHandlerFactory(
-                new InetSocketAddress("my.proxy.com", 1080), "username", "password"));
+    new ApnsClientBuilder<SimpleApnsPushNotification>()
+        .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
+        .setProxyHandlerFactory(new Socks5ProxyHandlerFactory(
+            new InetSocketAddress("my.proxy.com", 1080), "username", "password"))
+        .build();
 
 final Future<Void> connectFuture = apnsClient.connect(ApnsClient.DEVELOPMENT_APNS_HOST);
 connectFuture.await();
diff --git a/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClient.java b/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClient.java
index d5a5d08f..02315ccd 100644
--- a/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClient.java
+++ b/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClient.java
@@ -20,24 +20,12 @@
 
 package com.relayrides.pushy.apns;
 
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
 import java.net.InetSocketAddress;
-import java.security.KeyStore.PrivateKeyEntry;
-import java.security.KeyStoreException;
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.security.cert.X509Certificate;
 import java.util.IdentityHashMap;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 
-import javax.net.ssl.SSLException;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -57,18 +45,9 @@ import io.netty.channel.oio.OioEventLoopGroup;
 import io.netty.channel.socket.SocketChannel;
 import io.netty.channel.socket.nio.NioSocketChannel;
 import io.netty.channel.socket.oio.OioSocketChannel;
-import io.netty.handler.codec.http2.Http2SecurityUtil;
-import io.netty.handler.ssl.ApplicationProtocolConfig;
-import io.netty.handler.ssl.ApplicationProtocolConfig.Protocol;
-import io.netty.handler.ssl.ApplicationProtocolConfig.SelectedListenerFailureBehavior;
-import io.netty.handler.ssl.ApplicationProtocolConfig.SelectorFailureBehavior;
 import io.netty.handler.ssl.ApplicationProtocolNames;
 import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
-import io.netty.handler.ssl.OpenSsl;
 import io.netty.handler.ssl.SslContext;
-import io.netty.handler.ssl.SslContextBuilder;
-import io.netty.handler.ssl.SslProvider;
-import io.netty.handler.ssl.SupportedCipherSuiteFilter;
 import io.netty.handler.timeout.IdleStateHandler;
 import io.netty.handler.timeout.WriteTimeoutHandler;
 import io.netty.resolver.DefaultAddressResolverGroup;
@@ -91,12 +70,13 @@ import io.netty.util.concurrent.SucceededFuture;
  * and <a href="https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ProvisioningDevelopment.html#//apple_ref/doc/uid/TP40008194-CH104-SW1">certificate
  * provisioning</a>.</p>
  *
- * <p>To construct a client, callers will need to provide the certificate provisioned by Apple and its accompanying
- * private key. The certificate and key will be used to authenticate the client and identify the topics to which it can
- * send notifications. Callers may optionally specify an {@link EventLoopGroup} when constructing a new client. If no
- * event loop group is specified, clients will create and manage their own single-thread event loop group. If many
- * clients are operating in parallel, specifying a shared event loop group serves as a mechanism to keep the total
- * number of threads in check.</p>
+ * <p>Clients are constructed using an {@link ApnsClientBuilder}. To construct a client, callers will need to provide
+ * the certificate provisioned by Apple and its accompanying private key. The certificate and key will be used to
+ * authenticate the client and identify the topics to which it can send notifications. Callers may optionally specify an
+ * {@link EventLoopGroup} when constructing a new client. If no event loop group is specified, clients will create and
+ * manage their own single-thread event loop group. If many clients are operating in parallel, specifying a shared event
+ * loop group serves as a mechanism to keep the total number of threads in check. Callers may also want to provide a
+ * specific event loop group to take advantage of platform-specific features (i.e. epoll).</p>
  *
  * <p>Once a client has been constructed, it must connect to an APNs server before it can begin sending push
  * notifications. Apple provides a production and development gateway; see {@link ApnsClient#PRODUCTION_APNS_HOST} and
@@ -214,212 +194,6 @@ public class ApnsClient<T extends ApnsPushNotification> {
 
     private static final Logger log = LoggerFactory.getLogger(ApnsClient.class);
 
-    /**
-     * <p>Creates a new APNs client that will identify itself to the APNs gateway with the certificate and key from the
-     * given PKCS#12 file. The PKCS#12 file <em>must</em> contain a certificate/private key pair. If it contains more
-     * than one certificate/private key pair, the pair that will be used is undefined.</p>
-     *
-     * <p>Clients created using this method will use a default, internally-managed event loop group. Once the client
-     * has been disconnected via the {@link ApnsClient#disconnect()} method, its event loop will be shut down and the
-     * client cannot be reconnected.</p>
-     *
-     * @param p12File a PKCS#12-formatted file containing the certificate and private key to be used to identify the
-     * client to the APNs server
-     * @param password the password to be used to decrypt the contents of the given PKCS#12 file; passwords may be blank
-     * (e.g. {@code ""}), but must not be {@code null}
-     *
-     * @throws SSLException if the given PKCS#12 file could not be loaded or if any other SSL-related problem arises
-     * when constructing the context
-     * @throws FileNotFoundException if the given PKCS#12 file could not be found
-     * @throws IOException if any IO problem occurs while attempting to read the given PKCS#12 file
-     *
-     * @since 0.5
-     */
-    public ApnsClient(final File p12File, final String password) throws SSLException, FileNotFoundException, IOException {
-        this(p12File, password, null);
-    }
-
-    /**
-     * <p>Creates a new APNs client that will identify itself to the APNs gateway with the certificate and key from the
-     * given PKCS#12 file. The PKCS#12 file <em>must</em> contain a certificate/private key pair. If it contains more
-     * than one certificate/private key pair, the pair that will be used is undefined.</p>
-     *
-     * <p>Clients created using this method will use the provided event loop group, which may be useful in cases where
-     * multiple clients are running in parallel. If a client is created using this method, it is the responsibility of
-     * the caller to shut down the event loop group. Clients created with an externally-provided event loop group may be
-     * reconnected after being shut down via the {@link ApnsClient#disconnect()} method.</p>
-     *
-     * @param p12File a PKCS#12-formatted file containing the certificate and private key to be used to identify the
-     * client to the APNs server
-     * @param password the password to be used to decrypt the contents of the given PKCS#12 file; passwords may be blank
-     * (e.g. {@code ""}), but must not be {@code null}
-     * @param eventLoopGroup the event loop group to be used to handle I/O events for this client
-     *
-     * @throws SSLException if the given PKCS#12 file could not be loaded or if any other SSL-related problem arises
-     * when constructing the context
-     * @throws FileNotFoundException if the given PKCS#12 file could not be found
-     * @throws IOException if any IO problem occurs while attempting to read the given PKCS#12 file
-     *
-     * @since 0.5
-     */
-    public ApnsClient(final File p12File, final String password, final EventLoopGroup eventLoopGroup) throws SSLException, FileNotFoundException, IOException {
-        this(ApnsClient.getSslContextWithP12File(p12File, password), eventLoopGroup);
-    }
-
-    /**
-     * <p>Creates a new APNs client that will identify itself to the APNs gateway with the certificate and key from the
-     * given PKCS#12 input stream. The PKCS#12 input stream <em>must</em> contain a single certificate/private key
-     * pair. If it contains more than one certificate/private key pair, the pair that will be used is undefined.</p>
-     *
-     * <p>Clients created using this method will use a default, internally-managed event loop group. Once the client
-     * has been disconnected via the {@link ApnsClient#disconnect()} method, its event loop will be shut down and the
-     * client cannot be reconnected.</p>
-     *
-     * @param p12InputStream an input stream for PKCS#12-formatted data containing the certificate and private key to be
-     * used to identify the client to the APNs server
-     * @param password the password to be used to decrypt the contents of the given PKCS#12 data; passwords may be blank
-     * (e.g. {@code ""}), but must not be {@code null}
-     *
-     * @throws SSLException if the given PKCS#12 data could not be loaded or if any other SSL-related problem arises
-     * when constructing the context
-     *
-     * @since 0.5.2
-     */
-    public ApnsClient(final InputStream p12InputStream, final String password) throws SSLException {
-        this(p12InputStream, password, null);
-    }
-
-    /**
-     * <p>Creates a new APNs client that will identify itself to the APNs gateway with the certificate and key from the
-     * given PKCS#12 input stream. The PKCS#12 input stream <em>must</em> contain a single certificate/private key
-     * pair. If it contains more than one certificate/private key pair, the pair that will be used is undefined.</p>
-     *
-     * <p>Clients created using this method will use the provided event loop group, which may be useful in cases where
-     * multiple clients are running in parallel. If a client is created using this method, it is the responsibility of
-     * the caller to shut down the event loop group. Clients created with an externally-provided event loop group may be
-     * reconnected after being shut down via the {@link ApnsClient#disconnect()} method.</p>
-     *
-     * @param p12InputStream an input stream for PKCS#12-formatted data containing the certificate and private key to be
-     * used to identify the client to the APNs server
-     * @param password the password to be used to decrypt the contents of the given PKCS#12 data; passwords may be blank
-     * (e.g. {@code ""}), but must not be {@code null}
-     * @param eventLoopGroup the event loop group to be used to handle I/O events for this client
-     *
-     * @throws SSLException if the given PKCS#12 data could not be loaded or if any other SSL-related problem arises
-     * when constructing the context
-     *
-     * @since 0.5.2
-     */
-    public ApnsClient(final InputStream p12InputStream, final String password, final EventLoopGroup eventLoopGroup) throws SSLException {
-        this(ApnsClient.getSslContextWithP12InputStream(p12InputStream, password), eventLoopGroup);
-    }
-
-    /**
-     * <p>Creates a new APNs client that will identify itself to the APNs gateway with the given certificate and
-     * key.</p>
-     *
-     * <p>Clients created using this method will use a default, internally-managed event loop group. Once the client
-     * has been disconnected via the {@link ApnsClient#disconnect()} method, its event loop will be shut down and the
-     * client cannot be reconnected.</p>
-     *
-     * @param certificate the certificate to be used to identify the client to the APNs server
-     * @param privateKey the private key for the client certificate
-     * @param privateKeyPassword the password to be used to decrypt the private key; may be {@code null} if the private
-     * key does not require a password
-     *
-     * @throws SSLException if the given key or certificate could not be loaded or if any other SSL-related problem
-     * arises when constructing the context
-     *
-     * @since 0.5
-     */
-    public ApnsClient(final X509Certificate certificate, final PrivateKey privateKey, final String privateKeyPassword) throws SSLException {
-        this(certificate, privateKey, privateKeyPassword, null);
-    }
-
-    /**
-     * <p>Creates a new APNs client that will identify itself to the APNs gateway with the given certificate and
-     * key.</p>
-     *
-     * <p>Clients created using this method will use the provided event loop group, which may be useful in cases where
-     * multiple clients are running in parallel. If a client is created using this method, it is the responsibility of
-     * the caller to shut down the event loop group. Clients created with an externally-provided event loop group may be
-     * reconnected after being shut down via the {@link ApnsClient#disconnect()} method.</p>
-     *
-     * @param certificate the certificate to be used to identify the client to the APNs server
-     * @param privateKey the private key for the client certificate
-     * @param privateKeyPassword the password to be used to decrypt the private key; may be {@code null} if the private
-     * key does not require a password
-     * @param eventLoopGroup the event loop group to be used to handle I/O events for this client
-     *
-     * @throws SSLException if the given key or certificate could not be loaded or if any other SSL-related problem
-     * arises when constructing the context
-     *
-     * @since 0.5
-     */
-    public ApnsClient(final X509Certificate certificate, final PrivateKey privateKey, final String privateKeyPassword, final EventLoopGroup eventLoopGroup) throws SSLException {
-        this(ApnsClient.getSslContextWithCertificateAndPrivateKey(certificate, privateKey, privateKeyPassword), eventLoopGroup);
-    }
-
-    private static SslContext getSslContextWithP12File(final File p12File, final String password) throws FileNotFoundException, SSLException, IOException {
-        try (final InputStream p12InputStream = new FileInputStream(p12File)) {
-            return ApnsClient.getSslContextWithP12InputStream(p12InputStream, password);
-        }
-    }
-
-    private static SslContext getSslContextWithP12InputStream(final InputStream p12InputStream, final String password) throws SSLException {
-        final X509Certificate x509Certificate;
-        final PrivateKey privateKey;
-
-        try {
-            final PrivateKeyEntry privateKeyEntry = P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, password);
-
-            final Certificate certificate = privateKeyEntry.getCertificate();
-
-            if (!(certificate instanceof X509Certificate)) {
-                throw new KeyStoreException("Found a certificate in the provided PKCS#12 file, but it was not an X.509 certificate.");
-            }
-
-            x509Certificate = (X509Certificate) certificate;
-            privateKey = privateKeyEntry.getPrivateKey();
-        } catch (final KeyStoreException | IOException e) {
-            throw new SSLException(e);
-        }
-
-        return ApnsClient.getSslContextWithCertificateAndPrivateKey(x509Certificate, privateKey, password);
-    }
-
-    private static SslContext getSslContextWithCertificateAndPrivateKey(final X509Certificate certificate, final PrivateKey privateKey, final String privateKeyPassword) throws SSLException {
-        return ApnsClient.getBaseSslContextBuilder()
-                .keyManager(privateKey, privateKeyPassword, certificate)
-                .build();
-    }
-
-    private static SslContextBuilder getBaseSslContextBuilder() {
-        final SslProvider sslProvider;
-
-        if (OpenSsl.isAvailable()) {
-            if (OpenSsl.isAlpnSupported()) {
-                log.info("OpenSSL (via netty-tcnative) is available and supports ALPN; will use OpenSSL.");
-                sslProvider = SslProvider.OPENSSL;
-            } else {
-                log.info("OpenSSL (via netty-tcnative) is available, but does not support ALPN; will use JDK SSL provider.");
-                sslProvider = SslProvider.JDK;
-            }
-        } else {
-            log.info("OpenSSL (via netty-tcnative) not available; will use JDK SSL provider.");
-            sslProvider = SslProvider.JDK;
-        }
-
-        return SslContextBuilder.forClient()
-                .sslProvider(sslProvider)
-                .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
-                .applicationProtocolConfig(
-                        new ApplicationProtocolConfig(Protocol.ALPN,
-                                SelectorFailureBehavior.NO_ADVERTISE,
-                                SelectedListenerFailureBehavior.ACCEPT,
-                                ApplicationProtocolNames.HTTP_2));
-    }
-
     protected ApnsClient(final SslContext sslContext, final EventLoopGroup eventLoopGroup) {
         this.bootstrap = new Bootstrap();
 
@@ -552,7 +326,7 @@ public class ApnsClient<T extends ApnsPushNotification> {
      *
      * @since 0.5
      */
-    public void setConnectionTimeout(final int timeoutMillis) {
+    protected void setConnectionTimeout(final int timeoutMillis) {
         synchronized (this.bootstrap) {
             this.bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, timeoutMillis);
         }
@@ -567,10 +341,26 @@ public class ApnsClient<T extends ApnsPushNotification> {
      *
      * @since 0.6
      */
-    public void setMetricsListener(final ApnsClientMetricsListener metricsListener) {
+    protected void setMetricsListener(final ApnsClientMetricsListener metricsListener) {
         this.metricsListener = metricsListener != null ? metricsListener : new NoopMetricsListener();
     }
 
+    /**
+     * Sets the proxy handler factory to be used to construct proxy handlers when establishing a new connection to the
+     * APNs gateway. Proxy handlers are added to the beginning of the client's pipeline. A client's proxy handler
+     * factory may be {@code null}, in which case the client will connect to the gateway directly and will not use a
+     * proxy. By default, clients will not use a proxy.
+     *
+     * @param proxyHandlerFactory the proxy handler factory to be used to construct proxy handlers, or {@code null} if
+     * this client should not use a proxy
+     *
+     * @since 0.6
+     */
+    protected void setProxyHandlerFactory(final ProxyHandlerFactory proxyHandlerFactory) {
+        this.proxyHandlerFactory = proxyHandlerFactory;
+        this.bootstrap.resolver(proxyHandlerFactory == null ? DefaultAddressResolverGroup.INSTANCE : NoopAddressResolverGroup.INSTANCE);
+    }
+
     /**
      * <p>Sets the write timeout for this client. If an attempt to send a notification to the APNs server takes longer
      * than the given timeout, the connection will be closed (and automatically reconnected later). Note that write
@@ -589,7 +379,7 @@ public class ApnsClient<T extends ApnsPushNotification> {
      *
      * @since 0.6
      */
-    public void setWriteTimeout(final long writeTimeoutMillis) {
+    protected void setWriteTimeout(final long writeTimeoutMillis) {
         this.writeTimeoutMillis = writeTimeoutMillis;
     }
 
@@ -614,7 +404,7 @@ public class ApnsClient<T extends ApnsPushNotification> {
      *
      * @since 0.7
      */
-    public void setFlushThresholds(final int maxUnflushedNotifications, final long maxIdleTimeMillis) {
+    protected void setFlushThresholds(final int maxUnflushedNotifications, final long maxIdleTimeMillis) {
         if ((maxUnflushedNotifications > 0 && maxIdleTimeMillis > 0) || (maxUnflushedNotifications == 0 && maxIdleTimeMillis == 0)) {
             synchronized (this.bootstrap) {
                 this.maxUnflushedNotifications = maxUnflushedNotifications;
@@ -626,19 +416,29 @@ public class ApnsClient<T extends ApnsPushNotification> {
     }
 
     /**
-     * Sets the proxy handler factory to be used to construct proxy handlers when establishing a new connection to the
-     * APNs gateway. Proxy handlers are added to the beginning of the client's pipeline. A client's proxy handler
-     * factory may be {@code null}, in which case the client will connect to the gateway directly and will not use a
-     * proxy. By default, clients will not use a proxy.
+     * Sets the amount of time (in milliseconds) clients should wait for in-progress requests to complete before closing
+     * a connection during a graceful shutdown.
      *
-     * @param proxyHandlerFactory the proxy handler factory to be used to construct proxy handlers, or {@code null} if
-     * this client should not use a proxy
+     * @param timeoutMillis the number of milliseconds to wait for in-progress requests to complete before closing a
+     * connection
      *
-     * @since 0.6
+     * @see ApnsClient#disconnect()
+     *
+     * @since 0.5
      */
-    public void setProxyHandlerFactory(final ProxyHandlerFactory proxyHandlerFactory) {
-        this.proxyHandlerFactory = proxyHandlerFactory;
-        this.bootstrap.resolver(proxyHandlerFactory == null ? DefaultAddressResolverGroup.INSTANCE : NoopAddressResolverGroup.INSTANCE);
+    protected void setGracefulShutdownTimeout(final long timeoutMillis) {
+        synchronized (this.bootstrap) {
+            this.gracefulShutdownTimeoutMillis = timeoutMillis;
+
+            if (this.connectionReadyPromise != null) {
+                @SuppressWarnings("rawtypes")
+                final ApnsClientHandler handler = this.connectionReadyPromise.channel().pipeline().get(ApnsClientHandler.class);
+
+                if (handler != null) {
+                    handler.gracefulShutdownTimeoutMillis(timeoutMillis);
+                }
+            }
+        }
     }
 
     /**
@@ -951,32 +751,6 @@ public class ApnsClient<T extends ApnsPushNotification> {
         this.responsePromises.remove(response.getPushNotification()).setSuccess(response);
     }
 
-    /**
-     * Sets the amount of time (in milliseconds) clients should wait for in-progress requests to complete before closing
-     * a connection during a graceful shutdown.
-     *
-     * @param timeoutMillis the number of milliseconds to wait for in-progress requests to complete before closing a
-     * connection
-     *
-     * @see ApnsClient#disconnect()
-     *
-     * @since 0.5
-     */
-    public void setGracefulShutdownTimeout(final long timeoutMillis) {
-        synchronized (this.bootstrap) {
-            this.gracefulShutdownTimeoutMillis = timeoutMillis;
-
-            if (this.connectionReadyPromise != null) {
-                @SuppressWarnings("rawtypes")
-                final ApnsClientHandler handler = this.connectionReadyPromise.channel().pipeline().get(ApnsClientHandler.class);
-
-                if (handler != null) {
-                    handler.gracefulShutdownTimeoutMillis(timeoutMillis);
-                }
-            }
-        }
-    }
-
     /**
      * <p>Gracefully disconnects from the APNs gateway. The disconnection process will wait until notifications in
      * flight have been either accepted or rejected by the gateway. The returned {@code Future} will be marked as
diff --git a/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClientBuilder.java b/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClientBuilder.java
new file mode 100644
index 00000000..9a78b498
--- /dev/null
+++ b/pushy/src/main/java/com/relayrides/pushy/apns/ApnsClientBuilder.java
@@ -0,0 +1,461 @@
+/* Copyright (c) 2013-2016 RelayRides
+ *
+ * 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.relayrides.pushy.apns;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.KeyStore.PrivateKeyEntry;
+import java.security.KeyStoreException;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.SSLException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.relayrides.pushy.apns.proxy.ProxyHandlerFactory;
+
+import io.netty.channel.EventLoopGroup;
+import io.netty.handler.codec.http2.Http2SecurityUtil;
+import io.netty.handler.ssl.ApplicationProtocolConfig;
+import io.netty.handler.ssl.ApplicationProtocolConfig.Protocol;
+import io.netty.handler.ssl.ApplicationProtocolConfig.SelectedListenerFailureBehavior;
+import io.netty.handler.ssl.ApplicationProtocolConfig.SelectorFailureBehavior;
+import io.netty.handler.ssl.ApplicationProtocolNames;
+import io.netty.handler.ssl.OpenSsl;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslContextBuilder;
+import io.netty.handler.ssl.SslProvider;
+import io.netty.handler.ssl.SupportedCipherSuiteFilter;
+
+/**
+ * <p>An {@code ApnsClientBuilder} constructs new {@link ApnsClient} instances. Callers must supply client credentials
+ * via one of the {@code setClientCredentials} methods prior to constructing a new client with the
+ * {@link com.relayrides.pushy.apns.ApnsClientBuilder#build()} method; all other settings are optional.</p>
+ *
+ * <p>Client builders may be reused to generate multiple clients, and their settings may be changed from one client to
+ * the next.</p>
+ *
+ * @author <a href="https://github.com/jchambers">Jon Chambers</a>
+ *
+ * @param <T> the type of notification handled by client instances created by this builder
+ */
+public class ApnsClientBuilder<T extends ApnsPushNotification> {
+    private X509Certificate clientCertificate;
+    private PrivateKey privateKey;
+    private String privateKeyPassword;
+
+    private File trustedServerCertificatePemFile;
+    private InputStream trustedServerCertificateInputStream;
+    private X509Certificate[] trustedServerCertificates;
+
+    private EventLoopGroup eventLoopGroup;
+
+    private ApnsClientMetricsListener metricsListener;
+
+    private ProxyHandlerFactory proxyHandlerFactory;
+
+    private Long connectionTimeout;
+    private TimeUnit connectionTimeoutUnit;
+
+    private Integer maxUnflushedNotifications;
+    private Long maxIdleTimeBeforeFlush;
+    private TimeUnit maxIdleTimeBeforeFlushUnits;
+
+    private Long writeTimeout;
+    private TimeUnit writeTimeoutUnit;
+
+    private Long gracefulShutdownTimeout;
+    private TimeUnit gracefulShutdownTimeoutUnit;
+
+    private static final Logger log = LoggerFactory.getLogger(ApnsClientBuilder.class);
+
+    /**
+     * <p>Sets the credentials for the client under construction using the contents of the given PKCS#12 file. The
+     * PKCS#12 file <em>must</em> contain a certificate/private key pair.</p>
+     *
+     * @param p12File a PKCS#12-formatted file containing the certificate and private key to be used to identify the
+     * client to the APNs server
+     * @param p12Password the password to be used to decrypt the contents of the given PKCS#12 file; passwords may be
+     * blank (i.e. {@code ""}), but must not be {@code null}
+     *
+     * @throws SSLException if the given PKCS#12 file could not be loaded or if any other SSL-related problem arises
+     * when constructing the context
+     * @throws IOException if any IO problem occurred while attempting to read the given PKCS#12 file, or the PKCS#12
+     * file could not be found
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setClientCredentials(final File p12File, final String p12Password) throws SSLException, IOException {
+        try (final InputStream p12InputStream = new FileInputStream(p12File)) {
+            return this.setClientCredentials(p12InputStream, p12Password);
+        }
+    }
+
+    /**
+     * <p>Sets the credentials for the client under construction using the data from the given PKCS#12 input stream. The
+     * PKCS#12 data <em>must</em> contain a certificate/private key pair.</p>
+     *
+     * @param p12InputStream an input stream to a PKCS#12-formatted file containing the certificate and private key to
+     * be used to identify the client to the APNs server
+     * @param p12Password the password to be used to decrypt the contents of the given PKCS#12 file; passwords may be
+     * blank (i.e. {@code ""}), but must not be {@code null}
+     *
+     * @throws SSLException if the given PKCS#12 file could not be loaded or if any other SSL-related problem arises
+     * when constructing the context
+     * @throws IOException if any IO problem occurred while attempting to read the given PKCS#12 input stream
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setClientCredentials(final InputStream p12InputStream, final String p12Password) throws SSLException, IOException {
+        final X509Certificate x509Certificate;
+        final PrivateKey privateKey;
+
+        try {
+            final PrivateKeyEntry privateKeyEntry = P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, p12Password);
+
+            final Certificate certificate = privateKeyEntry.getCertificate();
+
+            if (!(certificate instanceof X509Certificate)) {
+                throw new KeyStoreException("Found a certificate in the provided PKCS#12 file, but it was not an X.509 certificate.");
+            }
+
+            x509Certificate = (X509Certificate) certificate;
+            privateKey = privateKeyEntry.getPrivateKey();
+        } catch (final KeyStoreException e) {
+            throw new SSLException(e);
+        }
+
+        return this.setClientCredentials(x509Certificate, privateKey, p12Password);
+    }
+
+    /**
+     * <p>Sets the credentials for the client under construction.</p>
+     *
+     * @param clientCertificate the certificate to be used to identify the client to the APNs server
+     * @param privateKey the private key for the client certificate
+     * @param privateKeyPassword the password to be used to decrypt the private key; may be {@code null} if the private
+     * key does not require a password
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setClientCredentials(final X509Certificate clientCertificate, final PrivateKey privateKey, final String privateKeyPassword) {
+        this.clientCertificate = clientCertificate;
+        this.privateKey = privateKey;
+        this.privateKeyPassword = privateKeyPassword;
+
+        return this;
+    }
+
+    /**
+     * <p>Sets the trusted certificate chain for the client under construction using the contents of the given PEM
+     * file. If not set (or {@code null}), the client will use the JVM's default trust manager.</p>
+     *
+     * <p>Callers will generally not need to set a trusted server certificate chain in normal operation, but may wish
+     * to do so for <a href="https://www.owasp.org/index.php/Certificate_and_Public_Key_Pinning">certificate pinning</a>
+     * or connecting to a mock server for integration testing or benchmarking.</p>
+     *
+     * @param certificatePemFile a PEM file containing one or more trusted certificates
+     *
+     * @return a reference to this builder
+     */
+    public ApnsClientBuilder<T> setTrustedServerCertificateChain(final File certificatePemFile) {
+        this.trustedServerCertificatePemFile = certificatePemFile;
+        this.trustedServerCertificateInputStream = null;
+        this.trustedServerCertificates = null;
+
+        return this;
+    }
+
+    /**
+     * <p>Sets the trusted certificate chain for the client under construction using the contents of the given PEM
+     * input stream. If not set (or {@code null}), the client will use the JVM's default trust manager.</p>
+     *
+     * <p>Callers will generally not need to set a trusted server certificate chain in normal operation, but may wish
+     * to do so for <a href="https://www.owasp.org/index.php/Certificate_and_Public_Key_Pinning">certificate pinning</a>
+     * or connecting to a mock server for integration testing or benchmarking.</p>
+     *
+     * @param certificateInputStream an input stream to PEM-formatted data containing one or more trusted certificates
+     *
+     * @return a reference to this builder
+     */
+    public ApnsClientBuilder<T> setTrustedServerCertificateChain(final InputStream certificateInputStream) {
+        this.trustedServerCertificatePemFile = null;
+        this.trustedServerCertificateInputStream = certificateInputStream;
+        this.trustedServerCertificates = null;
+
+        return this;
+    }
+
+    /**
+     * <p>Sets the trusted certificate chain for the client under construction. If not set (or {@code null}), the
+     * client will use the JVM's default trust manager.</p>
+     *
+     * <p>Callers will generally not need to set a trusted server certificate chain in normal operation, but may wish
+     * to do so for <a href="https://www.owasp.org/index.php/Certificate_and_Public_Key_Pinning">certificate pinning</a>
+     * or connecting to a mock server for integration testing or benchmarking.</p>
+     *
+     * @param certificates one or more trusted certificates
+     *
+     * @return a reference to this builder
+     */
+    public ApnsClientBuilder<T> setTrustedServerCertificateChain(final X509Certificate... certificates) {
+        this.trustedServerCertificatePemFile = null;
+        this.trustedServerCertificateInputStream = null;
+        this.trustedServerCertificates = certificates;
+
+        return this;
+    }
+
+    /**
+     * <p>Sets the event loop group to be used by the client under construction. If not set (or if {@code null}), the
+     * client will create and manage its own event loop group.</p>
+     *
+     * <p>Generally speaking, callers don't need to set event loop groups for clients, but it may be useful to specify
+     * an event loop group under certain circumstances. In particular, specifying an event loop group that is shared
+     * among multiple {@code ApnsClient} instances can keep thread counts manageable. Regardless of the number of
+     * concurrent {@code ApnsClient} instances, callers may also wish to specify an event loop group to take advantage
+     * of certain platform-specific optimizations (e.g. epoll event loop groups).</p>
+     *
+     * @param eventLoopGroup the event loop group to use for this client, or {@code null} to let the client manage its
+     * own event loop group
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setEventLoopGroup(final EventLoopGroup eventLoopGroup) {
+        this.eventLoopGroup = eventLoopGroup;
+        return this;
+    }
+
+    /**
+     * Sets the metrics listener for the client under construction. Metrics listeners gather information that describes
+     * the performance and behavior of a client, and are completely optional.
+     *
+     * @param metricsListener the metrics listener for the client under construction, or {@code null} if this client
+     * should not report metrics to a listener
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setMetricsListener(final ApnsClientMetricsListener metricsListener) {
+        this.metricsListener = metricsListener;
+        return this;
+    }
+
+    /**
+     * Sets the proxy handler factory to be used to construct proxy handlers when establishing a new connection to the
+     * APNs gateway. A client's proxy handler factory may be {@code null}, in which case the client will connect to the
+     * gateway directly and will not use a proxy. By default, clients will not use a proxy.
+     *
+     * @param proxyHandlerFactory the proxy handler factory to be used to construct proxy handlers, or {@code null} if
+     * this client should not use a proxy
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setProxyHandlerFactory(final ProxyHandlerFactory proxyHandlerFactory) {
+        this.proxyHandlerFactory = proxyHandlerFactory;
+        return this;
+    }
+
+    /**
+     * Sets the maximum amount of time, in milliseconds, that the client under construction will wait to establish a
+     * connection with the APNs server before the connection attempt is considered a failure.
+     *
+     * @param connectionTimeout the maximum amount of time to wait for a connection attempt to complete
+     * @param timeoutUnit the time unit for the given timeout
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setConnectionTimeout(final long connectionTimeout, final TimeUnit timeoutUnit) {
+        this.connectionTimeout = connectionTimeout;
+        this.connectionTimeoutUnit = timeoutUnit;
+
+        return this;
+    }
+
+    /**
+     * <p>Sets the thresholds at which this client under construction will flush notifications enqueued for sending to
+     * the APNs server. By default, notifications will be flushed after
+     * {@value com.relayrides.pushy.apns.ApnsClient#DEFAULT_MAX_UNFLUSHED_NOTIFICATIONS} unflushed
+     * notifications have been enqueued or {@value com.relayrides.pushy.apns.ApnsClient#DEFAULT_FLUSH_AFTER_IDLE_MILLIS}
+     * milliseconds of inactivity have elapsed.</p>
+     *
+     * <p>Callers may set both thresholds to zero to flush all notifications immediately, which will reduce latency at
+     * the expense of decreasing efficiency and throughput.</p>
+     *
+     * @param maxUnflushedNotifications The maximum number of notifications that may be enqueued before the sending
+     * queue is flushed. Must be positive if {@code maxIdleTimeMillis} is also positive or zero if
+     * {@code maxIdleTimeMillis} is also zero. If zero, notifications are always sent immediately.
+     * @param maxIdleTime The maximum amount of time since the last attempt to send a notification that may elapse
+     * before the sending queue is flushed. Must be positive if {@code maxUnflushedNotifications} is also positive or
+     * zero if {@code maxUnflushedNotifications} is also zero. If zero, notifications are always sent immediately.
+     * @param maxIdleTimeUnits the time unit for the given maximum idle time
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setFlushThresholds(final int maxUnflushedNotifications, final long maxIdleTime, final TimeUnit maxIdleTimeUnits) {
+        this.maxUnflushedNotifications = maxUnflushedNotifications;
+        this.maxIdleTimeBeforeFlush = maxIdleTime;
+        this.maxIdleTimeBeforeFlushUnits = maxIdleTimeUnits;
+
+        return this;
+    }
+
+    /**
+     * <p>Sets the write timeout for the client to build. If an attempt to send a notification to the APNs server takes
+     * longer than the given timeout, the connection will be closed (and automatically reconnected later). Note that
+     * write timeouts refer to the amount of time taken to <em>send</em> a notification to the server, and not the time
+     * taken by the server to process and respond to a notification.</p>
+     *
+     * <p>By default, clients have a write timeout of
+     * {@value com.relayrides.pushy.apns.ApnsClient#DEFAULT_WRITE_TIMEOUT_MILLIS} milliseconds.</p>
+     *
+     * @param writeTimeout the write timeout for the client under construction
+     * @param timeoutUnit the time unit for the given timeout
+     *
+     * @return a reference to this builder
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setWriteTimeout(final long writeTimeout, final TimeUnit timeoutUnit) {
+        this.writeTimeout = writeTimeout;
+        this.writeTimeoutUnit = timeoutUnit;
+
+        return this;
+    }
+
+    /**
+     * Sets the amount of time clients should wait for in-progress requests to complete before closing a connection
+     * during a graceful shutdown.
+     *
+     * @param gracefulShutdownTimeout the amount of time to wait for in-progress requests to complete before closing a
+     * connection
+     * @param timeoutUnit the time unit for the given timeout
+     *
+     * @return a reference to this builder
+     *
+     * @see ApnsClient#disconnect()
+     *
+     * @since 0.8
+     */
+    public ApnsClientBuilder<T> setGracefulShutdownTimeout(final long gracefulShutdownTimeout, final TimeUnit timeoutUnit) {
+        this.gracefulShutdownTimeout = gracefulShutdownTimeout;
+        this.gracefulShutdownTimeoutUnit = timeoutUnit;
+
+        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
+     *
+     * @since 0.8
+     */
+    public ApnsClient<T> build() throws SSLException {
+        Objects.requireNonNull(this.clientCertificate, "Client certificate must be set before building an APNs client.");
+        Objects.requireNonNull(this.privateKey, "Private key must be set before building an APNs client.");
+
+        final SslContext sslContext;
+        {
+            final SslProvider sslProvider;
+
+            if (OpenSsl.isAvailable()) {
+                if (OpenSsl.isAlpnSupported()) {
+                    log.info("Native SSL provider is available and supports ALPN; will use native provider.");
+                    sslProvider = SslProvider.OPENSSL;
+                } else {
+                    log.info("Native SSL provider is available, but does not support ALPN; will use JDK SSL provider.");
+                    sslProvider = SslProvider.JDK;
+                }
+            } else {
+                log.info("Native SSL provider not available; will use JDK SSL provider.");
+                sslProvider = SslProvider.JDK;
+            }
+
+            final SslContextBuilder sslContextBuilder = SslContextBuilder.forClient()
+                    .sslProvider(sslProvider)
+                    .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
+                    .keyManager(this.privateKey, this.privateKeyPassword, this.clientCertificate)
+                    .applicationProtocolConfig(
+                            new ApplicationProtocolConfig(Protocol.ALPN,
+                                    SelectorFailureBehavior.NO_ADVERTISE,
+                                    SelectedListenerFailureBehavior.ACCEPT,
+                                    ApplicationProtocolNames.HTTP_2));
+
+            if (this.trustedServerCertificatePemFile != null) {
+                sslContextBuilder.trustManager(this.trustedServerCertificatePemFile);
+            } else if (this.trustedServerCertificateInputStream != null) {
+                sslContextBuilder.trustManager(this.trustedServerCertificateInputStream);
+            } else if (this.trustedServerCertificates != null) {
+                sslContextBuilder.trustManager(this.trustedServerCertificates);
+            }
+
+            sslContext = sslContextBuilder.build();
+        }
+
+        final ApnsClient<T> apnsClient = new ApnsClient<T>(sslContext, this.eventLoopGroup);
+
+        apnsClient.setMetricsListener(this.metricsListener);
+        apnsClient.setProxyHandlerFactory(this.proxyHandlerFactory);
+
+        if (this.connectionTimeout != null) {
+            apnsClient.setConnectionTimeout((int) this.connectionTimeoutUnit.toMillis(this.connectionTimeout));
+        }
+
+        if (this.maxUnflushedNotifications != null) {
+            apnsClient.setFlushThresholds(this.maxUnflushedNotifications, this.maxIdleTimeBeforeFlushUnits.toMillis(this.maxIdleTimeBeforeFlush));
+        }
+
+        if (this.writeTimeout != null) {
+            apnsClient.setWriteTimeout(this.writeTimeoutUnit.toMillis(this.writeTimeout));
+        }
+
+        if (this.gracefulShutdownTimeout != null) {
+            apnsClient.setGracefulShutdownTimeout(this.gracefulShutdownTimeoutUnit.toMillis(this.gracefulShutdownTimeout));
+        }
+
+        return apnsClient;
+    }
+}


Test patch:
diff --git a/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientBuilderTest.java b/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientBuilderTest.java
new file mode 100644
index 00000000..026e1ef1
--- /dev/null
+++ b/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientBuilderTest.java
@@ -0,0 +1,75 @@
+package com.relayrides.pushy.apns;
+
+import java.io.File;
+import java.io.InputStream;
+import java.security.KeyStore.PrivateKeyEntry;
+import java.security.cert.X509Certificate;
+
+import org.junit.Test;
+
+import com.relayrides.pushy.apns.util.SimpleApnsPushNotification;
+
+public class ApnsClientBuilderTest {
+
+    private static final String SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME = "/single-topic-client.p12";
+    private static final String SINGLE_TOPIC_CLIENT_KEYSTORE_UNPROTECTED_FILENAME = "/single-topic-client-unprotected.p12";
+
+    private static final String KEYSTORE_PASSWORD = "pushy-test";
+
+    @Test
+    public void testBuildClientWithPasswordProtectedP12File() throws Exception {
+        // We're happy here as long as nothing throws an exception
+        new ApnsClientBuilder<SimpleApnsPushNotification>()
+        .setClientCredentials(new File(ApnsClientBuilderTest.class.getResource(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME).toURI()), KEYSTORE_PASSWORD)
+        .build();
+    }
+
+    @Test
+    public void testBuildClientWithPasswordProtectedP12InputStream() throws Exception {
+        // We're happy here as long as nothing throws an exception
+        try (final InputStream p12InputStream = ApnsClientBuilderTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            new ApnsClientBuilder<SimpleApnsPushNotification>()
+            .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+            .build();
+        }
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void testBuildClientWithNullPassword() throws Exception {
+        new ApnsClientBuilder<SimpleApnsPushNotification>()
+        .setClientCredentials(new File(ApnsClientBuilderTest.class.getResource(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME).toURI()), null)
+        .build();
+    }
+
+    @Test
+    public void testBuildClientWithCertificateAndPasswordProtectedKey() throws Exception {
+        // We're happy here as long as nothing throws an exception
+        try (final InputStream p12InputStream = ApnsClientBuilderTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            final PrivateKeyEntry privateKeyEntry =
+                    P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, KEYSTORE_PASSWORD);
+
+            new ApnsClientBuilder<SimpleApnsPushNotification>()
+            .setClientCredentials((X509Certificate) privateKeyEntry.getCertificate(), privateKeyEntry.getPrivateKey(), KEYSTORE_PASSWORD)
+            .build();
+        }
+    }
+
+    @Test
+    public void testBuildClientWithCertificateAndUnprotectedKey() throws Exception {
+        // We DO need a password to unlock the keystore, but the key itself should be unprotected
+        try (final InputStream p12InputStream = ApnsClientBuilderTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_UNPROTECTED_FILENAME)) {
+
+            final PrivateKeyEntry privateKeyEntry =
+                    P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, KEYSTORE_PASSWORD);
+
+            new ApnsClientBuilder<SimpleApnsPushNotification>()
+            .setClientCredentials((X509Certificate) privateKeyEntry.getCertificate(), privateKeyEntry.getPrivateKey(), null)
+            .build();
+        }
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void testBuildWithoutClientCredentials() throws Exception {
+        new ApnsClientBuilder<>().build();
+    }
+}
diff --git a/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientTest.java b/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientTest.java
index a7691cf0..50190d81 100644
--- a/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientTest.java
+++ b/pushy/src/test/java/com/relayrides/pushy/apns/ApnsClientTest.java
@@ -6,14 +6,7 @@ import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
-import java.io.IOException;
 import java.io.InputStream;
-import java.security.KeyStore.PrivateKeyEntry;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.security.UnrecoverableEntryException;
-import java.security.cert.CertificateException;
-import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
@@ -27,21 +20,11 @@ import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
+
 import com.relayrides.pushy.apns.util.ApnsPayloadBuilder;
 import com.relayrides.pushy.apns.util.SimpleApnsPushNotification;
 
 import io.netty.channel.nio.NioEventLoopGroup;
-import io.netty.handler.codec.http2.Http2SecurityUtil;
-import io.netty.handler.ssl.ApplicationProtocolConfig;
-import io.netty.handler.ssl.ApplicationProtocolConfig.Protocol;
-import io.netty.handler.ssl.ApplicationProtocolConfig.SelectedListenerFailureBehavior;
-import io.netty.handler.ssl.ApplicationProtocolConfig.SelectorFailureBehavior;
-import io.netty.handler.ssl.ApplicationProtocolNames;
-import io.netty.handler.ssl.OpenSsl;
-import io.netty.handler.ssl.SslContext;
-import io.netty.handler.ssl.SslContextBuilder;
-import io.netty.handler.ssl.SslProvider;
-import io.netty.handler.ssl.SupportedCipherSuiteFilter;
 import io.netty.util.concurrent.Future;
 import io.netty.util.concurrent.GenericFutureListener;
 
@@ -50,7 +33,6 @@ public class ApnsClientTest {
     private static NioEventLoopGroup EVENT_LOOP_GROUP;
 
     private static final String SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME = "/single-topic-client.p12";
-    private static final String SINGLE_TOPIC_CLIENT_KEYSTORE_UNPROTECTED_FILENAME = "/single-topic-client-unprotected.p12";
     private static final String MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME = "/multi-topic-client.p12";
     private static final String UNTRUSTED_CLIENT_KEYSTORE_FILENAME = "/untrusted-client.p12";
 
@@ -214,9 +196,13 @@ public class ApnsClientTest {
         this.server = new MockApnsServer(EVENT_LOOP_GROUP);
         this.server.start(PORT).await();
 
-        this.client = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            this.client = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         this.client.connect(HOST, PORT).await();
     }
@@ -241,57 +227,15 @@ public class ApnsClientTest {
     }
 
     @Test
-    public void testApnsClientWithPasswordProtectedP12File() throws Exception {
-        // We're happy here as long as nothing throws an exception
-        new ApnsClient<SimpleApnsPushNotification>(
-                new File(ApnsClientTest.class.getResource(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME).toURI()),
-                KEYSTORE_PASSWORD);
-    }
-
-    @Test
-    public void testApnsClientWithPasswordProtectedP12InputStream() throws Exception {
-        // We're happy here as long as nothing throws an exception
-        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
-            new ApnsClient<SimpleApnsPushNotification>(p12InputStream, KEYSTORE_PASSWORD);
-        }
-    }
-
-    @Test(expected = NullPointerException.class)
-    public void testApnsClientWithNullPassword() throws Exception {
-        new ApnsClient<SimpleApnsPushNotification>(
-                new File(ApnsClientTest.class.getResource(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME).toURI()),
-                null);
-    }
+    public void testApnsClientWithManagedEventLoopGroup() throws Exception {
+        final ApnsClient<SimpleApnsPushNotification> managedGroupClient;
 
-    @Test
-    public void testApnsClientWithCertificateAndPasswordProtectedKey() throws Exception {
-        // We're happy here as long as nothing throws an exception
         try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
-            final PrivateKeyEntry privateKeyEntry =
-                    P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, KEYSTORE_PASSWORD);
-
-            new ApnsClient<SimpleApnsPushNotification>(
-                    (X509Certificate) privateKeyEntry.getCertificate(), privateKeyEntry.getPrivateKey(), KEYSTORE_PASSWORD);
-        }
-    }
-
-    @Test
-    public void testApnsClientWithCertificateAndUnprotectedKey() throws Exception {
-        // We DO need a password to unlock the keystore, but the key itself should be unprotected
-        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_UNPROTECTED_FILENAME)) {
-
-            final PrivateKeyEntry privateKeyEntry =
-                    P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, KEYSTORE_PASSWORD);
-
-            new ApnsClient<SimpleApnsPushNotification>(
-                    (X509Certificate) privateKeyEntry.getCertificate(), privateKeyEntry.getPrivateKey(), null);
+            managedGroupClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .build();
         }
-    }
-
-    @Test
-    public void testApnsClientWithManagedEventLoopGroup() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> managedGroupClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD), null);
 
         assertTrue(managedGroupClient.connect(HOST, PORT).await().isSuccess());
         assertTrue(managedGroupClient.disconnect().await().isSuccess());
@@ -316,8 +260,14 @@ public class ApnsClientTest {
 
     @Test
     public void testRestartApnsClientWithManagedEventLoopGroup() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> managedGroupClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD), null);
+        final ApnsClient<SimpleApnsPushNotification> managedGroupClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            managedGroupClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .build();
+        }
 
         assertTrue(managedGroupClient.connect(HOST, PORT).await().isSuccess());
         assertTrue(managedGroupClient.disconnect().await().isSuccess());
@@ -373,9 +323,15 @@ public class ApnsClientTest {
 
     @Test
     public void testGetReconnectionFutureWhenNotConnected() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> unconnectedClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> unconnectedClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            unconnectedClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         final Future<Void> reconnectionFuture = unconnectedClient.getReconnectionFuture();
 
@@ -387,9 +343,15 @@ public class ApnsClientTest {
 
     @Test
     public void testConnectWithUntrustedCertificate() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> untrustedClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(UNTRUSTED_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> untrustedClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(UNTRUSTED_CLIENT_KEYSTORE_FILENAME)) {
+            untrustedClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         final Future<Void> connectFuture = untrustedClient.connect(HOST, PORT).await();
         assertFalse(connectFuture.isSuccess());
@@ -399,9 +361,15 @@ public class ApnsClientTest {
 
     @Test
     public void testSendNotificationBeforeConnected() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> unconnectedClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> unconnectedClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            unconnectedClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         final String testToken = ApnsClientTest.generateRandomToken();
 
@@ -578,9 +546,15 @@ public class ApnsClientTest {
 
     @Test
     public void testSendNotificationWithMissingTopic() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> multiTopicClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> multiTopicClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            multiTopicClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         multiTopicClient.connect(HOST, PORT).await();
 
@@ -602,9 +576,15 @@ public class ApnsClientTest {
 
     @Test
     public void testSendNotificationWithSpecifiedTopic() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> multiTopicClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> multiTopicClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            multiTopicClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         multiTopicClient.connect(HOST, PORT).await();
 
@@ -655,9 +635,15 @@ public class ApnsClientTest {
 
     @Test
     public void testWriteFailureMetrics() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> unconnectedClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> unconnectedClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            unconnectedClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         final TestMetricsListener metricsListener = new TestMetricsListener();
         unconnectedClient.setMetricsListener(metricsListener);
@@ -705,9 +691,15 @@ public class ApnsClientTest {
 
     @Test
     public void testSuccessfulConnectionMetrics() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> unconnectedClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> unconnectedClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            unconnectedClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         final TestMetricsListener metricsListener = new TestMetricsListener();
         unconnectedClient.setMetricsListener(metricsListener);
@@ -725,9 +717,15 @@ public class ApnsClientTest {
 
     @Test
     public void testFailedConnectionMetrics() throws Exception {
-        final ApnsClient<SimpleApnsPushNotification> unconnectedClient = new ApnsClient<>(
-                ApnsClientTest.getSslContextForTestClient(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME, KEYSTORE_PASSWORD),
-                EVENT_LOOP_GROUP);
+        final ApnsClient<SimpleApnsPushNotification> unconnectedClient;
+
+        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(SINGLE_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
+            unconnectedClient = new ApnsClientBuilder<SimpleApnsPushNotification>()
+                    .setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
+                    .setTrustedServerCertificateChain(CA_CERTIFICATE)
+                    .setEventLoopGroup(EVENT_LOOP_GROUP)
+                    .build();
+        }
 
         final TestMetricsListener metricsListener = new TestMetricsListener();
         unconnectedClient.setMetricsListener(metricsListener);
@@ -745,23 +743,6 @@ public class ApnsClientTest {
         assertEquals(0, metricsListener.getSuccessfulConnectionAttempts().get());
     }
 
-    private static SslContext getSslContextForTestClient(final String p12Filename, final String password) throws NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException, UnrecoverableEntryException {
-        try (final InputStream p12InputStream = ApnsClientTest.class.getResourceAsStream(p12Filename)) {
-            final PrivateKeyEntry privateKeyEntry = P12Util.getFirstPrivateKeyEntryFromP12InputStream(p12InputStream, password);
-
-            return SslContextBuilder.forClient()
-                    .sslProvider(OpenSsl.isAlpnSupported() ? SslProvider.OPENSSL : SslProvider.JDK)
-                    .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
-                    .keyManager(privateKeyEntry.getPrivateKey(), (X509Certificate) privateKeyEntry.getCertificate())
-                    .trustManager(CA_CERTIFICATE)
-                    .applicationProtocolConfig(new ApplicationProtocolConfig(Protocol.ALPN,
-                            SelectorFailureBehavior.NO_ADVERTISE,
-                            SelectedListenerFailureBehavior.ACCEPT,
-                            ApplicationProtocolNames.HTTP_2))
-                    .build();
-        }
-    }
-
     private static String generateRandomToken() {
         final byte[] tokenBytes = new byte[TOKEN_LENGTH];
         new Random().nextBytes(tokenBytes);
diff --git a/pushy/src/test/java/com/relayrides/pushy/apns/ExampleApp.java b/pushy/src/test/java/com/relayrides/pushy/apns/ExampleApp.java
index bc2a4165..dd000e56 100644
--- a/pushy/src/test/java/com/relayrides/pushy/apns/ExampleApp.java
+++ b/pushy/src/test/java/com/relayrides/pushy/apns/ExampleApp.java
@@ -25,8 +25,10 @@ public class ExampleApp {
         // certificate and private key to authenticate with the APNs server. The
         // most common way to store the certificate and key is in a
         // password-protected PKCS#12 file.
-        final ApnsClient<SimpleApnsPushNotification> apnsClient = new ApnsClient<>(
-                new File("/path/to/certificate.p12"), "p12-file-password");
+        final ApnsClient<SimpleApnsPushNotification> apnsClient =
+                new ApnsClientBuilder<SimpleApnsPushNotification>()
+                .setClientCredentials(new File("/path/to/certificate.p12"), "p12-file-password")
+                .build();
 
         // Optional: we can listen for metrics by setting a metrics listener.
         apnsClient.setMetricsListener(new NoopMetricsListener());