Do not index
Do not index
Push Notifications have always been a big subject. You can find countless threads on Stackoverflow, videos on YouTube, and even entire books on them.
To keep the scope of this article centric around a specific problem, we need to make assumptions. For the sake of this article, I will assume that you have a solid understanding of Swift, you understand the theory of how Push Notifications work, and how Firebase works in general.
Recently, my mentee was struggling with her application and the desire to set up remote notifications. Since her application uses Firebase as a backend, I helped her implement notifications. This article will walk you through the process.
The Scope
What we’re trying to accomplish is the following: We have a to-do app and we want to send every user a notification reminding them of their open to-do tasks, every morning.
- We want localized notifications for English and Spanish speaking users.
- We need to prepare the iOS application to receive notifications.
- Firebase Cloud Functions will be used to create the payloads.
- Google Cloud Scheduler will be used to set up daily CRON jobs.
1. Localization
Generally speaking, localization provides us with the opportunity to dynamically define text content, matching the user’s language settings. For Spanish speaking users, the app could appear in Spanish.
Push Notifications should also be localized. We’re able to accomplish this in two different ways:
- The backend defines the localization for each user
- The iOS application makes this decision
One of the advantages of the first approach is: we don’t have to ship an update of our app every time we want to change the notification title/body.
This article will nevertheless cover the second approach, simply because it requires much less work and “localization” is not the core subject of this article.
Please find Paul Hudson’s article on how to set up localization if you’re unfamiliar.
Initial localization for English and Spanish
In the English version, you might want to add
key/value
pairs like the following:"OPEN_TASKS_HEADER" = "Your tasks are waiting";
"OPEN_TASKS_BODY" = "You have %@ task/s pending";
And for Spanish:
"OPEN_TASKS_HEADER" = "Tus tareas estan esperandog";
"OPEN_TASKS_BODY" = "Tienes %@ tareas pendientes";
%@
is a String format specifier. We're going to replace it with an integer specifying how many tasks are pending.Please hire a translator or ask a native speaking friend to translate for you. Nothing can ruin an application experience faster than poor Google translations
2. Preparing the application
To prepare, please follow the basic setup guide provided by Firebase. You need to set your bundle ID, upload your APN key and turn on the Push Notification Capability of your app.
To promote a clean app architecture and to separate the differentiation of concerns, we’re going to wrap everything related to notifications into a wrapper class.
import UIKit
import UserNotifications
import Firebase
typealias NotificationPayload = [AnyHashable: Any]
protocol NotificationManagerDelegate: class {
func notificationsManager(didReceiveToken token: String)
func notificationsManager(didReceiveError error: Error)
func notificationsManager(didReceiveNotification payload: NotificationPayload, withResponse didRespond: Bool)
}
class NotificationsManager: NSObject {
// The delegate that communicates outside of this class
private let delegate: NotificationManagerDelegate
// The Firebase key, to help us to differentiate
// firebase notifications from potentially others
private let messageKey = "gcm.message_id"
// We need to initate the wrapper with the application,
// and the delegate to communicate to the rest of the app
init(registerIn application: UIApplication,
delegate: NotificationManagerDelegate) {
self.delegate = delegate
super.init()
register(application)
}
// MARK: - Public
// In order to send notifications to a specific user,
// we need to associate a token with a user.
// The idea of this function is to have a trigger available,
// ready to be fired as soon as the user is verified to be logged in
// and we're ready to emit the new token to the database.
public func publishCurrentToken() {
Messaging.messaging().token { [weak self] token, error in
guard error == nil else {
self?.delegate.notificationsManager(didReceiveError: error!)
return
}
guard let token = token else {
return
}
self?.delegate.notificationsManager(didReceiveToken: token)
}
}
// MARK: - Private
// It's generally good practice, to call the registration
// after the user has seen and acknowledged some kind of
// explanation screen why they should accept notifications.
// It drastically increases the chance of the user
// being willing to receive them.
private func register(_ application: UIApplication) {
// Setting the Firebase Messaging delegate to self
// so that we are getting all the information to this wrapper class
Messaging.messaging().delegate = self
// Same for Apple's Notification Center
UNUserNotificationCenter.current().delegate = self
// The notification elements we care about
let options: UNAuthorizationOptions = [.alert, .badge, .sound]
// Register for remote notifications.
// This shows a permission dialog on first run,
// to show the dialog at a more appropriate time
// move this registration accordingly.
UNUserNotificationCenter.current().requestAuthorization(options: options) {_, _ in }
// It's important to call the registration function on the main thread.
// https://bit.ly/3oqSq0z
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
// Function to manually set the badge on the App icon.
// You might want to call that function whenever the observer,
// to download your data model, fires.
public func setBadge(to count: Int) {
UIApplication.shared.applicationIconBadgeNumber = count
}
// MARK: - Private
// This function gets called whenever a new Push Notifications has been received.
// The `didRespond` flag provides the opportunity to handle on user action.
// An example could be a notification about a specific task,
// and when `didRespond == true`, you could open a dedicated screen for the task.
// That's called deep linking.
private func didReceive(_ notification: UNNotification, withResponse didRespond: Bool = false) {
// The `userInfo` is the information that came with the notification
// aka the payload that our server sent.
let userInfo = notification.request.content.userInfo
// Make sure it's a notification from Firebase.
// You could in theory implement more providers
// for all kinds of purposes.
if userInfo[messageKey] != nil {
// Lastly we want to delegate the information to the rest of the app.
delegate.notificationsManager(didReceiveNotification: userInfo, withResponse: didRespond)
}
}
}
extension NotificationsManager : UNUserNotificationCenterDelegate {
// System function that gets fired when the phone
// is about to present the incoming Push Notification.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
didReceive(notification)
// Returning the same options we've requested
completionHandler([.alert, .badge, .sound])
}
// System function that gets fired when the user
// clicked on the incoming Push Notification.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
didReceive(response.notification, withResponse: true)
completionHandler()
}
}
// MARK: - MessagingDelegate
extension NotificationsManager: MessagingDelegate {
// Firebase Function that gets called whenever our token changes.
// This can happen at any time, so wer're making sure to send it to the database.
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) {
delegate.notificationsManager(didReceiveToken: fcmToken)
}
}
To hook the wrapper, all we need to do is the following:
import UIKit
import Firebase
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Always call first, before doing anything Firebase related.
FirebaseApp.configure()
// The reference to our wrapper.
// You might want to pass it down to your controllers using dependency injection.
// Wherever you're checking the auth status of your users.
let notificationsManager = NotificationsManager(registerIn: application, delegate: self)
...
return true
}
}
// MARK: - NotificationManagerDelegate
extension AppDelegate: NotificationManagerDelegate {
// The following is an implementation example
func notificationsManager(didReceiveToken token: String) {
// Once we have a new token, we need to associate it with a user
guard let userID = authManager?.userID else {
return
}
// Then we need to pass it to the database.
// Now the backend handles the rest...
databaseManager?.updateUser(
["gcmToken": token],
for: userID)
}
func notificationsManager(didReceiveError error: Error) {
// Handle appropriatly
}
func notificationsManager(didReceiveNotification payload: NotificationPayload, withResponse didRespond: Bool) {
// Utilize the information as needed.
// In most cases you want to deep link now on user action.
}
}
3. Firebase Cloud Functions
We’ll be utilizing Firebase Cloud Functions to calculate the payload and to send the messages. Please follow the Getting Started guide to create your environment. If you’re new to Firebase Functions, you can find more tutorials on YouTube than you’ll ever be able to watch.
The Functions are developed using a programming language called TypeScript, that is remarkably similar to Swift. Even with no JavaScript experience, you should be able to follow the code based on my extensive comments.
First we create a function to download all tasks.
// A simple type declaration for our Task model
// You can look at it as a light-weight model.
// Predefining what keys and types a dictionary has.
type Task = {
createdAt?: string
doneAt?: string
isDone: Boolean
taskType: string
title: string
userID: string
}
/**
* TODO:
* For this function to be truly scalable,
* we need to work with pointers.
* We start by having a Cursor = 0
* We set the query limit to 100 and the start to 100 x Cursor.
* Therefore the first run will load everything from 0-100.
* Then we restart the function to get the next 100.
* We update the cursor by +1.
* Therefore the next time we will get everything from 100-200.
* Then 200-300, and so on.
*/
function getIncompleteTasks(): Promise<Task[]> {
// A Promise in Swift terms is a callback or closure.
// The Swift function signature could look something like this:
// func getIncompleteTasks(onSuccess: ([Task]) -> Void, onFailure: (Error) -> Void)
// Where resolve means success, and reject means failure.
return new Promise((resolve, reject) => {
// Store is the reference to the Firestore
// We want to get the collection `tasks`
// And we want to filter the query by tasks that are not done.
store
.collection("tasks")
.where('isDone', '==', false)
.get()
.then((docs) => {
// The keyword `.then` signals an escaping closure.
// In swift it would look something like:
// completion: { docs in ... })
// We're initiating an empty Task array.
const tasks: Task[] = []
// We check if docs is not undefined, or "nil"
if (docs) {
// Then we iterate the array of docs
docs.forEach((doc: any) => {
// And we cast the data to Task
// while "appending" it to the array.
tasks.push(doc.data() as Task);
});
}
resolve(tasks);
})
.catch((error) => {
reject(error);
});
});
}
The database structure in this example is as follows. All tasks, regardless to whom they belong, are all in a huge collection. Look at it as a huge Array of Tasks. When downloading all tasks, we now need to split the Array in groups, using each item’s
userID
key.For this task, we’re utilizing an npm package called Lodash. This library provides the function
groupBy
, that takes an Array and a key, and returns a dictionary, with each key as (you guessed it) key, and in groups separated each key's Array elements.// Import statement to use lodash
const _ = require('lodash')
/**
* Converts the array to an array per userID
* @param tasks Tasks to convert
* @returns a dictionary of [userID: [Task]]
*/
function getFormatted(tasks: Task[]): { [userID: string]: Task[]; } {
// Use lodash function groupBy.
// Pass it the Array, and the key.
return _.groupBy(tasks, 'userID')
}
Before and after using Lodash’s groupBy method.
Now that we know how many open tasks each user has, it’s time to implement the function that sends the messages using Firebase’s Cloud Messaging service.
/**
* Function to send a Push Notification using Firebase Messaging.
* @param userID The user to receive the push message.
* @param taskCount The amount of open tasks.
* @returns Returns the message ID or undefined in case of failure.
*/
function notifyUser(userID: string, taskCount: number): Promise<string | undefined> {
// Again we're just initiating a new Promise.
// A closure that can succeed and fail.
return new Promise(async (resolve, reject) => {
// This function just gets us the user based on the userID.
// There's no value in me providing this function.
// It's just getting the data associated with an ID from the database.
const user = await getUser(userID)
// Next we need to check, if the user exists
// AND -> If they registered for push notifications, or not.
// The `gcmToken` is what we sent from the mobile application to the server
if (!user || !user.gcmToken) {
// If we end up here, then we want to pass undefined
// (aka nil) to the closure that expects a message ID
resolve(undefined)
return
}
// The following is the actual payload.
// Most of it is boilerplate code.
// Check out firebase.messaging.Message for more.
// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages
//
// `titleLocKey`, `locKey` and `locArgs` are the only relevant fields for us.
// `token` defines who the receiver of the Notification will be.
const payload = {
apns: {
payload: {
aps: {
alert: {
titleLocKey: "OPEN_TASKS_HEADER",
locKey: "OPEN_TASKS_BODY",
locArgs: [
`${taskCount}`
]
},
"mutable-content": false,
"thread-id": "open-tasks",
badge: taskCount
},
}
},
token: user.gcmToken
}
// The rest is just boilerplate.
// Using Firebase's messaging object, to send the payload.
messaging.send(payload)
.then((response: string) => {
// Handle this however you see fit.
// The message was sent successfully.
resolve(`Did send message to: ${userID} about ${taskCount} task/s ID: ${response}`)
})
.catch((error: Error) => {
// The message was not sent.
// You need to check the error and debug why.
reject(error)
});
})
}
Now that we have added the ability to send Push Notifications, we need to create the API function, which we can call from outside the code environment to trigger everything above.
An API is a way of communication. We’re communicating to our cloud server that we want to send the Push Notifications.
/**
* This function will be called every day at 8AM pacific time.
* Scheduled as CRON job in cloud scheduler.
*/
// This function create an https API that we can call using a GET request.
export const setNotificationsTrigger = functions.https.onRequest(async (_, response) => {
// We're using a try catch block to take advantage of the
// very nice `await` syntax, while also handling errors properly.
try {
// First we want to get our tasks.
// Await basically means, that we want to wait for the closure
// result of `getIncompleteTasks` before proceeding.
const tasks = await getIncompleteTasks()
// Then we use our formatter function to get a dictionary.
// [userID: [Task]]
const convertedTasks = getFormatted(tasks)
// Now we create an empty array of Promises.
// Look at it as an Array of closures.
// The Swift syntax could be something like:
// let promises = [(Any) -> Void]()
const promises: Promise<any>[] = []
// We want to iterate over our dictionary
for (let userID in convertedTasks) {
// To get each user's tasks
let userTasks = convertedTasks[userID]
// And if we have at least 1 open task...
if (userTasks.length > 0) {
// We need to add the functiont to notify a user
// to the Promises array.
// Note: We're not adding the result of the notification function,
// but the actual function.
// We want to add the function's callback to our Array of callbacks.
promises.push(notifyUser(userID, userTasks.length))
}
}
// Lastly we await for the execution and
// result of ALL functions at the same time
const result = await Promise.all(promises)
// Here we're just sending the response to close the network request.
response.send(result.filter((element: string) => {
// The filter now filters out all Promises that
// did not return a value for the result of the message.
// Remember, if the user.fcmToken was undefined,
// we did not send a notification, but returned undefined.
return typeof element !== 'undefined' && element !== null
}));
} catch (error) {
// Return the error if there was one.
response.send(error);
}
})
4. Google Cloud Scheduler
Once we deploy the
setNotificationsTrigger
function, we can use any kind of tool– including the browser's address bar–to perform a GET
request that sends out notifications to all users who have chosen to accept Push Notifications.To call the API on a fixed interval we’ll be utilizing the Cloud Scheduler.
The following shows the body I used to create a daily trigger at 8AM. The hardest part here is the Frequency as it’s using the CRON format.
With our Scheduler in place we’re done and our users will receive a notification every morning, reminding them of their unresolved tasks.
To improve, the next step should implement proper paging when downloading the tasks in the cloud functions as described in the code snippets.
It is okay to test without implementing a scalable solution if your application is still in the proof of concept stage. However, as soon as the application undergoes heavy usage, the function to send out the Notifications will simply fail. The tasks will exceed the allocated memory.