|
| 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