java-apns-gae

Java APNS library that works on Google App Engine

View the Project on GitHub ZsoltSafrany/java-apns-gae

Introduction

This is a Java Apple Push Notification Service library that works on Google App Engine. It was designed to work (and be used) on Google App Engine but it doesn't have any GAE specific dependencies; you may use it anywhere where Java runs.

new PushNotification()
  .setAlert("You got your emails.")
  .setBadge(9)
  .setSound("bingbong.aiff")
  .setDeviceTokens("777d2ac490a17bb1d4c8a6ec7c50d4b1b9a36499acd45bf5fcac103cde038eff");

The library works on Java 7 and above.

Features

Create a push notification

Create a simple push notification

new PushNotification()
  .setAlert("You got your emails.")
  .setBadge(9)
  .setSound("bingbong.aiff")
  .setDeviceTokens("777d2ac490a17bb1d4c8a6ec7c50d4b1b9a36499acd45bf5fcac103cde038eff");

Note, that if you don't set a Badge value then the badge won't change. To remove the badge, set the value to 0.

You can send the same push notification to more than one device. The overloaded methods you can use are:

.setDeviceTokens(String... deviceTokens)
.setDeviceTokens(Collection<String> deviceTokens)

Create with complex alert

ComplexAlert complexAlert = new ComplexAlert()
  .setBody("Bob wants to play poker")
  .setActionLocKey("PLAY")
  .setLocKey("GAME_PLAY_REQUEST_FORMAT")
  .setLocArgs("Jenna", "Frank")
  .setLaunchImage("AllIn.png");
new PushNotification().setComplexAlert(complexAlert);

For a description of these properties check out the Local and Push Notification Programming Guide.

Create with custom payload

new PushNotification()
  .setAlert("This is a notification with custom payload.")
  .setDeviceTokens("777d2ac490a17bb1d4c8a6ec7c50d4b1b9a36499acd45bf5fcac103cde038eff")
  .setCustomPayload("acme1", "bar");
  .setCustomPayload("acme2", 42);

You should not include customer information (or any sensitive data) as custom payload data.

Remember, that the maximum size allowed for a notification payload is 2048 bytes. PushNotificationService.send() will throw a PayloadException when you try to pass a PushNotification that results in a payload that exceeds this limit.

Send a push notification

To send a push notification you will need two more things: a PushNotificationService and an ApnsConnection. If you have both you just say:

PushNotification pn = ...; // push notification you created
PushNotificationService pns = ...; // obtain one
ApnsConnection connection = ...; // obtain one
pns.send(pn, connection);

Obtain a PushNotificationService

This is quite simple; use the provided default implementation:

PushNotificationService pns = new DefaultPushNotificationService();

Obtain an ApnsConnection

You can create ApnsConnection instances with an ApnsConnectionFactory. Use the provided default factory implementation DefaultApnsConnectionFactory:

DefaultApnsConnectionFactory.Builder builder = DefaultApnsConnectionFactory.Builder.get();
if (usingProductionApns) {
  KeyStoreProvider ksp = new ClassPathResourceKeyStoreProvider("apns/apns_certificates_production.p12", KeyStoreType.PKCS12, KEYSTORE_PASSWORD);
  builder.setProductionKeyStoreProvider(ksp);
} else {
  KeyStoreProvider ksp = new ClassPathResourceKeyStoreProvider("apns/apns_certificates_sandbox.p12", KeyStoreType.PKCS12, KEYSTORE_PASSWORD);
  builder.setSandboxKeyStoreProvider(ksp);
}
ApnsConnectionFactory acf = builder.build();

ApnsConnection connection = acf.openPushConnection();

Each call to openPushConnection() will open and create a new connection to APNS.

The example uses keystores that are located on the classpath. In a usual Maven project, these two example keystores are located at src/main/resources/apns/apns_certificates_{production|sandbox}.p12. If you want to load the keystores yourself then use class WrapperKeyStoreProvider instead:

KeyStore ks = ...; // keystore you loaded manually
KeyStoreProvider ksp = new WrapperKeyStoreProvider(ks, KEYSTORE_PASSWORD);
builder.setProductionKeyStoreProvider(ksp);

Read the Feedback Service

The Apple Push Notification Service includes a feedback service to give you information about failed push notifications.

To read the feedback service you will need two things: a FeedbackService and an ApnsConnection. If you have both you just say:

ApnsConnectionFactory acf = ...; // obtain one
ApnsConnection connection = acf.openFeedbackConnection();
FeedbackService fs = new DefaultFeedbackService();
List<FailedDeviceToken> failedTokens = fs.read(connection);

