feat(notification): implement Android and iOS APIs (#340)

This commit is contained in:
Lucas Fernandes Nogueira
2023-05-05 05:22:19 -07:00
committed by GitHub
parent 1397172e95
commit be1c775b8d
25 changed files with 3700 additions and 90 deletions
+4 -4
View File
@@ -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")
],
@@ -0,0 +1,272 @@
import Tauri
import UserNotifications
enum NotificationError: LocalizedError {
case contentNoId
case contentNoTitle
case contentNoBody
case triggerRepeatIntervalTooShort
case attachmentNoId
case attachmentNoUrl
case attachmentFileNotFound(path: String)
case attachmentUnableToCreate(String)
case pastScheduledTime
case invalidDate(String)
var errorDescription: String? {
switch self {
case .contentNoId:
return "Missing notification identifier"
case .contentNoTitle:
return "Missing notification title"
case .contentNoBody:
return "Missing notification body"
case .triggerRepeatIntervalTooShort:
return "Schedule interval too short, must be a least 1 minute"
case .attachmentNoId:
return "Missing attachment identifier"
case .attachmentNoUrl:
return "Missing attachment URL"
case .attachmentFileNotFound(let path):
return "Unable to find file \(path) for attachment"
case .attachmentUnableToCreate(let error):
return "Failed to create attachment: \(error)"
case .pastScheduledTime:
return "Scheduled time must be *after* current time"
case .invalidDate(let date):
return "Could not parse date \(date)"
}
}
}
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(_ schedule: JSObject) throws
-> UNNotificationTrigger?
{
let kind = schedule["kind"] as? String ?? ""
let payload = schedule["data"] as? JSObject ?? [:]
switch kind {
case "At":
let date = payload["date"] as? String ?? ""
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
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() {
throw NotificationError.pastScheduledTime
}
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 {
throw NotificationError.invalidDate(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
}
}
@@ -0,0 +1,131 @@
import Tauri
import UserNotifications
enum CategoryError: LocalizedError {
case noId
case noActionId
var errorDescription: String? {
switch self {
case .noId:
return "Action type `id` missing"
case .noActionId:
return "Action `id` missing"
}
}
}
public func makeCategories(_ actionTypes: [JSObject]) throws {
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 {
throw CategoryError.noId
}
let hiddenBodyPlaceholder = type["hiddenPreviewsBodyPlaceholder"] as? String ?? ""
let actions = type["actions"] as? [JSObject] ?? []
let newActions = try 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]) throws -> [UNNotificationAction] {
var createdActions = [UNNotificationAction]()
for action in actions {
guard let id = action["id"] as? String else {
throw CategoryError.noActionId
}
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["customDismissAction"] as? Bool ?? false
let carPlay = type["allowInCarPlay"] as? Bool ?? false
let hiddenPreviewsShowTitle = type["hiddenPreviewsShowTitle"] as? Bool ?? false
let hiddenPreviewsShowSubtitle = type["hiddenPreviewsShowSubtitle"] as? Bool ?? false
if customDismiss {
return .customDismissAction
}
if carPlay {
return .allowInCarPlay
}
if hiddenPreviewsShowTitle {
return .hiddenPreviewsShowTitle
}
if hiddenPreviewsShowSubtitle {
return .hiddenPreviewsShowSubtitle
}
return UNNotificationCategoryOptions(rawValue: 0)
}
@@ -0,0 +1,116 @@
import Tauri
import UserNotifications
public class NotificationHandler: NSObject, NotificationHandlerProtocol {
public weak var plugin: Plugin?
private var notificationsMap = [String: JSObject]()
public func saveNotification(_ key: String, _ notification: JSObject) {
notificationsMap.updateValue(notification, forKey: key)
}
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)
self.plugin?.trigger("notification", 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)
self.plugin?.trigger("actionPerformed", data: data)
}
/**
* 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"] ?? [JSObject]()
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
}
}
@@ -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()
}
}
@@ -1,24 +1,209 @@
import UIKit
import WebKit
import Tauri
import SwiftRs
import Tauri
import UIKit
import UserNotifications
import WebKit
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(schedule)
}
} 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"])
}
let notificationHandler = NotificationHandler()
let notificationManager = NotificationManager()
@objc public func permissionState(_ invoke: Invoke) throws {
invoke.resolve(["permissionState": "granted"])
}
override init() {
super.init()
notificationManager.notificationHandler = notificationHandler
notificationHandler.plugin = self
}
@objc public func show(_ invoke: Invoke) throws {
let request = try showNotification(invoke: invoke, notification: invoke.data)
notificationHandler.saveNotification(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 = [Int]()
for notification in notifications {
let request = try showNotification(invoke: invoke, notification: notification)
notificationHandler.saveNotification(request.identifier, notification)
ids.append(Int(request.identifier) ?? -1)
}
invoke.resolve([
"notifications": ids
])
}
@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", NSNumber.self),
notifications.count > 0
else {
invoke.reject("`notifications` input is required")
return
}
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: notifications.map({ (id) -> String in
return id.stringValue
})
)
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) throws {
guard let types = invoke.getArray("types", JSObject.self) else {
return
}
try makeCategories(types)
invoke.resolve()
}
@objc func removeActive(_ invoke: Invoke) {
if let notifications = invoke.getArray("notifications", JSObject.self) {
let ids = notifications.map { "\($0["id"] ?? "")" }
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
invoke.resolve()
} else {
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
DispatchQueue.main.async(execute: {
UIApplication.shared.applicationIconBadgeNumber = 0
})
invoke.resolve()
}
}
@objc func getActive(_ 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")
}
@objc public func notify(_ invoke: Invoke) throws {
// TODO
invoke.resolve()
}
}
@_cdecl("init_plugin_notification")
func initPlugin(name: SRString, webview: WKWebView?) {
Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: NotificationPlugin())
func initPlugin() -> Plugin {
return NotificationPlugin()
}