APNS. Pushing the limits of your notifications.

Push notifications (APNS) are one of those pesky subjects that make developers freak out. Today there are of course different online tools that can help your APNS setup, one of them - as an exmaple - being UrbanAirship. The downsides of using third party services are, as always, maintenance costs as your app scales in volume, changing APIs, being subject to someone else's TOCs and the lack of control of what's really going on under the hood.

What we're going to do here, is leveraging open-source libraries to show a full featured solution to:

  • build a Ruby on Rails APNS server from scratch;
  • deploy the service to Heroku;
  • integrate an iOS application and send our first push notification.

Intro

Anatomy of a push notification

To get a good overview of how APNS works, you should definetely take a look at the official documentation. A brief exmaple would go like that:

  • an user accepts to receive push notifications from an app;
  • the app register for push notifications to Apple servers;
  • the app gets a unique token from the servers
  • the app calls the web service that will let admin push notifications to registered devices
  • the server storess the device's token
  • the server starts a push notifications sending the payload to Apple servers together with the token
  • Apple servers send push notification back to the app

Requirements

The following tutorial assumes you are using the following stack: Xcode 4.6.3, iOS 6.1+, Ruby 2.0.0-p195, Rails 4 (which are the newest versions of said technologies at the moment of this writing). Good chances though are that everything will work with other versions of those tools / languages.

And now, let's get coding!

Setting up the server

Building the Rails app

So, let's start from the server side of things. Open the Terminal, navigate to the directory you want your application to reside in, and type rails new APNS.
Open your Gemfile and add the following line at the end of the file:

gem 'apns'  

Now let's scaffold a controller. From the Terminal run rails g controller home index. And in your config/routes.rblet's default your root route to the index action/view of the controller you just generated. Your routing file should look like this:

APNS::Application.routes.draw do  
  get "home/index"
  root 'home#index'
end  

Let's now create a model for the devices. We're just going to persist to the database the unique token of each device (the one basically that will be sent to Apple servers in order to handle notifications). From the Terminal run rails g model Device token. Let's also verify that tokens are indeed unique by enforcing a model validation inside your model file.

Your app/model/device.rb should now look like this:

# == Schema Information
#
# Table name: devices
#
#  id         :integer          not null, primary key
#  token      :string(255)
#  created_at :datetime
#  updated_at :datetime
#

class Device < ActiveRecord::Base  
  validates_uniqueness_of :token
end  

Migrate your database with rake db:migrate. We're now ready to implement a very simple API that we'll be using from our client to register the device's token.

Inside your routes.rb file define two new routes:

get '/register_device/:token', to: 'home#register_device_with_token'  
get '/send_notification/:token', to: 'home#send_notification'  

Modify your home_controller.rb accordingly.

class HomeController < ApplicationController  
  http_basic_authenticate_with name: "basic_auth_username", password: "easy_password" #, except: :index

  def index
    @devices = Device.all
  end

  def register_device_with_token
    Device.create(:token => params[:token])
    redirect_to home_index_path
  end

  def send_notification
    logger.info "Device token registrato: " + params[:token].to_s
    APNS.send_notification(params[:token].to_s, 'Hello from Rails app!' )
    redirect_to home_index_path, :notice => "Notification sent to device #{params[:token]}"
  end
end  

What are we doing here?

  • in the index action we are retrieving from the database an array of all the devices currently registered on the server app. Later on we are going to modify the corresponding view in order to visualize those devices. Of course this is just for testing purposes, you wouldn't want to do such a thing in your production app!
  • in the register_device_with_token method we're just creating a new device with the token passed in by the mobile device as a parameter in the URL string. To take a peak at what's coming, from the iOS device will be making up the URL like so:
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@%@%@", BASE_URL, @"/register_device/", deviceTokenAsString]];  
  • in the send_notification action we are leveraging the power of the APNS gem to send the notification to the device by passing in a token from the view;
  • with the initial http_basic_authenticate_with method, we are using some basic authentication to make indiscrete eyes stay away from our service.

