Skip to content

Commit 75fb912

Browse files
authored
Notifications and Background Fetch (#1)
* add bg fetch draft * finish writing * add lead-in * smaller review image * better alignment * fixup images * editing
1 parent d3524f1 commit 75fb912

File tree

1 file changed

+205
-0
lines changed

1 file changed

+205
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
---
2+
layout: post
3+
author: ryannystrom
4+
title: Local Notifications with Background Fetch
5+
---
6+
7+
This is how we used [Background Fetch](https://developer.apple.com/documentation/uikit/core_app/managing_your_app_s_life_cycle/preparing_your_app_to_run_in_the_background/updating_your_app_with_background_app_refresh) to show Local Notifications instead of traditional Push Notifications in GitHawk. We used [FMDB](https://github.com/ccgus/fmdb) to track delivered alerts in our [implementation](https://github.com/GitHawkApp/GitHawk/blob/master/Classes/Systems/LocalNotificationsCache.swift).
8+
9+
<p align="center"><img src="https://user-images.githubusercontent.com/739696/46327776-782f4280-c5d1-11e8-8242-561b77e79e54.jpg" /></p>
10+
11+
One of our values with GitHawk is to respect your privacy, especially when it comes to your GitHub data. We always connect directly to GitHub via the official API, and store all of your authentication information on your phone. No servers involved!
12+
13+
However, that poses a big challenge for adding our most-requested feature for GitHawk: **Push Notifications**.
14+
15+
<p align="center"><img src="https://user-images.githubusercontent.com/739696/46327873-ff7cb600-c5d1-11e8-873c-26ae39bc202a.jpg" width="600" /></p>
16+
17+
Traditional Apple Push Notifications (APN) require you to:
18+
19+
1. Ask the user permission
20+
2. Get a callback with the device token
21+
3. Send that token to your server and save it
22+
4. Whenever you need to send a notification, send content along with the token to Apple's APN servers
23+
24+
That middle step (sending a token to our servers) wont work for GitHawk because we don't want to send your data off of your phone.
25+
26+
GitHawk has been using [app background fetch](https://developer.apple.com/documentation/uikit/core_app/managing_your_app_s_life_cycle/preparing_your_app_to_run_in_the_background/updating_your_app_with_background_app_refresh) APIs (glorified polling) to update the badge icon for months. We decided to piggy-back off of this existing feature and "fake" push notifications.
27+
28+
## Local Notifications
29+
30+
Sending local notifications is _easy_ with the `UserNotifications` framework:
31+
32+
```swift
33+
let content = UNMutableNotificationContent()
34+
content.title = "Alert!"
35+
content.body = "Something happened"
36+
37+
let request = UNNotificationRequest(
38+
identifier: UUID(),
39+
content: content,
40+
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0, repeats: false)
41+
)
42+
UNUserNotificationCenter.current().add(request)
43+
```
44+
45+
The hard part is making this work well with the background fetch API and not annoying your users.
46+
47+
### Permissions and Setup
48+
49+
Before anything can happen, you have to ask for notification permissions!
50+
51+
```swift
52+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert], completionHandler: { (granted, _) in
53+
// handle if granted or not
54+
})
55+
```
56+
57+
In GitHawk, we disable notifications **by default** and let people enable them in the app settings. This way you aren't bombarded with annoying permissions dialogs the first time you open the app.
58+
59+
Next set the background fetch interval. Set this to the minimum time interval since we want to show alerts as soon as possible.
60+
61+
```swift
62+
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplicationBackgroundFetchIntervalMinimum)
63+
```
64+
65+
> iOS decides when to wake up your app for background fetches based on a bunch factors: Low Power Mode, how often you use the app, and more. Experience may vary!
66+
67+
Handle background fetch events by overriding the `UIApplication.application(_:, performFetchWithCompletionHandler:)` function.
68+
69+
```swift
70+
func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
71+
sessionManager.client.fetch(application: application, handler: completionHandler)
72+
}
73+
```
74+
75+
Make sure to **always** call the `completionHandler`!
76+
77+
> In GitHawk we lazily initialize session objects on the `AppDelegate`. This makes networking during background fetch _really_ easy.
78+
79+
### Notification Fatigue
80+
81+
If GitHawk alerted content that you've already seen, you'd uninstall it pretty quick!
82+
83+
A naive approach would be to store IDs and timestamps in `UserDefaults`, but `UserDefaults` are loaded into memory on app start. That's guaranteed to tank performance down the road.
84+
85+
Thankfully this is exactly what databases are for! SQLite is a lightweight database with first-class support on iOS, and [FMDB](https://github.com/ccgus/fmdb) removes all the hairy bits from working with it.
86+
87+
Before we jump into code, let's first design how the system should work:
88+
89+
- We need an `id: String` for each content that should alert. This will be the key in our table.
90+
- Create the table if it doesn't already exist
91+
- Select all `id`s already in the table and **remove** them from content that will alert.
92+
- Insert all of the new `id`s into the table
93+
- Trim the table so it doesn't grow unbounded
94+
95+
> Trimming the table may be an eager optimization, but the last thing I want is a 100MB SQLite file on someone's phone just for notification receipts.
96+
97+
In order to keep this thread-safe, use `FMDatabaseQueue` and execute all database transactions in a closure:
98+
99+
```swift
100+
let queue = FMDatabaseQueue(path: databasePath)
101+
queue.inDatabase { db in
102+
// queries and stuff
103+
}
104+
```
105+
106+
In GitHawk, the first thing we do is convert an array of content into a mutable `Dictionary<String: Content>` so that we can remove already-alerted `id`s and later lookup the original `Content`.
107+
108+
```swift
109+
var map = [String: Content]()
110+
contents.forEach { map[$0.id] = $0 }
111+
let ids = map.keys.map { $0 }
112+
```
113+
114+
Then in the transaction closure, create the SQLite table:
115+
116+
```swift
117+
queue.inDatabase { db in
118+
do {
119+
try db.executeUpdate(
120+
"create table if not exists receipts (id integer primary key autoincrement, content_id text)",
121+
values: nil
122+
)
123+
} catch {
124+
print("failed: \(error.localizedDescription)")
125+
}
126+
}
127+
```
128+
129+
> SQLite's `if not exists` makes creating the table _once_ a breeze!
130+
131+
Next select `id`s that already exist in the table and remove them from the `map` so that you're left with content that hasn't been seen.
132+
133+
```swift
134+
queue.inDatabase { db in
135+
do {
136+
// create table...
137+
138+
let selectParams = map.keys.map { _ in "?" }.joined(separator: ", ")
139+
let rs = try db.executeQuery(
140+
"select content_id from receipts where content_id in (\(selectParams))",
141+
values: ids
142+
)
143+
while rs.next() {
144+
if let key = rs.string(forColumn: "content_id") {
145+
map.removeValue(forKey: key)
146+
}
147+
}
148+
149+
} catch {
150+
print("failed: \(error.localizedDescription)")
151+
}
152+
}
153+
```
154+
155+
Now all of the `map` entries need to be added to the table:
156+
157+
```swift
158+
queue.inDatabase { db in
159+
do {
160+
// create table...
161+
// select content ids...
162+
163+
let insertParams = map.keys.map { _ in "(?)" }.joined(separator: ", ")
164+
try db.executeUpdate(
165+
"insert into receipts (content_id) values \(insertParams)",
166+
values: map.keys.map { $0 }
167+
)
168+
169+
} catch {
170+
print("failed: \(error.localizedDescription)")
171+
}
172+
}
173+
```
174+
175+
Before we're done with the database, let's cap the `receipts` table to 1000 entries. We can do this easily just by using the `autoincrement` column:
176+
177+
```swift
178+
queue.inDatabase { db in
179+
do {
180+
// create table...
181+
// select content ids...
182+
// insert content ids...
183+
184+
try db.executeUpdate(
185+
"delete from receipts where id not in (select id from receipts order by id desc limit 1000)",
186+
values: nil
187+
)
188+
189+
} catch {
190+
print("failed: \(error.localizedDescription)")
191+
}
192+
}
193+
```
194+
195+
> GitHub's API only returns 50 notifications at a time, so limiting the history to 1,000 shouldn't have any noticeable repeated notifications.
196+
197+
All that's left to do is iterate through remaining `map.values` and fire off some `UNNotificationRequest`s!
198+
199+
## Wrapping Up
200+
201+
While not as fully featured as traditional Apple Push Notifications (realtime alerts, rich notifications, etc), using Local Notifications and Background Fetch got us an MVP of the most-requested feature in users hands quick. And most importantly, it avoids any need for us to store private authentication data off people's phones.
202+
203+
You can check out our implementation of this system in [LocalNotificationCache.swift](https://github.com/GitHawkApp/GitHawk/blob/7b0746332129d3077f1ba036c9c20ebe45d27751/Classes/Systems/LocalNotificationsCache.swift) and [BadgeNotifications.swift](https://github.com/GitHawkApp/GitHawk/blob/7b0746332129d3077f1ba036c9c20ebe45d27751/Classes/Systems/BadgeNotifications.swift#L125-L144).
204+
205+
> If there's enough interest, I'm happy to extract this functionality into its own Pod!

0 commit comments

Comments
 (0)