import CocoaLumberjackSwift import Foundation public enum RemoteNotificationsControllerError: LocalizedError { case databaseUnavailable case attemptingToRefreshBeforeDeadline case failurePullingAppLanguage public var errorDescription: String? { return CommonStrings.genericErrorDescription } } @objc public final class RemoteNotificationsController: NSObject { private let apiController: RemoteNotificationsAPIController private let refreshDeadlineController = RemoteNotificationsRefreshDeadlineController() private let languageLinkController: MWKLanguageLinkController private let authManager: WMFAuthenticationManager private var _modelController: RemoteNotificationsModelController? private var modelController: RemoteNotificationsModelController? { get { guard let modelController = _modelController else { DDLogError("Missing RemoteNotificationsModelController. Confirm Core Data stack was successfully set up.") return nil } return modelController } set { _modelController = newValue } } private var _operationsController: RemoteNotificationsOperationsController? private var operationsController: RemoteNotificationsOperationsController? { get { guard let operationsController = _operationsController else { DDLogError("Missing RemoteNotificationsOperationsController. Confirm Core Data stack was successfully set up in RemoteNotificationsModelController.") return nil } return operationsController } set { _operationsController = newValue } } public var isLoadingNotifications: Bool { return operationsController?.isLoadingNotifications ?? false } public var areFiltersEnabled: Bool { return filterState.readStatus != .all || filterState.offProjects.count != 0 || filterState.offTypes.count != 0 } public static let didUpdateFilterStateNotification = NSNotification.Name(rawValue: "RemoteNotificationsControllerDidUpdateFilterState") public let configuration: Configuration @objc public required init(session: Session, configuration: Configuration, languageLinkController: MWKLanguageLinkController, authManager: WMFAuthenticationManager) { self.apiController = RemoteNotificationsAPIController(session: session, configuration: configuration) self.configuration = configuration self.authManager = authManager self.languageLinkController = languageLinkController super.init() do { modelController = try RemoteNotificationsModelController(containerURL: FileManager.default.wmf_containerURL()) } catch let error { DDLogError("Failed to initialize RemoteNotificationsModelController: \(error)") modelController = nil } NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(authManagerDidLogIn), name:WMFAuthenticationManager.didLogInNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(authManagerDidLogOut), name: WMFAuthenticationManager.didLogOutNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(modelControllerDidLoadPersistentStores(_:)), name: RemoteNotificationsModelController.didLoadPersistentStoresNotification, object: nil) } // MARK: NSNotification Listeners @objc private func modelControllerDidLoadPersistentStores(_ note: Notification) { guard let modelController = modelController else { return } if let object = note.object, let error = object as? Error { DDLogError("RemoteNotificationsModelController failed to load persistent stores with error \(error); not instantiating RemoteNotificationsOperationsController") return } operationsController = RemoteNotificationsOperationsController(languageLinkController: languageLinkController, authManager: authManager, apiController: apiController, modelController: modelController) populateFilterStateFromPersistence() } @objc private func applicationDidBecomeActive() { loadNotifications(force: false) } @objc private func authManagerDidLogOut() { do { filterState = RemoteNotificationsFilterState(readStatus: .all, offTypes: [], offProjects: []) allInboxProjects = [] try modelController?.resetDatabaseAndSharedCache() } catch let error { DDLogError("Error resetting notifications database on logout: \(error)") } } @objc private func authManagerDidLogIn() { loadNotifications(force: true) } // MARK: Public /// Fetches notifications from the server and saves them into the local database. Updates local database on a backgroundContext. /// - Parameters: /// - force: Flag to force an API call, otherwise this will exit early if it's been less than 30 seconds since the last load attempt. /// - completion: Completion block called once refresh attempt is complete. public func loadNotifications(force: Bool, completion: ((Result) -> Void)? = nil) { guard let operationsController = operationsController else { completion?(.failure(RemoteNotificationsControllerError.databaseUnavailable)) return } guard authManager.isLoggedIn else { completion?(.failure(RequestError.unauthenticated)) return } if !force && !refreshDeadlineController.shouldRefresh { completion?(.failure(RemoteNotificationsControllerError.attemptingToRefreshBeforeDeadline)) return } operationsController.loadNotifications { [weak self] result in guard let self = self else { return } switch result { case .success: do { try self.updateAllInboxProjects() completion?(.success(())) } catch let error { completion?(.failure(error)) } case .failure(let error): completion?(.failure(error)) } } refreshDeadlineController.reset() } /// Triggers fetching notifications from the server and saving them into the local database with no completion handler. Used as a bridge for Objective-C use as the `Result` type is unavailable there. /// - Parameter force: Flag to force an API call, otherwise this will exit early if it's been less than 30 seconds since the last load attempt. @objc public func triggerLoadNotifications(force: Bool) { loadNotifications(force: force) } /// Marks notifications as read or unread in the local database and on the server. Errors are not returned. Updates local database on a backgroundContext. /// - Parameters: /// - identifierGroups: Set of IdentifierGroup objects to identify the correct notification. /// - shouldMarkRead: Boolean for marking as read or unread. public func markAsReadOrUnread(identifierGroups: Set, shouldMarkRead: Bool, completion: ((Result) -> Void)? = nil) { guard let operationsController = operationsController else { completion?(.failure(RemoteNotificationsControllerError.databaseUnavailable)) return } guard authManager.isLoggedIn else { completion?(.failure(RequestError.unauthenticated)) return } operationsController.markAsReadOrUnread(identifierGroups: identifierGroups, shouldMarkRead: shouldMarkRead, languageLinkController: languageLinkController, completion: completion) } /// Asks server to mark all notifications as read for projects that contain local unread notifications. Errors are not returned. Updates local database on a backgroundContext. public func markAllAsRead(completion: ((Result) -> Void)? = nil) { guard let operationsController = operationsController else { completion?(.failure(RemoteNotificationsControllerError.databaseUnavailable)) return } guard authManager.isLoggedIn else { completion?(.failure(RequestError.unauthenticated)) return } operationsController.markAllAsRead(languageLinkController: languageLinkController, completion: completion) } /// Asks server to mark all notifications as seen for the primary app language public func markAllAsSeen(completion: @escaping ((Result) -> Void)) { guard authManager.isLoggedIn else { completion(.failure(RequestError.unauthenticated)) return } guard let appLanguage = languageLinkController.appLanguage else { completion(.failure(RemoteNotificationsControllerError.failurePullingAppLanguage)) return } let appLanguageProject = WikimediaProject.wikipedia(appLanguage.languageCode, appLanguage.localizedName, appLanguage.languageVariantCode) apiController.markAllAsSeen(project: appLanguageProject, completion: completion) } /// Passthrough method to listen for NSManagedObjectContextObjectsDidChange notifications on the viewContext, in order to encapsulate viewContext within the WMF Framework. /// - Parameters: /// - observer: NSNotification observer /// - selector: Selector to call on the observer once the NSNotification fires public func addObserverForViewContextChanges(observer: AnyObject, selector: Selector) { guard let viewContext = modelController?.viewContext else { return } NotificationCenter.default.addObserver(observer, selector: selector, name: Notification.Name.NSManagedObjectContextObjectsDidChange, object: viewContext) } /// Fetches notifications from the local database. Uses the viewContext and must be called from the main thread /// - Parameters: /// - fetchLimit: Number of notifications to fetch. Defaults to 50. /// - fetchOffset: Offset for fetching notifications. Use when fetching later pages of data /// - Returns: Array of RemoteNotifications public func fetchNotifications(fetchLimit: Int = 50, fetchOffset: Int = 0, completion: @escaping (Result<[RemoteNotification], Error>) -> Void) { guard let modelController = modelController else { return completion(.failure(RemoteNotificationsControllerError.databaseUnavailable)) } let fetchFromDatabase: () -> Void = { [weak self] in guard let self = self else { return } let predicate = self.predicateForFilterSavedState(self.filterState) do { let notifications = try modelController.fetchNotifications(fetchLimit: fetchLimit, fetchOffset: fetchOffset, predicate: predicate) completion(.success(notifications)) } catch let error { completion(.failure(error)) } } guard !isFullyImported else { fetchFromDatabase() return } loadNotifications(force: true) { result in switch result { case .success: fetchFromDatabase() case .failure(let error): completion(.failure(error)) } } } /// Fetches a count of unread notifications from the local database. Uses the viewContext and must be called from the main thread @objc public func numberOfUnreadNotifications() throws -> NSNumber { guard let modelController = modelController else { throw RemoteNotificationsControllerError.databaseUnavailable } let count = try modelController.numberOfUnreadNotifications() return NSNumber(value: count) } /// Fetches a count of all notifications from the local database. Uses the viewContext and must be called from the main thread public func numberOfAllNotifications() throws -> Int { guard let modelController = modelController else { throw RemoteNotificationsControllerError.databaseUnavailable } return try modelController.numberOfAllNotifications() } /// List of all possible inbox projects available Notifications Center. Used for populating the Inbox screen and the project count toolbar public private(set) var allInboxProjects: Set = [] /// A count of showing inbox projects (i.e. allInboxProjects minus those toggled off in the inbox filter screen) public var countOfShowingInboxProjects: Int { let filteredProjects = filterState.offProjects return allInboxProjects.subtracting(filteredProjects).count } @objc public func updateCacheWithCurrentUnreadNotificationsCount() throws { let currentCount = try numberOfUnreadNotifications().intValue let sharedCache = SharedContainerCache(fileName: SharedContainerCacheCommonNames.pushNotificationsCache, defaultCache: { PushNotificationsCache(settings: .default, notifications: []) }) var pushCache = sharedCache.loadCache() pushCache.currentUnreadCount = currentCount sharedCache.saveCache(pushCache) } public var filterPredicate: NSPredicate? { predicateForFilterSavedState(filterState) } public var filterState: RemoteNotificationsFilterState = RemoteNotificationsFilterState(readStatus: .all, offTypes: [], offProjects: []) { didSet { guard let modelController = modelController else { return } // save to library modelController.setFilterSettingsToLibrary(dictionary: filterState.serialize()) NotificationCenter.default.post(name: RemoteNotificationsController.didUpdateFilterStateNotification, object: nil) } } public var isFullyImported: Bool { guard let modelController = modelController else { return false } let appLanguageProjects = languageLinkController.preferredLanguages.map { WikimediaProject.wikipedia($0.languageCode, $0.localizedName, $0.languageVariantCode) } for project in appLanguageProjects { if !modelController.isProjectAlreadyImported(project: project) { return false } } return true } // MARK: Internal @objc func deleteLegacyDatabaseFiles() throws { guard let modelController = modelController else { throw RemoteNotificationsControllerError.databaseUnavailable } try modelController.deleteLegacyDatabaseFiles() } // MARK: Private /// Pulls filter state from local persistence and saves it in memory private func populateFilterStateFromPersistence() { guard let modelController = modelController, let persistentFiltersDict = modelController.getFilterSettingsFromLibrary(), let persistentFilters = RemoteNotificationsFilterState(nsDictionary: persistentFiltersDict, languageLinkController: languageLinkController) else { return } self.filterState = persistentFilters } /// Fetches from the local database all projects that contain a local notification on device. Uses the viewContext and must be called from the main thread. /// - Returns: Array of WikimediaProject private func projectsFromLocalNotifications() throws -> Set { guard let modelController = modelController else { return [] } let wikis = try modelController.distinctWikis(predicate: nil) let projects = wikis.compactMap { WikimediaProject(notificationsApiIdentifier: $0, languageLinkController: languageLinkController) } return Set(projects) } private func predicateForFilterSavedState(_ filterState: RemoteNotificationsFilterState) -> NSPredicate? { var readStatusPredicate: NSPredicate? let readStatus = filterState.readStatus switch readStatus { case .all: readStatusPredicate = nil case .read: readStatusPredicate = NSPredicate(format: "isRead == %@", NSNumber(value: true)) case .unread: readStatusPredicate = NSPredicate(format: "isRead == %@", NSNumber(value: false)) } let offTypes = filterState.offTypes let onTypes = RemoteNotificationFilterType.orderingForFilters.filter {!offTypes.contains($0)} var typePredicates: [NSPredicate] = [] let otherIsOff = offTypes.contains(.other) if onTypes.isEmpty { return NSPredicate(format: "FALSEPREDICATE") } if otherIsOff { typePredicates = onTypes.compactMap { settingType in let categoryStrings = RemoteNotificationFilterType.categoryStringsForFilterType(type: settingType) let typeStrings = RemoteNotificationFilterType.typeStringForFilterType(type: settingType) guard categoryStrings.count > 0 && typeStrings.count > 0 else { return nil } return NSPredicate(format: "(categoryString IN %@ AND typeString IN %@)", categoryStrings, typeStrings) } } else { typePredicates = offTypes.compactMap { settingType in let categoryStrings = RemoteNotificationFilterType.categoryStringsForFilterType(type: settingType) let typeStrings = RemoteNotificationFilterType.typeStringForFilterType(type: settingType) guard categoryStrings.count > 0 && typeStrings.count > 0 else { return nil } return NSPredicate(format: "NOT (categoryString IN %@ AND typeString IN %@)", categoryStrings, typeStrings) } } let offProjects = filterState.offProjects let offProjectPredicates: [NSPredicate] = offProjects.compactMap { return NSPredicate(format: "NOT (wiki == %@)", $0.notificationsApiWikiIdentifier) } guard readStatusPredicate != nil || typePredicates.count > 0 || offProjectPredicates.count > 0 else { return nil } var combinedOffTypePredicate: NSPredicate? = nil if typePredicates.count > 0 { if otherIsOff { combinedOffTypePredicate = NSCompoundPredicate(orPredicateWithSubpredicates: typePredicates) } else { combinedOffTypePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: typePredicates) } } var combinedOffProjectPredicate: NSPredicate? = nil if offProjectPredicates.count > 0 { combinedOffProjectPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: offProjectPredicates) } let finalPredicates = [readStatusPredicate, combinedOffTypePredicate, combinedOffProjectPredicate].compactMap { $0 } return finalPredicates.count > 0 ? NSCompoundPredicate(andPredicateWithSubpredicates: finalPredicates) : nil } /// Updates value of allInboxProjects by gathering list of static projects, app language projects, and local notifications projects. Involves a fetch to the local database. Uses the viewContext and must be called from the main thread private func updateAllInboxProjects() throws { let sideProjects: Set = [.commons, .wikidata] let appLanguageProjects = languageLinkController.preferredLanguages.map { WikimediaProject.wikipedia($0.languageCode, $0.localizedName, $0.languageVariantCode) } var inboxProjects = sideProjects.union(appLanguageProjects) let localProjects = try projectsFromLocalNotifications() for localProject in localProjects { inboxProjects.insert(localProject) } self.allInboxProjects = inboxProjects } } public struct RemoteNotificationsFilterState: Equatable { public enum ReadStatus: Int, CaseIterable { case all case read case unread var localizedDescription: String { switch self { case .all: return CommonStrings.notificationsCenterAllNotificationsStatus case .read: return CommonStrings.notificationsCenterReadNotificationsStatus case .unread: return CommonStrings.notificationsCenterUnreadNotificationsStatus } } } public let readStatus: ReadStatus public let offTypes: Set public let offProjects: Set public init(readStatus: ReadStatus, offTypes: Set, offProjects: Set) { self.readStatus = readStatus self.offTypes = offTypes self.offProjects = offProjects } private var isReadStatusOrTypeFiltered: Bool { return (readStatus != .all || !offTypes.isEmpty) } public var stateDescription: String { let filteredBy = WMFLocalizedString("notifications-center-status-filtered-by", value: "Filtered by", comment: "Status header text in Notifications Center displayed when filtering notifications.") let allNotifications = WMFLocalizedString("notifications-center-status-all-notifications", value: "All notifications", comment: "Status header text in Notifications Center displayed when viewing unfiltered list of notifications.") let headerText = isReadStatusOrTypeFiltered ? filteredBy : allNotifications return headerText } public static var detailDescriptionHighlightDelineator = "**" public func detailDescription(totalProjectCount: Int, showingProjectCount: Int) -> String? { // Generic templates let doubleConcatenationTemplate = WMFLocalizedString("notifications-center-status-double-concatenation", value: "%1$@ in %2$@", comment: "Notifications Center status description. %1$@ is replaced with the currently applied filters and %2$@ is replaced with the count of projects/inboxes.") let tripleConcatenationTemplate = WMFLocalizedString("notifications-center-status-triple-concatenation", value: "%1$@ and %2$@ in %3$@", comment: "Notifications Center status description. %1$@ is replaced with the currently applied read status filter, %2$@ is replaced with the count of notification type filters, and %3$@ is replaced with the count of projects/inboxes.") // Specific plurals let inProjects = WMFLocalizedString("notifications-center-status-in-projects", value: "{{PLURAL:%1$d|1=In 1 project|In %1$d projects}}", comment: "Notifications Center status description when filtering by projects/inboxes. %1$d is replaced by the count of local projects.") let projectsPlain = WMFLocalizedString("notifications-center-status-in-projects-plain", value: "{{PLURAL:%1$d|1=1 project|%1$d projects}}", comment: "Notifications Center status description when filtering by projects/inboxes, without preposition. %1$d is replaced by the count of local projects.") let typesPlain = WMFLocalizedString("notifications-center-status-in-types", value: "{{PLURAL:%1$d|1=1 type|%1$d types}}", comment: "Notifications Center status description when filtering by types. %1$d is replaced by the count of filtered types.") var descriptionString: String? switch (readStatus, offTypes.count, offProjects.count) { case (.all, 0, 0): // No filtering descriptionString = String.localizedStringWithFormat(inProjects, totalProjectCount) case (.all, 1..., 0): // Only filtering by type let typesString = String.localizedStringWithFormat(typesPlain, offTypes.count).highlightDelineated let totalProjectString = String.localizedStringWithFormat(projectsPlain, totalProjectCount) descriptionString = String.localizedStringWithFormat(doubleConcatenationTemplate, typesString, totalProjectString) case (.all, 0, 1...): // Only filtering by project/inbox descriptionString = String.localizedStringWithFormat(inProjects, showingProjectCount).highlightDelineated case (.read, 0, 0), (.unread, 0, 0): // Only filtering by read status let totalProjectString = String.localizedStringWithFormat(projectsPlain, totalProjectCount) descriptionString = String.localizedStringWithFormat(doubleConcatenationTemplate, readStatus.localizedDescription.highlightDelineated, totalProjectString) case (.read, 1..., 0), (.unread, 1..., 0): // Filtering by read status and type let typesString = String.localizedStringWithFormat(typesPlain, offTypes.count).highlightDelineated let totalProjectString = String.localizedStringWithFormat(projectsPlain, totalProjectCount) descriptionString = String.localizedStringWithFormat(tripleConcatenationTemplate, readStatus.localizedDescription.highlightDelineated, typesString, totalProjectString) case (.read, 0, 1...), (.unread, 0, 1...): // Filtering by read status and project/inbox let projectString = String.localizedStringWithFormat(projectsPlain, showingProjectCount).highlightDelineated descriptionString = String.localizedStringWithFormat(doubleConcatenationTemplate, readStatus.localizedDescription.highlightDelineated, projectString) case (let readStatus, 1..., 1...): // Filtering by type, project/inbox, and potentially read status switch readStatus { case .all: // Filtering by type and project/inbox let typesString = String.localizedStringWithFormat(typesPlain, offTypes.count).highlightDelineated let projectString = String.localizedStringWithFormat(projectsPlain, showingProjectCount).highlightDelineated descriptionString = String.localizedStringWithFormat(doubleConcatenationTemplate, typesString, projectString) case .read, .unread: // Filtering by read status, type, and project/inbox let readString = readStatus.localizedDescription.highlightDelineated let typesString = String.localizedStringWithFormat(typesPlain, offTypes.count).highlightDelineated let projectString = String.localizedStringWithFormat(projectsPlain, showingProjectCount).highlightDelineated descriptionString = String.localizedStringWithFormat(tripleConcatenationTemplate, readString, typesString, projectString) } default: break } return descriptionString } private let readStatusKey = "readStatus" private let offTypesKey = "offTypes" private let offProjectsKey = "offProjects" func serialize() -> NSDictionary? { let mutableDictionary = NSMutableDictionary() let numReadStatus = NSNumber(value: readStatus.rawValue) mutableDictionary.setValue(numReadStatus, forKey: readStatusKey) let offTypeIdentifiers = offTypes.compactMap { $0.filterIdentifier as NSString? } mutableDictionary.setValue(NSArray(array: offTypeIdentifiers), forKey: offTypesKey) let offProjectIdentifiers = offProjects.compactMap { $0.notificationsApiWikiIdentifier as NSString? } mutableDictionary.setValue(NSArray(array: offProjectIdentifiers), forKey: offProjectsKey) return mutableDictionary.copy() as? NSDictionary } init?(nsDictionary: NSDictionary, languageLinkController: MWKLanguageLinkController) { guard let dictionary = nsDictionary as? [String: AnyObject] else { return nil } guard let numReadStatus = dictionary[readStatusKey] as? NSNumber, let readStatus = ReadStatus(rawValue: numReadStatus.intValue), let offTypeIdentifiers = dictionary[offTypesKey] as? [NSString], let offProjectApiIdentifiers = dictionary[offProjectsKey] as? [NSString] else { return nil } let offTypes = offTypeIdentifiers.compactMap { RemoteNotificationFilterType(from: $0 as String) } let offProjects = offProjectApiIdentifiers.compactMap { WikimediaProject(notificationsApiIdentifier: $0 as String, languageLinkController: languageLinkController) } self.readStatus = readStatus self.offTypes = Set(offTypes) self.offProjects = Set(offProjects) } } fileprivate extension String { /// Delineated section of string to be highlighted in attributed string var highlightDelineated: String { return RemoteNotificationsFilterState.detailDescriptionHighlightDelineator + self + RemoteNotificationsFilterState.detailDescriptionHighlightDelineator } }