com.relayrides.pushy.apns.FeedbackServiceConnection.java Source code

Java tutorial

Introduction

Here is the source code for com.relayrides.pushy.apns.FeedbackServiceConnection.java

Source

/* Copyright (c) 2013 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 io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.ReplayingDecoder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutException;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;

import java.util.Date;
import java.util.List;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <p>A connection to the APNs feedback service that listens for expired tokens, then disconnects after a period of
 * inactivity. According to Apple's documentation:</p>
 *
 * <blockquote><p>The Apple Push Notification Service includes a feedback service to give you information about failed
 * push notifications. When a push notification cannot be delivered because the intended app does not exist on the
 * device, the feedback service adds that device's token to its list. Push notifications that expire before being
 * delivered are not considered a failed delivery and don't impact the feedback service...</p>
 *
 * <p>Query the feedback service daily to get the list of device tokens. Use the timestamp to verify that the device
 * tokens haven't been reregistered since the feedback entry was generated. For each device that has not been
 * reregistered, stop sending notifications.</p></blockquote>
 *
 * <p>Generally, users of Pushy should <em>not</em> instantiate a {@code FeedbackServiceConnection} directly, but should
 * instead call {@link com.relayrides.pushy.apns.PushManager#requestExpiredTokens()}, which will manage the creation and
 * configuration of a {@code FeedbackServiceConnection} internally.</p>
 *
 * @author <a href="mailto:jon@relayrides.com">Jon Chambers</a>
 *
 * @see <a href="http://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3">
 * Local and Push Notification Programming Guide - Provider Communication with Apple Push Notification Service - The
 * Feedback Service</a>
 */
public class FeedbackServiceConnection {

    private final ApnsEnvironment environment;
    private final SSLContext sslContext;
    private final NioEventLoopGroup eventLoopGroup;
    private final FeedbackConnectionConfiguration configuration;
    private final FeedbackServiceListener listener;
    private final String name;

    private ChannelFuture connectFuture;

    private static final Logger log = LoggerFactory.getLogger(FeedbackServiceConnection.class);

    private enum ExpiredTokenDecoderState {
        EXPIRATION, TOKEN_LENGTH, TOKEN
    }

    private static class ExpiredTokenDecoder extends ReplayingDecoder<ExpiredTokenDecoderState> {

        private Date expiration;
        private byte[] token;

        public ExpiredTokenDecoder() {
            super(ExpiredTokenDecoderState.EXPIRATION);
        }

        @Override
        protected void decode(final ChannelHandlerContext context, final ByteBuf in, final List<Object> out) {
            switch (this.state()) {
            case EXPIRATION: {
                final long timestamp = (in.readInt() & 0xFFFFFFFFL) * 1000L;
                this.expiration = new Date(timestamp);

                this.checkpoint(ExpiredTokenDecoderState.TOKEN_LENGTH);

                break;
            }

            case TOKEN_LENGTH: {
                this.token = new byte[in.readShort() & 0x0000FFFF];
                this.checkpoint(ExpiredTokenDecoderState.TOKEN);

                break;
            }

            case TOKEN: {
                in.readBytes(this.token);
                out.add(new ExpiredToken(this.token, this.expiration));

                this.checkpoint(ExpiredTokenDecoderState.EXPIRATION);

                break;
            }
            }
        }
    }

    private static class FeedbackClientHandler extends SimpleChannelInboundHandler<ExpiredToken> {

        private final FeedbackServiceConnection feedbackClient;

        public FeedbackClientHandler(final FeedbackServiceConnection feedbackClient) {
            this.feedbackClient = feedbackClient;
        }

        @Override
        protected void channelRead0(final ChannelHandlerContext context, final ExpiredToken expiredToken) {
            if (this.feedbackClient.listener != null) {
                this.feedbackClient.listener.handleExpiredToken(feedbackClient, expiredToken);
            }
        }

        @Override
        public void exceptionCaught(final ChannelHandlerContext context, final Throwable cause) {

            if (!(cause instanceof ReadTimeoutException)) {
                log.debug("Caught an unexpected exception while waiting for expired tokens.", cause);
            }

            context.close();
        }

        @Override
        public void channelInactive(final ChannelHandlerContext context) throws Exception {
            super.channelInactive(context);

            // Channel closure implies that the connection attempt had fully succeeded, so we only want to notify
            // listeners if the handshake has completed. Otherwise, we'll notify listeners of a connection failure (as
            // opposed to closure) elsewhere.
            final SslHandler sslHandler = context.pipeline().get(SslHandler.class);

            if (sslHandler != null && sslHandler.handshakeFuture().isSuccess()) {
                if (this.feedbackClient.listener != null) {
                    this.feedbackClient.listener.handleConnectionClosure(this.feedbackClient);
                }
            }
        }
    }

