10.
Modifying the Payload
Written by Scott Grosch
Sometimes, you’ll need to take extra steps before a notification is presented to the user. For example, you may wish to download an image or change the text of a notification.
In the DidIWin lottery app, for example, you’d want the notification to tell the user exactly how much money they have won. Given the push notification simply contains today’s drawing numbers, you’ll be using a Notification Service Extension to intercept those numbers and apply logic to them.
You can think of a Notification Service Extension as middleware between APNs and your UI. With it, you can receive a remote notification and modify its content before it’s presented to the user. Considering the fact notification payloads are limited in size, this can be a very useful trick! Another common use case for modifying the payload is if you’re sending encrypted data to your app. The service extension is where you’d decrypt the data so that it’s properly displayed to your end user.
In this chapter, you’ll go over what it takes to build a Notification Service app extension and how to implement some of its most common use cases.
Configuring Xcode for a service extension
Due to your proven track record of writing amazing apps, your country’s spy agency has contracted you to write the app that its field agents will use to receive updates from headquarters. Of course, the agency sends all of its data using massive encryption, so you’ll need to handle the decryption for the agents. Nobody wants to read a gobbledygook text!
Open the starter project for this chapter. Remember to set the team signing as discussed in Chapter 7, “Expanding the Application.”
Gibberish
Build and run your app, and send yourself a push notification with the following payload:
{
"aps": {
"alert": {
"title": "Lbhe Gnetrg",
"body": "Guvf vf lbhe arkg nffvtazrag."
},
"sound": "default",
"badge": 1,
"mutable-content": 1
},
"media-url":
"uggcf://jbyirevar.enljraqreyvpu.pbz/obbxf/abg/ohaal.zc4"
}
If everything goes correctly, you should see a notification on your device. However, this notification is encrypted by the agency, and you need to decrypt the contents before displaying the notification on the device.
Creating the service extension
You need to add a service extension target so that you can handle the encryption being used.
- In Xcode, select File ▸ New ▸ Target….
- Make sure iOS is selected and choose the Notification Service Extension.
- For the product name specify Payload Modification.
- Press Finish.
- When asked about scheme activation, select Cancel.
Note: You don’t actually run a service extension so that’s why you didn’t let it make the new target your active scheme.
You can name the new target anything that makes sense for you, but it can be helpful to use the above name because, when you glance at your project, you will immediately know what that target is doing.
If you look in the Project navigator (⌘ + 1), you’ll see you now have a new folder group called Payload Modification. You’ll notice that there’s a NotificationService.swift file but no views. This is because service extensions don’t present any type of UI. They are called before the UI is presented, be it yours or the one Apple displays for you. You’ll get into UI modifications in the next chapter.
Decrypting the payload
As mentioned at the start of the chapter, the payload you receive has encrypted the data. Your country is a little bit behind the times though, and it is still using the ROT13 letter substitution cipher in which each letter is simply replaced by the letter 13 places further along in the alphabet, wrapping back to the beginning of the alphabet if necessary.
In your Payload Modification target create a new Swift file named ROT13.swift and paste this code into it:
import Foundation
struct ROT13 {
static let shared = ROT13()
private let upper = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
private let lower = Array("abcdefghijklmnopqrstuvwxyz")
private var mapped: [Character: Character] = [:]
private init() {
for i in 0 ..< 26 {
let idx = (i + 13) % 26
mapped[upper[i]] = upper[idx]
mapped[lower[i]] = lower[idx]
}
}
public func decrypt(_ str: String) -> String {
return String(str.map { mapped[$0] ?? $0 })
}
}
You can find many different ways of implementing this cipher in Swift. The above is just a quick and dirty way to handle the American English alphabet.
Obviously, the above code makes a nice sample for a book as it doesn’t require downloads and configuration. However, for real security, you should look to something like the CryptoSwift (cryptoswift.io) library.
Open the NotificationService.swift file and you’ll see a bit of content already provided for you by Apple. The first method in this file, didReceive(_:withContentHandler:) is called when your notification arrives. You have roughly 30 seconds to perform whatever actions you need to take. If you run out of time, iOS will call the second method, serviceExtensionTimeWillExpire to give you one last chance to hurry up and finish.
If you’re using a restartable network connection, the second method might give you just enough time to finish. Don’t try to perform the same actions again in the serviceExtensionTimeWillExpire method though. The intent of this method is that you perform a much smaller change that can happen quickly. You may have a slow network connection, for example, so there’s no point in trying yet another network download. Instead, it might be a good idea to tell the user that they got a new image or a new video, even if you didn’t get a chance to download it.
Note: If you haven’t called the completion handler before time runs out, iOS will continue on with the original payload.
You may make any modification to the payload you want — except for one. You may not remove the alert text. If you don’t have alert text, then iOS will ignore your modifications and proceed with the original payload.
Now, back in your NotificationService.swift file, find the lines in didReceive(_:withContentHandler:) that show an example modification:
// Modify the notification content here...
bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
Replace them with code to decrypt the data:
bestAttemptContent.title = ROT13.shared.decrypt(bestAttemptContent.title)
bestAttemptContent.body = ROT13.shared.decrypt(bestAttemptContent.body)
Build and run your app again on a physical device, then send yourself the same push notification again.
Note: Simulator will not currently run a service extension.
If everything worked correctly, you should see a decrypted push notification appear on your phone.
Downloading a video
Service extensions are also the place in which you can download videos or other content from the internet. First, you need to find the URL of the attached media. Once you have that, you can try to download it into a temporary directory somewhere on the user’s device. Once you have the data, you can create a UNNotificationAttachment object, which you can attach to the actual notification.
Go back to NotificationService.swift and replace the if check with a guard statement instead:
guard let bestAttemptContent = bestAttemptContent else {
return
}
Using a guard statement is usually preferable to wrapping an entire method in a conditional check. After your statements that replace the title and body of the push notification, you’ll now need to check and see if there’s media to download. Add these lines of code:
guard let urlPath = request.content.userInfo["media-url"] as? String,
let url = URL(string: ROT13.shared.decrypt(urlPath)) else {
contentHandler(bestAttemptContent)
return
}
You’re first checking to determine whether the payload includes the media-url key. If it does, you then decrypt the URL just as you did for the title and body. Finally, you then attempt to convert that to an actual URL object. If any of the checks failed then there are no other actions which need to be performed so you call the completion handler and exit the method.
Note: You didn’t call the completion handler inside the first
guardstatement as you didn’t have content at that point. In the secondguardyou’ve already updated the title and body, so now you must call the completion handler.
Now it’s time to download the media, using the following code:
// 1
URLSession.shared.dataTask(with: url) { data, response, _ in
// 2
defer { contentHandler(bestAttemptContent) }
// 3
guard let data = data else { return }
// 4
let file = response?.suggestedFilename ?? url.lastPathComponent
let destination = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(file)
do {
try data.write(to: destination)
// 5
let attachment = try UNNotificationAttachment(
identifier: "",
url: destination)
bestAttemptContent.attachments = [attachment]
} catch {
// 6
}
}.resume()
Here’s what the above code is doing:
- Download the media using
dataTask(with:)instead ofdownloadTask(with:). The latter method will handle creating the file on your device but the filename extension will not be correct. - It’s critical to call the content handler, so a
deferstatement is a great choice here. - If the download failed for any reason, there’s nothing else to do. It doesn’t matter why or how it failed.
- You’ll need to write the downloaded data to a file, but not just any temporary file. The extension for the file must be correct for iOS to know how to display the media. If the provider specified a filename, use that. Otherwise, just take the end of the URL path.
- Once you’ve written the data to disk, you create a
UNNotificationAttachment. iOS will generate a unique identifier for you if you leave it empty. Finally, add the attachment to the content of the push notification. - There’s not really anything you can do if the download fails, so you’ll be leaving the error case empty.
Notice how the method ends at that point, yet you didn’t call the completion handler. The data download is an asynchronous action, meaning the method will end before the download completes. That’s OK because you ensured, via the defer statement, that the completion handler will be called when the download exits.
Build and run your app again, and then resend the same push notification.
You should get a push notification that has a small image on the right-hand side. Long-press the notification and you’ll see a video with your next target!
Service extension payloads
You don’t necessarily always want an extension to run every time you receive a push notification — just when it needs to be modified. In the above example, you’d obviously use it 100% of the time as you’re decrypting data. But what if you were just downloading a video? You don’t always send videos.
To tell iOS that the service extension should be used, simply add a mutable-content key to the aps dictionary with an integer value of 1.
Note: If you forget to add this key, your service extension will never be called. You’re most likely going to forget to do this and have a heck of a time figuring out why your code doesn’t work!
Sharing data with your main target
Your primary app target and your extension are two separate processes. You can’t share data between them by default. If you do more than the most simplistic of things with your extension, you’ll quickly find yourself wanting to be able to pass data back and forth. This is easily accomplished via Application Groups, which allows access to group containers that are shared between multiple related apps and extensions.
To enable this capability, press ⌘ + 1 to go back to the Project navigator and click on your main target. Next, navigate to the Signing & Capabilities tab again. Click the + Capability button in the top-left and you’ll see App Groups near the top of the list. Double-click it.
You should see a new section pop up called App Groups in the tab. Press the + button and then set the name you wish to use. Generally, you’ll want the same name as your bundle identifier, just prefixed with group:
You’ll notice, in this image, that an App Group has already been created for another project, so that’s also shown in the image. Be sure that you only select the one group you want if there are multiple listed.
Now, go into your Payload Modification target’s capabilities tab and enable the App Groups there as well, selecting the same app group you selected for your app target.
Badging the app icon
A great use for service extensions is to handle the app badge. As discussed in Chapter 3, “Remote Notification Payload”, iOS will set the badge to exactly what you specify in the payload, if you provide a number. What happens if the end user has ignored your notifications so far? Maybe you’ve sent them three new items at this point. You’d rather the badge said 3 and not 1, right?
Historically, app developers have sent information back to the server as to how many badges the app icon is currently displaying, and then the push notification would increment that number by one. While that’s doable, it’s quite a bit of extra overhead to deal with on your server. By utilizing a service extension, you can now just pretend that the badge key being there means to increment the badge count by that number. You’re now just storing locally how many items are unread versus having to send those details back to your server for tracking.
As this is just an integer value, you can make use of the UserDefaults class with one small change — assuming you’ve already enabled App Groups. You have to specify the suite that is used to enable it to span targets. To do so, add a new Swift file to your primary target, not the extension, called UserDefaults.swift:
import Foundation
extension UserDefaults {
// 1
static let suiteName = "group.com.raywenderlich.PushNotifications"
static let extensions = UserDefaults(suiteName: suiteName)!
// 2
private enum Keys {
static let badge = "badge"
}
// 3
var badge: Int {
get { UserDefaults.extensions.integer(forKey: Keys.badge) }
set { UserDefaults.extensions.set(newValue, forKey: Keys.badge) }
}
}
- First, you define a new
extensionsproperty, providing aUserDefaultsobject you’d use when you want to share your defaults between targets. Change thesuitNameto be the ID of the App Group you selected in your targets. - Hardcoding strings is a bad idea, so you create an
enumwith a staticletso that you only have to do it once. Astructwould work here just as well. The reason you want to use anenumis that you can’t accidentally instantiate it. - Finally, you wrap up by creating a computed property for
badgethat handles the get/set. Again, this is just good coding style to make life easier on the caller.
Right now, this file is only accessible from the main target though. Bring up the File inspector by pressing ⌥ + ⌘ + 1 and in the Target Membership section check the boxes next to your service extension as well as the primary target:
Now, back in NotificationService.swift, edit the didReceive(_:withContentHandler:) method. You can check for badging information by placing the following code just before you assign the title and body:
if let increment = bestAttemptContent.badge as? Int {
if increment == 0 {
UserDefaults.extensions.badge = 0
bestAttemptContent.badge = 0
} else {
let current = UserDefaults.extensions.badge
let new = current + increment
UserDefaults.extensions.badge = new
bestAttemptContent.badge = NSNumber(value: new)
}
}
It’s important to store the value to a UserDefaults type structure so you modify that value in your primary target as well. When your user accesses the part of your app that the badge refers to, you’ll want to decrement the badge count so that the app icon is updated.
Build and run the app. Send yourself push notifications a few times and the badge number should increase for each notification you receive:
Accessing Core Data
Writing to a UserDefaults key can be incredibly useful, but isn’t normally good enough. Sometimes, you really just need access to your actual app’s data store in your extension. Most commonly, you’ll look for a way to access Core Data. It’s easy enough to do once you’ve enabled App Groups.
First, select your data model (Model.xcdatamodeld). Then, in the Target Membership section of the File inspector, add a checkmark next to your service notification target. If you created any NSManagedObject subclasses that you need to use, do the same thing with them.
Second, edit your Persistence.swift file, making a small change to the container setup. You’ve got to tell the container exactly where to store the data. Replace the if inMemory check with this:
let url: URL
if inMemory {
url = URL(fileURLWithPath: "/dev/null")
} else {
let groupName = "group.com.raywenderlich.PushNotifications"
url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupName)!
.appendingPathComponent("PushNotifications.sqlite")
}
container.persistentStoreDescriptions.first!.url = url
Note: The newer Xcode templates write to Persistence.swift instead of AppDelegate.swift.
You have to tell iOS exactly where to write the internal .sqlite file since the default doesn’t work with app groups. Using the code shown allows you to identify exactly where Core Data should store the database. Be sure the group name you specify exactly matches what you specified for the App Group.
Remember to tell your service extension about this file. Bring up the File inspector by pressing ⌥ + ⌘ + 1 and in the Target Membership section check the box next to your service extension.
Localization
If you’re modifying the content of your payload, you might be modifying the text as well. Always keep in mind that not everyone speaks the same language you do, so you still need to follow all the localization rules you normally would.
Note: There’s currently a bug in Xcode in which your base language will not always be used in an extension. To work around this bug, simply make sure that you have a Localizable.strings for your base language defined.
If the only reason you’re using an extension is to perform localizations on text, you should instead look at the keys of the aps alert dictionary, as explained back in Chapter 3, “Remote Notification Payload”, as there are multiple items there to perform this action for you.
Debugging
Sometimes, no matter how hard you try, things just don’t go right. Debugging a service extension works almost the same as any other Xcode project. However, because it’s a target and not an app, you have to take a few extra steps.
-
Open up your NotificationService.swift file and set a breakpoint on the line where you decode the title.
-
Build and run your app.
-
In Xcode’s menu bar, choose Debug ▸ Attach to Process by PID or Name….
-
In the dialog window that appears, enter Payload Modification — or whatever you named your target.
-
Press the Attach button.
If you send yourself another push notification, Xcode should stop execution at the breakpoint you set. Be aware that debugging service extensions is a bit finicky and sometimes it just plain doesn’t work. If you aren’t able to find your process listed, you might have to go through a full restart of Xcode and possibly even a reboot of your device.
Key points
- A Notification Service Extension is a sort of middleware between APNs and your UI. With it, you can receive a remote notification and modify its content before it’s presented to the user.
- You may make any modification to the payload you want — except for one. You may not remove the alert text. If you don’t have alert text, then iOS will ignore your modifications and proceed with the original payload.
- You can use service extensions to download videos or other content from the internet. Once downloaded, create a
UNNotificationAttachmentobject that you attach to the push notification. - Your primary app target and your extension are two separate processes and cannot share data between them by default. You can overcome this using App Groups.
- Service extensions can be used to handle your app’s badge so that the badge reflects the number of unseen notifications without having to involve server side storage.
- You can access your app’s data store in your extension once you have App Groups set up.
- When modifying the content of your payload, if your text is also changed, follow localization rules to account for different languages.