for (FailedDeviceToken failedToken : failedTokens) {
  failedToken.getFailTimestamp();
  failedToken.getDeviceToken();
}

Note that this time the ApnsConnection is obtained with openFeedbackConnection() instead of openPushConnection() (as the feedback service listens on a different address than the push service).

Use getFailTimestamp() to verify that the device token hasn’t been reregistered since the feedback entry was generated. For each device that has not been reregistered, stop sending notifications.

The feedback service’s list is cleared after you read it. Each time you call FeedbackService.read(), the information it returns lists only the failures that have happened since the last read().

Test your code

You can test your code that uses this library quite easily as ApnsConnection, ApnsConnectionFactory, PushNotificationService and FeedbackService are all interfaces. You may create whatever mock objects you need for testing. However, the library includes some convenient mock implementations:

MockApnsConnectionFactory

It returns mock connections. Use this to create ApnsConnection instances for the following two classes.

MockPushNotificationService

MockPushNotificationService mpns = new MockPushNotificationService();

// code under test
PushNotificationService pns = mpns;
pns.send(pushNotification, connection);

// assertions
String deviceToken = "777d2ac490a17bb1d4c8a6ec7c50d4b1b9a36499acd45bf5fcac103cde038eff";
assertTrue(mpns.pushWasSentTo(deviceToken));
PushNotification pn = mpns.getLastPushSentToDevice(deviceToken); 

This way you can assert that a push notification was sent; you can make sure that your code under test did send a push notification to a specific device token.

With getLastPushSentToDevice() you can also obtain the push notification itself that was "sent" (passed to the mock implementation).

MockFeedbackService

MockFeedbackService mfs = new MockFeedbackService();
mfs.add("777d2ac490a17bb1d4c8a6ec7c50d4b1b9a36499acd45bf5fcac103cde038eff");

FeedbackService fs = mfs;
List<FailedDeviceToken> failedTokens = rs.read(connection);

This way you can simulate the feedback service giving back a list of failed tokens; you can make sure, for instance, that your code under test does delete device tokens (returned by the feedback service) from your database.

Like in case of the real feedback service, the list of failed tokens are cleared after you call read().

Make it work on App Engine

So far everything was independent from App Engine. In this section you'll see how to use the library efficiently on GAE.

Sockets; the 2 minutes problem

Sockets may be reclaimed after 2 minutes of inactivity on App Engine. See Limitations and restrictions.

And the problem is you can't keep a socket connected to APNS artifically open; without sending actual push notifications. The only way to keep it open is to send some arbitrary data/bytes but that would result in an immediate closure of the socket; APNS closes the connection as soon as it detects something that does not conform to the protocol, i.e. something that is not an actual push notification.

SO_KEEPALIVE

What about SO_KEEPALIVE? App Engine explicitly says it is supported. I think it just means it won't throw an exception when you call Socket.setKeepAlive(true); calls wanted to set socket options raised Not Implemented exceptions before. Even if you enable keep-alive your socket will be reclaimed (closed) if you don't send something for more than 2 minutes; at least on App Engine as of 08.22.2014.

Actually, it's not a big surprise. RFC1122 that specifies TCP Keep Alive explicitly states that TCP Keep Alives are not to be sent more than once every two hours, and then, it is only necessary if there was no other traffic. Although, it also says that this interval must be also configurable, there is no API on java.net.Socket you could use to configure that (most probably because it's highly OS dependent) and I doubt it would be set to 2 minutes on App Engine.

SO_TIMEOUT

What about SO_TIMEOUT? It is for something completely else. The javadoc of Socket.setSoTimeout() states:

Enable/disable SO_TIMEOUT with the specified timeout, in milliseconds. With this 
option set to a non-zero timeout, a read() call on the InputStream associated 
with this Socket will block for only this amount of time. If the timeout expires, 
a java.net.SocketTimeoutException is raised, though the Socket is still valid. 
The option must be enabled prior to entering the blocking operation to have effect. 
The timeout must be > 0. A timeout of zero is interpreted as an infinite timeout.

That is, when read() is blocking for too long because there's nothing to read you can say "ok, I don't want to wait (block) anymore; let's do something else instead". It's not going to help with our "2 minutes" problem.

What then?

The only way you can work around this problem is this: detect when a connection is reclaimed/closed then throw it away and open a new connection. And this library supports exactly that.

// do this only once
ApnsConnectionFactory acf = ...; // obtain one
PushNotificationService pns = ...; // obtain one

