Java tutorial
/* 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.buffer.PooledByteBufAllocator; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; 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.ByteToMessageDecoder; import io.netty.handler.codec.MessageToByteEncoder; import io.netty.handler.ssl.SslHandler; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GenericFutureListener; import java.nio.charset.Charset; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * <p>A connection to an APNs gateway. An {@code ApnsConnection} is responsible for sending push notifications to the * APNs gateway, and reports lifecycle events via its {@link ApnsConnectionListener}.</p> * * <p>Generally, connections should be managed by a parent {@link PushManager} and not manipulated directly (although * connections are fully functional on their own). Connections are created in a disconnected state, and must be * explicitly connected before they can be used to send push notifications.</p> * * @see PushManager * * @author <a href="mailto:jon@relayrides.com">Jon Chambers</a> */ public class ApnsConnection<T extends ApnsPushNotification> { private final ApnsEnvironment environment; private final SSLContext sslContext; private final NioEventLoopGroup eventLoopGroup; private final ApnsConnectionListener<T> listener; private static final AtomicInteger connectionCounter = new AtomicInteger(0); private final String name; private ChannelFuture connectFuture; private volatile boolean handshakeCompleted = false; private boolean closeOnRegistration; private int sequenceNumber = 0; private final Object pendingWriteMonitor = new Object(); private int pendingWriteCount = 0; private SendableApnsPushNotification<KnownBadPushNotification> shutdownNotification; private boolean rejectionReceived = false; private final SentNotificationBuffer<T> sentNotificationBuffer; private static final Logger log = LoggerFactory.getLogger(ApnsConnection.class); public static final int DEFAULT_SENT_NOTIFICATION_BUFFER_CAPACITY = 8192; private class RejectedNotificationDecoder extends ByteToMessageDecoder { // Per Apple's docs, APNS errors will have a one-byte "command", a one-byte status, and a 4-byte notification ID private static final int EXPECTED_BYTES = 6; private static final byte EXPECTED_COMMAND = 8; @Override protected void decode(final ChannelHandlerContext context, final ByteBuf in, final List<Object> out) { if (in.readableBytes() >= EXPECTED_BYTES) { final byte command = in.readByte(); final byte code = in.readByte(); final int notificationId = in.readInt(); if (command != EXPECTED_COMMAND) { log.error("Unexpected command: {}", command); } final RejectedNotificationReason errorCode = RejectedNotificationReason.getByErrorCode(code); out.add(new RejectedNotification(notificationId, errorCode)); } } } private class ApnsPushNotificationEncoder extends MessageToByteEncoder<SendableApnsPushNotification<T>> { private static final byte ENHANCED_PUSH_NOTIFICATION_COMMAND = 1; private static final int EXPIRE_IMMEDIATELY = 0; private final Charset utf8 = Charset.forName("UTF-8"); @Override protected void encode(final ChannelHandlerContext context, final SendableApnsPushNotification<T> sendablePushNotification, final ByteBuf out) throws Exception { out.writeByte(ENHANCED_PUSH_NOTIFICATION_COMMAND); out.writeInt(sendablePushNotification.getSequenceNumber()); if (sendablePushNotification.getPushNotification().getDeliveryInvalidationTime() != null) { out.writeInt(this.getTimestampInSeconds( sendablePushNotification.getPushNotification().getDeliveryInvalidationTime())); } else { out.writeInt(EXPIRE_IMMEDIATELY); } out.writeShort(sendablePushNotification.getPushNotification().getToken().length); out.writeBytes(sendablePushNotification.getPushNotification().getToken()); final byte[] payloadBytes = sendablePushNotification.getPushNotification().getPayload().getBytes(utf8); out.writeShort(payloadBytes.length); out.writeBytes(payloadBytes); } private int getTimestampInSeconds(final Date date) { return (int) (date.getTime() / 1000); } } private class ApnsConnectionHandler extends SimpleChannelInboundHandler<RejectedNotification> { private final ApnsConnection<T> apnsConnection; public ApnsConnectionHandler(final ApnsConnection<T> clientThread) { this.apnsConnection = clientThread; } @Override public void channelRegistered(final ChannelHandlerContext context) throws Exception { super.channelRegistered(context); synchronized (this.apnsConnection.connectFuture) { if (this.apnsConnection.closeOnRegistration) { log.debug("Channel registered for {}, but shutting down immediately.", this.apnsConnection.name); context.channel().eventLoop().execute(this.apnsConnection.getImmediateShutdownRunnable()); } } } @Override protected void channelRead0(final ChannelHandlerContext context, final RejectedNotification rejectedNotification) { log.debug("APNs gateway rejected notification with sequence number {} from {} ({}).", rejectedNotification.getSequenceNumber(), this.apnsConnection.name, rejectedNotification.getReason()); this.apnsConnection.rejectionReceived = true; this.apnsConnection.sentNotificationBuffer .clearNotificationsBeforeSequenceNumber(rejectedNotification.getSequenceNumber()); final boolean isKnownBadRejection = this.apnsConnection.shutdownNotification != null && rejectedNotification.getSequenceNumber() == this.apnsConnection.shutdownNotification .getSequenceNumber(); // We only want to notify listeners of an actual rejection if something actually went wrong. We don't want // to notify listeners if a known-bad notification was rejected because that's an expected case, and we // don't want to notify listeners if the gateway is shutting down the connection, but still processed the // named notification successfully. if (!isKnownBadRejection && !RejectedNotificationReason.SHUTDOWN.equals(rejectedNotification.getReason())) { final T notification = this.apnsConnection.sentNotificationBuffer .getNotificationWithSequenceNumber(rejectedNotification.getSequenceNumber()); if (notification != null) { this.apnsConnection.listener.handleRejectedNotification(this.apnsConnection, notification, rejectedNotification.getReason()); } else { log.error( "{} failed to find rejected notification with sequence number {} (buffer has range {} to " + "{}); this may mean the sent notification buffer is too small. Please report this as a bug.", this.apnsConnection.name, rejectedNotification.getSequenceNumber(), this.apnsConnection.sentNotificationBuffer.getLowestSequenceNumber(), this.apnsConnection.sentNotificationBuffer.getHighestSequenceNumber()); } } // Regardless of the cause, we ALWAYS want to notify listeners that some sent notifications were not // processed by the gateway (assuming there are some such notifications). final Collection<T> unprocessedNotifications = this.apnsConnection.sentNotificationBuffer .getAllNotificationsAfterSequenceNumber(rejectedNotification.getSequenceNumber()); if (!unprocessedNotifications.isEmpty()) { this.apnsConnection.listener.handleUnprocessedNotifications(this.apnsConnection, unprocessedNotifications); } this.apnsConnection.sentNotificationBuffer.clearAllNotifications(); } @Override public void exceptionCaught(final ChannelHandlerContext context, final Throwable cause) { // Since this is happening on the inbound side, the most likely case is that a read timed out or the remote // host closed the connection. We should log the problem, but generally assume that channel closure will be // handled by channelInactive. log.debug("{} caught an exception.", this.apnsConnection.name, cause); } @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.apnsConnection.handshakeCompleted) { this.apnsConnection.listener.handleConnectionClosure(this.apnsConnection); } } @Override public void channelWritabilityChanged(final ChannelHandlerContext context) throws Exception { super.channelWritabilityChanged(context); this.apnsConnection.listener.handleConnectionWritabilityChange(this.apnsConnection, context.channel().isWritable()); } } /** * Constructs a new APNs connection. The connection connects to the APNs gateway in the given environment with the * credentials and key/trust managers in the given SSL context. * * @param environment the environment in which this connection will operate * @param sslContext an SSL context with the keys/certificates and trust managers this connection should use when * communicating with the APNs gateway * @param eventLoopGroup the event loop group this connection should use for asynchronous network operations * @param sentNotificationBufferCapacity the capacity of this connection's sent notification buffer * @param listener the listener to which this connection will report lifecycle events; must not be {@code null} */ public ApnsConnection(final ApnsEnvironment environment, final SSLContext sslContext, final NioEventLoopGroup eventLoopGroup, final int sentNotificationBufferCapacity, final ApnsConnectionListener<T> listener) { if (listener == null) { throw new NullPointerException("Listener must not be null."); } this.environment = environment; this.sslContext = sslContext; this.eventLoopGroup = eventLoopGroup; this.listener = listener; this.sentNotificationBuffer = new SentNotificationBuffer<T>(sentNotificationBufferCapacity); this.name = String.format("ApnsConnection-%d", ApnsConnection.connectionCounter.getAndIncrement()); } /** * Asynchronously connects to the APNs gateway in this connection's environment. The outcome of the connection * attempt is reported via this connection's listener. * * @see ApnsConnectionListener#handleConnectionSuccess(ApnsConnection) * @see ApnsConnectionListener#handleConnectionFailure(ApnsConnection, Throwable) */ @SuppressWarnings("deprecation") public synchronized void connect() { final ApnsConnection<T> apnsConnection = this; 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); bootstrap.option(ChannelOption.SO_KEEPALIVE, true); bootstrap.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT); // TODO Remove this when Netty 5 is available bootstrap.option(ChannelOption.AUTO_CLOSE, false); bootstrap.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(final SocketChannel channel) { final ChannelPipeline pipeline = channel.pipeline(); final SSLEngine sslEngine = apnsConnection.sslContext.createSSLEngine(); sslEngine.setUseClientMode(true); pipeline.addLast("ssl", new SslHandler(sslEngine)); pipeline.addLast("decoder", new RejectedNotificationDecoder()); pipeline.addLast("encoder", new ApnsPushNotificationEncoder()); pipeline.addLast("handler", new ApnsConnectionHandler(apnsConnection)); } }); log.debug("{} beginning connection process.", apnsConnection.name); this.connectFuture = bootstrap.connect(this.environment.getApnsGatewayHost(), this.environment.getApnsGatewayPort()); this.connectFuture.addListener(new GenericFutureListener<ChannelFuture>() { public void operationComplete(final ChannelFuture connectFuture) { if (connectFuture.isSuccess()) { log.debug("{} connected; waiting for TLS handshake.", apnsConnection.name); final SslHandler sslHandler = connectFuture.channel().pipeline().get(SslHandler.class); try { sslHandler.handshakeFuture().addListener(new GenericFutureListener<Future<Channel>>() { public void operationComplete(final Future<Channel> handshakeFuture) { if (handshakeFuture.isSuccess()) { log.debug("{} successfully completed TLS handshake.", apnsConnection.name); apnsConnection.handshakeCompleted = true; apnsConnection.listener.handleConnectionSuccess(apnsConnection); } else { log.debug("{} failed to complete TLS handshake with APNs gateway.", apnsConnection.name, handshakeFuture.cause()); connectFuture.channel().close(); apnsConnection.listener.handleConnectionFailure(apnsConnection, handshakeFuture.cause()); } } }); } catch (NullPointerException e) { log.warn("{} failed to get SSL handler and could not wait for a TLS handshake.", apnsConnection.name); connectFuture.channel().close(); apnsConnection.listener.handleConnectionFailure(apnsConnection, e); } } else { log.debug("{} failed to connect to APNs gateway.", apnsConnection.name, connectFuture.cause()); apnsConnection.listener.handleConnectionFailure(apnsConnection, connectFuture.cause()); } } }); } /** * Asynchronously sends a push notification to the connected APNs gateway. Successful notifications are * <strong>not</strong> acknowledged by the APNs gateway; failed attempts to write push notifications to the * outbound buffer and notification rejections are reported via this connection's listener. * * @param notification the notification to send * * @see ApnsConnectionListener#handleWriteFailure(ApnsConnection, ApnsPushNotification, Throwable) * @see ApnsConnectionListener#handleRejectedNotification(ApnsConnection, ApnsPushNotification, RejectedNotificationReason) */ public synchronized void sendNotification(final T notification) { final ApnsConnection<T> apnsConnection = this; if (!this.handshakeCompleted) { throw new IllegalStateException(String.format("%s has not completed handshake.", this.name)); } this.connectFuture.channel().eventLoop().execute(new Runnable() { public void run() { final SendableApnsPushNotification<T> sendableNotification = new SendableApnsPushNotification<T>( notification, apnsConnection.sequenceNumber++); log.trace("{} sending {}", apnsConnection.name, sendableNotification); apnsConnection.pendingWriteCount += 1; apnsConnection.connectFuture.channel().writeAndFlush(sendableNotification) .addListener(new GenericFutureListener<ChannelFuture>() { public void operationComplete(final ChannelFuture writeFuture) { if (writeFuture.isSuccess()) { log.trace("{} successfully wrote notification {}", apnsConnection.name, sendableNotification.getSequenceNumber()); if (apnsConnection.rejectionReceived) { // Even though the write succeeded, we know for sure that this notification was never // processed by the gateway because it had already rejected another notification from // this connection. apnsConnection.listener.handleUnprocessedNotifications(apnsConnection, java.util.Collections.singletonList(notification)); } else { apnsConnection.sentNotificationBuffer .addSentNotification(sendableNotification); } } else { log.trace("{} failed to write notification {}", apnsConnection.name, sendableNotification, writeFuture.cause()); // Assume this is a temporary failure (we know it's not a permanent rejection because we didn't // even manage to write the notification to the wire) and re-enqueue for another send attempt. apnsConnection.listener.handleWriteFailure(apnsConnection, notification, writeFuture.cause()); } apnsConnection.pendingWriteCount -= 1; assert apnsConnection.pendingWriteCount >= 0; if (apnsConnection.pendingWriteCount == 0) { synchronized (apnsConnection.pendingWriteMonitor) { apnsConnection.pendingWriteMonitor.notifyAll(); } } } }); } }); } /** * <p>Waits for all pending write operations to finish. When this method exits normally (i.e. when it does * not throw an {@code InterruptedException}), All pending writes will have either finished successfully or failed * and passed to this connection's listener via the * {@link ApnsConnectionListener#handleWriteFailure(ApnsConnection, ApnsPushNotification, Throwable)} method.</p> * * <p>It is <em>not</em> guaranteed that all write operations will have finished by the time a connection has * closed. Applications that need to know when all writes have finished should call this method after a connection * closes, but must not do so in an IO thread (i.e. the thread that called the * {@link ApnsConnectionListener#handleConnectionClosure(ApnsConnection)} method.</p> * * @throws InterruptedException if interrupted while waiting for pending read/write operations to finish */ public void waitForPendingWritesToFinish() throws InterruptedException { synchronized (this.pendingWriteMonitor) { while (this.pendingWriteCount > 0) { this.pendingWriteMonitor.wait(); } } } /** * <p>Gracefully and asynchronously shuts down this connection. Graceful disconnection is triggered by sending a * known-bad notification to the APNs gateway; when the gateway rejects the notification, it is guaranteed that * preceding notifications were processed successfully and that all following notifications were not processed at * all. The gateway will close the connection after rejecting the notification, and this connection's listener will * be notified when the connection is closed.</p> * * <p>Note that if/when the known-bad notification is rejected by the APNs gateway, this connection's listener will * <em>not</em> be notified of the rejection.</p> * * <p>Calling this method before establishing a connection with the APNs gateway or while a graceful shutdown * attempt is already in progress has no effect.</p> * * @see ApnsConnectionListener#handleRejectedNotification(ApnsConnection, ApnsPushNotification, RejectedNotificationReason) * @see ApnsConnectionListener#handleConnectionClosure(ApnsConnection) */ public synchronized void shutdownGracefully() { final ApnsConnection<T> apnsConnection = this; // We only need to send a known-bad notification if we were ever connected in the first place and if we're // still connected. if (this.handshakeCompleted && this.connectFuture.channel().isActive()) { this.connectFuture.channel().eventLoop().execute(new Runnable() { public void run() { // Don't send a second shutdown notification if we've already started the graceful shutdown process. if (apnsConnection.shutdownNotification == null) { log.debug("{} sending known-bad notification to shut down.", apnsConnection.name); apnsConnection.shutdownNotification = new SendableApnsPushNotification<KnownBadPushNotification>( new KnownBadPushNotification(), apnsConnection.sequenceNumber++); apnsConnection.pendingWriteCount += 1; apnsConnection.connectFuture.channel().writeAndFlush(apnsConnection.shutdownNotification) .addListener(new GenericFutureListener<ChannelFuture>() { public void operationComplete(final ChannelFuture future) { if (future.isSuccess()) { log.trace("{} successfully wrote known-bad notification {}", apnsConnection.name, apnsConnection.shutdownNotification.getSequenceNumber()); } else { log.trace("{} failed to write known-bad notification {}", apnsConnection.name, apnsConnection.shutdownNotification, future.cause()); // Try again! apnsConnection.shutdownNotification = null; apnsConnection.shutdownGracefully(); } apnsConnection.pendingWriteCount -= 1; assert apnsConnection.pendingWriteCount >= 0; if (apnsConnection.pendingWriteCount == 0) { synchronized (apnsConnection.pendingWriteMonitor) { apnsConnection.pendingWriteMonitor.notifyAll(); } } } }); } } }); } else { // While we can't guarantee that the handshake won't complete in another thread, we CAN guarantee that no // new notifications will be sent until shutdownImmediately happens because everything is synchronized. this.shutdownImmediately(); } } /** * <p>Immediately closes this connection (assuming it was ever open). No guarantees are made with regard to the * state of sent notifications, and callers should generally prefer {@link ApnsConnection#shutdownGracefully} to * this method. If the connection was previously open, the connection's listener will be notified of the * connection's closure. If a connection attempt was in progress, the listener will be notified of a connection * failure. If the connection was never open, this method has no effect.</p> * * <p>Calling this method while not connected has no effect.</p> * * @see ApnsConnectionListener#handleConnectionClosure(ApnsConnection) */ public synchronized void shutdownImmediately() { if (this.connectFuture != null) { synchronized (this.connectFuture) { if (this.connectFuture.channel().isRegistered()) { this.connectFuture.channel().eventLoop().execute(this.getImmediateShutdownRunnable()); } else { this.closeOnRegistration = true; } } } } private Runnable getImmediateShutdownRunnable() { final ApnsConnection<T> apnsConnection = this; return new Runnable() { public void run() { final SslHandler sslHandler = apnsConnection.connectFuture.channel().pipeline() .get(SslHandler.class); if (apnsConnection.connectFuture.isCancellable()) { apnsConnection.connectFuture.cancel(true); } else if (sslHandler != null && sslHandler.handshakeFuture().isCancellable()) { sslHandler.handshakeFuture().cancel(true); } else { apnsConnection.connectFuture.channel().close(); } } }; } @Override public String toString() { return "ApnsConnection [name=" + name + "]"; } }