/
RemoteNotificationsModelController.swift
261 lines (226 loc) · 11.1 KB
/
RemoteNotificationsModelController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import CocoaLumberjackSwift
@objc enum RemoteNotificationsModelChangeType: Int {
case addedNewNotifications
case updatedExistingNotifications
}
@objc final class RemoteNotificationsModelChange: NSObject {
@objc let type: RemoteNotificationsModelChangeType
@objc let notificationsGroupedByCategoryNumber: [NSNumber: [RemoteNotification]]
init(type: RemoteNotificationsModelChangeType, notificationsGroupedByCategoryNumber: [NSNumber: [RemoteNotification]]) {
self.type = type
self.notificationsGroupedByCategoryNumber = notificationsGroupedByCategoryNumber
super.init()
}
}
@objc final class RemoteNotificationsModelChangeResponseCoordinator: NSObject {
@objc let modelChange: RemoteNotificationsModelChange
private let modelController: RemoteNotificationsModelController
init(modelChange: RemoteNotificationsModelChange, modelController: RemoteNotificationsModelController) {
self.modelChange = modelChange
self.modelController = modelController
super.init()
}
@objc(markAsReadNotificationWithID:)
func markAsRead(notificationWithID notificationID: String) {
modelController.markAsRead(notificationWithID: notificationID)
}
@objc func markAsExcluded(_ notification: RemoteNotification) {
modelController.markAsExcluded(notification)
}
@objc(markAsSeenNotificationWithID:)
func markAsSeen(notificationWithID notificationID: String) {
modelController.markAsSeen(notificationWithID: notificationID)
}
}
final class RemoteNotificationsModelController: NSObject {
public static let modelDidChangeNotification = NSNotification.Name(rawValue: "RemoteNotificationsModelDidChange")
public static let didLoadPersistentStoresNotification = NSNotification.Name(rawValue: "ModelControllerDidLoadPersistentStores")
let managedObjectContext: NSManagedObjectContext
enum InitializationError: Error {
case unableToCreateModelURL(String, String, Bundle)
case unableToCreateModel(URL, String)
var localizedDescription: String {
switch self {
case .unableToCreateModelURL(let modelName, let modelExtension, let modelBundle):
return "Couldn't find url for resource named \(modelName) with extension \(modelExtension) in bundle \(modelBundle); make sure you're providing the right name, extension and bundle"
case .unableToCreateModel(let modelURL, let modelName):
return "Couldn't create model with contents of \(modelURL); make sure \(modelURL) is the correct url for \(modelName)"
}
}
}
required init?(_ initializationError: inout Error?) {
let modelName = "RemoteNotifications"
let modelExtension = "momd"
let modelBundle = Bundle.wmf
guard let modelURL = modelBundle.url(forResource: modelName, withExtension: modelExtension) else {
let error = InitializationError.unableToCreateModelURL(modelName, modelExtension, modelBundle)
assertionFailure(error.localizedDescription)
initializationError = error
return nil
}
guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
let error = InitializationError.unableToCreateModel(modelURL, modelName)
assertionFailure(error.localizedDescription)
initializationError = error
return nil
}
let container = NSPersistentContainer(name: modelName, managedObjectModel: model)
let sharedAppContainerURL = FileManager.default.wmf_containerURL()
let remoteNotificationsStorageURL = sharedAppContainerURL.appendingPathComponent(modelName)
let description = NSPersistentStoreDescription(url: remoteNotificationsStorageURL)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { (storeDescription, error) in
DispatchQueue.main.async {
NotificationCenter.default.post(name: RemoteNotificationsModelController.didLoadPersistentStoresNotification, object: error)
}
}
managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.persistentStoreCoordinator = container.persistentStoreCoordinator
super.init()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
typealias ResultHandler = (Set<RemoteNotification>?) -> Void
public func getUnreadNotifications(_ completion: @escaping ResultHandler) {
return notifications(with: NSPredicate(format: "stateNumber == nil"), completion: completion)
}
public func getReadNotifications(_ completion: @escaping ResultHandler) {
let read = RemoteNotification.State.read.number
return notifications(with: NSPredicate(format: "stateNumber == %@", read), completion: completion)
}
public func getAllNotifications(_ completion: @escaping ResultHandler) {
return notifications(completion: completion)
}
private func notifications(with predicate: NSPredicate? = nil, completion: @escaping ResultHandler) {
let fetchRequest: NSFetchRequest<RemoteNotification> = RemoteNotification.fetchRequest()
fetchRequest.predicate = predicate
let moc = managedObjectContext
moc.perform {
guard let notifications = try? moc.fetch(fetchRequest) else {
completion(nil)
return
}
completion(Set(notifications))
}
}
private func save() {
let moc = managedObjectContext
if moc.hasChanges {
do {
try moc.save()
} catch let error {
DDLogError("Error saving RemoteNotificationsModelController managedObjectContext: \(error)")
}
}
}
public func createNewNotifications(from notificationsFetchedFromTheServer: Set<RemoteNotificationsAPIController.NotificationsResult.Notification>, completion: @escaping () -> Void) throws {
managedObjectContext.perform {
for notification in notificationsFetchedFromTheServer {
self.createNewNotification(from: notification)
}
self.save()
completion()
}
}
// Reminder: Methods that access managedObjectContext should perform their operations
// inside the perform(_:) or the performAndWait(_:) methods.
// https://developer.apple.com/documentation/coredata/using_core_data_in_the_background
private func createNewNotification(from notification: RemoteNotificationsAPIController.NotificationsResult.Notification) {
guard let date = date(from: notification.timestamp?.utciso8601) else {
assertionFailure("Notification should have a date")
return
}
guard let id = notification.id else {
assertionFailure("Notification must have an id")
return
}
let message = notification.message?.header?.wmf_stringByRemovingHTML()
let _ = managedObjectContext.wmf_create(entityNamed: "RemoteNotification",
withKeysAndValues: ["id": id,
"categoryString" : notification.category,
"typeString": notification.type,
"agent": notification.agent?.name,
"affectedPageID": notification.affectedPageID?.full,
"message": message,
"wiki": notification.wiki,
"date": date])
}
private func date(from dateString: String?) -> Date? {
guard let dateString = dateString else {
return nil
}
return DateFormatter.wmf_iso8601()?.date(from: dateString)
}
public func updateNotifications(_ savedNotifications: Set<RemoteNotification>, with notificationsFetchedFromTheServer: Set<RemoteNotificationsAPIController.NotificationsResult.Notification>, completion: @escaping () -> Void) throws {
let savedIDs = Set(savedNotifications.compactMap { $0.id })
let fetchedIDs = Set(notificationsFetchedFromTheServer.compactMap { $0.id })
let commonIDs = savedIDs.intersection(fetchedIDs)
let moc = managedObjectContext
moc.perform {
// Delete notifications that were marked as read on the server
for notification in savedNotifications {
guard let id = notification.id, !commonIDs.contains(id) else {
continue
}
moc.delete(notification)
}
for notification in notificationsFetchedFromTheServer {
guard let id = notification.id else {
assertionFailure("Expected notification to have id")
continue
}
guard !commonIDs.contains(id) else {
if let savedNotification = savedNotifications.first(where: { $0.id == id }) {
// Update notifications that weren't seen so that moc is notified of the update
savedNotification.state = .read
}
continue
}
self.createNewNotification(from: notification)
}
self.save()
completion()
}
}
// MARK: Mark as read
public func markAsRead(_ notification: RemoteNotification) {
self.managedObjectContext.perform {
notification.state = .read
self.save()
}
}
public func markAsRead(notificationWithID notificationID: String) {
processNotificationWithID(notificationID) { (notification) in
notification.state = .read
}
}
// MARK: Mark as excluded
public func markAsExcluded(_ notification: RemoteNotification) {
let moc = managedObjectContext
moc.perform {
notification.state = .excluded
self.save()
}
}
// MARK: Mark as seen
public func markAsSeen(notificationWithID notificationID: String) {
processNotificationWithID(notificationID) { (notification) in
notification.state = .seen
}
}
private func processNotificationWithID(_ notificationID: String, handler: @escaping (RemoteNotification) -> Void) {
let moc = managedObjectContext
moc.perform {
let fetchRequest: NSFetchRequest<RemoteNotification> = RemoteNotification.fetchRequest()
fetchRequest.fetchLimit = 1
let predicate = NSPredicate(format: "id == %@", notificationID)
fetchRequest.predicate = predicate
guard let notifications = try? moc.fetch(fetchRequest), let notification = notifications.first else {
return
}
handler(notification)
self.save()
}
}
}