Java APNS library that works on Google App Engine
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.
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)
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.
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.
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);
This is quite simple; use the provided default implementation:
PushNotificationService pns = new DefaultPushNotificationService();
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);
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()
.
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:
It returns mock connections. Use this to create ApnsConnection
instances for the following two classes.
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 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()
.
So far everything was independent from App Engine. In this section you'll see how to use the library efficiently on GAE.
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.
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.
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.
apns
moduleLet'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.
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:
apns
(same name as our module but it can be anything).max-concurrent-requests
to 5
; it makes sense to set this to a value same as the capacity of our connection pool (see below).target
to apns
; tasks will be executed in our apns
module.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.
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.
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.
The source code of the library is available on GitHub.
<dependency>
<groupId>com.zsoltsafrany</groupId>
<artifactId>java-apns-gae</artifactId>
<version>1.2.0</version>
</dependency>
compile 'com.zsoltsafrany:java-apns-gae:1.2.0'
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.