iosswiftsdkusernotifications

Will a local notification action execute if selected while an app is not running?


I use local notifications to notify my users when a session is ready to begin or end. My app performs several tasks when a session is started or ended. Currently, the user has to launch the app to manually start/end a session. I would like to offer notification actions to start/end a session without launching the app in the foreground.

When the app is running in the background and I select a notification action, the UNUserNotificationCenterDelegate's didReceive method is called, I receive a UNNotificationResponse, and my app performs the tasks associated with starting/ending a session as expected.

However, if I force close my app from the App Switcher and then select a notification action when the notification comes in, it appears the didReceive delegate method is not being called. My app doesn't run the tasks needed to start/end a session properly.

Apple's docs on Actionable Notifications states: "Actionable notifications let the user respond to a delivered notification without launching the corresponding app. Other notifications display information in a notification interface, but the user’s only course of action is to launch the app. For an actionable notification, the system displays one or more buttons in addition to the notification interface. Tapping a button sends the selected action to the app, which then processes the action in the background."

Am I doing something wrong here or perhaps misunderstanding how notification actions work?

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    let injectionContainer = AppContainer()
    let dbMaintenanceManager = DBMaintenanceManager()
    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]?) -> Bool {
        
        requestNotificationAuthorization()
        
        dbMaintenanceManager.performMaintenanceTasks()
        
        let appearance = UINavigationBarAppearance()
        appearance.backgroundColor = Color.primaryBlue
        appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
        
        UINavigationBar.appearance().standardAppearance = appearance
        UINavigationBar.appearance().scrollEdgeAppearance = appearance
        UINavigationBar.appearance().tintColor = .white
      
        let mainVC = injectionContainer.makeMainViewController()

        window = UIWindow(frame: UIScreen.main.bounds)
        window?.overrideUserInterfaceStyle = .light
        window?.makeKeyAndVisible()
        window?.rootViewController = mainVC

        return true
    }
        
    func applicationWillEnterForeground(_ application: UIApplication) {
        dbMaintenanceManager.performMaintenanceTasks()
    }
    
    private func requestNotificationAuthorization() {
        let notificationCenter = UNUserNotificationCenter.current()
        
        notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            if let error = error {
                print("Error requesting authorization: \(error)")
                return
            }

            if granted {
                let startAction = UNNotificationAction(identifier: "START_ACTION",
                                                              title: "Start Session",
                                                       options: [])
                
                let endAction = UNNotificationAction(identifier: "END_ACTION",
                                                            title: "End Session",
                                                     options: [])
                
                let startCategory = UNNotificationCategory(identifier: "START_CATEGORY",
                                                                  actions: [startAction],
                                                                  intentIdentifiers: [],
                                                                  options: [])
                
                let endCategory = UNNotificationCategory(identifier: "END_CATEGORY",
                                                                  actions: [endAction],
                                                                  intentIdentifiers: [],
                                                                  options: [])
                
                let notificationCenter = UNUserNotificationCenter.current()
                notificationCenter.setNotificationCategories([startCategory, endCategory])
                notificationCenter.delegate = self
                print("Notification permission authorized")
            } else {
                print("Notification permission denied")
            }
        }
    }
}

// MARK: - UNUserNotificationCenterDelegate

extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        NotificationCenter.default.post(name: .didReceivePushNotification, object: notification)
        completionHandler([.banner, .list, .sound])
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        handleNotificationResponse(response)
        completionHandler()
    }
        
    private func handleNotificationResponse(_ response: UNNotificationResponse) {
        let userInfo = response.notification.request.content.userInfo
        guard let id = userInfo["sessionId"] as? String else {
            print("Error reading session id associated with notification")
            return
        }

        switch response.actionIdentifier {
        case "START_ACTION":
            NotificationCenter.default.post(name: .didStartSession, object: nil, userInfo: ["sessionId" : id])
        case "END_ACTION":
            NotificationCenter.default.post(name: .didEndSession, object: nil, userInfo: ["sessionId" : id])
        default:
            break
        }
    }
}

extension Notification.Name {
    static let didReceivePushNotification = Notification.Name("didReceivePushNotification")
    static let didStartSession = Notification.Name("didStartSession")
    static let didEndSession = Notification.Name("didEndSession")
}
class NotificationScheduler {
    
    enum NotificationType {
        case reminder
        case completion
    }
    
    let notificationCenter = UNUserNotificationCenter.current()
    
    func scheduleNotification(for session: Session, type: NotificationType) {
        switch session.type {
        case .now:
            scheduleNowNotification(session: session, type: type)
        case .later:
            scheduleLaterNotification(session: session, type: type)
        case .recurring:
            scheduleRecurringNotification(session: session, type: type)
        case .none:
            break
        }
    }
    
