// // TopNotificationHandler.swift // MVMCoreUI // // Created by Scott Pfeil on 4/11/23. // Copyright © 2023 Verizon Wireless. All rights reserved. // import MVMCore import Dispatch import Combine /// Handles the UI tasks for the notification. public protocol NotificationTransitionDelegateProtocol { @MainActor func show(notification: UIView) async @MainActor func hide(notification: UIView) async @MainActor func update(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) } /// An operation for managing the life cycle of the notification. public class NotificationOperation: MVMCoreOperation { public let notification: UIView public var notificationModel: NotificationModel /// The delegate that manages transitioning the notification. private let transitionDelegate: NotificationTransitionDelegateProtocol /// The showing animation transition operation. private weak var showTransitionOperation: Operation? /// The stop timer for non-persistent notifications. private var timerSource: DispatchSourceTimer? /// Determines if the operation is ready. For example, certain notifications are only meant to be displayed on certain pages and this can be set accordingly. public var isDisplayable: Bool { get { var isDisplayable: Bool = true //displayableQueue.sync { isDisplayable = _isDisplayable //} return isDisplayable } set { guard newValue != isDisplayable else { return } //displayableQueue.async(flags: .barrier) { [weak self] in self._isDisplayable = newValue //} } } /// Thread safety. private var displayableQueue = DispatchQueue(label: "displayable", attributes: .concurrent) /// Updates the operation readiness accordingly. private var _isDisplayable: Bool = true { willSet { guard super.isReady else { return } willChangeValue(for: \.isReady) } didSet { guard super.isReady else { return } didChangeValue(for: \.isReady) } } /// This operation is ready only if this notification is allowed to show. public override var isReady: Bool { get { guard !isCancelled else { return super.isReady } return super.isReady && isDisplayable } } public actor Properties { /// If the notification is currently displayed. private var isDisplayed: Bool = false /// If the notification is currently animating (showing/hiding). private var isAnimating: Bool = false fileprivate func set(displayed: Bool) { isDisplayed = displayed } public func getIsDisplayed() -> Bool { return isDisplayed } fileprivate func set(animating: Bool) { isAnimating = animating } public func getIsAnimating() -> Bool { return isAnimating } } /// Actor isolated properties for the operation. public var properties = Properties() /// A flag for tracking if the operation needs to be re-added because it was cancelled for a higher priority notification. public var reAddAfterCancel = false public init(with notification: UIView, notificationModel: NotificationModel, transitionDelegate: NotificationTransitionDelegateProtocol) { self.notification = notification self.notificationModel = notificationModel self.transitionDelegate = transitionDelegate super.init() queuePriority = notificationModel.priority } public override func main() { log(message: "Operation Started") guard !checkAndHandleForCancellation() else { return } Task { await withCheckedContinuation { continuation in // Show the notification. showTransitionOperation = add(transition: { [weak self] in guard let self = self else { return } NotificationHandler.shared()?.onNotificationWillShow.send((self.notification, self.notificationModel)) self.log(message: "Operation Will Show") await self.showNotification() }, completionBlock: { continuation.resume() }) } guard await properties.getIsDisplayed() else { // If the animation did not complete... log(message: "Operation Never Shown") markAsFinished() return } // Publish that the notification has been shown. NotificationHandler.shared()?.onNotificationShown.send((notification, notificationModel)) log(message: "Operation Did Show") guard !isCancelled else { // If cancelled during the animation, dismiss immediately. stop() return } updateStopTimer() } } public func stop() { if let timerSource = timerSource { timerSource.cancel() } Task { guard await properties.getIsDisplayed(), await !properties.getIsAnimating() else { return } // Hide the notification await withCheckedContinuation({ continuation in _ = add(transition: { [weak self] in guard let self = self else { return } self.log(message: "Operation Will Dismiss") NotificationHandler.shared()?.onNotificationWillDismiss.send((notification, notificationModel)) await self.hideNotification() }, completionBlock: { continuation.resume() }) }) // The animation must complete... guard await !self.properties.getIsDisplayed() else { return } // Publish that the notification has been hidden. NotificationHandler.shared()?.onNotificationDismissed.send((notification, notificationModel)) log(message: "Operation Did Dismiss") guard isCancelled || !notificationModel.persistent else { return } markAsFinished() } } public override func markAsFinished() { log(message: "Operation Finished") super.markAsFinished() } public override func cancel() { super.cancel() log(message: "Operation Cancelled") Task { if await !properties.getIsDisplayed() { // Cancel any pending show transitions. showTransitionOperation?.cancel() } // Do nothing if animating. guard await !properties.getIsAnimating() else { return } if await properties.getIsDisplayed() { stop() } else if isExecuting { markAsFinished() } } } public func log(message: String) { MVMCoreUILoggingHandler.logDebugMessage(withDelegate: "------Notification message: \(message) type: \(notificationModel.type) title: \(String(describing: (notificationModel.molecule as? NotificationMoleculeModel)?.headline.text)) id: \(notificationModel.id) priority: e\(notificationModel.priority.rawValue) a\(queuePriority.rawValue) operation: \(String(describing: self)) ------") } // MARK: - Automatic /// Sets up a timer to hide the notification. private func updateStopTimer() { if let timerSource = timerSource { timerSource.cancel() } guard !notificationModel.persistent else { return } timerSource = DispatchSource.makeTimerSource() timerSource?.setEventHandler(handler: { [weak self] in self?.log(message: "Operation Time Out") guard let self = self, !self.isFinished, !self.checkAndHandleForCancellation() else { return } // If voice over is on and the notification is focused, do not collapse until unfocused. guard !MVMCoreUIUtility.viewContainsAccessiblityFocus(notification) else { NotificationCenter.default.addObserver(self, selector: #selector(accessibilityFocusChanged), name: UIAccessibility.elementFocusedNotification, object: nil) return } self.stop() }) timerSource?.setCancelHandler(handler: { [weak self] in self?.log(message: "Operation Time Out Cancel") }) timerSource?.schedule(deadline: .now() + .seconds(notificationModel.dismissTime)) timerSource?.activate() } /// If the voice over user leaves top alert focus, hide. @objc func accessibilityFocusChanged(_ notification: NSNotification) { guard let _ = notification.userInfo?[UIAccessibility.focusedElementUserInfoKey], !MVMCoreUIUtility.viewContainsAccessiblityFocus(self.notification) else { return } self.log(message: "Operation Accessibility Focus Removed") NotificationCenter.default.removeObserver(self, name: UIAccessibility.elementFocusedNotification, object: nil) stop() } // MARK: - Transitions /// Adds the transition of the notification to the navigation queue to avoid animation collisions. private func add(transition: @escaping () async -> Void, completionBlock: (() -> Void)?) -> Operation { let transitionOperation = MVMCoreBlockOperation(block: { blockOperation in guard !blockOperation.checkAndHandleForCancellation() else { return } Task { await transition() blockOperation.markAsFinished() } })! transitionOperation.completionBlock = completionBlock MVMCoreNavigationHandler.shared()?.addNavigationOperation(transitionOperation) return transitionOperation } @MainActor private func showNotification() async { await properties.set(animating: true) await transitionDelegate.show(notification: notification) await properties.set(displayed: true) await properties.set(animating: false) } @MainActor private func hideNotification() async { await properties.set(animating: true) await transitionDelegate.hide(notification: notification) await properties.set(displayed: false) await properties.set(animating: false) } } extension NotificationOperation: NSCopying { public func copy(with zone: NSZone? = nil) -> Any { let operation = NotificationOperation(with: notification, notificationModel: notificationModel, transitionDelegate: transitionDelegate) operation.reAddAfterCancel = reAddAfterCancel operation.isDisplayable = isDisplayable for dependency in dependencies { operation.addDependency(dependency) } operation.name = name operation.qualityOfService = qualityOfService return operation } } /// Manages notifications. open class NotificationHandler { private var queue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 return queue }() private var transitionDelegate: NotificationTransitionDelegateProtocol // MARK: - Publishers /// Publishes when a notification will show. public let onNotificationWillShow = PassthroughSubject<(UIView, NotificationModel), Never>() /// Publishes when a notification is shown. public let onNotificationShown = PassthroughSubject<(UIView, NotificationModel), Never>() /// Publishes when a notification will dismissed. public let onNotificationWillDismiss = PassthroughSubject<(UIView, NotificationModel), Never>() /// Publishes when a notification is dismissed. public let onNotificationDismissed = PassthroughSubject<(UIView, NotificationModel), Never>() /// Publishes when a notification is updated. public let onNotificationUpdated = PassthroughSubject<(UIView, NotificationModel), Never>() // MARK: - /// Returns the handler stored in the CoreUIObject public static func shared() -> Self? { guard let shared = CoreUIObject.sharedInstance()?.topNotificationHandler else { return nil } return MVMCoreActionUtility.fatalClassCheck(object: shared) } public init(with transitionDelegate: NotificationTransitionDelegateProtocol) { self.transitionDelegate = transitionDelegate registerWithNotificationCenter() registerForPageChanges() } // MARK: - JSON Handling /// Registers with the notification center to know when json is updated. private func registerWithNotificationCenter() { NotificationCenter.default.addObserver(self, selector: #selector(responseJSONUpdated(notification:)), name: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil) } /// Registers to know when pages change. private func registerForPageChanges() { MVMCoreNavigationHandler.shared()?.addDelegate(self) } /// Checks for new top alert json @objc private func responseJSONUpdated(notification: Notification) { guard let loadObject = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject) else { return } let delegateObject = loadObject.delegateObject as? MVMCoreUIDelegateObject // Dismiss any top alerts that server wants us to dismiss. if let disableType = loadObject.responseInfoMap?.optionalStringForKey("disableType") { NotificationHandler.shared()?.cancelNotification(using: { view, model in return model.type == disableType }) } // Show any new top alert. guard let responseJSON = loadObject.responseJSON, let json = responseJSON.optionalDictionaryForKey(KeyTopAlert) else { return } Task { await showNotification(for: json, delegateObject: delegateObject) } } /// Converts the json to a model and creates the view and queues up the notification. open func showNotification(for json: [AnyHashable: Any], delegateObject: MVMCoreUIDelegateObject?) async { do { let model = try NotificationModel.decode(json: json, delegateObject: delegateObject) try await showNotification(for: model, delegateObject: delegateObject) } catch { if let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: "\(self)") { MVMCoreUILoggingHandler.addError(toLog: errorObject) } } } // MARK: - Operation Handling /// Adds the operation to the queue. private func add(operation: NotificationOperation) { operation.completionBlock = { [weak self] in // If the alert was cancelled to show another with higher priority, re-add to the operation when cancelled to the queue. if operation.reAddAfterCancel { let newOperation: NotificationOperation = operation.copy() as! NotificationOperation newOperation.reAddAfterCancel = false self?.add(operation: newOperation) } operation.completionBlock = nil } let currentPageType = (MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() as? MVMCoreViewControllerProtocol)?.pageType operation.updateDisplayable(by: currentPageType) queue.addOperation(operation) operation.log(message: "Operation Added") reevaluteQueue() } /// Checks for existing top alert object of same type and updates it. Only happens for molecular top alerts. Returns true if we updated. open func checkAndUpdateExisting(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) -> Bool { for case let operation as NotificationOperation in queue.operations { guard operation.notificationModel.type == model.type else { continue } operation.update(with: model, delegateObject: delegateObject) let pageType = (MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() as? MVMCoreViewControllerProtocol)?.pageType operation.updateDisplayable(by: pageType) reevaluteQueue() return true } return false } /// Re-evaluates the queue operations private func reevaluteQueue() { var highestReadyOperation: NotificationOperation? var executingOperation: NotificationOperation? for case let operation as NotificationOperation in queue.operations { guard !operation.isCancelled, !operation.isFinished else { continue } if operation.isReady, highestReadyOperation == nil || operation.notificationModel.priority.rawValue > highestReadyOperation!.notificationModel.priority.rawValue { highestReadyOperation = operation } if operation.isExecuting { executingOperation = operation } } guard let currentOperation = executingOperation else { return } // Cancel the executing operation if it is no longer ready to run. Re-add for later if it is persistent. guard currentOperation.isReady else { currentOperation.reAddAfterCancel = currentOperation.notificationModel.persistent currentOperation.cancel() return } // If the highest priority operation is not executing, and the executing operation is persistent, cancel it. if let newOperation = highestReadyOperation, currentOperation != newOperation, currentOperation.notificationModel.persistent { currentOperation.reAddAfterCancel = true currentOperation.cancel() } } // MARK: - Verify /// Returns if any notification is executing open func isNotificationShowing() -> Bool { return queue.operations.first(where: { operation in return operation.isExecuting }) != nil } /** Returns if the first executing operation matches the provided predicate. * @param predicate The predicate block to decide if it is the notification. */ open func hasNotification(using predicate: ((UIView, NotificationModel) -> Bool)) -> Bool { return queue.operations.first(where: { operation in guard operation.isExecuting, let operation = operation as? NotificationOperation else { return false } return predicate(operation.notification, operation.notificationModel) }) as? NotificationOperation != nil } /// Returns the current executing notification view and model open func getCurrentNotification() async -> (UIView, NotificationModel)? { for operation in queue.operations { guard operation.isExecuting, let operation = operation as? NotificationOperation, await operation.properties.getIsDisplayed() else { continue } return (operation.notification, operation.notificationModel) } return nil } // MARK: - Show and hide /// Creates the view and queues up the notification. open func showNotification(for model: NotificationModel, delegateObject: MVMCoreUIDelegateObject? = nil) async throws { guard !checkAndUpdateExisting(with: model, delegateObject: delegateObject) else { return } let view = try await createNotification(with: model, delegateObject: delegateObject) let operation = NotificationOperation(with: view, notificationModel: model, transitionDelegate: transitionDelegate) add(operation: operation) } @MainActor private func createNotification(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) async throws -> UIView & MoleculeViewProtocol { guard let view = ModelRegistry.createMolecule(model.molecule, delegateObject: delegateObject, additionalData: nil) else { throw ModelRegistry.Error.decoderOther(message: "Molecule not mapped") } return view } /// Cancel the current top alert view. open func hideNotification() { guard let currentOperation = queue.operations.first(where: { operation in return operation.isExecuting && !operation.isCancelled }) as? NotificationOperation else { return } currentOperation.notificationModel.persistent = false currentOperation.reAddAfterCancel = false currentOperation.cancel() } /** Iterates through all scheduled notifications and cancels any that match the provided predicate. * @param predicate The predicate block to decide whether to cancel an notification. */ open func cancelNotification(using predicate: ((UIView, NotificationModel) -> Bool)) { for case let operation as NotificationOperation in queue.operations { if predicate(operation.notification, operation.notificationModel) { operation.reAddAfterCancel = false operation.cancel() } } } /// Cancel all notifications, current or pending. open func removeAllNotifications() { queue.cancelAllOperations() } } extension NotificationHandler: MVMCorePresentationDelegateProtocol { // Update displayable for each top alert operation when page type changes, in top queue priority order. public func navigationController(_ navigationController: UINavigationController, displayedViewController viewController: UIViewController) { guard queue.operations.count > 0 else { return } let viewController = MVMCoreUIUtility.getViewControllerTraversingManagers(viewController) guard viewController == MVMCoreUISplitViewController.main()?.getCurrentViewController() else { return } let pageType = (viewController as? MVMCoreViewControllerProtocol)?.pageType Task { queue.operations.compactMap { $0 as? NotificationOperation }.sorted { $0.notificationModel.priority.rawValue > $1.notificationModel.priority.rawValue }.forEach { $0.updateDisplayable(by: pageType) } reevaluteQueue() } } } extension NotificationOperation { /// Updates the operation and notification with the new model. public func update(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) { self.notificationModel = model queuePriority = model.priority guard isExecuting, !isCancelled else { return } self.log(message: "Operation Updated") updateStopTimer() Task { @MainActor in transitionDelegate.update(with: notificationModel, delegateObject: delegateObject) } } /// Updates if the operation is displayable based on the page type. func updateDisplayable(by pageType: String?) { guard let pages = notificationModel.pages else { isDisplayable = true return } guard let pageType = pageType else { isDisplayable = false return } isDisplayable = pages.contains(pageType) } }