// do this every time you want to send a push notification
PushNotification pn = ...; // create push notification
ApnsConnection connection = ...; // already opened and cached connection
try {
  pns.send(pn, connection);
} catch (CannotUseConnectionException e) {
  // throw away connection (i.e. just simply lose all your references to the object) 
  // and open new connection
  connection = acf.openPushConnection();
  pns.send(pn, connection); // it should work now; we are using a just-opened connection
  cacheConnection(connection); // hoping you can successfuly reuse it the next time
}

It is possible that the socket is not reclaimed but you still receive a CannotUseConnectionException. This can happen because of any transient issue (e.g. networking, App Engine, APNS) and you should throw away the connection just the very same way; should happen very rarely, though.

More detailed App Engine example

Now it's clear that reusing, throwing away and opening new connections is the way to go. Let me show you a more detailed example of how the library could be used on App Engine. I will assume that you are familiar with App Engine itself.

Let's give an overview of the example: we'll a have separate (basic-scaling) App Engine module (let's call it apns) that will be doing only one thing, sending push notifications to APNS. We will never send push notficiations in the current, user-initiated request; we will have a push queue for enqueuing tasks responsible for sending push notifications. Tasks will be executed in the apns module; tasks can be enqueued from anywhere. Our apns module will have some (let's say 5) concurrent socket connections (well, ApnsConnection instances) that the module will keep in a pool for reuse.

So, let's see how to do that.

Separate apns module

Let's create a separate module. Its appengine-web.xml file looks like this:

<?xml version="1.0" encoding="utf-8"?>
<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
    <application>your-app-name</application>
    <module>apns</module>
    <version>your-version</version>
    <threadsafe>true</threadsafe>
    <instance-class>B1</instance-class>
    <basic-scaling>
        <max-instances>1</max-instances>
        <idle-timeout>10m</idle-timeout>
    </basic-scaling>

    <system-properties>
        <property name="java.util.logging.config.file" value="WEB-INF/logging.properties"/>
    </system-properties>
</appengine-web-app>

It says it's a basic-scaling module with the cheapest instance. We use basic-scaling because this way (unlike automatic-scaling) we can limit the number of instances and thus the number of concurrent APNS connections, plus (unlike manual-scaling) we don't need to bother about instance startup/shutdown as idle instances will be shut down (saving us money).

In our example, we set the limit of maximum instances to 1 and that one instance will be shut down if it's idle for more than 10 minutes.

Push queue

Let's define the queue responsible for sending push notifications. Let's add the queue definition to queue.xml:

<queue-entries>

  [...]

  <queue>
    <name>apns</name>
    <rate>20/s</rate>
    <bucket-size>10</bucket-size>
    <max-concurrent-requests>5</max-concurrent-requests>
    <retry-parameters>
        <task-age-limit>3h</task-age-limit>
        <min-backoff-seconds>10</min-backoff-seconds>
        <max-backoff-seconds>600</max-backoff-seconds>
        <max-doublings>5</max-doublings>
    </retry-parameters>
    <target>apns</target>
  </queue>

  [...]

</queue-entries>

Always place the queue.xml file in the default module to save yourself from troubles; even though the target is not the default module.

A few things to note here:

  1. We named our push queue apns (same name as our module but it can be anything).
  2. We set max-concurrent-requests to 5; it makes sense to set this to a value same as the capacity of our connection pool (see below).
  3. We set target to apns; tasks will be executed in our apns module.

Task definition

This is the most interesting part. This is how a task that we put into the queue is defined:

public class SendPushNotificationToApnsTask implements DeferredTask {

  private static final long serialVersionUID = 1L;

  private static volatile ApnsConnectionFactory sApnsConnectionFactory;
  private static volatile ApnsConnectionPool sApnsConnectionPool;
  private static volatile PushNotificationService sPushNotificationService;
  private static final int APNS_CONNECTION_POOL_CAPACITY = 5;

  private final PushNotification mPushNotification;

  public SendPushNotificationToApnsTask(PushNotification pushNotification) {
    mPushNotification = pushNotification;
  }

  @Override
  public void run() {
    try {
      trySendingPushNotification();
    } catch (CannotOpenConnectionException e) {
      throw new RuntimeException("Could not connect to APNS", e);
    } catch (CannotUseConnectionException e) {
       throw new RuntimeException("Could not send: " + mPushNotification, e);
    } catch (PayloadException e) {
        getLogger().error("Could not send push notification (dropping task)", e);
    }
  }

