636 lines
30 KiB
Swift

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, Error>) -> 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<RemoteNotification.IdentifierGroup>, shouldMarkRead: Bool, completion: ((Result<Void, Error>) -> 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, Error>) -> 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, Error>) -> 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<WikimediaProject> = []
/// 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<PushNotificationsCache>(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<WikimediaProject> {
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<WikimediaProject> = [.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<RemoteNotificationFilterType>
public let offProjects: Set<WikimediaProject>
public init(readStatus: ReadStatus, offTypes: Set<RemoteNotificationFilterType>, offProjects: Set<WikimediaProject>) {
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
}
}