Chapters

Hide chapters

Push Notifications by Tutorials

Second Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: Push Notifications by Tutorials

Section 1: 14 chapters
Show chapters Hide chapters

6. Server-Side Pushes
Written by Scott Grosch

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

While you’ve successfully sent yourself a notification, doing this manually won’t be very useful. As customers run your app and register for receiving notifications, you’ll need to somehow store their device tokens so you can send them notifications at a later date.

Using third-party services

There are a slew of services online that will handle the server side for you. You can simply search Google for something along the lines of “Apple push notification companies” and you’ll find multiple examples. Some of the most popular one are:

Each company will vary in its pricing and API, so discussing any specific service is beyond the scope of this book. If you want to get running quickly or don’t want to deal with anything on the server side, then solutions like the above may be perfect for you.

You may find, however, that you prefer avoiding third-party services, as you can run into issues if the service changes how its API works or if the company goes out of business. These services will usually also charge a fee based on how many notifications you send.

As an iOS developer, you might already be paying for a web hosting service for your website, which gives you the tools you need to do this work yourself — and you can find multiple vendors that charge $10 or less per month. Most web hosting services provide SSH access and the ability to run a database. Since handling the server side only requires a single database table, a couple of REST endpoints and a few easy-to-write pieces of code, you may want to do this work yourself.

If you have no interest in running your own server, you can skip to Chapter 7, “Expanding the Application.”

Setting up Docker

If you’d like to follow along with this chapter but don’t have a server readily available to use, don’t fret! Utilizing Docker, you can run a local SQL server without modifying your system.

Setting up a SQL server

The first step is to set up a SQL server to store the device tokens that your users send to you. PostgreSQL is a great service that is readily available, but any SQL server you wish to use will do.

$ createuser -P -h yourServer -U postgres -W apns
$ createdb -O apns -h yourServer -U postgres -W apns
$ docker run --name postgres \
    -e POSTGRES_DB=apns \
    -e POSTGRES_USER=apns \
    -e POSTGRES_PASSWORD=password \
    -p 5432:5432 \
    -d postgres
CREATE TABLE tokens (
  id UUID PRIMARY KEY,
  token TEXT UNIQUE NOT NULL,
  debug BOOLEAN NOT NULL
);

Setting up Vapor

Now that you have somewhere to store your device tokens, you need something to handle your web connections. For this tutorial, you’ll use Vapor for this purpose. Vapor is a very well supported implementation of server side development using Swift. Without too much code you can use it to control your SQL database as well as your RESTful API. In order to use Vapor, though, there’s a little bit of setup that needs to happen.

$ brew install vapor/tap/vapor

Creating the model

Vapor projects include numerous Xcode schemes and, if you’re not using the proper one, the compile/run phases will fail with an overwhelming number of compiler errors! Always make sure that the active scheme is set to Run and My Mac.

import FluentPostgreSQL
import Vapor

final class Token: PostgreSQLUUIDModel {
  static let entity = "tokens"
  
  var id: UUID?
  let token: String
  let debug: Bool

  init(token: String, debug: Bool) {
    self.token = token
    self.debug = debug
  }
}

extension Token: Migration {
  static func prepare(on connection: PostgreSQLConnection) -> Future<Void> {
    return Database.create(self, on: connection) { builder in
      try addProperties(to: builder)
      builder.unique(on: \.token)
    }
  }
}

extension Token: Content {}
extension Token: Parameter {}

Creating the controller

Now that you’ve got a model, you’ll need to create the controller that will respond to your HTTP POST and DELETE requests. Edit the Sources/App/Controllers/TokenController.swift file to use the model. Navigate to the file and add the following code:

import FluentPostgreSQL
import Vapor

final class TokenController: RouteCollection {
  func boot(router: Router) throws {
    let routes = router.grouped("api", "token")
    routes.post(Token.self, use: storeToken)
    routes.delete(String.parameter, use: removeToken)
  }
}
func storeToken(_ req: Request, token: Token) throws -> Future<Token> {
  return token.save(on: req)
}
func removeToken(_ req: Request) throws -> Future<HTTPStatus> {
  let tokenStr = try req.parameters.next(String.self)

  return Token.query(on: req)
    .filter(\.token == tokenStr)
    .delete()
    .transform(to: .ok)
}

Updating the routes

In order to tell the app how to route requests to your new controller, you’ll need to make some changes to the Sources/App/routes.swift file.

//let tokenController = TokenController()
//try router.register(collection: tokenController)

Configuring the app

Because you’re running the server locally, you’ll have to take an extra step to tell Vapor that it should respond to more than just local connections. You only have to do this during development and it’s as simple as telling Vapor what IP address it should use when listening for network connections.

Copying your IP address

Click on the Apple icon in your Mac’s menubar and then choose the System Preferences… option.

severConfig.hostname = "192.168.1.1"

Running the migrations

There’s just one step left to make everything work. You have to tell Vapor that it should run the migrations for the Token class. While still in configure.swift, find this commented out line at the bottom of the file.

//migrations.add(model: Token.self, database: .psql)
[ INFO ] Migrating 'psql' database (FluentProvider.swift:28)  
[ INFO ] Preparing migration 'Token' (MigrationContainer.swift:50)  
[ INFO ] Migrations complete (FluentProvider.swift:32)  
Running default command: /Users/scott/Library/Developer/Xcode/DerivedData/dts-fdkxzqveujzmdycmruvnzrydjvwn/Build/Products/Debug/Run serve  
Server starting on http://192.168.1.1:8080

Testing your API

At this point, you can use any REST-capable app to test out your endpoints. A good choice is Rested, which is available as a free download from the Mac App Store at https://apple.co/2HP0lEH.