And finally, here's our /app/views/home/index.html.erb view:

<h1>Listing devices</h1>

<table>  
  <tr>
    <th>Token</th>
    <th></th>
    <th></th>
    <th></th>
  </tr>

<% @devices.each do |device| %>  
  <tr>
    <td><%= device.token %></td>
    <td><%= link_to 'Send notification',  controller: "home", action: "send_notification", token: device.token%></td>
  </tr>
<% end %>  
</table>  

We iterate thru the devices and list them in a simple table. On the right of each device we define a link - Send notification - that will let us send the push notification to the selected device. The APNS gem supports sending a single notification to multiple devices at once by passing an array of tokens to Apple's servers, but we ain't going to cover this here since it's out of scope for this demo.

If you now run your server (rails s) and visit from your browser the following URL http://localhost:3000/register_device/any_token you should be able to register devices by passing in a string of characters (or numbers by the way). When you visit your root path http://localhost:3000 you should then see a list of them.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/rails_1.jpg

Now that we got our feet wet and our first API calls are working, let's get onto the boring stuff...

Yo, get those certificates!

Whenever you need to generate a digital certificate, you need to provide a Certificate Signing Request (CSR). When you create the CSR, a new private key is generated and inserted into your keychain. You then need to upload the CSR to a certificate authority (in this case to Apple's iOS Developer Portal), which will generate the certificate that you're then going to use to create the .pem file to use in your Rails backend. Seems pretty complex... and it is! Let's break down things a little and proceed step by step.

Open Keychain Access on your Mac (you can find it in /Applications/Utilities) and choose the menu option Request a Certificate from a Certificate Authority....

http://cocoahunter-blog.s3.amazonaws.com/apns-test/keychain_1.jpg

Fill in the email address and save the CSR to disk.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/keychain_2.jpg

Now inside Keychain, in the Keys section you should see that a new private key has appeared. Now go at developer.apple.com and sign into your developer account.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/adc_1.jpg

Once logged into the Dev Center, create a new AppID for the app that will be accepting push notifications. Enter the description, bundle ID (we're going to use re.touchwa.cocoahunterapnstest for our test app) and check the Push Notifications option in the App Services section at the bottom. Complete the registration of the new AppID by confirming and submitting the data.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/adc_2.jpg

Now we are all set up and ready to create a new certificate. Go into the Certificates section and click on the top right + button to create a new one.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/adc_3.jpg

Since development and production environments require different types of certificates, for our test purposes we are going to use the sandboxed environment, but for production apps you should of course choose the production ceertificate.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/adc_4.jpg

Next step is associating the certificate you're generating with the AppID you created in the previous step. You then should be asked to upload the CSR request you generated in Keychain.app (by default they are named CertificateSigningRequest.certSigningRequest). Choose the file and upload it, then click Generate. Once the certificated is ready, click on download. Double click on the .cer file to import it into the Keychain. Almost there!

http://cocoahunter-blog.s3.amazonaws.com/apns-test/adc_5.jpg

We now need to convert the certificate in what is called a PEM (Privacy Enhanced Mail) file (if you really want ot dive deeper into the subject, a good starting point would be this Wikipedia article). With the aps_development.cer in the keychain do the following:

http://cocoahunter-blog.s3.amazonaws.com/apns-test/keychain_4.jpg

  • Launch Keychain Access and from the login keychain, filter by the Certificates category. You will see an expandable option called Apple Development Push Services;
  • Right click on Apple Development Push Services > Export Apple Development Push Services ... Save the file again as APNSCocoaHunterTest.p12 somewhere you can access it. There is no need to enter a password.
  • Finally from the Termianal run the following:
openssl pkcs12 -in APNSCocoaHunterTest.p12 -out APNSCocoaHunter.pem -nodes -clcerts  

To test that the certificate you created is actually working, you should be able to run the following command in the Terminal:

openssl s_client -connect gateway.sandbox.push.apple.com:2195 -cert APNSCocoaHunter.pem  
Enter pass phrase for APNSCocoaHunter.pem:  

Leave the password empty and press Enter, you should see some text scrolling, then if you press Enter again at the prompt the server should disconnect. That's a good sign that things are working correctly!

If you're still with me, it's now time to wrap things up with the server side of things! Let's go configure the APNS gem.

Configuring the APNS gem

Back to the Rails app, copy the .pem file (APNSCocoaHunter.pem) into your lib folder (actually you can put it wherever you want). In your config/application.rb file paste the following code:

APNS.pem  = File.join(Rails.root, 'lib','APNSCocoaHunter.pem')  
# this is the file you just created

APNS.port = 2195  

As you see the APNS.pem snippet is referencing the file in the 'lib' folder. Should you place your file anywhere else, be sure to input the right path so that the gem will be able to find it. Let's now deploy our app to Heroku so that we can test it on real devices!

Deploying to Heroku

First of all, let's make sure that in our Gemfile we're actually using PostgreSQL as a DB in production instead of SQLite (which of course is handy for development purposes, but nothing more).

gem 'sqlite3', group: :development  
gem 'pg', group: :production  

I assume you already have an Heroku account and you have correctly setup your CLI. For further instructions and an introductory tutorial on this topic you can head to Heroku's website.

To actually deploy a Rails app to Heroku (or any other suported app for that matter), you need to first setup a git repo. We should have done this from the beginning, but we actually haven't written too much code and - by the way - this is just a tutorial. Anyway, in a production app it's always best to commit early and commit often. That said, let's init our repo. From the Terminal run (from inside your Rails app folder):

git init && git add . && git commit -m "Inital commit"  

Now you can create you Heorku app by running (from the same folder where you created your git repo):

heroku create desired-app-name  

...then:

git push heroku master  

Once the app is uploaded and the slug compiled, migrate your DB:

heroku run rake db:migrate  

Now your backend should be ready to go. Run heroku open and your default browser should be pointed to the newly created web app. Of course you will be prompted with a basic auth HTTP form (since you requested it in home_controller.rb). Fill in the credentials and, boom!, you're good to go. Now let's move on to the mobile side of things.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/basic_auth.png

Setting up the mobile client

Generate the provisioning profile

First thing first: generate the provisionig profile for your app. Head over to the iOS Dev Center and click on Provisioning Profile in the sidebar. Let's make a development profile for the time being. Click on the + button on the upper right of the screen:

http://cocoahunter-blog.s3.amazonaws.com/apns-test/provisioning_1.jpg

Choose the iOS App Development profile.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/provisioning_2.jpg

Select the app ID you previously created to generate the certificate:

http://cocoahunter-blog.s3.amazonaws.com/apns-test/provisioning_3.jpg

Select the certificate:

http://cocoahunter-blog.s3.amazonaws.com/apns-test/provisioning_4.jpg

Select the development devices you wish to install your app on:

http://cocoahunter-blog.s3.amazonaws.com/apns-test/provisioning_5.jpg

Name the profile and generate:

http://cocoahunter-blog.s3.amazonaws.com/apns-test/provisioning_6.jpg

Finally, download it and add the provisioning profile to Xcode by double-clicking it or dragging it onto the Xcode icon.

Create the project

Now open Xcode and create a new empty project. We are not going to make anything in the UI and we will dump all the code we need in the AppDelegate, so you can use whichever template you want to. Note that you should choose a Product Name and Company Identifier that correspond to the app ID that you earlier created in the Provisioning Portal.

The actual code

Download and import into your project the following two files, NSData+Base64.h and NSData+Base64.m, from this Github repo. We are going to use this NSData category for the 'stringification' of our token later on. If you created your project as ARC-enabled (as I expect you did), you need to add the flag -fno-objc-arc to that file. Adding the flag is just a matter of going in Targets > Build Phases > Compile Sources. You have to double click on the right column of the row under Compiler Flags.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/xcode_1.jpg

In your AppDelegate .m file, add at the top the following lines:

#import "NSData+Base64.h"
#define BASE_URL @"http://your-app-address.herokuapp.com/"

Of course in the BASE_URL #define you should substitute your Heroku URL.

Now, inside you -(BOOL)application:didFinishLaunchingWithOptions: method, add the following call to start the process for registering the device to our backend service:

[[UIApplication sharedApplication] registerForRemoteNotificationTypes:
 (UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)];

Everything else is happening in the following method (which is basically a callback that returns the device token as raw data bytes): -(void)application:didRegisterForRemoteNotificationsWithDeviceToken:.

So, here's the code:

#pragma mark - Push Notifications

- (void)application:(UIApplication*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken
{
  // Convert the binary data token into an NSString (see below for the implementation of this function)
    NSString *deviceTokenAsString = stringFromDeviceTokenData(deviceToken);

    // Show the device token obtained from apple to the log
    NSLog(@"deviceToken: %@", deviceTokenAsString);

    NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@%@%@", BASE_URL, @"/register_device/", deviceTokenAsString]];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    NSURLConnection *connection = [NSURLConnection connectionWithRequest:request delegate:self];
    [connection start];
}

// NSURLConnection Delegates
- (void)connection:(NSURLConnection *)connection didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
    if ([challenge previousFailureCount] == 0) {
        NSLog(@"received authentication challenge");
        NSURLCredential *newCredential = [NSURLCredential credentialWithUser:[self username]
                                                                    password:[self password]
                                                                 persistence:NSURLCredentialPersistenceForSession];
        NSLog(@"credential created");
        [[challenge sender] useCredential:newCredential forAuthenticationChallenge:challenge];
        NSLog(@"responded to authentication challenge");
    }
    else {
        NSLog(@"previous authentication failure");
    }
}

- (void)application:(UIApplication*)application didFailToRegisterForRemoteNotificationsWithError:(NSError*)error
{
  NSLog(@"Failed to get token, error: %@", error);
}

- (NSString *)username {
    return @"basic_auth_username";
}

- (NSString *)password {
    return @"easy_password";
}

NSString* stringFromDeviceTokenData(NSData *deviceToken)  
{
    const char *data = [deviceToken bytes];
    NSMutableString* token = [NSMutableString string];
    for (int i = 0; i < [deviceToken length]; i++) {
        [token appendFormat:@"%02.2hhX", data[i]];
    }

    return [token copy];
}

Let's take a look at the code by steps:

  • first we convert with a C function the token (NSData) we get back from the callback to an NSString that we are going to post to our Rails server;
  • then we build the URL and we instantiate a request, and we start the connection by registering the AppDelegate as the NSURLConnection's delegate;
  • As expected, we should get from our backend an authentication challenge. We're taking care of that by instantiating an NSURLCredential and responding to the challenge with the correct username and password.

Run your app on an actual device. The screen of course will be blank, but as soon as your app opens, it will try to communicate with the backend and register for push notifications. Now if you open your Rails app beckend, you should see the token listed in the index page!

http://cocoahunter-blog.s3.amazonaws.com/apns-test/list_token.jpg

Sending your first push notification!

Here comes the moment of truth! Press the 'Send notification' link to the right of the token. Boom! Your app should receive the push notification as expected.

http://cocoahunter-blog.s3.amazonaws.com/apns-test/notification.jpg

So this was a pretty long run, but we've covered quite some stuff. This is just a basic intro (though fully functional) of how to build an APNS server. The APNS gem of course will let you send notifications in batch to multiple devices at once. Just read the documentation. Congratulations, you've just built your own push notification service!

The whole code can be found on GitHub: