Publish notifications from a news CMS to an iOS app with Pusher Beams
You will need the following to work through this tutorial: A Mac with Xcode, Laravel CLI, SQLite and CocoaPods installed Knowledge of Xcode, Swift and Laravel A Pusher account
A recent piece about the New York Times tech team “How to Push a Story” chronicled the lengths they go to make sure that the push notifications they send are relevant, timely, and interesting.
The publishing platform at the NYT lets editors put notifications through an approval process, measures the tolerance for the frequency of notifications, and tracks whether users unsubscribe from them.
In this article, we will build a simple news publishing CMS and iOS mobile application that has the ability to send the latest news as a push notification. We will also show how you can use interests to segment users who receive push notifications based on specific news categories like “Business” or “Sports”.
When we are done, we will have our application function like this:
Prerequisites
- A Mac with Xcode installed. Download Xcode here.
- Knowledge of using Xcode.
- Knowledge of Swift.
- A Pusher account. Create one here.
- Knowledge of Laravel/PHP.
- Laravel CLI installed on your machine.
- SQLite installed on your machine. Here’s an installation guide.
- CocoaPods installed on your machine.
Once you have the requirements, let’s start.
Creating our Pusher application
⚠️ You need to be enrolled to the Apple Developer program to be able to use the push notifications feature. Also push notifications do not work on simulators so you will need an actual iOS device to test.
Pusher Beams has first-class support for native iOS applications. Your iOS app instances subscribe to Interests; then your servers send push notifications to those interests. Every app instance subscribed to that interest will receive the notification, even if the app is not open on the device at the time.
This section describes how you can set up an iOS app to receive transactional push notifications about news updates through Pusher.
Configure APNs
Pusher relies on the Apple Push Notification service (APNs) to deliver push notifications to iOS application users on your behalf. When we deliver push notifications, we use your key that has APNs service enabled. This page guides you through the process of getting the key and how to provide it to Pusher.
Head over to the Apple Developer dashboard by clicking here and then create a new key as seen below:
When you have created the key, download it. Keep it safe as we will need it in the next section.
⚠️ You have to keep the generated key safe as you cannot get it back if you lose it.
Creating your Pusher application
The next thing you need to do is create a new Pusher Beams application from the Pusher dashboard.
When you have created the application, you should be presented with a quickstart that will help you set up the application.
In order to configure your Beams instance you will need to get the key with APNs service enabled from Apple. This is the same key as the one we downloaded in the previous section. Once you’ve got the key, upload it.
Enter your Apple Team ID. You can get the Team ID from here. You can then continue with the setup wizard and copy the instance ID and secret key for your Pusher application.
Building our backend using Laravel
The first thing we need to do, is build the backend application. Start a new Laravel project by running the command below in your terminal:
$ laravel new project-name
When the process is complete, open your project in a code editor of your choice. Next, let’s start building out our project.
Open the .env
file and in the file add the following to the bottom:
PUSHER_PN_INSTANCE_ID=ENTER_INSTANCE_ID
PUSHER_PN_SECRET_KEY=ENTER_SECRET_KEY
in the same file, replace the following lines:
DB_CONNECTION=mysql
DB_DATABASE=homestead
DB_USERNAME=username
DB_PASSWORD=password
With
DB_CONNECTION=sqlite
DB_DATABASE=/full/path/to/database.sqlite
Then create a new empty file database.sqlite
in the database
directory.
Next, let’s install the Pusher Beams SDK for PHP to our application. Run the command below to install using composer:
$ composer install pusher/pusher-push-notifications
When installation is completed, let’s create our model and migration files. Run the command below to generate our model and migrations for our stories
and story_categories
table:
$ php artisan make:model StoryCategory -m
$ php artisan make:model Story -m
💡 Adding the
-m
flag will make artisan generate a corresponding migration file for the Model. Also, the order the command is run is important because thestories
table will have a foreign key relationship with thestory_categories
table so the latter needs to exist first.
Open the app/StoryCategory.php
model and add the property below to the class:
protected $fillable = ['title', 'interest']
Next, open the corresponding migration file in database/migrations
directory and replace the up
method with the following:
public function up()
{
Schema::create('story_categories', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->string('interest');
$table->timestamps();
});
}
Open the app/Story.php
model and replace the contents with the following:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Pusher\PushNotifications\PushNotifications;
class Story extends Model
{
protected $with = ['category'];
protected $fillable = [
'category_id', 'title', 'notification', 'content', 'featured_image'
];
public function push(): array
{
if (!$this->exists or $this->notification == null) {
return [];
}
$pushNotifications = new PushNotifications([
'instanceId' => env('PUSHER_PN_INSTANCE_ID'),
'secretKey' => env('PUSHER_PN_SECRET_KEY'),
]);
$publishResponse = (array) $pushNotifications->publish(
[$this->category->interest],
[
'apns' => [
'aps' => [
'alert' => [
'title' => "📖 {$this->title}",
'body' => (string) $this->notification,
],
'mutable-content' => 0,
'category' => 'pusher',
'sound' => 'default'
],
'data' => array_only($this->toArray(), [
'title', 'content'
]),
],
]
);
return $publishResponse;
}
public function category()
{
return $this->belongsTo(StoryCategory::class);
}
}
In the model above, we have a push
method. This method is a shortcut that helps us send push notifications on the loaded story model. This way we can do something similar to:
App\Story::find(1)->push()
Open the corresponding migration file for the Story model in database/migrations
and add replace the up
method with the following code:
public function up()
{
Schema::create('stories', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('category_id');
$table->foreign('category_id')->references('id')->on('story_categories');
$table->string('title');
$table->string('notification')->nullable();
$table->text('content');
$table->timestamps();
});
}
Now run the command below to process the migrations:
$ php artisan migrate
If you have setup the database properly, you should see a ‘migration successful’ response from the terminal.
Next, let’s create the routes our application will need. Open the routes/web.php
file and replace the contents with the following code:
<?php
use App\Story;
use Illuminate\Http\Request;
Route::view('/stories/create', 'story');
Route::post('/stories/create', function (Request $request) {
$data = $request->validate([
'title' => 'required|string',
'content' => 'required|string',
'notification' => 'nullable|string',
'category_id' => 'required|exists:story_categories,id',
]);
$story = Story::create($data);
$story->push();
return back()->withMessage('Post Added Successfully.');
});
Route::get('/stories/{id}', function (int $id) {
return Story::findOrFail($id);
});
Route::get('/stories', function () {
return Story::orderBy('id', 'desc')->take(20)->get();
});
Above, we have four routes:
GET /stories/create
which just loads a viewstory.blade.php
. This view will be used to display a form where we can enter new content.POST /stories/create
which processes the form data from above, adds the content and sends a push notification if appropriate.GET /stories/{id}
which loads a single story.GET /stories
which loads the 20 of the most recent stories.
Next, let’s create the view file for the first route as that is the only missing piece. Create a story.blade.php
file in the resources/views
directory and paste the following HTML code:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Create new post</title>
<meta charset="utf-8">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
.invalid-feedback { width: 100%; margin-top: .25rem; font-size: 80%; color: #dc3545; }
</style>
</head>
<body style="margin-top: 120px;">
<nav class="navbar navbar-inverse bg-inverse fixed-top">
<a class="navbar-brand" href="#" style="font-weight: bold">TECHTIMES</a>
</nav>
<div class="container">
@if (Session::has('message'))
<div class="alert alert-success" role="alert">{{ session('message') }}</div>
@endif
<div class="starter-template">
<form action="/stories/create" method="POST">
{{ csrf_field() }}
<div class="form-group">
<label for="post-title">Post Title</label>
<input name="title" type="text" class="form-control" id="post-title" placeholder="Enter Post Title">
@if ($errors->has('title'))
<div class="invalid-feedback">{{ $errors->first('title') }}</div>
@endif
</div>
<div class="form-group">
<label for="post-category">Category</label>
<select name="category_id" id="post-category" class="form-control">
<option value="">Select A Category</option>
@foreach (App\StoryCategory::all() as $category)
<option value="{{ $category->id}}">{{ $category->title }}</option>
@endforeach
</select>
@if ($errors->has('category_id'))
<div class="invalid-feedback">{{ $errors->first('category_id') }}</div>
@endif
</div>
<div class="form-group">
<label for="post-content">Post Content</label>
<textarea name="content" id="post-content" cols="30" rows="10" placeholder="Enter your post content" class="form-control"></textarea>
@if ($errors->has('content'))
<div class="invalid-feedback">{{ $errors->first('content') }}</div>
@endif
</div>
<div class="form-group">
<label for="post-notification">Push Notification</label>
<input name="notification" type="text" class="form-control" id="post-notification" placeholder="Enter Push Notification Message">
@if ($errors->has('notiifcation'))
<div class="invalid-feedback">{{ $errors->first('notiifcation') }}</div>
@else
<small class="form-text text-muted">Leave this blank if you do not want to publish a push notification for this post.</small>
@endif
</div>
<button type="submit" class="btn btn-primary">Save Post</button>
</form>
</div>
</div>
</body>
</html>
The above is just a basic bootstrap powered view that allows the user to create content as seen in the screen recording at the beginning of the article.
That’s it. The backend application is complete. To start serving the application, run the following command:
$ php artisan serve
This will start a PHP server running on port 8000. You can access it by going to http://127.0.0.1:8000/stories.
Building our iOS application using Swift
Now that we have a backend server that can serve us all the information we want and also send push notifications, let us create our iOS application, which will be the client application.
Launch Xcode and create a new ‘Single Page Application’ project. We will be calling ours TechTimes. When the project is created, exit Xcode and cd
to the root of the project using a terminal. In the root of the project create a Podfile
and paste the following into the file:
platform :ios, '11.0'
target 'techtimes' do
use_frameworks!
pod 'Alamofire', '~> 4.6.0'
pod 'PushNotifications', '~> 0.10.7'
end
Then run the command below to start installing the dependencies we defined above:
$ pod install
When the installation is complete, we will have a new .xcworkspace
file in the root of the project. Double-click the workspace file to relaunch Xcode.
Next let’s create our storyboard. Open your Main.storyboard
file. We want to design it to look similar to this:
The initial view controller is a UINavigationController
, which is connected to a UITableViewController
. The Table View Controller has custom cells that have the class StoryTableViewCell
, which we will use to display the data in our cell. The cells have a reuse identifier: Story
.
The second scene has a custom class of StoriesTableViewController
. We have designed the scene to have a button on the top right which is connected to the Controller via an @IBAction
. When the button is clicked we want to display the bottom View Controller.
The third scene has a custom class of StoryViewController
and it has a UIScrollView
where we have an image, post title and post content. We have created an @IBOutlet
for both the title and the content text to the custom class Controller so we can override the contents.
We give each controller a unique storyboard identifier which is the custom class name so we can navigate to them using their storyboard ID.
When you are done creating the storyboard, let’s create the custom classes for each story board scene.
Create a new class StoriesTableViewController.swift
and paste the following code into it:
import UIKit
import Alamofire
class StoriesTableViewController: UITableViewController {
var stories: [Stories.Story] = []
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.rowHeight = UITableViewAutomaticDimension
self.tableView.estimatedRowHeight = 140
self.fetchStories { response in
guard let response = response else { return }
self.stories = response.stories
self.tableView.reloadData()
}
}
private func fetchStories(completion: @escaping(Stories.Response?) -> Void) {
let request = Stories.Request()
Alamofire.request(request.URL).validate().responseJSON { response in
guard response.result.isSuccess,
let stories = response.result.value as? [[String:AnyObject]] else {
self.noStoriesAlert()
return completion(nil)
}
completion(Stories.Response(stories: stories))
}
}
private func noStoriesAlert() {
let alert = UIAlertController(
title: "Error Fetching News",
message: "An error occurred while fetching the latest news.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Okay", style: .default, handler: nil))
self.present(alert, animated: true, completion: nil)
}
}
In viewDidLoad
above, we call the fetchStories
method, which fetches the stories from the remote application. When the fetch is complete, we then set the stories
property and reload the table view.
In the same file let’s create an extension to the controller. In this extension, we will override our Table View Controller delegate methods:
extension StoriesTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return stories.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Story", for: indexPath) as! StoryTableViewCell
let story = stories[indexPath.row]
let randomNum = arc4random_uniform(6) + 1
cell.imageView?.image = UIImage(named: "image-\(randomNum)")
cell.storyTitle?.text = story.title
cell.storyContent?.text = story.content
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyboard.instantiateViewController(withIdentifier: "StoryViewController") as! StoryViewController
vc.story = stories[indexPath.row]
self.navigationController?.pushViewController(vc, animated: true)
}
}
In the tableView(_:cellForRowAt:)
method, we define the title and content for our cell. For our image, we are using random images we stored manually in our Asset.xcassets
collection. In a real application you would probably want to load the UIImage
from the URL returned by the API.
In the tableView(_:didSelectRowAt:)
method, we instantiate the *StoryViewController*
, set the story
property of the controller and then navigate to the controller.
Let’s define the code to the StoryTableViewCell
. Create a new file called StoryTableViewCell.swift
and paste in the following:
import UIKit
class StoryTableViewCell: UITableViewCell {
@IBOutlet weak var storyTitle: UILabel!
@IBOutlet weak var storyContent: UILabel!
@IBOutlet weak var featuredImageView: UIImageView!
override func awakeFromNib() {
super.awakeFromNib()
}
}
💡 Make sure this is set as the custom class for our
StoryTableViewController
’s cells.
Next, let’s create the Stories
struct we references in the stories
property of out StoryTableViewController
class. Create a Stories.swift
file and paste in the following:
import UIKit
struct Stories {
struct Request {
let URL = "http://127.0.0.1:8000/stories"
}
struct Response {
var stories: [Story]
init(stories: [[String:AnyObject]]) {
self.stories = []
for story in stories {
self.stories.append(Story(story: story))
}
}
}
struct Story {
let title: String
let content: String
let featuredImage: UIImage?
init(story: [String:AnyObject]) {
self.title = story["title"] as! String
self.content = story["content"] as! String
self.featuredImage = nil
}
}
}
In the Stories
struct we have the Request
struct which contains the URL of the API. We also have the Response
struct and in there, in the init
method we pass the data from the API which will then create a Stories.Story
instance.
The Stories.Story
struct has an init
function that takes a dictionary and assigns it to the properties on the struct. With this we can easily map all the results from the API to the Stories.Story
struct. This helps keep things structured and clean.
Next, let’s create the StoryViewController
class that will be the custom class for our third scene in the storyboard. Create a new StoryViewController.swift
file and paste in the following:
import UIKit
class StoryViewController: UIViewController {
var story: Stories.Story?
@IBOutlet weak var storyTitle: UILabel!
@IBOutlet weak var storyContent: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
storyTitle.text = story?.title
storyContent.text = story?.content
}
}
In the viewDidLoad
method, we are simply setting the values for our @IBOutlet
s using the story
property. This property is set in the tableView(_:didSelectRowAt:)
method in the StoriesTableViewController
class.
The next class we have to create is the AlertViewController
. This will be the custom class to our last scene. This is where the user can subscribe (or unsubscribe) to an interest. When users subscribe to an interest, they start receiving push notifications when new stories are added to that category.
Create a new class AlertViewController
and paste in the following code:
import UIKit
class AlertsTableViewController: UITableViewController {
var categories: [[String: Any]] = []
override func viewDidLoad() {
super.viewDidLoad()
self.getCategories()
self.tableView.reloadData()
navigationItem.title = "Configure Alerts"
}
private func getCategories() {
guard let categories = UserDefaults.standard.array(forKey: "categories") as? [[String: Any]] else {
self.categories = [
["name": "Breaking News", "interest": "breaking_news", "subscribed": false],
["name": "Sports", "interest": "sports", "subscribed": false],
["name": "Politics", "interest": "politics", "subscribed": false],
["name": "Business", "interest": "business", "subscribed": false],
["name": "Culture", "interest": "culture", "subscribed": false],
]
return self.saveCategories()
}
self.categories = categories
}
private func saveCategories() {
UserDefaults.standard.set(self.categories, forKey: "categories")
}
@objc func switchChanged(_ sender: UISwitch) {
categories[sender.tag]["subscribed"] = sender.isOn
self.saveCategories()
}
}
In the viewDidLoad
we call the *getCategories*
method. In the *getCategories*
method, we load the categories from UserDefaults
. If it does not exist, we create the default categories and save them to UserDefaults
. We need to maintain state on the user defaults so we know when the user subscribes and unsubscribes. When the application is restarted, the settings will still be saved.
In the saveCategories
method, we just use UserDefaults
to save the changes to the categories
property. The switchChanged
is a listener for when the switch on one of the categories is changed.
Next, in the same file, add the following extension which will conform to the UITableViewController
:
extension AlertTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return categories.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Alert", for: indexPath)
let category = categories[indexPath.row]
let switchView = UISwitch(frame: .zero)
switchView.tag = indexPath.row
switchView.setOn(category["subscribed"] as! Bool, animated: true)
switchView.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
cell.accessoryView = switchView
cell.textLabel?.text = category["name"] as? String
return cell
}
}
In the tableView(_:cellForRowAt:)
method, we configure our cell and then create a UISwitch
view and set that as the accessory view. We also use the tag
property of the switchView
to save the indexPath.row
. This is so we can then tell which category’s notification switch was toggled. We then set the switchChanged
method as the listener for when the switch is toggled.
The next thing we need to do is set up our application to receive and act on push notifications.
Adding push notifications to our iOS new application
Now that we have the application working, let’s integrate push notifications to the application. The first thing we need to do is turn on push notifications from the capabilities list on Xcode.
In the project navigator, select your project, and click on the Capabilities tab. Enable Push Notifications by turning the switch ON.
Next open your AlertsTableViewController
class and in there import the PushNotifications
library:
import PushNotifications
In the same file, replace the switchChanged(_:)
method with the following code:
@objc func switchChanged(_ sender: UISwitch) {
categories[sender.tag]["subscribed"] = sender.isOn
let pushNotifications = PushNotifications.shared
let interest = categories[sender.tag]["interest"] as! String
if sender.isOn {
try? pushNotifications.subscribe(interest: interest) {
self.saveCategories()
}
} else {
try? pushNotifications.unsubscribe(interest: interest) {
self.saveCategories()
}
}
}
In the code above, when the switch is turned on or off, the user subscription to the interest gets turned on or off also.
Next open the AppDelegate
class and import the packages below:
import PushNotifications
import UserNotifications
Then in the same file add the following lines of code:
let pushNotifications = PushNotifications.shared
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
self.pushNotifications.start(instanceId: "PUSHER_NOTIFICATION_INSTANCE_ID")
self.pushNotifications.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current()
center.delegate = self
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
self.pushNotifications.registerDeviceToken(deviceToken)
}
⚠️ Replace
PUSHER_NOTIFICATION_INSTANCE_ID
with the keys from your Pusher dashboard.
If you have done everything correctly, your applications should now be able to receive push notifications any time the client is subscribed to the news category and there is a new post.
However, right now, when the push notification is clicked, the application will be launched and it’ll just list the articles and not link to the specific articles. Let’s fix that by deeplinking to the specific article when the push notification is opened.
Deeplinking our iOS push notifications
When users click on our push notification, we want to direct the user to the story in the application and not just launch the app. Let’s add this feature. For this we will be implementing tips from the excellent article here.
Create a new file in Xcode called Deeplink.swift
and paste the following code into the file:
import UIKit
enum DeeplinkType {
case story(story: Stories.Story)
}
let Deeplinker = DeepLinkManager()
class DeepLinkManager {
fileprivate init() {}
private var deeplinkType: DeeplinkType?
func checkDeepLink() {
guard let deeplinkType = self.deeplinkType else {
return
}
DeeplinkNavigator.shared.proceedToDeeplink(deeplinkType)
self.deeplinkType = nil
}
func handleRemoteNotification(_ notification: [AnyHashable: Any]) {
if let data = notification["data"] as? [String: AnyObject] {
let story = Stories.Story(story: data)
self.deeplinkType = DeeplinkType.story(story: story)
} else {
self.deeplinkType = nil
}
}
}
In our DeeplinkManager
we have two methods. The first, checkDeeplink
checks the deep link and gets the deeplinkType
and then it calls DeeplinkNavigator.shared.proceedToDeeplink
which then navigates the user to the deep link. We will create this method later.
The next method is the handleRemoteNotification
method. This simply sets the deeplinkType
on the DeeplinkManager
class based on the notification
received.
In the same file, add the following code to the bottom:
class DeeplinkNavigator {
static let shared = DeeplinkNavigator()
private init() {}
func proceedToDeeplink(_ type: DeeplinkType) {
switch type {
case .story(story: let story):
if let rootVc = UIApplication.shared.keyWindow?.rootViewController as? UINavigationController {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
if let vc = storyboard.instantiateViewController(withIdentifier: "StoryViewController") as? StoryViewController {
vc.story = story
rootVc.show(vc, sender: rootVc)
}
}
}
}
}
This is the code to the DeeplinkNavigator
we referenced in the checkDeeplink
method earlier. In this class we have one method proceedToDeeplink
and this method navigates the user to a controller depending on the deeplinkType
. In our case, it will navigate to the story.
Next, open the AppDelegate
and add the following methods to the class:
func applicationDidBecomeActive(_ application: UIApplication) {
Deeplinker.checkDeepLink()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (_ options: UNNotificationPresentationOptions) -> Void) {
Deeplinker.handleRemoteNotification(notification.request.content.userInfo)
completionHandler([.alert, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let notification = response.notification.request.content.userInfo
Deeplinker.handleRemoteNotification(notification)
completionHandler()
}
In the applicationDidBecomeActive(:)
method we call our Deeplinker.checkDeepLink()
method. This checks for a deeplink when the application becomes active.
The other two methods are basically listeners that get fired when there is a new push notification. In these methods, we call the handleRemoteNotification
method so the push notification can be handled.
Allowing our application to connect locally
If you are going to be testing the app’s backend using a local server, then there is one last thing we need to do. Open the info.plist
file and add an entry to the plist
file to allow connection to our local server:
That’s it now. We can run our application. However, remember that to demo the push notifications, you will need an actual iOS device.
Here is the application one more time:
Conclusion
In this article, we have shown how you can use the power of interests to segment the push notifications that gets sent to your users. Hopefully, you have learnt a thing or two and you can come up with interesting ways to segment your users based on their interests.
The source code to the application is on GitHub. If you have any questions, do not hesitate to ask using the comment box below.
30 April 2018
by Neo Ighodaro