Package com.relayrides.pushy.apns

Source Code of com.relayrides.pushy.apns.FeedbackServiceConnection

/* 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 final Object channelRegistrationMonitor = new Object();
  private ChannelFuture connectFuture;
  private volatile boolean handshakeCompleted = false;
  private volatile boolean closeOnRegistration;

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

  private enum ExpiredTokenDecoderState {
    EXPIRATION,
    TOKEN_LENGTH,
    TOKEN
  }

  private 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 class FeedbackClientHandler extends SimpleChannelInboundHandler<ExpiredToken> {

    private final FeedbackServiceConnection feedbackClient;

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

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

      synchronized (this.feedbackClient.channelRegistrationMonitor) {
        if (this.feedbackClient.closeOnRegistration) {
          log.debug("Channel registered for {}, but shutting down immediately.", this.feedbackClient.name);
          context.channel().eventLoop().execute(this.feedbackClient.getImmediateShutdownRunnable());
        }
      }
    }

    @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.
      if (this.feedbackClient.handshakeCompleted) {
        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);

                  feedbackConnection.handshakeCompleted = true;

                  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) {
      synchronized (this.channelRegistrationMonitor) {
        if (this.connectFuture.channel().isRegistered()) {
          this.connectFuture.channel().eventLoop().execute(this.getImmediateShutdownRunnable());
        } else {
          this.closeOnRegistration = true;
        }
      }
    }
  }

  private Runnable getImmediateShutdownRunnable() {
    final FeedbackServiceConnection feedbackConnection = this;

    return new Runnable() {
      @Override
      public void run() {
        final SslHandler sslHandler = feedbackConnection.connectFuture.channel().pipeline().get(SslHandler.class);

        if (feedbackConnection.connectFuture.isCancellable()) {
          feedbackConnection.connectFuture.cancel(true);
        } else if (sslHandler != null && sslHandler.handshakeFuture().isCancellable()) {
          sslHandler.handshakeFuture().cancel(true);
        } else {
          feedbackConnection.connectFuture.channel().close();
        }
      }
    };
  }

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

Related Classes of com.relayrides.pushy.apns.FeedbackServiceConnection

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.