From eff7041d6222cbecd38b2b452f8a3eaad90a4199 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Fri, 28 Apr 2023 12:03:37 -0300 Subject: [PATCH] ios impl --- .../src/main/java/NotificationPlugin.kt | 6 +- .../src/main/java/NotificationSchedule.kt | 6 +- plugins/notification/guest-js/index.ts | 19 +- plugins/notification/ios/Package.swift | 8 +- .../ios/Sources/Notification.swift | 253 ++++++++++++++++++ .../ios/Sources/NotificationCategory.swift | 119 ++++++++ .../ios/Sources/NotificationHandler.swift | 112 ++++++++ .../ios/Sources/NotificationManager.swift | 39 +++ .../ios/Sources/NotificationPlugin.swift | 236 ++++++++++++++-- plugins/notification/src/commands.rs | 37 +-- plugins/notification/src/lib.rs | 9 +- plugins/notification/src/mobile.rs | 2 +- plugins/notification/src/models.rs | 4 + 13 files changed, 785 insertions(+), 65 deletions(-) create mode 100644 plugins/notification/ios/Sources/Notification.swift create mode 100644 plugins/notification/ios/Sources/NotificationCategory.swift create mode 100644 plugins/notification/ios/Sources/NotificationHandler.swift create mode 100644 plugins/notification/ios/Sources/NotificationManager.swift diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index d47414f6..99e04694 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -62,12 +62,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } }*/ - /** - * Schedule a notification invoke from JavaScript - * Creates local notification in system. - */ @Command - fun schedule(invoke: Invoke) { + fun batch(invoke: Invoke) { val localNotifications = Notification.buildNotificationList(invoke) ?: return val ids = manager!!.schedule(invoke, localNotifications) diff --git a/plugins/notification/android/src/main/java/NotificationSchedule.kt b/plugins/notification/android/src/main/java/NotificationSchedule.kt index caed28c1..916d4254 100644 --- a/plugins/notification/android/src/main/java/NotificationSchedule.kt +++ b/plugins/notification/android/src/main/java/NotificationSchedule.kt @@ -45,7 +45,7 @@ class NotificationSchedule(val scheduleObj: JSObject) { val payload = scheduleObj.getJSObject("data", JSObject()) when (val scheduleKind = scheduleObj.getString("kind", "")) { - "at" -> { + "At" -> { val dateString = payload.getString("date") if (dateString.isNotEmpty()) { val sdf = SimpleDateFormat(JS_DATE_FORMAT) @@ -60,11 +60,11 @@ class NotificationSchedule(val scheduleObj: JSObject) { throw Exception("`at` date cannot be empty") } } - "interval" -> { + "Interval" -> { val dateMatch = onFromJson(payload) kind = ScheduleKind.Interval(dateMatch) } - "every" -> { + "Every" -> { val interval = NotificationInterval.valueOf(payload.getString("interval")) kind = ScheduleKind.Every(interval, payload.getInteger("count", 1)) } diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 90374a97..17a34d50 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -72,7 +72,9 @@ interface Options { */ actionTypeId?: string /** - * Identifier used to group multiple notifications on Android. + * Identifier used to group multiple notifications. + * + * https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier */ group?: string /** @@ -156,20 +158,23 @@ enum ScheduleEvery { Day = 'Day', Hour = 'Hour', Minute = 'Minute', + /** + * Not supported on iOS. + */ Second = 'Second' } type ScheduleData = { - kind: 'at', + kind: 'At', data: { date: Date repeating: boolean } } | { - kind: 'interval', + kind: 'Interval', data: ScheduleInterval } | { - kind: 'every', + kind: 'Every', data: { interval: ScheduleEvery } @@ -185,15 +190,15 @@ class Schedule { } static at(date: Date, repeating = false) { - return new Schedule({ kind: 'at', data: { date, repeating }}) + return new Schedule({ kind: 'At', data: { date, repeating }}) } static interval(interval: ScheduleInterval) { - return new Schedule({ kind: 'interval', data: interval }) + return new Schedule({ kind: 'Interval', data: interval }) } static every(kind: ScheduleEvery) { - return new Schedule({ kind: 'every', data: { interval: kind }}) + return new Schedule({ kind: 'Every', data: { interval: kind }}) } } diff --git a/plugins/notification/ios/Package.swift b/plugins/notification/ios/Package.swift index ff9991fa..bfcaf338 100644 --- a/plugins/notification/ios/Package.swift +++ b/plugins/notification/ios/Package.swift @@ -4,16 +4,16 @@ import PackageDescription let package = Package( - name: "tauri-plugin-{{ plugin_name }}", + name: "tauri-plugin-notification", platforms: [ .iOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "tauri-plugin-{{ plugin_name }}", + name: "tauri-plugin-notification", type: .static, - targets: ["tauri-plugin-{{ plugin_name }}"]), + targets: ["tauri-plugin-notification"]), ], dependencies: [ .package(name: "Tauri", path: "../.tauri/tauri-api") @@ -22,7 +22,7 @@ let package = Package( // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "tauri-plugin-{{ plugin_name }}", + name: "tauri-plugin-notification", dependencies: [ .byName(name: "Tauri") ], diff --git a/plugins/notification/ios/Sources/Notification.swift b/plugins/notification/ios/Sources/Notification.swift new file mode 100644 index 00000000..a6218c31 --- /dev/null +++ b/plugins/notification/ios/Sources/Notification.swift @@ -0,0 +1,253 @@ +import Tauri +import UserNotifications + +enum NotificationError: LocalizedError { + case contentNoId + case contentNoTitle + case contentNoBody + case triggerConstructionFailed + case triggerRepeatIntervalTooShort + case attachmentNoId + case attachmentNoUrl + case attachmentFileNotFound(path: String) + case attachmentUnableToCreate(String) + + var errorDescription: String? { + switch self { + case .attachmentFileNotFound(let path): + return "Unable to find file \(path) for attachment" + default: + return "" + } + } +} + +func makeNotificationContent(_ notification: JSObject) throws -> UNNotificationContent { + guard let title = notification["title"] as? String else { + throw NotificationError.contentNoTitle + } + guard let body = notification["body"] as? String else { + throw NotificationError.contentNoBody + } + + let extra = notification["extra"] as? JSObject ?? [:] + let schedule = notification["schedule"] as? JSObject ?? [:] + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString(forKey: title, arguments: nil) + content.body = NSString.localizedUserNotificationString( + forKey: body, + arguments: nil) + + content.userInfo = [ + "__EXTRA__": extra, + "__SCHEDULE__": schedule, + ] + + if let actionTypeId = notification["actionTypeId"] as? String { + content.categoryIdentifier = actionTypeId + } + + if let threadIdentifier = notification["group"] as? String { + content.threadIdentifier = threadIdentifier + } + + if let summaryArgument = notification["summary"] as? String { + content.summaryArgument = summaryArgument + } + + if let sound = notification["sound"] as? String { + content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) + } + + if let attachments = notification["attachments"] as? [JSObject] { + content.attachments = try makeAttachments(attachments) + } + + return content +} + +func makeAttachments(_ attachments: [JSObject]) throws -> [UNNotificationAttachment] { + var createdAttachments = [UNNotificationAttachment]() + + for attachment in attachments { + guard let id = attachment["id"] as? String else { + throw NotificationError.attachmentNoId + } + guard let url = attachment["url"] as? String else { + throw NotificationError.attachmentNoUrl + } + guard let urlObject = makeAttachmentUrl(url) else { + throw NotificationError.attachmentFileNotFound(path: url) + } + + let options = attachment["options"] as? JSObject ?? [:] + + do { + let newAttachment = try UNNotificationAttachment( + identifier: id, url: urlObject, options: makeAttachmentOptions(options)) + createdAttachments.append(newAttachment) + } catch { + throw NotificationError.attachmentUnableToCreate(error.localizedDescription) + } + } + + return createdAttachments +} + +func makeAttachmentUrl(_ path: String) -> URL? { + return URL(string: path) +} + +func makeAttachmentOptions(_ options: JSObject) -> JSObject { + var opts: JSObject = [:] + + if let iosUNNotificationAttachmentOptionsTypeHintKey = options[ + "iosUNNotificationAttachmentOptionsTypeHintKey"] as? String + { + opts[UNNotificationAttachmentOptionsTypeHintKey] = iosUNNotificationAttachmentOptionsTypeHintKey + } + if let iosUNNotificationAttachmentOptionsThumbnailHiddenKey = options[ + "iosUNNotificationAttachmentOptionsThumbnailHiddenKey"] as? String + { + opts[UNNotificationAttachmentOptionsThumbnailHiddenKey] = + iosUNNotificationAttachmentOptionsThumbnailHiddenKey + } + if let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey = options[ + "iosUNNotificationAttachmentOptionsThumbnailClippingRectKey"] as? String + { + opts[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = + iosUNNotificationAttachmentOptionsThumbnailClippingRectKey + } + if let iosUNNotificationAttachmentOptionsThumbnailTimeKey = options[ + "iosUNNotificationAttachmentOptionsThumbnailTimeKey"] as? String + { + opts[UNNotificationAttachmentOptionsThumbnailTimeKey] = + iosUNNotificationAttachmentOptionsThumbnailTimeKey + } + return opts +} + +func handleScheduledNotification(_ invoke: Invoke, _ schedule: JSObject) throws + -> UNNotificationTrigger? +{ + let kind = schedule["kind"] as? String ?? "" + let payload = schedule["data"] as? JSObject ?? [:] + switch kind { + case "At": + let date = payload["at"] as? String ?? "" + let dateFormatter = ISO8601DateFormatter() + if let at = dateFormatter.date(from: date) { + let repeats = payload["repeats"] as? Bool ?? false + + let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) + + if dateInfo.date! < Date() { + invoke.reject("Scheduled time must be *after* current time") + return nil + } + + let dateInterval = DateInterval(start: Date(), end: dateInfo.date!) + + // Notifications that repeat have to be at least a minute between each other + if repeats && dateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: dateInterval.duration, repeats: repeats) + + } else { + invoke.reject("could not parse `at` date") + } + case "Interval": + let dateComponents = getDateComponents(payload) + return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + case "Every": + let interval = payload["interval"] as? String ?? "" + let count = schedule["count"] as? Int ?? 1 + + if let repeatDateInterval = getRepeatDateInterval(interval, count) { + // Notifications that repeat have to be at least a minute between each other + if repeatDateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: repeatDateInterval.duration, repeats: true) + } + + default: + return nil + } + + return nil +} + +/// Given our schedule format, return a DateComponents object +/// that only contains the components passed in. + +func getDateComponents(_ at: JSObject) -> DateComponents { + // var dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: Date()) + // dateInfo.calendar = Calendar.current + var dateInfo = DateComponents() + + if let year = at["year"] as? Int { + dateInfo.year = year + } + if let month = at["month"] as? Int { + dateInfo.month = month + } + if let day = at["day"] as? Int { + dateInfo.day = day + } + if let hour = at["hour"] as? Int { + dateInfo.hour = hour + } + if let minute = at["minute"] as? Int { + dateInfo.minute = minute + } + if let second = at["second"] as? Int { + dateInfo.second = second + } + if let weekday = at["weekday"] as? Int { + dateInfo.weekday = weekday + } + return dateInfo +} + +/// Compute the difference between the string representation of a date +/// interval and today. For example, if every is "month", then we +/// return the interval between today and a month from today. + +func getRepeatDateInterval(_ every: String, _ count: Int) -> DateInterval? { + let cal = Calendar.current + let now = Date() + switch every { + case "Year": + let newDate = cal.date(byAdding: .year, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Month": + let newDate = cal.date(byAdding: .month, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "TwoWeeks": + let newDate = cal.date(byAdding: .weekOfYear, value: 2 * count, to: now)! + return DateInterval(start: now, end: newDate) + case "Week": + let newDate = cal.date(byAdding: .weekOfYear, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Day": + let newDate = cal.date(byAdding: .day, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Hour": + let newDate = cal.date(byAdding: .hour, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Minute": + let newDate = cal.date(byAdding: .minute, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Second": + let newDate = cal.date(byAdding: .second, value: count, to: now)! + return DateInterval(start: now, end: newDate) + default: + return nil + } +} diff --git a/plugins/notification/ios/Sources/NotificationCategory.swift b/plugins/notification/ios/Sources/NotificationCategory.swift new file mode 100644 index 00000000..32f23af3 --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationCategory.swift @@ -0,0 +1,119 @@ +import Tauri +import UserNotifications + +public func makeCategories(_ actionTypes: [JSObject]) { + var createdCategories = [UNNotificationCategory]() + + let generalCategory = UNNotificationCategory( + identifier: "GENERAL", + actions: [], + intentIdentifiers: [], + options: .customDismissAction) + + createdCategories.append(generalCategory) + for type in actionTypes { + guard let id = type["id"] as? String else { + Logger.error("Action type must have an id field") + continue + } + let hiddenBodyPlaceholder = type["iosHiddenPreviewsBodyPlaceholder"] as? String ?? "" + let actions = type["actions"] as? [JSObject] ?? [] + + let newActions = makeActions(actions) + + // Create the custom actions for the TIMER_EXPIRED category. + var newCategory: UNNotificationCategory? + + newCategory = UNNotificationCategory( + identifier: id, + actions: newActions, + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: hiddenBodyPlaceholder, + options: makeCategoryOptions(type)) + + createdCategories.append(newCategory!) + } + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories(Set(createdCategories)) +} + +func makeActions(_ actions: [JSObject]) -> [UNNotificationAction] { + var createdActions = [UNNotificationAction]() + + for action in actions { + guard let id = action["id"] as? String else { + Logger.error("Action must have an id field") + continue + } + let title = action["title"] as? String ?? "" + let input = action["input"] as? Bool ?? false + + var newAction: UNNotificationAction + if input { + let inputButtonTitle = action["inputButtonTitle"] as? String + let inputPlaceholder = action["inputPlaceholder"] as? String ?? "" + + if inputButtonTitle != nil { + newAction = UNTextInputNotificationAction( + identifier: id, + title: title, + options: makeActionOptions(action), + textInputButtonTitle: inputButtonTitle!, + textInputPlaceholder: inputPlaceholder) + } else { + newAction = UNTextInputNotificationAction( + identifier: id, title: title, options: makeActionOptions(action)) + } + } else { + // Create the custom actions for the TIMER_EXPIRED category. + newAction = UNNotificationAction( + identifier: id, + title: title, + options: makeActionOptions(action)) + } + createdActions.append(newAction) + } + + return createdActions +} + +func makeActionOptions(_ action: JSObject) -> UNNotificationActionOptions { + let foreground = action["foreground"] as? Bool ?? false + let destructive = action["destructive"] as? Bool ?? false + let requiresAuthentication = action["requiresAuthentication"] as? Bool ?? false + + if foreground { + return .foreground + } + if destructive { + return .destructive + } + if requiresAuthentication { + return .authenticationRequired + } + return UNNotificationActionOptions(rawValue: 0) +} + +func makeCategoryOptions(_ type: JSObject) -> UNNotificationCategoryOptions { + let customDismiss = type["iosCustomDismissAction"] as? Bool ?? false + let carPlay = type["iosAllowInCarPlay"] as? Bool ?? false + let hiddenPreviewsShowTitle = type["iosHiddenPreviewsShowTitle"] as? Bool ?? false + let hiddenPreviewsShowSubtitle = type["iosHiddenPreviewsShowSubtitle"] as? Bool ?? false + + if customDismiss { + return .customDismissAction + } + if carPlay { + return .allowInCarPlay + } + + if hiddenPreviewsShowTitle { + return .hiddenPreviewsShowTitle + } + if hiddenPreviewsShowSubtitle { + return .hiddenPreviewsShowSubtitle + } + + return UNNotificationCategoryOptions(rawValue: 0) +} diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift new file mode 100644 index 00000000..40fe5608 --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -0,0 +1,112 @@ +import Tauri +import UserNotifications + +public class NotificationHandler: NSObject, NotificationHandlerProtocol { + + public weak var plugin: Plugin? + + var notificationsMap = [String: JSObject]() + + public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.badge, .alert, .sound]) { (granted, error) in + completion?(granted, error) + } + } + + public func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + completion?(settings.authorizationStatus) + } + } + + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + // let notificationData = makeNotificationRequestJSObject(notification.request) + // TODO self.plugin?.trigger("received", data: notificationData) + + if let options = notificationsMap[notification.request.identifier] { + let silent = options["silent"] as? Bool ?? false + if silent { + return UNNotificationPresentationOptions.init(rawValue: 0) + } + } + + return [ + .badge, + .sound, + .alert, + ] + } + + public func didReceive(response: UNNotificationResponse) { + var data = JSObject() + + let originalNotificationRequest = response.notification.request + let actionId = response.actionIdentifier + + // We turn the two default actions (open/dismiss) into generic strings + if actionId == UNNotificationDefaultActionIdentifier { + data["actionId"] = "tap" + } else if actionId == UNNotificationDismissActionIdentifier { + data["actionId"] = "dismiss" + } else { + data["actionId"] = actionId + } + + // If the type of action was for an input type, get the value + if let inputType = response as? UNTextInputNotificationResponse { + data["inputValue"] = inputType.userText + } + + data["notification"] = makeNotificationRequestJSObject(originalNotificationRequest) + + // TODO self.plugin?.trigger("localNotificationActionPerformed", data: data, retainUntilConsumed: true) + } + + /** + * Turn a UNNotificationRequest into a JSObject to return back to the client. + */ + func makeNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + let notificationRequest = notificationsMap[request.identifier] ?? [:] + var notification = makePendingNotificationRequestJSObject(request) + notification["sound"] = notificationRequest["sound"] ?? "" + notification["actionTypeId"] = request.content.categoryIdentifier + notification["attachments"] = notificationRequest["attachments"] ?? [] + return notification + } + + func makePendingNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + var notification: JSObject = [ + "id": Int(request.identifier) ?? -1, + "title": request.content.title, + "body": request.content.body, + ] + + if let userInfo = JSTypes.coerceDictionaryToJSObject(request.content.userInfo) { + var extra = userInfo["__EXTRA__"] as? JSObject ?? userInfo + + // check for any dates and convert them to strings + for (key, value) in extra { + if let date = value as? Date { + let dateString = ISO8601DateFormatter().string(from: date) + extra[key] = dateString + } + } + + notification["extra"] = extra + + if var schedule = userInfo["__SCHEDULE__"] as? JSObject { + // convert schedule at date to string + if let date = schedule["at"] as? Date { + let dateString = ISO8601DateFormatter().string(from: date) + schedule["at"] = dateString + } + + notification["schedule"] = schedule + } + } + + return notification + } +} diff --git a/plugins/notification/ios/Sources/NotificationManager.swift b/plugins/notification/ios/Sources/NotificationManager.swift new file mode 100644 index 00000000..857636fb --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationManager.swift @@ -0,0 +1,39 @@ +import Foundation +import UserNotifications + +@objc public protocol NotificationHandlerProtocol { + func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions + func didReceive(response: UNNotificationResponse) +} + +@objc public class NotificationManager: NSObject, UNUserNotificationCenterDelegate { + public weak var notificationHandler: NotificationHandlerProtocol? + + override init() { + super.init() + let center = UNUserNotificationCenter.current() + center.delegate = self + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + var presentationOptions: UNNotificationPresentationOptions? = nil + + if notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true { + presentationOptions = notificationHandler?.willPresent(notification: notification) + } + + completionHandler(presentationOptions ?? []) + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + if response.notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true { + notificationHandler?.didReceive(response: response) + } + + completionHandler() + } +} diff --git a/plugins/notification/ios/Sources/NotificationPlugin.swift b/plugins/notification/ios/Sources/NotificationPlugin.swift index 3d520a92..f71554f3 100644 --- a/plugins/notification/ios/Sources/NotificationPlugin.swift +++ b/plugins/notification/ios/Sources/NotificationPlugin.swift @@ -1,24 +1,228 @@ +import SwiftRs +import Tauri import UIKit +import UserNotifications import WebKit -import Tauri -import SwiftRs + +enum ShowNotificationError: LocalizedError { + case noId + case make(Error) + case create(Error) + + var errorDescription: String? { + switch self { + case .noId: + return "notification `id` missing" + case .make(let error): + return "Unable to make notification: \(error)" + case .create(let error): + return "Unable to create notification: \(error)" + } + } +} + +func showNotification(invoke: Invoke, notification: JSObject) + throws -> UNNotificationRequest +{ + guard let identifier = notification["id"] as? Int else { + throw ShowNotificationError.noId + } + + var content: UNNotificationContent + do { + content = try makeNotificationContent(notification) + } catch { + throw ShowNotificationError.make(error) + } + + var trigger: UNNotificationTrigger? + + do { + if let schedule = notification["schedule"] as? JSObject { + try trigger = handleScheduledNotification(invoke, schedule) + } else { + let match = Calendar.current.dateComponents( + [.timeZone, .year, .month, .day, .hour, .minute], from: Date()) + trigger = UNCalendarNotificationTrigger(dateMatching: match, repeats: false) + } + } catch { + throw ShowNotificationError.create(error) + } + + // Schedule the request. + let request = UNNotificationRequest( + identifier: "\(identifier)", content: content, trigger: trigger + ) + + let center = UNUserNotificationCenter.current() + center.add(request) { (error: Error?) in + if let theError = error { + invoke.reject(theError.localizedDescription) + } + } + + return request +} class NotificationPlugin: Plugin { - @objc public func requestPermission(_ invoke: Invoke) throws { - invoke.resolve(["permissionState": "granted"]) - } - - @objc public func permissionState(_ invoke: Invoke) throws { - invoke.resolve(["permissionState": "granted"]) - } - - @objc public func notify(_ invoke: Invoke) throws { - // TODO - invoke.resolve() - } + let notificationHandler = NotificationHandler() + let notificationManager = NotificationManager() + + override init() { + super.init() + notificationHandler.plugin = self + notificationManager.notificationHandler = notificationHandler + } + + @objc public func show(_ invoke: Invoke) throws { + let request = try showNotification(invoke: invoke, notification: invoke.data) + // TODO self.notificationHandler.notificationsMap[request.identifier] = invoke.data + invoke.resolve([ + "id": Int(request.identifier) ?? -1 + ]) + } + + @objc public func batch(_ invoke: Invoke) throws { + guard let notifications = invoke.getArray("notifications", JSObject.self) else { + invoke.reject("`notifications` array is required") + return + } + var ids = [String]() + + for notification in notifications { + let request = try showNotification(invoke: invoke, notification: notification) + // TODO self.notificationHandler.notificationsMap[request.identifier] = notification + ids.append(request.identifier) + } + + let ret = ids.map({ (id) -> JSObject in + return [ + "id": Int(id) ?? -1 + ] + }) + invoke.resolve([ + "notifications": ret + ]) + } + + @objc public override func requestPermissions(_ invoke: Invoke) { + notificationHandler.requestPermissions { granted, error in + guard error == nil else { + invoke.reject(error!.localizedDescription) + return + } + invoke.resolve(["permissionState": granted ? "granted" : "denied"]) + } + } + + @objc public override func checkPermissions(_ invoke: Invoke) { + notificationHandler.checkPermissions { status in + let permission: String + + switch status { + case .authorized, .ephemeral, .provisional: + permission = "granted" + case .denied: + permission = "denied" + case .notDetermined: + permission = "default" + @unknown default: + permission = "default" + } + + invoke.resolve(["permissionState": permission]) + } + } + + @objc func cancel(_ invoke: Invoke) { + guard let notifications = invoke.getArray("notifications", JSObject.self), + notifications.count > 0 + else { + invoke.reject("`notifications` input is required") + return + } + + let ids = notifications.map({ (value: JSObject) -> String in + if let idString = value["id"] as? String { + return idString + } else if let idNum = value["id"] as? NSNumber { + return idNum.stringValue + } + return "" + }) + + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + invoke.resolve() + } + + @objc func getPending(_ invoke: Invoke) { + UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: { + (notifications) in + let ret = notifications.compactMap({ [weak self] (notification) -> JSObject? in + return self?.notificationHandler.makePendingNotificationRequestJSObject(notification) + }) + + invoke.resolve([ + "notifications": ret + ]) + }) + } + + @objc func registerActionTypes(_ invoke: Invoke) { + guard let types = invoke.getArray("types", JSObject.self) else { + return + } + makeCategories(types) + invoke.resolve() + } + + @objc func removeDeliveredNotifications(_ invoke: Invoke) { + guard let notifications = invoke.getArray("notifications", JSObject.self) else { + invoke.reject("`notifications` input is required") + return + } + + let ids = notifications.map { "\($0["id"] ?? "")" } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + invoke.resolve() + } + + @objc func removeAllDeliveredNotifications(_ invoke: Invoke) { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + DispatchQueue.main.async(execute: { + UIApplication.shared.applicationIconBadgeNumber = 0 + }) + invoke.resolve() + } + + @objc func getDeliveredNotifications(_ invoke: Invoke) { + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { + (notifications) in + let ret = notifications.map({ (notification) -> [String: Any] in + return self.notificationHandler.makeNotificationRequestJSObject( + notification.request) + }) + invoke.resolve([ + "notifications": ret + ]) + }) + } + + @objc func createChannel(_ invoke: Invoke) { + invoke.reject("not implemented") + } + + @objc func deleteChannel(_ invoke: Invoke) { + invoke.reject("not implemented") + } + + @objc func listChannels(_ invoke: Invoke) { + invoke.reject("not implemented") + } + } @_cdecl("init_plugin_notification") -func initPlugin(name: SRString, webview: WKWebView?) { - Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: NotificationPlugin()) +func initPlugin() -> Plugin { + return NotificationPlugin() } diff --git a/plugins/notification/src/commands.rs b/plugins/notification/src/commands.rs index 710235c1..4af85585 100644 --- a/plugins/notification/src/commands.rs +++ b/plugins/notification/src/commands.rs @@ -2,30 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use serde::Deserialize; use tauri::{command, AppHandle, Runtime, State}; -use crate::{Notification, PermissionState, Result}; - -/// The options for the notification API. -#[derive(Debug, Clone, Deserialize)] -pub struct NotificationOptions { - /// The notification title. - pub title: String, - /// The notification body. - pub body: Option, - /// The notification icon. - pub icon: Option, -} +use crate::{Notification, NotificationData, PermissionState, Result}; #[command] pub(crate) async fn is_permission_granted( _app: AppHandle, notification: State<'_, Notification>, -) -> Result { - notification - .permission_state() - .map(|s| s == PermissionState::Granted) +) -> Result> { + let state = notification.permission_state()?; + match state { + PermissionState::Granted => Ok(Some(true)), + PermissionState::Denied => Ok(Some(false)), + PermissionState::Unknown => Ok(None), + } } #[command] @@ -40,15 +31,9 @@ pub(crate) async fn request_permission( pub(crate) async fn notify( _app: AppHandle, notification: State<'_, Notification>, - options: NotificationOptions, + options: NotificationData, ) -> Result<()> { - let mut builder = notification.builder().title(options.title); - if let Some(body) = options.body { - builder = builder.body(body); - } - if let Some(icon) = options.icon { - builder = builder.icon(icon); - } - + let mut builder = notification.builder(); + builder.data = options; builder.show() } diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index cb63758a..a0429a74 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[cfg(mobile)] use tauri::plugin::PluginHandle; #[cfg(desktop)] @@ -30,8 +30,11 @@ use desktop::Notification; #[cfg(mobile)] use mobile::Notification; -#[derive(Debug, Default, Serialize)] +#[derive(Debug, Default, Serialize, Deserialize)] struct NotificationData { + /// Notification id. + #[serde(default)] + id: usize, /// The notification title. title: Option, /// The notification body. @@ -47,7 +50,7 @@ pub struct NotificationBuilder { app: AppHandle, #[cfg(mobile)] handle: PluginHandle, - data: NotificationData, + pub(crate) data: NotificationData, } impl NotificationBuilder { diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs index f8485cb4..1379470a 100644 --- a/plugins/notification/src/mobile.rs +++ b/plugins/notification/src/mobile.rs @@ -31,7 +31,7 @@ pub fn init( impl crate::NotificationBuilder { pub fn show(self) -> crate::Result<()> { self.handle - .run_mobile_plugin("notify", self.data) + .run_mobile_plugin("show", self.data) .map_err(Into::into) } } diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index d1cf0e4b..0ec2506f 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -13,6 +13,8 @@ pub enum PermissionState { Granted, /// Permission access has been denied. Denied, + /// Unknown state. Must request permission. + Unknown, } impl Display for PermissionState { @@ -20,6 +22,7 @@ impl Display for PermissionState { match self { Self::Granted => write!(f, "granted"), Self::Denied => write!(f, "denied"), + Self::Unknown => write!(f, "Unknown"), } } } @@ -42,6 +45,7 @@ impl<'de> Deserialize<'de> for PermissionState { match s.to_lowercase().as_str() { "granted" => Ok(Self::Granted), "denied" => Ok(Self::Denied), + "default" => Ok(Self::Unknown), _ => Err(DeError::custom(format!("unknown permission state '{s}'"))), } }