    private func scheduleNowNotification(session: Session, type: NotificationType) {
        if type == .completion { scheduleCompletionNotification(session: session, date: session.endTime) }
    }
    
    private func scheduleLaterNotification(session: Session, type: NotificationType) {
        if type == .reminder { scheduleReminderNotification(session: session, date: session.startTime) }
        if type == .completion { scheduleCompletionNotification(session: session, date: session.endTime) }
    }
    
    private func scheduleRecurringNotification(session: Session, type: NotificationType) {
        for day in session.recurringDays {
            if type == .reminder { scheduleReminderNotification(session: session, date: session.startTime, recurringDay: day) }
            if type == .completion { scheduleCompletionNotification(session: session, date: session.endTime, recurringDay: day) }
        }
    }
    
    private func scheduleReminderNotification(session: Session, date: Date, recurringDay: Weekday? = nil) {
        let content = UNMutableNotificationContent()
        content.title = "Reminder"
        content.body = "Your session \(session.name) is starting."
        content.sound = .default
        content.categoryIdentifier = "START_CATEGORY"
        content.userInfo = ["sessionId" : session._id.stringValue]
        
        let trigger: UNNotificationTrigger
        if let recurringDay = recurringDay {
            trigger = createWeeklyTrigger(date: date, weekday: recurringDay)
        } else {
            trigger = createTrigger(date: date)
        }
        
        let request = UNNotificationRequest(identifier: "\(session._id)-reminder", content: content, trigger: trigger)
        notificationCenter.add(request, withCompletionHandler: nil)
    }
    
    private func scheduleCompletionNotification(session: Session, date: Date, recurringDay: Weekday? = nil) {
        let content = UNMutableNotificationContent()
        content.title = "Session Complete"
        content.body = "Your session \(session.name) is complete."
        content.sound = .default
        content.categoryIdentifier = "END_CATEGORY"
        content.userInfo = ["sessionId" : session._id.stringValue]
        
        let trigger: UNNotificationTrigger
        if let recurringDay = recurringDay {
            trigger = createWeeklyTrigger(date: date, weekday: recurringDay)
        } else {
            trigger = createTrigger(date: date)
        }
        
        let request = UNNotificationRequest(identifier: "\(session._id)-completion", content: content, trigger: trigger)
        notificationCenter.add(request, withCompletionHandler: nil)
    }
    
    private func createTrigger(date: Date) -> UNCalendarNotificationTrigger {
        let calendar = Calendar.current
        let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
        return UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
    }
    
    private func createWeeklyTrigger(date: Date, weekday: Weekday) -> UNCalendarNotificationTrigger {
        var components = Calendar.current.dateComponents([.hour, .minute, .second], from: date)
        components.weekday = weekday.rawValue
        return UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
    }
    
    func updateNotification(for session: Session) {
        // Remove existing notifications for this session
        removeNotification(for: session._id)
        
        // Schedule new notifications
        if session.type == .now {
            scheduleNotification(for: session, type: .completion)
        } else {
            scheduleNotification(for: session, type: .reminder)
        }
        
    }
    
    func removeNotification(for sessionId: ObjectId) {
        notificationCenter.removePendingNotificationRequests(withIdentifiers: ["\(sessionId)-reminder"])
    }
}```

Solution

  • The problem is that you're looking for the user's action response in the wrong place. If the app is killed, whether by the user or by the system, and the local notification alert appears and the user taps an action, that action arrives immediately, in the launchOptions dictionary of application(_:didFinishLaunchingWithOptions:). You have to grab it — and your code is not grabbing it.

    (In a modern architecture app, which I presume your app is not since you seem to be creating the window in the app delegate, you would have a scene delegate, and the scene delegate's scene(_:willConnectTo:options:) would be called with the notificationResponse in its options: parameter. Again, your job would be to grab it there.)

    If your app delegate is the user notification center delegate, then the action can also arrive in the userNotificationCenter(_:didReceive:withCompletionHandler:) method. The trouble here, though, is that because of the way you've written your requestNotificationAuthorization, your app delegate is not assigned as the notification center delegate until after the launch has completed — and so it happens too late to "take the call". That's why, as you have observed, it isn't called under those circumstances. The time to assign your app as the user notification center delegate would have been much earlier — right at the start of application(_:didFinishLaunchingWithOptions:), before anything else has a chance to happen.

    However, that second point doesn't really matter; I'm just explaining the phenomenon to you. Just pick up the action in the launchOptions dictionary directly and all will be well.