mvm_core_ui/MVMCoreUI/Notification/NotificationHandler.swift

587 lines
24 KiB
Swift

//
// 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
// TODO: Causes crash. Check thoughts of reviewer.
//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)
NotificationCenter.default.addObserver(self, selector: #selector(accessibilityFocusChanged), name: UIAccessibility.voiceOverStatusDidChangeNotification, 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 UIAccessibility.isVoiceOverRunning else {
accessibilityFocusFinished()
return
}
guard let _ = notification.userInfo?[UIAccessibility.focusedElementUserInfoKey],
!MVMCoreUIUtility.viewContainsAccessiblityFocus(self.notification) else { return }
accessibilityFocusFinished()
}
/// Dismisses the nofitication when we lose focus.
private func accessibilityFocusFinished() {
log(message: "Operation Accessibility Focus Removed")
NotificationCenter.default.removeObserver(self, name: UIAccessibility.elementFocusedNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIAccessibility.voiceOverStatusDidChangeNotification, 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 dismiss.
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 {
do {
try await showNotification(for: json, delegateObject: delegateObject)
} catch {
if let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: "\(self)") {
MVMCoreUILoggingHandler.addError(toLog: errorObject)
}
}
}
}
/// 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 throws {
let model = try NotificationModel.decode(json: json, delegateObject: delegateObject)
try await showNotification(for: model, delegateObject: delegateObject)
}
// 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. 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")
displayableQueue.async(flags: .barrier) { [self] in
updateStopTimer()
}
Task {
await transitionDelegate.update(with: notificationModel, delegateObject: delegateObject)
NotificationHandler.shared()?.onNotificationUpdated.send((notification, notificationModel))
}
}
/// 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)
}
}