    /**
     * <p>Constructs a new feedback client that connects to the feedback service in the given environment with the
     * credentials and key/trust managers in the given SSL context.</p>
        
     * @param environment the environment in which this feedback client will operate
     * @param sslContext an SSL context with the keys/certificates and trust managers this client should use when
     * communicating with the APNs feedback service
     * @param eventLoopGroup the event loop group this client should use for asynchronous network operations
     * @param configuration the set of configuration options to use for this connection. The configuration object is
     * copied and changes to the original object will not propagate to the connection after creation. Must not be
     * {@code null}.
     * @param name a human-readable name for this connection; names must not be {@code null}
     */
    public FeedbackServiceConnection(final ApnsEnvironment environment, final SSLContext sslContext,
            final NioEventLoopGroup eventLoopGroup, final FeedbackConnectionConfiguration configuration,
            final FeedbackServiceListener listener, final String name) {
        if (environment == null) {
            throw new NullPointerException("Environment must not be null.");
        }

        if (sslContext == null) {
            throw new NullPointerException("SSL context must not be null.");
        }

        if (eventLoopGroup == null) {
            throw new NullPointerException("Event loop group must not be null.");
        }

        if (configuration == null) {
            throw new NullPointerException("Feedback service connection configuration must not be null.");
        }

        if (name == null) {
            throw new NullPointerException("Feedback service connection name must not be null.");
        }

        this.environment = environment;
        this.sslContext = sslContext;
        this.eventLoopGroup = eventLoopGroup;
        this.configuration = configuration;
        this.listener = listener;
        this.name = name;
    }

    /**
     * <p>Connects to the APNs feedback service and waits for expired tokens to arrive. Be warned that this is a
     * <strong>destructive operation</strong>. According to Apple's documentation:</p>
     *
     * <blockquote>The feedback service's list is cleared after you read it. Each time you connect to the feedback
     * service, the information it returns lists only the failures that have happened since you last
     * connected.</blockquote>
     */
    public synchronized void connect() {

        if (this.connectFuture != null) {
            throw new IllegalStateException(String.format("%s already started a connection attempt.", this.name));
        }

        final Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(this.eventLoopGroup);
        bootstrap.channel(NioSocketChannel.class);

        final FeedbackServiceConnection feedbackConnection = this;
        bootstrap.handler(new ChannelInitializer<SocketChannel>() {

            @Override
            protected void initChannel(final SocketChannel channel) throws Exception {
                final ChannelPipeline pipeline = channel.pipeline();

                final SSLEngine sslEngine = feedbackConnection.sslContext.createSSLEngine();
                sslEngine.setUseClientMode(true);

                pipeline.addLast("ssl", new SslHandler(sslEngine));
                pipeline.addLast("readTimeoutHandler",
                        new ReadTimeoutHandler(feedbackConnection.configuration.getReadTimeout()));
                pipeline.addLast("decoder", new ExpiredTokenDecoder());
                pipeline.addLast("handler", new FeedbackClientHandler(feedbackConnection));
            }
        });

        this.connectFuture = bootstrap.connect(this.environment.getFeedbackHost(),
                this.environment.getFeedbackPort());
        this.connectFuture.addListener(new GenericFutureListener<ChannelFuture>() {

            @Override
            public void operationComplete(final ChannelFuture connectFuture) {

                if (connectFuture.isSuccess()) {
                    log.debug("{} connected; waiting for TLS handshake.", feedbackConnection.name);

                    final SslHandler sslHandler = connectFuture.channel().pipeline().get(SslHandler.class);

                    try {
                        sslHandler.handshakeFuture().addListener(new GenericFutureListener<Future<Channel>>() {

                            @Override
                            public void operationComplete(final Future<Channel> handshakeFuture) {
                                if (handshakeFuture.isSuccess()) {
                                    log.debug("{} successfully completed TLS handshake.", feedbackConnection.name);

                                    if (feedbackConnection.listener != null) {
                                        feedbackConnection.listener.handleConnectionSuccess(feedbackConnection);
                                    }

                                } else {
                                    log.debug("{} failed to complete TLS handshake with APNs feedback service.",
                                            feedbackConnection.name, handshakeFuture.cause());

                                    connectFuture.channel().close();

                                    if (feedbackConnection.listener != null) {
                                        feedbackConnection.listener.handleConnectionFailure(feedbackConnection,
                                                handshakeFuture.cause());
                                    }
                                }
                            }
                        });
                    } catch (NullPointerException e) {
                        log.warn("{} failed to get SSL handler and could not wait for a TLS handshake.",
                                feedbackConnection.name);

                        connectFuture.channel().close();

                        if (feedbackConnection.listener != null) {
                            feedbackConnection.listener.handleConnectionFailure(feedbackConnection, e);
                        }
                    }
                } else {
                    log.debug("{} failed to connect to APNs feedback service.", feedbackConnection.name,
                            connectFuture.cause());

                    if (feedbackConnection.listener != null) {
                        feedbackConnection.listener.handleConnectionFailure(feedbackConnection,
                                connectFuture.cause());
                    }
                }
            }
        });
    }

    /**
     * Closes this feedback connection as soon as possible. Calling this method when the feedback connection is not
     * connected has no effect.
     */
    public synchronized void shutdownImmediately() {
        if (this.connectFuture != null) {
            this.connectFuture.channel().close();
            this.connectFuture.cancel(false);
        }
    }

    @Override
    public String toString() {
        return "FeedbackServiceConnection [name=" + name + "]";
    }
}