  private void trySendingPushNotification() throws CannotOpenConnectionException, CannotUseConnectionException, PayloadException {
    ApnsConnection apnsConnection = getApnsConnectionPool().obtain();
    if (apnsConnection == null) {
      apnsConnection = openConnection();
    }

    try {
      getLogger().debug("Sending push notification: {}", mPushNotification);
      getPushNotificationService().send(mPushNotification, apnsConnection);
      getApnsConnectionPool().put(apnsConnection);
    } catch (CannotUseConnectionException e) {
      getLogger().debug("Could not send push notification - opening new connection");
      apnsConnection = openConnection();
      getLogger().debug("Retrying sending push notification");
      getPushNotificationService().send(mPushNotification, apnsConnection);
      getApnsConnectionPool().put(apnsConnection);
    }
  }

  private static ApnsConnectionPool getApnsConnectionPool() {
    if (sApnsConnectionPool == null) {
      synchronized (SendPushNotificationToApnsTask.class) {
        if (sApnsConnectionPool == null) {
          sApnsConnectionPool = new ApnsConnectionPool(APNS_CONNECTION_POOL_CAPACITY);
        }        
      }
    }  
    return sApnsConnectionPool;
  }

  private static PushNotificationService getPushNotificationService() {
    if (sPushNotificationService == null) {
      synchronized (SendPushNotificationToApnsTask.class) {
        if (sPushNotificationService == null) {
          sPushNotificationService = new DefaultPushNotificationService();
        }        
      }
    }  
    return sPushNotificationService;
  }

  private static ApnsConnection openConnection() throws CannotOpenConnectionException {
    getLogger().debug("Connecting to APNS");
    return getApnsConnectionFactory().openPushConnection();
  }  

  private static ApnsConnectionFactory getApnsConnectionFactory() {
    if (sApnsConnectionFactory == null) {
      synchronized (SendPushNotificationToApnsTask.class) {
        if (sApnsConnectionFactory == null) {
          DefaultApnsConnectionFactory.Builder builder = DefaultApnsConnectionFactory.Builder.get();
          KeyStoreProvider ksp = new ClassPathResourceKeyStoreProvider(PATH_TO_KEYSTORE, KeyStoreType.PKCS12, KEYSTORE_PWD);
          builder.setSandboxKeyStoreProvider(ksp);        
          try {
            sApnsConnectionFactory = builder.build();
          } catch (ApnsException e) {
            throw new ApnsRuntimeException("Could not create APNS connection factory", e);
          }
      }
    }  
    return sApnsConnectionFactory;
  }

  private org.slf4j.Logger getLogger() {
    [...]
  }
}


This task is executed in the apns module.

Our connection pool has a capacity same as max-concurrent-requests in our queue.xml.

Class PushNotification implements Serializable so you can simply pass a push notification instance to the task's constructor and save it in an instance field.

Very important, that you set a serialVersionUID because if you don't it gets autogenerated for you and you might end up having a different version uid even though you didn't make any incomaptible changes to the class. If serialVersionUID changes then you won't be able to process old tasks still remaining in the queue with old uid (because deserialization will fail).

Note, that every time a runtime exception escapes the run() method our task is considered as failed and will be retried by App Engine later (based on retry-parameters in queue.xml).

You'll get a PayloadException if the payload is larger than 2048 bytes. Note that we don't throw a runtime exception in this case to avoid the hot-potato anti-pattern (payload will be still too large the next time we retry).

We create static members lazily because we don't want them to be created when we're just enqueuing a task.

Enqueuing tasks

PushNotification pn = ...; // create one
DeferredTask task = new SendPushNotificationToApnsTask(pn);
QueueFactory.getQueue("apns").add(TaskOptions.Builder.withPayload(task));

You can enqueue the task from any module.

Epilogue

In our example we assumed that each push notification is sent to one specific user with at most a couple of devices. If you want to send a single push notification to thousands of devices you could quickly run into problems. In this case you may want to check out a more sophisticated example with pull queues and query cursors from Google.

In case you find something that is not working as expected please report it in the issue tracker.

We use this library in our own projects thus you can expect that it will remain maintained.

Download

↓ Latest JAR

The source code of the library is available on GitHub.

Maven

<dependency>
  <groupId>com.zsoltsafrany</groupId>
  <artifactId>java-apns-gae</artifactId>
  <version>1.2.0</version>
</dependency>

Gradle

compile 'com.zsoltsafrany:java-apns-gae:1.2.0'

License

The MIT License (MIT)

Copyright (c) 2014 Zsolt Safrany

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.