Sending pushes

As surprising as it is, Apple has not provided any way for a Swift app to natively send a push notification. Sending a push notification uses HTTP/2 now, but the standard Foundation classes don’t support it well. While you could use a URLSession method to send an HTTP/2 packet, there’s no way to tell the session to stay open. What this means is that every push you send creates a new connection to the APNs and Apple will therefore eventually consider you to be attempting a denial-of-service attack.

$ curl -V  
curl 7.48.0 (x86_64-pc-linux-gnu) libcurl/7.48.0 OpenSSL/1.0.2h zlib/1.2.7 libidn/1.28 libssh2/1.4.3 nghttp2/1.11.1    
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtsp scp sftp smb smbs smtp smtps telnet tftp   
Features: IDN IPv6 Largefile NTLM NTLM_WB SSL libz TLS-SRP **HTTP2** UnixSockets 
$ brew install curl-openssl
$ echo 'export PATH="/usr/local/opt/curl-openssl/bin:$PATH"' >> ~/.zshrc
<?php
  
const AUTH_KEY_PATH = '/full/path/to/AuthKey_keyid.p8';
const AUTH_KEY_ID = '<your auth key id here>';
const TEAM_ID = '<your team id here>';
const BUNDLE_ID = 'com.raywenderlich.APNS';

$payload = [
  'aps' => [
    'alert' => [
      'title' => 'This is the notification.',
    ],
    'sound'=> 'default',
  ],
];
$db = new PDO('pgsql:host=localhost;dbname=apns;user=apns;password=password');

function tokensToReceiveNotification($debug)
{
  $sql = 'SELECT DISTINCT token FROM tokens WHERE debug = :debug';
  $stmt = $GLOBALS['db']->prepare($sql);
  $stmt->execute(['debug' => $debug ? 't' : 'f']);

  return $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
}
function generateAuthenticationHeader()
{
  // 1
  $header = base64_encode(json_encode([
                 'alg' => 'ES256',
                 'kid' => AUTH_KEY_ID
            ]));

  // 2
  $claims = base64_encode(json_encode([
                 'iss' => TEAM_ID,
                 'iat' => time()
            ]));

  // 3
  $pkey = openssl_pkey_get_private('file://' . AUTH_KEY_PATH);
  openssl_sign("$header.$claims", $signature, $pkey, 'sha256');

  // 4
  $signed = base64_encode($signature);
  
  // 5
  return "$header.$claims.$signed";
}
function sendNotifications($debug) {
  $ch = curl_init();
  curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($GLOBALS['payload']));
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'apns-topic: ' . BUNDLE_ID,
      'authorization: bearer ' . generateAuthenticationHeader(),
      'apns-push-type: alert'
  ]);
}
$removeToken = $GLOBALS['db']->prepare('DELETE FROM apns WHERE token = ?');
$server = $debug ? 'api.development' : 'api';
$tokens = tokensToReceiveNotification($debug);
foreach ($tokens as $token) {
  // 1
  $url = "https://$server.push.apple.com/3/device/$token";
  curl_setopt($ch, CURLOPT_URL, "{$url}");

  // 2
  $response = curl_exec($ch);
  if ($response === false) {
    echo("curl_exec failed: " . curl_error($ch));
    continue;
  }

  // 3
  $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  if ($code === 400 || $code === 410) {
    $json = @json_decode($response);
    if ($json->reason === 'BadDeviceToken') {
      $removeToken->execute([$token]);
    }
  }
}

curl_close($ch);
sendNotifications(true); // Development (Sandbox)
sendNotifications(false); // Production
?>
$ php sendPushes.php
$ npm install apn --save
$ npm install pg --save
#!/usr/bin/env node
  
var apn = require('apn');
const { Client } = require('pg')

const options = {
  token: {
    key: '/full/path/to/AuthKey_keyid.p8',
    keyId: '',
    teamId: ''
  },
  production: false
}

const apnProvider = new apn.Provider(options);

var note = new apn.Notification();
note.expiry = Math.floor(Date.now() / 1000) + 3600; // 1 hour
note.badge = 3;
note.sound = "default";
note.alert = "Your alert here";
note.topic = "com.raywenderlich.PushNotifications";

const client = new Client({
  user: 'apns',
  host: 'localhost',
  database: 'apns',
  password: 'apns',
  port: 5433
})

client.connect()

client.query('SELECT DISTINCT token FROM tokens WHERE debug = true', (err, res) => {
  client.end()

  const tokens = res.rows.map(row => row.token)

  apnProvider.send(note, tokens).then( (response) => {
    // response.sent has successful pushes
    // response.failed has error details
  });
})

But they disabled push!

You’ll notice that you remove tokens from your database when a failure occurs. There’s nothing there to handle the case where your user disables push notifications, nor should there be. Your user can toggle the status of push notifications at any time, and nothing requires them to go into the app to do that, since it’s done from their device’s Settings. Even if push notifications are disabled, it’s still valid for Apple to send the push. The device simply ignores the push when it arrives.

Key points

  • You’ll need to have a SQL server available to store device tokens.
  • You’ll need an API available to your iOS app to store and delete tokens.
  • Do not use native Swift network commands to send push notifications until HTTP/2 becomes available, as it will appear to Apple as a denial of service attack due to repetitive opening and closing of connections.
  • There are many options available for building your push server. Choose the one(s) that work best for your skillset.

Where to go from here?

As stated, if you are interested in learning more about the Vapor framework, you can check out our great set of videos at https://bit.ly/2JTxX0B as well as our recent book, Server Side Swift with Vapor at https://bit.ly/2FI9wAR.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now