From be1c775b8d4f6ac2ee3c71edd49497d02ffdfe4f Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Fri, 5 May 2023 05:22:19 -0700 Subject: [PATCH] feat(notification): implement Android and iOS APIs (#340) --- Cargo.lock | 4 + plugins/notification/Cargo.toml | 4 + plugins/notification/android/build.gradle.kts | 4 +- .../android/src/main/AndroidManifest.xml | 21 +- .../android/src/main/java/AssetUtils.kt | 25 + .../android/src/main/java/ChannelManager.kt | 150 +++++ .../android/src/main/java/Notification.kt | 165 +++++ .../src/main/java/NotificationAction.kt | 47 ++ .../src/main/java/NotificationAttachment.kt | 48 ++ .../src/main/java/NotificationPlugin.kt | 264 +++++++- .../src/main/java/NotificationSchedule.kt | 305 ++++++++++ .../src/main/java/NotificationStorage.kt | 131 ++++ .../src/main/java/TauriNotificationManager.kt | 569 ++++++++++++++++++ .../src/main/res/drawable/ic_transparent.xml | 12 + plugins/notification/guest-js/index.ts | 510 +++++++++++++++- plugins/notification/ios/Package.swift | 8 +- .../ios/Sources/Notification.swift | 272 +++++++++ .../ios/Sources/NotificationCategory.swift | 131 ++++ .../ios/Sources/NotificationHandler.swift | 116 ++++ .../ios/Sources/NotificationManager.swift | 39 ++ .../ios/Sources/NotificationPlugin.swift | 217 ++++++- plugins/notification/src/commands.rs | 37 +- plugins/notification/src/lib.rs | 137 ++++- plugins/notification/src/mobile.rs | 113 +++- plugins/notification/src/models.rs | 463 +++++++++++++- 25 files changed, 3701 insertions(+), 91 deletions(-) create mode 100644 plugins/notification/android/src/main/java/AssetUtils.kt create mode 100644 plugins/notification/android/src/main/java/ChannelManager.kt create mode 100644 plugins/notification/android/src/main/java/Notification.kt create mode 100644 plugins/notification/android/src/main/java/NotificationAction.kt create mode 100644 plugins/notification/android/src/main/java/NotificationAttachment.kt create mode 100644 plugins/notification/android/src/main/java/NotificationSchedule.kt create mode 100644 plugins/notification/android/src/main/java/NotificationStorage.kt create mode 100644 plugins/notification/android/src/main/java/TauriNotificationManager.kt create mode 100644 plugins/notification/android/src/main/res/drawable/ic_transparent.xml 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/Cargo.lock b/Cargo.lock index 365ada67b..4624b32c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4927,11 +4927,15 @@ version = "0.1.0" dependencies = [ "log", "notify-rust", + "rand 0.8.5", "serde", "serde_json", + "serde_repr", "tauri", "tauri-build", "thiserror", + "time 0.3.20", + "url", "win7-notifications", ] diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index 57d5c149e..f136b2e53 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -16,6 +16,10 @@ serde_json.workspace = true tauri.workspace = true log.workspace = true thiserror.workspace = true +rand = "0.8" +time = { version = "0.3", features = ["serde", "parsing", "formatting"] } +url = { version = "2", features = ["serde"] } +serde_repr = "0.1" [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] notify-rust = "4.5" diff --git a/plugins/notification/android/build.gradle.kts b/plugins/notification/android/build.gradle.kts index 5fdedcf4d..7d961104a 100644 --- a/plugins/notification/android/build.gradle.kts +++ b/plugins/notification/android/build.gradle.kts @@ -5,11 +5,11 @@ plugins { android { namespace = "app.tauri.notification" - compileSdk = 32 + compileSdk = 33 defaultConfig { minSdk = 24 - targetSdk = 32 + targetSdk = 33 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") diff --git a/plugins/notification/android/src/main/AndroidManifest.xml b/plugins/notification/android/src/main/AndroidManifest.xml index 9a40236b9..986d5f850 100644 --- a/plugins/notification/android/src/main/AndroidManifest.xml +++ b/plugins/notification/android/src/main/AndroidManifest.xml @@ -1,3 +1,20 @@ - - + + + + + + + + + + + + + + + diff --git a/plugins/notification/android/src/main/java/AssetUtils.kt b/plugins/notification/android/src/main/java/AssetUtils.kt new file mode 100644 index 000000000..c97cd528d --- /dev/null +++ b/plugins/notification/android/src/main/java/AssetUtils.kt @@ -0,0 +1,25 @@ +package app.tauri.notification + +import android.annotation.SuppressLint +import android.content.Context + +class AssetUtils { + companion object { + const val RESOURCE_ID_ZERO_VALUE = 0 + + @SuppressLint("DiscouragedApi") + fun getResourceID(context: Context, resourceName: String?, dir: String?): Int { + return context.resources.getIdentifier(resourceName, dir, context.packageName) + } + + fun getResourceBaseName(resPath: String?): String? { + if (resPath == null) return null + if (resPath.contains("/")) { + return resPath.substring(resPath.lastIndexOf('/') + 1) + } + return if (resPath.contains(".")) { + resPath.substring(0, resPath.lastIndexOf('.')) + } else resPath + } + } +} diff --git a/plugins/notification/android/src/main/java/ChannelManager.kt b/plugins/notification/android/src/main/java/ChannelManager.kt new file mode 100644 index 000000000..cf68e6668 --- /dev/null +++ b/plugins/notification/android/src/main/java/ChannelManager.kt @@ -0,0 +1,150 @@ +package app.tauri.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ContentResolver +import android.content.Context +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import app.tauri.Logger +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject + +private const val CHANNEL_ID = "id" +private const val CHANNEL_NAME = "name" +private const val CHANNEL_DESCRIPTION = "description" +private const val CHANNEL_IMPORTANCE = "importance" +private const val CHANNEL_VISIBILITY = "visibility" +private const val CHANNEL_SOUND = "sound" +private const val CHANNEL_VIBRATE = "vibration" +private const val CHANNEL_USE_LIGHTS = "lights" +private const val CHANNEL_LIGHT_COLOR = "lightColor" + +class ChannelManager(private var context: Context) { + private var notificationManager: NotificationManager? = null + + init { + notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + } + + fun createChannel(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = JSObject() + if (invoke.getString(CHANNEL_ID) != null) { + channel.put(CHANNEL_ID, invoke.getString(CHANNEL_ID)) + } else { + invoke.reject("Channel missing identifier") + return + } + if (invoke.getString(CHANNEL_NAME) != null) { + channel.put(CHANNEL_NAME, invoke.getString(CHANNEL_NAME)) + } else { + invoke.reject("Channel missing name") + return + } + channel.put( + CHANNEL_IMPORTANCE, + invoke.getInt(CHANNEL_IMPORTANCE, NotificationManager.IMPORTANCE_DEFAULT) + ) + channel.put(CHANNEL_DESCRIPTION, invoke.getString(CHANNEL_DESCRIPTION, "")) + channel.put( + CHANNEL_VISIBILITY, + invoke.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC) + ) + channel.put(CHANNEL_SOUND, invoke.getString(CHANNEL_SOUND)) + channel.put(CHANNEL_VIBRATE, invoke.getBoolean(CHANNEL_VIBRATE, false)) + channel.put(CHANNEL_USE_LIGHTS, invoke.getBoolean(CHANNEL_USE_LIGHTS, false)) + channel.put(CHANNEL_LIGHT_COLOR, invoke.getString(CHANNEL_LIGHT_COLOR)) + createChannel(channel) + invoke.resolve() + } else { + invoke.reject("channel not available") + } + } + + private fun createChannel(channel: JSObject) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + channel.getString(CHANNEL_ID), + channel.getString(CHANNEL_NAME), + channel.getInteger(CHANNEL_IMPORTANCE)!! + ) + notificationChannel.description = channel.getString(CHANNEL_DESCRIPTION) + notificationChannel.lockscreenVisibility = channel.getInteger(CHANNEL_VISIBILITY, android.app.Notification.VISIBILITY_PRIVATE) + notificationChannel.enableVibration(channel.getBoolean(CHANNEL_VIBRATE, false)) + notificationChannel.enableLights(channel.getBoolean(CHANNEL_USE_LIGHTS, false)) + val lightColor = channel.getString(CHANNEL_LIGHT_COLOR) + if (lightColor.isNotEmpty()) { + try { + notificationChannel.lightColor = Color.parseColor(lightColor) + } catch (ex: IllegalArgumentException) { + Logger.error( + Logger.tags("NotificationChannel"), + "Invalid color provided for light color.", + null + ) + } + } + var sound = channel.getString(CHANNEL_SOUND) + if (sound.isNotEmpty()) { + if (sound.contains(".")) { + sound = sound.substring(0, sound.lastIndexOf('.')) + } + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + val soundUri = + Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/" + sound) + notificationChannel.setSound(soundUri, audioAttributes) + } + notificationManager?.createNotificationChannel(notificationChannel) + } + } + + fun deleteChannel(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = invoke.getString("id") + notificationManager?.deleteNotificationChannel(channelId) + invoke.resolve() + } else { + invoke.reject("channel not available") + } + } + + fun listChannels(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannels: List = + notificationManager?.notificationChannels ?: listOf() + val channels = JSArray() + for (notificationChannel in notificationChannels) { + val channel = JSObject() + channel.put(CHANNEL_ID, notificationChannel.id) + channel.put(CHANNEL_NAME, notificationChannel.name) + channel.put(CHANNEL_DESCRIPTION, notificationChannel.description) + channel.put(CHANNEL_IMPORTANCE, notificationChannel.importance) + channel.put(CHANNEL_VISIBILITY, notificationChannel.lockscreenVisibility) + channel.put(CHANNEL_SOUND, notificationChannel.sound) + channel.put(CHANNEL_VIBRATE, notificationChannel.shouldVibrate()) + channel.put(CHANNEL_USE_LIGHTS, notificationChannel.shouldShowLights()) + channel.put( + CHANNEL_LIGHT_COLOR, String.format( + "#%06X", + 0xFFFFFF and notificationChannel.lightColor + ) + ) + channels.put(channel) + } + val result = JSObject() + result.put("channels", channels) + invoke.resolve(result) + } else { + invoke.reject("channel not available") + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/Notification.kt b/plugins/notification/android/src/main/java/Notification.kt new file mode 100644 index 000000000..3839807bc --- /dev/null +++ b/plugins/notification/android/src/main/java/Notification.kt @@ -0,0 +1,165 @@ +package app.tauri.notification + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.json.JSONException +import org.json.JSONObject + +class Notification { + var title: String? = null + var body: String? = null + var largeBody: String? = null + var summary: String? = null + var id: Int = 0 + private var sound: String? = null + private var smallIcon: String? = null + private var largeIcon: String? = null + var iconColor: String? = null + var actionTypeId: String? = null + var group: String? = null + var inboxLines: List? = null + var isGroupSummary = false + var isOngoing = false + var isAutoCancel = false + var extra: JSObject? = null + var attachments: List? = null + var schedule: NotificationSchedule? = null + var channelId: String? = null + var source: JSObject? = null + var visibility: Int? = null + var number: Int? = null + + fun getSound(context: Context, defaultSound: Int): String? { + var soundPath: String? = null + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val name = AssetUtils.getResourceBaseName(sound) + if (name != null) { + resId = AssetUtils.getResourceID(context, name, "raw") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = defaultSound + } + if (resId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + soundPath = + ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + resId + } + return soundPath + } + + fun setSound(sound: String?) { + this.sound = sound + } + + fun setSmallIcon(smallIcon: String?) { + this.smallIcon = AssetUtils.getResourceBaseName(smallIcon) + } + + fun setLargeIcon(largeIcon: String?) { + this.largeIcon = AssetUtils.getResourceBaseName(largeIcon) + } + + fun getIconColor(globalColor: String): String { + // use the one defined local before trying for a globally defined color + return iconColor ?: globalColor + } + + fun getSmallIcon(context: Context, defaultIcon: Int): Int { + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + if (smallIcon != null) { + resId = AssetUtils.getResourceID(context, smallIcon, "drawable") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = defaultIcon + } + return resId + } + + fun getLargeIcon(context: Context): Bitmap? { + if (largeIcon != null) { + val resId: Int = AssetUtils.getResourceID(context, largeIcon, "drawable") + return BitmapFactory.decodeResource(context.resources, resId) + } + return null + } + + val isScheduled = schedule != null + + companion object { + fun fromJson(jsonNotification: JSONObject): Notification { + val notification: JSObject = try { + val identifier = jsonNotification.getLong("id") + if (identifier > Int.MAX_VALUE || identifier < Int.MIN_VALUE) { + throw Exception("The notification identifier should be a 32-bit integer") + } + JSObject.fromJSONObject(jsonNotification) + } catch (e: JSONException) { + throw Exception("Invalid notification JSON object", e) + } + return fromJSObject(notification) + } + + fun fromJSObject(jsonObject: JSObject): Notification { + val notification = Notification() + notification.source = jsonObject + notification.id = jsonObject.getInteger("id") ?: throw Exception("Missing notification identifier") + notification.body = jsonObject.getString("body", null) + notification.largeBody = jsonObject.getString("largeBody", null) + notification.summary = jsonObject.getString("summary", null) + notification.actionTypeId = jsonObject.getString("actionTypeId", null) + notification.group = jsonObject.getString("group", null) + notification.setSound(jsonObject.getString("sound", null)) + notification.title = jsonObject.getString("title", null) + notification.setSmallIcon(jsonObject.getString("icon", null)) + notification.setLargeIcon(jsonObject.getString("largeIcon", null)) + notification.iconColor = jsonObject.getString("iconColor", null) + notification.attachments = NotificationAttachment.getAttachments(jsonObject) + notification.isGroupSummary = jsonObject.getBoolean("groupSummary", false) + notification.channelId = jsonObject.getString("channelId", null) + val schedule = jsonObject.getJSObject("schedule") + if (schedule != null) { + notification.schedule = NotificationSchedule(schedule) + } + notification.extra = jsonObject.getJSObject("extra") + notification.isOngoing = jsonObject.getBoolean("ongoing", false) + notification.isAutoCancel = jsonObject.getBoolean("autoCancel", true) + notification.visibility = jsonObject.getInteger("visibility") + notification.number = jsonObject.getInteger("number") + try { + val inboxLines = jsonObject.getJSONArray("inboxLines") + val inboxStringList: MutableList = ArrayList() + for (i in 0 until inboxLines.length()) { + inboxStringList.add(inboxLines.getString(i)) + } + notification.inboxLines = inboxStringList + } catch (_: Exception) { + } + return notification + } + + fun buildNotificationPendingList(notifications: List): JSObject { + val result = JSObject() + val jsArray = JSArray() + for (notification in notifications) { + val jsNotification = JSObject() + jsNotification.put("id", notification.id) + jsNotification.put("title", notification.title) + jsNotification.put("body", notification.body) + val schedule = notification.schedule + if (schedule != null) { + val jsSchedule = JSObject() + jsSchedule.put("kind", schedule.scheduleObj.getString("kind", null)) + jsSchedule.put("data", schedule.scheduleObj.getJSObject("data")) + jsNotification.put("schedule", jsSchedule) + } + jsNotification.put("extra", notification.extra) + jsArray.put(jsNotification) + } + result.put("notifications", jsArray) + return result + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationAction.kt b/plugins/notification/android/src/main/java/NotificationAction.kt new file mode 100644 index 000000000..c1a964b45 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationAction.kt @@ -0,0 +1,47 @@ +package app.tauri.notification + +import app.tauri.Logger +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.json.JSONObject + +class NotificationAction() { + var id: String? = null + var title: String? = null + var input = false + + constructor(id: String?, title: String?, input: Boolean): this() { + this.id = id + this.title = title + this.input = input + } + + companion object { + fun buildTypes(types: JSArray): Map> { + val actionTypeMap: MutableMap> = HashMap() + try { + val objects: List = types.toList() + for (obj in objects) { + val jsObject = JSObject.fromJSONObject( + obj + ) + val actionGroupId = jsObject.getString("id") + val actions = jsObject.getJSONArray("actions") + val typesArray = mutableListOf() + for (i in 0 until actions.length()) { + val notificationAction = NotificationAction() + val action = JSObject.fromJSONObject(actions.getJSONObject(i)) + notificationAction.id = action.getString("id") + notificationAction.title = action.getString("title") + notificationAction.input = action.getBoolean("input") + typesArray.add(notificationAction) + } + actionTypeMap[actionGroupId] = typesArray.toList() + } + } catch (e: Exception) { + Logger.error(Logger.tags("Notification"), "Error when building action types", e) + } + return actionTypeMap + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationAttachment.kt b/plugins/notification/android/src/main/java/NotificationAttachment.kt new file mode 100644 index 000000000..1cc35e89b --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationAttachment.kt @@ -0,0 +1,48 @@ +package app.tauri.notification + +import app.tauri.plugin.JSObject +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class NotificationAttachment { + var id: String? = null + var url: String? = null + var options: JSONObject? = null + + companion object { + fun getAttachments(notification: JSObject): List { + val attachmentsList: MutableList = ArrayList() + var attachments: JSONArray? = null + try { + attachments = notification.getJSONArray("attachments") + } catch (_: Exception) { + } + if (attachments != null) { + for (i in 0 until attachments.length()) { + val newAttachment = NotificationAttachment() + var jsonObject: JSONObject? = null + try { + jsonObject = attachments.getJSONObject(i) + } catch (e: JSONException) { + } + if (jsonObject != null) { + var jsObject: JSObject? = null + try { + jsObject = JSObject.fromJSONObject(jsonObject) + } catch (_: JSONException) { + } + newAttachment.id = jsObject!!.getString("id") + newAttachment.url = jsObject.getString("url") + try { + newAttachment.options = jsObject.getJSONObject("options") + } catch (_: JSONException) { + } + attachmentsList.add(newAttachment) + } + } + } + return attachmentsList + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index ab6c9df70..f87bcf172 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -1,31 +1,263 @@ package app.tauri.notification +import android.Manifest +import android.annotation.SuppressLint import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.webkit.WebView +import app.tauri.PermissionState import app.tauri.annotation.Command +import app.tauri.annotation.Permission +import app.tauri.annotation.PermissionCallback import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin -import app.tauri.plugin.Invoke +import org.json.JSONException +import org.json.JSONObject + +const val LOCAL_NOTIFICATIONS = "permissionState" -@TauriPlugin +@TauriPlugin( + permissions = [ + Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState") + ] +) class NotificationPlugin(private val activity: Activity): Plugin(activity) { - @Command - fun requestPermission(invoke: Invoke) { - val ret = JSObject() - ret.put("permissionState", "granted") - invoke.resolve(ret) + private var webView: WebView? = null + private lateinit var manager: TauriNotificationManager + private lateinit var notificationManager: NotificationManager + private lateinit var notificationStorage: NotificationStorage + private var channelManager = ChannelManager(activity) + + companion object { + var instance: NotificationPlugin? = null + + fun triggerNotification(notification: JSObject) { + instance?.trigger("notification", notification) + } + } + + override fun load(webView: WebView) { + instance = this + + super.load(webView) + this.webView = webView + notificationStorage = NotificationStorage(activity) + + val manager = TauriNotificationManager( + notificationStorage, + activity, + activity, + getConfig() + ) + manager.createNotificationChannel() + + this.manager = manager + + notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (Intent.ACTION_MAIN != intent.action) { + return + } + val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) + if (dataJson != null) { + trigger("actionPerformed", dataJson) + } + } + + @Command + fun show(invoke: Invoke) { + val notification = Notification.fromJSObject(invoke.data) + val id = manager.schedule(notification) + + val returnVal = JSObject().put("id", id) + invoke.resolve(returnVal) + } + + @Command + fun batch(invoke: Invoke) { + val notificationArray = invoke.getArray("notifications") + if (notificationArray == null) { + invoke.reject("Missing `notifications` argument") + return + } + + val notifications: MutableList = + ArrayList(notificationArray.length()) + val notificationsInput: List = try { + notificationArray.toList() + } catch (e: JSONException) { + invoke.reject("Provided notification format is invalid") + return + } + + for (jsonNotification in notificationsInput) { + val notification = Notification.fromJson(jsonNotification) + notifications.add(notification) } - @Command - fun permissionState(invoke: Invoke) { - val ret = JSObject() - ret.put("permissionState", "granted") - invoke.resolve(ret) + val ids = manager.schedule(notifications) + notificationStorage.appendNotifications(notifications) + + val result = JSObject() + result.put("notifications", ids) + invoke.resolve(result) + } + + @Command + fun cancel(invoke: Invoke) { + val notifications: List = invoke.getArray("notifications", JSArray()).toList() + if (notifications.isEmpty()) { + invoke.reject("Must provide notifications array as notifications option") + return + } + + manager.cancel(notifications) + invoke.resolve() + } + + @Command + fun removeActive(invoke: Invoke) { + val notifications = invoke.getArray("notifications") + if (notifications == null) { + notificationManager.cancelAll() + invoke.resolve() + } else { + try { + for (o in notifications.toList()) { + if (o is JSONObject) { + val notification = JSObject.fromJSONObject((o)) + val tag = notification.getString("tag", null) + val id = notification.getInteger("id", 0) + if (tag == null) { + notificationManager.cancel(id) + } else { + notificationManager.cancel(tag, id) + } + } else { + invoke.reject("Unexpected notification type") + return + } + } + } catch (e: JSONException) { + invoke.reject(e.message) + } + invoke.resolve() + } + } + + @Command + fun getPending(invoke: Invoke) { + val notifications= notificationStorage.getSavedNotifications() + val result = Notification.buildNotificationPendingList(notifications) + invoke.resolve(result) + } + + @Command + fun registerActionTypes(invoke: Invoke) { + val types = invoke.getArray("types", JSArray()) + val typesArray = NotificationAction.buildTypes(types) + notificationStorage.writeActionGroup(typesArray) + invoke.resolve() + } + + @SuppressLint("ObsoleteSdkInt") + @Command + fun getActive(invoke: Invoke) { + val notifications = JSArray() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val activeNotifications = notificationManager.activeNotifications + for (activeNotification in activeNotifications) { + val jsNotification = JSObject() + jsNotification.put("id", activeNotification.id) + jsNotification.put("tag", activeNotification.tag) + val notification = activeNotification.notification + if (notification != null) { + jsNotification.put("title", notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE)) + jsNotification.put("body", notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)) + jsNotification.put("group", notification.group) + jsNotification.put( + "groupSummary", + 0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY + ) + val extras = JSObject() + for (key in notification.extras.keySet()) { + extras.put(key!!, notification.extras.getString(key)) + } + jsNotification.put("data", extras) + } + notifications.put(jsNotification) + } } + val result = JSObject() + result.put("notifications", notifications) + invoke.resolve(result) + } + + @Command + fun createChannel(invoke: Invoke) { + channelManager.createChannel(invoke) + } + + @Command + fun deleteChannel(invoke: Invoke) { + channelManager.deleteChannel(invoke) + } + + @Command + fun listChannels(invoke: Invoke) { + channelManager.listChannels(invoke) + } + + @Command + override fun checkPermissions(invoke: Invoke) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } else { + super.checkPermissions(invoke) + } + } + + @Command + override fun requestPermissions(invoke: Invoke) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + permissionState(invoke) + } else { + if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { + requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "permissionsCallback") + } + } + } + + @Command + fun permissionState(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } + + @PermissionCallback + private fun permissionsCallback(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("display", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } - @Command - fun notify(invoke: Invoke) { - // TODO - invoke.resolve() + private fun getPermissionState(): String { + return if (manager.areNotificationsEnabled()) { + "granted" + } else { + "denied" } + } } diff --git a/plugins/notification/android/src/main/java/NotificationSchedule.kt b/plugins/notification/android/src/main/java/NotificationSchedule.kt new file mode 100644 index 000000000..89edbc9db --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationSchedule.kt @@ -0,0 +1,305 @@ +package app.tauri.notification + +import android.annotation.SuppressLint +import android.text.format.DateUtils +import app.tauri.plugin.JSObject +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.TimeZone + +const val JS_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + +enum class NotificationInterval { + Year, Month, TwoWeeks, Week, Day, Hour, Minute, Second +} + +fun getIntervalTime(interval: NotificationInterval, count: Int): Long { + return when (interval) { + // This case is just approximation as not all years have the same number of days + NotificationInterval.Year -> count * DateUtils.WEEK_IN_MILLIS * 52 + // This case is just approximation as months have different number of days + NotificationInterval.Month -> count * 30 * DateUtils.DAY_IN_MILLIS + NotificationInterval.TwoWeeks -> count * 2 * DateUtils.WEEK_IN_MILLIS + NotificationInterval.Week -> count * DateUtils.WEEK_IN_MILLIS + NotificationInterval.Day -> count * DateUtils.DAY_IN_MILLIS + NotificationInterval.Hour -> count * DateUtils.HOUR_IN_MILLIS + NotificationInterval.Minute -> count * DateUtils.MINUTE_IN_MILLIS + NotificationInterval.Second -> count * DateUtils.SECOND_IN_MILLIS + } +} + +sealed class ScheduleKind { + // At specific moment of time (with repeating option) + class At(var date: Date, val repeating: Boolean): ScheduleKind() + class Interval(val interval: DateMatch): ScheduleKind() + class Every(val interval: NotificationInterval, val count: Int): ScheduleKind() +} + +@SuppressLint("SimpleDateFormat") +class NotificationSchedule(val scheduleObj: JSObject) { + val kind: ScheduleKind + // Schedule this notification to fire even if app is idled (Doze) + var whileIdle: Boolean = false + + init { + val payload = scheduleObj.getJSObject("data", JSObject()) + + when (val scheduleKind = scheduleObj.getString("kind", "")) { + "At" -> { + val dateString = payload.getString("date") + if (dateString.isNotEmpty()) { + val sdf = SimpleDateFormat(JS_DATE_FORMAT) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val at = sdf.parse(dateString) + if (at == null) { + throw Exception("could not parse `at` date") + } else { + kind = ScheduleKind.At(at, payload.getBoolean("repeating")) + } + } else { + throw Exception("`at` date cannot be empty") + } + } + "Interval" -> { + val dateMatch = onFromJson(payload) + kind = ScheduleKind.Interval(dateMatch) + } + "Every" -> { + val interval = NotificationInterval.valueOf(payload.getString("interval")) + kind = ScheduleKind.Every(interval, payload.getInteger("count", 1)) + } + else -> { + throw Exception("Unknown schedule kind $scheduleKind") + } + } + whileIdle = scheduleObj.getBoolean("allowWhileIdle", false) + } + + private fun onFromJson(onJson: JSObject): DateMatch { + val match = DateMatch() + match.year = onJson.getInteger("year") + match.month = onJson.getInteger("month") + match.day = onJson.getInteger("day") + match.weekday = onJson.getInteger("weekday") + match.hour = onJson.getInteger("hour") + match.minute = onJson.getInteger("minute") + match.second = onJson.getInteger("second") + return match + } + + fun isRemovable(): Boolean { + return when (kind) { + is ScheduleKind.At -> !kind.repeating + else -> false + } + } +} + +class DateMatch { + var year: Int? = null + var month: Int? = null + var day: Int? = null + var weekday: Int? = null + var hour: Int? = null + var minute: Int? = null + var second: Int? = null + + // Unit used to save the last used unit for a trigger. + // One of the Calendar constants values + var unit: Int? = -1 + + /** + * Gets a calendar instance pointing to the specified date. + * + * @param date The date to point. + */ + private fun buildCalendar(date: Date): Calendar { + val cal: Calendar = Calendar.getInstance() + cal.time = date + cal.set(Calendar.MILLISECOND, 0) + return cal + } + + /** + * Calculates next trigger date for + * + * @param date base date used to calculate trigger + * @return next trigger timestamp + */ + fun nextTrigger(date: Date): Long { + val current: Calendar = buildCalendar(date) + val next: Calendar = buildNextTriggerTime(date) + return postponeTriggerIfNeeded(current, next) + } + + /** + * Postpone trigger if first schedule matches the past + */ + private fun postponeTriggerIfNeeded(current: Calendar, next: Calendar): Long { + if (next.timeInMillis <= current.timeInMillis && unit != -1) { + var incrementUnit = -1 + if (unit == Calendar.YEAR || unit == Calendar.MONTH) { + incrementUnit = Calendar.YEAR + } else if (unit == Calendar.DAY_OF_MONTH) { + incrementUnit = Calendar.MONTH + } else if (unit == Calendar.DAY_OF_WEEK) { + incrementUnit = Calendar.WEEK_OF_MONTH + } else if (unit == Calendar.HOUR_OF_DAY) { + incrementUnit = Calendar.DAY_OF_MONTH + } else if (unit == Calendar.MINUTE) { + incrementUnit = Calendar.HOUR_OF_DAY + } else if (unit == Calendar.SECOND) { + incrementUnit = Calendar.MINUTE + } + if (incrementUnit != -1) { + next.set(incrementUnit, next.get(incrementUnit) + 1) + } + } + return next.timeInMillis + } + + private fun buildNextTriggerTime(date: Date): Calendar { + val next: Calendar = buildCalendar(date) + if (year != null) { + next.set(Calendar.YEAR, year ?: 0) + if (unit == -1) unit = Calendar.YEAR + } + if (month != null) { + next.set(Calendar.MONTH, month ?: 0) + if (unit == -1) unit = Calendar.MONTH + } + if (day != null) { + next.set(Calendar.DAY_OF_MONTH, day ?: 0) + if (unit == -1) unit = Calendar.DAY_OF_MONTH + } + if (weekday != null) { + next.set(Calendar.DAY_OF_WEEK, weekday ?: 0) + if (unit == -1) unit = Calendar.DAY_OF_WEEK + } + if (hour != null) { + next.set(Calendar.HOUR_OF_DAY, hour ?: 0) + if (unit == -1) unit = Calendar.HOUR_OF_DAY + } + if (minute != null) { + next.set(Calendar.MINUTE, minute ?: 0) + if (unit == -1) unit = Calendar.MINUTE + } + if (second != null) { + next.set(Calendar.SECOND, second ?: 0) + if (unit == -1) unit = Calendar.SECOND + } + return next + } + + override fun toString(): String { + return "DateMatch{" + + "year=" + + year + + ", month=" + + month + + ", day=" + + day + + ", weekday=" + + weekday + + ", hour=" + + hour + + ", minute=" + + minute + + ", second=" + + second + + '}' + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val dateMatch = other as DateMatch + if (if (year != null) year != dateMatch.year else dateMatch.year != null) return false + if (if (month != null) month != dateMatch.month else dateMatch.month != null) return false + if (if (day != null) day != dateMatch.day else dateMatch.day != null) return false + if (if (weekday != null) weekday != dateMatch.weekday else dateMatch.weekday != null) return false + if (if (hour != null) hour != dateMatch.hour else dateMatch.hour != null) return false + if (if (minute != null) minute != dateMatch.minute else dateMatch.minute != null) return false + return if (second != null) second == dateMatch.second else dateMatch.second == null + } + + override fun hashCode(): Int { + var result = if (year != null) year.hashCode() else 0 + result = 31 * result + if (month != null) month.hashCode() else 0 + result = 31 * result + if (day != null) day.hashCode() else 0 + result = 31 * result + if (weekday != null) weekday.hashCode() else 0 + result = 31 * result + if (hour != null) hour.hashCode() else 0 + result = 31 * result + if (minute != null) minute.hashCode() else 0 + result += 31 + if (second != null) second.hashCode() else 0 + return result + } + + /** + * Transform DateMatch object to CronString + * + * @return + */ + fun toMatchString(): String { + val matchString = year.toString() + + separator + + month + + separator + + day + + separator + + weekday + + separator + + hour + + separator + + minute + + separator + + second + + separator + + unit + return matchString.replace("null", "*") + } + + companion object { + private const val separator = " " + + /** + * Create DateMatch object from stored string + * + * @param matchString + * @return + */ + fun fromMatchString(matchString: String): DateMatch { + val date = DateMatch() + val split = matchString.split(separator.toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (split.size == 7) { + date.year = getValueFromCronElement(split[0]) + date.month = getValueFromCronElement(split[1]) + date.day = getValueFromCronElement(split[2]) + date.weekday = getValueFromCronElement(split[3]) + date.hour = getValueFromCronElement(split[4]) + date.minute = getValueFromCronElement(split[5]) + date.unit = getValueFromCronElement(split[6]) + } + if (split.size == 8) { + date.year = getValueFromCronElement(split[0]) + date.month = getValueFromCronElement(split[1]) + date.day = getValueFromCronElement(split[2]) + date.weekday = getValueFromCronElement(split[3]) + date.hour = getValueFromCronElement(split[4]) + date.minute = getValueFromCronElement(split[5]) + date.second = getValueFromCronElement(split[6]) + date.unit = getValueFromCronElement(split[7]) + } + return date + } + + private fun getValueFromCronElement(token: String): Int? { + return try { + token.toInt() + } catch (e: NumberFormatException) { + null + } + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationStorage.kt b/plugins/notification/android/src/main/java/NotificationStorage.kt new file mode 100644 index 000000000..bfddfcc2a --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationStorage.kt @@ -0,0 +1,131 @@ +package app.tauri.notification + +import android.content.Context +import android.content.SharedPreferences +import app.tauri.plugin.JSObject +import org.json.JSONException +import java.text.ParseException + +// Key for private preferences +private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE" +// Key used to save action types +private const val ACTION_TYPES_ID = "ACTION_TYPE_STORE" + +class NotificationStorage(private val context: Context) { + fun appendNotifications(localNotifications: List) { + val storage = getStorage(NOTIFICATION_STORE_ID) + val editor = storage.edit() + for (request in localNotifications) { + if (request.isScheduled) { + val key: String = request.id.toString() + editor.putString(key, request.source.toString()) + } + } + editor.apply() + } + + fun getSavedNotificationIds(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + return if (all != null) { + ArrayList(all.keys) + } else ArrayList() + } + + fun getSavedNotifications(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + if (all != null) { + val notifications = ArrayList() + for (key in all.keys) { + val notificationString = all[key] as String? + val jsNotification = getNotificationFromJSONString(notificationString) + if (jsNotification != null) { + try { + val notification = + Notification.fromJSObject(jsNotification) + notifications.add(notification) + } catch (_: ParseException) { + } + } + } + return notifications + } + return ArrayList() + } + + private fun getNotificationFromJSONString(notificationString: String?): JSObject? { + if (notificationString == null) { + return null + } + val jsNotification = try { + JSObject(notificationString) + } catch (ex: JSONException) { + return null + } + return jsNotification + } + + fun getSavedNotificationAsJSObject(key: String?): JSObject? { + val storage = getStorage(NOTIFICATION_STORE_ID) + val notificationString = try { + storage.getString(key, null) + } catch (ex: ClassCastException) { + return null + } ?: return null + + val jsNotification = try { + JSObject(notificationString) + } catch (ex: JSONException) { + return null + } + return jsNotification + } + + fun getSavedNotification(key: String?): Notification? { + val jsNotification = getSavedNotificationAsJSObject(key) ?: return null + val notification = try { + Notification.fromJSObject(jsNotification) + } catch (ex: ParseException) { + return null + } + return notification + } + + fun deleteNotification(id: String?) { + val editor = getStorage(NOTIFICATION_STORE_ID).edit() + editor.remove(id) + editor.apply() + } + + private fun getStorage(key: String): SharedPreferences { + return context.getSharedPreferences(key, Context.MODE_PRIVATE) + } + + fun writeActionGroup(typesMap: Map>) { + for ((id, notificationActions) in typesMap) { + val editor = getStorage(ACTION_TYPES_ID + id).edit() + editor.clear() + editor.putInt("count", notificationActions.size) + for (i in notificationActions.indices) { + editor.putString("id$i", notificationActions[i].id) + editor.putString("title$i", notificationActions[i].title) + editor.putBoolean("input$i", notificationActions[i].input) + } + editor.apply() + } + } + + fun getActionGroup(forId: String): Array { + val storage = getStorage(ACTION_TYPES_ID + forId) + val count = storage.getInt("count", 0) + val actions: Array = arrayOfNulls(count) + for (i in 0 until count) { + val id = storage.getString("id$i", "") + val title = storage.getString("title$i", "") + val input = storage.getBoolean("input$i", false) + actions[i] = NotificationAction(id, title, input) + } + return actions + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt new file mode 100644 index 000000000..79e679088 --- /dev/null +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -0,0 +1,569 @@ +package app.tauri.notification + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.os.UserManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import app.tauri.Logger +import app.tauri.plugin.JSObject +import app.tauri.plugin.PluginManager +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date + +// Action constants +const val NOTIFICATION_INTENT_KEY = "NotificationId" +const val NOTIFICATION_OBJ_INTENT_KEY = "LocalNotficationObject" +const val ACTION_INTENT_KEY = "NotificationUserAction" +const val NOTIFICATION_IS_REMOVABLE_KEY = "NotificationRepeating" +const val REMOTE_INPUT_KEY = "NotificationRemoteInput" +const val DEFAULT_NOTIFICATION_CHANNEL_ID = "default" +const val DEFAULT_PRESS_ACTION = "tap" + +class TauriNotificationManager( + private val storage: NotificationStorage, + private val activity: Activity?, + private val context: Context, + private val config: JSObject +) { + private var defaultSoundID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + private var defaultSmallIconID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + + fun handleNotificationActionPerformed( + data: Intent, + notificationStorage: NotificationStorage + ): JSObject? { + Logger.debug(Logger.tags("Notification"), "Notification received: " + data.dataString) + val notificationId = + data.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (notificationId == Int.MIN_VALUE) { + Logger.debug(Logger.tags("Notification"), "Activity started without notification attached") + return null + } + val isRemovable = + data.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + if (isRemovable) { + notificationStorage.deleteNotification(notificationId.toString()) + } + val dataJson = JSObject() + val results = RemoteInput.getResultsFromIntent(data) + val input = results?.getCharSequence(REMOTE_INPUT_KEY) + dataJson.put("inputValue", input?.toString()) + val menuAction = data.getStringExtra(ACTION_INTENT_KEY) + dismissVisibleNotification(notificationId) + dataJson.put("actionId", menuAction) + var request: JSONObject? = null + try { + val notificationJsonString = + data.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY) + if (notificationJsonString != null) { + request = JSObject(notificationJsonString) + } + } catch (_: JSONException) { + } + dataJson.put("notification", request) + return dataJson + } + + /** + * Create notification channel + */ + fun createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Default" + val description = "Default" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + val soundUri = getDefaultSoundUrl(context) + if (soundUri != null) { + channel.setSound(soundUri, audioAttributes) + } + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + private fun trigger(notificationManager: NotificationManagerCompat, notification: Notification): Int { + dismissVisibleNotification(notification.id) + cancelTimerForNotification(notification.id) + buildNotification(notificationManager, notification) + + return notification.id + } + + fun schedule(notification: Notification): Int { + val notificationManager = NotificationManagerCompat.from(context) + return trigger(notificationManager, notification) + } + + fun schedule(notifications: List): List { + val ids = mutableListOf() + val notificationManager = NotificationManagerCompat.from(context) + + for (notification in notifications) { + val id = trigger(notificationManager, notification) + ids.add(id) + } + + return ids + } + + // TODO Progressbar support + // TODO System categories (DO_NOT_DISTURB etc.) + // TODO use NotificationCompat.MessagingStyle for latest API + // TODO expandable notification NotificationCompat.MessagingStyle + // TODO media style notification support NotificationCompat.MediaStyle + @SuppressLint("MissingPermission") + private fun buildNotification( + notificationManager: NotificationManagerCompat, + notification: Notification, + ) { + val channelId = notification.channelId ?: DEFAULT_NOTIFICATION_CHANNEL_ID + val mBuilder = NotificationCompat.Builder( + context, channelId + ) + .setContentTitle(notification.title) + .setContentText(notification.body) + .setAutoCancel(notification.isAutoCancel) + .setOngoing(notification.isOngoing) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setGroupSummary(notification.isGroupSummary) + if (notification.largeBody != null) { + // support multiline text + mBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(notification.largeBody) + .setSummaryText(notification.summary) + ) + } else if (notification.inboxLines != null) { + val inboxStyle = NotificationCompat.InboxStyle() + for (line in notification.inboxLines ?: listOf()) { + inboxStyle.addLine(line) + } + inboxStyle.setBigContentTitle(notification.title) + inboxStyle.setSummaryText(notification.summary) + mBuilder.setStyle(inboxStyle) + } + val sound = notification.getSound(context, getDefaultSound(context)) + if (sound != null) { + val soundUri = Uri.parse(sound) + // Grant permission to use sound + context.grantUriPermission( + "com.android.systemui", + soundUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + mBuilder.setSound(soundUri) + mBuilder.setDefaults(android.app.Notification.DEFAULT_VIBRATE or android.app.Notification.DEFAULT_LIGHTS) + } else { + mBuilder.setDefaults(android.app.Notification.DEFAULT_ALL) + } + val group = notification.group + if (group != null) { + mBuilder.setGroup(group) + if (notification.isGroupSummary) { + mBuilder.setSubText(notification.summary) + } + } + mBuilder.setVisibility(notification.visibility ?: NotificationCompat.VISIBILITY_PRIVATE) + mBuilder.setOnlyAlertOnce(true) + mBuilder.setSmallIcon(notification.getSmallIcon(context, getDefaultSmallIcon(context))) + mBuilder.setLargeIcon(notification.getLargeIcon(context)) + val iconColor = notification.getIconColor(config.getString("iconColor")) + if (iconColor.isNotEmpty()) { + try { + mBuilder.color = Color.parseColor(iconColor) + } catch (ex: IllegalArgumentException) { + throw Exception("Invalid color provided. Must be a hex string (ex: #ff0000") + } + } + createActionIntents(notification, mBuilder) + // notificationId is a unique int for each notification that you must define + val buildNotification = mBuilder.build() + if (notification.isScheduled) { + triggerScheduledNotification(buildNotification, notification) + } else { + notificationManager.notify(notification.id, buildNotification) + try { + NotificationPlugin.triggerNotification(notification.source ?: JSObject()) + } catch (_: JSONException) { + } + } + } + + // Create intents for open/dismiss actions + private fun createActionIntents( + notification: Notification, + mBuilder: NotificationCompat.Builder + ) { + // Open intent + val intent = buildIntent(notification, DEFAULT_PRESS_ACTION) + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getActivity(context, notification.id, intent, flags) + mBuilder.setContentIntent(pendingIntent) + + // Build action types + val actionTypeId = notification.actionTypeId + if (actionTypeId != null) { + val actionGroup = storage.getActionGroup(actionTypeId) + for (notificationAction in actionGroup) { + // TODO Add custom icons to actions + val actionIntent = buildIntent(notification, notificationAction!!.id) + val actionPendingIntent = PendingIntent.getActivity( + context, + (notification.id) + notificationAction.id.hashCode(), + actionIntent, + flags + ) + val actionBuilder: NotificationCompat.Action.Builder = NotificationCompat.Action.Builder( + R.drawable.ic_transparent, + notificationAction.title, + actionPendingIntent + ) + if (notificationAction.input) { + val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel( + notificationAction.title + ).build() + actionBuilder.addRemoteInput(remoteInput) + } + mBuilder.addAction(actionBuilder.build()) + } + } + + // Dismiss intent + val dissmissIntent = Intent( + context, + NotificationDismissReceiver::class.java + ) + dissmissIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + dissmissIntent.putExtra(NOTIFICATION_INTENT_KEY, notification.id) + dissmissIntent.putExtra(ACTION_INTENT_KEY, "dismiss") + val schedule = notification.schedule + dissmissIntent.putExtra( + NOTIFICATION_IS_REMOVABLE_KEY, + schedule == null || schedule.isRemovable() + ) + flags = 0 + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val deleteIntent = + PendingIntent.getBroadcast(context, notification.id, dissmissIntent, flags) + mBuilder.setDeleteIntent(deleteIntent) + } + + private fun buildIntent(notification: Notification, action: String?): Intent { + val intent = if (activity != null) { + Intent(context, activity.javaClass) + } else { + val packageName = context.packageName + context.packageManager.getLaunchIntentForPackage(packageName)!! + } + intent.action = Intent.ACTION_MAIN + intent.addCategory(Intent.CATEGORY_LAUNCHER) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.putExtra(NOTIFICATION_INTENT_KEY, notification.id) + intent.putExtra(ACTION_INTENT_KEY, action) + intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, notification.source.toString()) + val schedule = notification.schedule + intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()) + return intent + } + + /** + * Build a notification trigger, such as triggering each N seconds, or + * on a certain date "shape" (such as every first of the month) + */ + // TODO support different AlarmManager.RTC modes depending on priority + @SuppressLint("SimpleDateFormat") + private fun triggerScheduledNotification(notification: android.app.Notification, request: Notification) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val schedule = request.schedule + val notificationIntent = Intent( + context, + TimedNotificationPublisher::class.java + ) + notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.id) + notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification) + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + var pendingIntent = + PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + + when (val scheduleKind = schedule?.kind) { + is ScheduleKind.At -> { + val at = scheduleKind.date + if (at.time < Date().time) { + Logger.error(Logger.tags("Notification"), "Scheduled time must be *after* current time", null) + return + } + if (scheduleKind.repeating) { + val interval: Long = at.time - Date().time + alarmManager.setRepeating(AlarmManager.RTC, at.time, interval, pendingIntent) + } else { + setExactIfPossible(alarmManager, schedule, at.time, pendingIntent) + } + } + is ScheduleKind.Interval -> { + val trigger = scheduleKind.interval.nextTrigger(Date()) + notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, scheduleKind.interval.toMatchString()) + pendingIntent = + PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + setExactIfPossible(alarmManager, schedule, trigger, pendingIntent) + val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") + Logger.debug( + Logger.tags("Notification"), + "notification " + request.id + " will next fire at " + sdf.format(Date(trigger)) + ) + } + is ScheduleKind.Every -> { + val everyInterval = getIntervalTime(scheduleKind.interval, scheduleKind.count) + val startTime: Long = Date().time + everyInterval + alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent) + } + else -> {} + } + } + + @SuppressLint("ObsoleteSdkInt", "MissingPermission") + private fun setExactIfPossible( + alarmManager: AlarmManager, + schedule: NotificationSchedule, + trigger: Long, + pendingIntent: PendingIntent + ) { + if (SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + if (SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } + } else { + if (SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + } + } + + fun cancel(notifications: List) { + for (id in notifications) { + dismissVisibleNotification(id) + cancelTimerForNotification(id) + storage.deleteNotification(id.toString()) + } + } + + private fun cancelTimerForNotification(notificationId: Int) { + val intent = Intent(context, TimedNotificationPublisher::class.java) + var flags = 0 + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val pi = PendingIntent.getBroadcast(context, notificationId, intent, flags) + if (pi != null) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pi) + } + } + + private fun dismissVisibleNotification(notificationId: Int) { + val notificationManager = NotificationManagerCompat.from( + context + ) + notificationManager.cancel(notificationId) + } + + fun areNotificationsEnabled(): Boolean { + val notificationManager = NotificationManagerCompat.from(context) + return notificationManager.areNotificationsEnabled() + } + + private fun getDefaultSoundUrl(context: Context): Uri? { + val soundId = getDefaultSound(context) + return if (soundId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + soundId) + } else null + } + + private fun getDefaultSound(context: Context): Int { + if (defaultSoundID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSoundID + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val soundConfigResourceName = AssetUtils.getResourceBaseName(config.getString("sound")) + if (soundConfigResourceName != null) { + resId = AssetUtils.getResourceID(context, soundConfigResourceName, "raw") + } + defaultSoundID = resId + return resId + } + + private fun getDefaultSmallIcon(context: Context): Int { + if (defaultSmallIconID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSmallIconID + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val smallIconConfigResourceName = AssetUtils.getResourceBaseName(config.getString("icon")) + if (smallIconConfigResourceName != null) { + resId = AssetUtils.getResourceID(context, smallIconConfigResourceName, "drawable") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = android.R.drawable.ic_dialog_info + } + defaultSmallIconID = resId + return resId + } +} + +class NotificationDismissReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val intExtra = + intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (intExtra == Int.MIN_VALUE) { + Logger.error(Logger.tags("Notification"), "Invalid notification dismiss operation", null) + return + } + val isRemovable = + intent.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + if (isRemovable) { + val notificationStorage = NotificationStorage(context) + notificationStorage.deleteNotification(intExtra.toString()) + } + } +} + +class TimedNotificationPublisher : BroadcastReceiver() { + /** + * Restore and present notification + */ + override fun onReceive(context: Context, intent: Intent) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + NOTIFICATION_KEY, + android.app.Notification::class.java + ) + } else { + getParcelableExtraLegacy(intent, NOTIFICATION_KEY) + } + notification?.`when` = System.currentTimeMillis() + val id = intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (id == Int.MIN_VALUE) { + Logger.error(Logger.tags("Notification"), "No valid id supplied", null) + } + val storage = NotificationStorage(context) + val notificationJson = storage.getSavedNotificationAsJSObject(id.toString()) + if (notificationJson != null) { + NotificationPlugin.triggerNotification(notificationJson) + } + notificationManager.notify(id, notification) + if (!rescheduleNotificationIfNeeded(context, intent, id)) { + storage.deleteNotification(id.toString()) + } + } + + @Suppress("DEPRECATION") + private fun getParcelableExtraLegacy(intent: Intent, string: String): android.app.Notification? { + return intent.getParcelableExtra(string) + } + + @SuppressLint("MissingPermission", "SimpleDateFormat") + private fun rescheduleNotificationIfNeeded(context: Context, intent: Intent, id: Int): Boolean { + val dateString = intent.getStringExtra(CRON_KEY) + if (dateString != null) { + val date = DateMatch.fromMatchString(dateString) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val trigger = date.nextTrigger(Date()) + val clone = intent.clone() as Intent + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getBroadcast(context, id, clone, flags) + if (SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") + Logger.debug( + Logger.tags("Notification"), + "notification " + id + " will next fire at " + sdf.format(Date(trigger)) + ) + return true + } + return false + } + + companion object { + var NOTIFICATION_KEY = "NotificationPublisher.notification" + var CRON_KEY = "NotificationPublisher.cron" + } +} + +class LocalNotificationRestoreReceiver : BroadcastReceiver() { + @SuppressLint("ObsoleteSdkInt") + override fun onReceive(context: Context, intent: Intent) { + if (SDK_INT >= Build.VERSION_CODES.N) { + val um = context.getSystemService( + UserManager::class.java + ) + if (um == null || !um.isUserUnlocked) return + } + val storage = NotificationStorage(context) + val ids = storage.getSavedNotificationIds() + val notifications = mutableListOf() + val updatedNotifications = mutableListOf() + for (id in ids) { + val notification = storage.getSavedNotification(id) ?: continue + val schedule = notification.schedule + if (schedule != null && schedule.kind is ScheduleKind.At) { + val at: Date = schedule.kind.date + if (at.before(Date())) { + // modify the scheduled date in order to show notifications that would have been delivered while device was off. + val newDateTime = Date().time + 15 * 1000 + schedule.kind.date = Date(newDateTime) + updatedNotifications.add(notification) + } + } + notifications.add(notification) + } + if (updatedNotifications.size > 0) { + storage.appendNotifications(updatedNotifications) + } + + val notificationManager = TauriNotificationManager(storage, null, context, PluginManager.loadConfig(context, "notification")) + notificationManager.schedule(notifications) + } +} diff --git a/plugins/notification/android/src/main/res/drawable/ic_transparent.xml b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml new file mode 100644 index 000000000..fc1779e2b --- /dev/null +++ b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 7a186795c..0efa168d5 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -24,7 +24,7 @@ * @module */ -import { invoke } from "@tauri-apps/api/tauri"; +import { invoke, transformCallback } from '@tauri-apps/api/tauri' /** * Options to send a notification. @@ -32,12 +32,269 @@ import { invoke } from "@tauri-apps/api/tauri"; * @since 1.0.0 */ interface Options { - /** Notification title. */ - title: string; - /** Optional notification body. */ - body?: string; - /** Optional notification icon. */ - icon?: string; + /** + * The notification identifier to reference this object later. Must be a 32-bit integer. + */ + id?: number + /** + * Identifier of the {@link Channel} that deliveres this notification. + * + * If the channel does not exist, the notification won't fire. + * Make sure the channel exists with {@link listChannels} and {@link createChannel}. + */ + channelId?: string + /** + * Notification title. + */ + title: string + /** + * Optional notification body. + * */ + body?: string + /** + * Schedule this notification to fire on a later time or a fixed interval. + */ + schedule?: Schedule + /** + * Multiline text. + * Changes the notification style to big text. + * Cannot be used with `inboxLines`. + */ + largeBody?: string + /** + * Detail text for the notification with `largeBody`, `inboxLines` or `groupSummary`. + */ + summary?: string + /** + * Defines an action type for this notification. + */ + actionTypeId?: string + /** + * Identifier used to group multiple notifications. + * + * https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier + */ + group?: string + /** + * Instructs the system that this notification is the summary of a group on Android. + */ + groupSummary?: boolean + /** + * The sound resource name. Only available on mobile. + */ + sound?: string + /** + * List of lines to add to the notification. + * Changes the notification style to inbox. + * Cannot be used with `largeBody`. + * + * Only supports up to 5 lines. + */ + inboxLines?: string[] + /** + * Notification icon. + * + * On Android the icon must be placed in the app's `res/drawable` folder. + */ + icon?: string + /** + * Notification large icon (Android). + * + * The icon must be placed in the app's `res/drawable` folder. + */ + largeIcon?: string + /** + * Icon color on Android. + */ + iconColor?: string + /** + * Notification attachments. + */ + attachments?: Attachment[] + /** + * Extra payload to store in the notification. + */ + extra?: { [key: string]: unknown } + /** + * If true, the notification cannot be dismissed by the user on Android. + * + * An application service must manage the dismissal of the notification. + * It is typically used to indicate a background task that is pending (e.g. a file download) + * or the user is engaged with (e.g. playing music). + */ + ongoing?: boolean + /** + * Automatically cancel the notification when the user clicks on it. + */ + autoCancel?: boolean + /** + * Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + */ + silent?: boolean + /** + * Notification visibility. + */ + visibility?: Visibility + /** + * Sets the number of items this notification represents on Android. + */ + number?: number +} + +type ScheduleInterval = { + year?: number + month?: number + day?: number + /** + * 1 - Sunday + * 2 - Monday + * 3 - Tuesday + * 4 - Wednesday + * 5 - Thursday + * 6 - Friday + * 7 - Saturday + */ + weekday?: number + hour?: number + minute?: number + second?: number +} + +enum ScheduleEvery { + Year = 'Year', + Month = 'Month', + TwoWeeks = 'TwoWeeks', + Week = 'Week', + Day = 'Day', + Hour = 'Hour', + Minute = 'Minute', + /** + * Not supported on iOS. + */ + Second = 'Second' +} + +type ScheduleData = { + kind: 'At', + data: { + date: Date + repeating: boolean + } +} | { + kind: 'Interval', + data: ScheduleInterval +} | { + kind: 'Every', + data: { + interval: ScheduleEvery + } +} + +class Schedule { + kind: string + data: unknown + + private constructor(schedule: ScheduleData) { + this.kind = schedule.kind + this.data = schedule.data + } + + static at(date: Date, repeating = false) { + return new Schedule({ kind: 'At', data: { date, repeating } }) + } + + static interval(interval: ScheduleInterval) { + return new Schedule({ kind: 'Interval', data: interval }) + } + + static every(kind: ScheduleEvery) { + return new Schedule({ kind: 'Every', data: { interval: kind } }) + } +} + +/** + * Attachment of a notification. + */ +interface Attachment { + /** Attachment identifier. */ + id: string + /** Attachment URL. Accepts the `asset` and `file` protocols. */ + url: string +} + +interface Action { + id: string + title: string + requiresAuthentication?: boolean + foreground?: boolean + destructive?: boolean + input?: boolean + inputButtonTitle?: string + inputPlaceholder?: string +} + +interface ActionType { + /** + * The identifier of this action type + */ + id: string + /** + * The list of associated actions + */ + actions: Action[] + hiddenPreviewsBodyPlaceholder?: string, + customDismissAction?: boolean, + allowInCarPlay?: boolean, + hiddenPreviewsShowTitle?: boolean, + hiddenPreviewsShowSubtitle?: boolean, +} + +interface PendingNotification { + id: number + title?: string + body?: string + schedule: Schedule +} + +interface ActiveNotification { + id: number + tag?: string + title?: string + body?: string + group?: string + groupSummary: boolean + data: Record + extra: Record + attachments: Attachment[] + actionTypeId?: string + schedule?: Schedule + sound?: string +} + +enum Importance { + None = 0, + Min, + Low, + Default, + High +} + +enum Visibility { + Secret = -1, + Private, + Public +} + +interface Channel { + id: string + name: string + description?: string + sound?: string + lights?: boolean + lightColor?: string + vibration?: boolean + importance?: Importance + visibility?: Visibility } /** Possible permission values. */ @@ -108,6 +365,241 @@ function sendNotification(options: Options | string): void { } } -export type { Options, Permission }; +/** + * Register actions that are performed when the user clicks on the notification. + * + * @example + * ```typescript + * import { registerActionTypes } from '@tauri-apps/api/notification'; + * await registerActionTypes([{ + * id: 'tauri', + * actions: [{ + * id: 'my-action', + * title: 'Settings' + * }] + * }]) + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function registerActionTypes(types: ActionType[]): Promise { + return invoke('plugin:notification|register_action_types', { types }) +} + +/** + * Retrieves the list of pending notifications. + * + * @example + * ```typescript + * import { pending } from '@tauri-apps/api/notification'; + * const pendingNotifications = await pending(); + * ``` + * + * @returns A promise resolving to the list of pending notifications. + * + * @since 2.0.0 + */ +async function pending(): Promise { + return invoke('plugin:notification|get_pending') +} + +/** + * Cancels the pending notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/api/notification'; + * await cancel([-34234, 23432, 4311]); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancel(notifications: number[]): Promise { + return invoke('plugin:notification|cancel', { notifications }) +} + +/** + * Cancels all pending notifications. + * + * @example + * ```typescript + * import { cancelAll } from '@tauri-apps/api/notification'; + * await cancelAll(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancelAll(): Promise { + return invoke('plugin:notification|cancel') +} -export { sendNotification, requestPermission, isPermissionGranted }; +/** + * Retrieves the list of active notifications. + * + * @example + * ```typescript + * import { active } from '@tauri-apps/api/notification'; + * const activeNotifications = await active(); + * ``` + * + * @returns A promise resolving to the list of active notifications. + * + * @since 2.0.0 + */ +async function active(): Promise { + return invoke('plugin:notification|get_active') +} + +/** + * Removes the active notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/api/notification'; + * await cancel([-34234, 23432, 4311]) + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeActive(notifications: number[]): Promise { + return invoke('plugin:notification|remove_active', { notifications }) +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { removeAllActive } from '@tauri-apps/api/notification'; + * await removeAllActive() + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeAllActive(): Promise { + return invoke('plugin:notification|remove_active') +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { createChannel, Importance, Visibility } from '@tauri-apps/api/notification'; + * await createChannel({ + * id: 'new-messages', + * name: 'New Messages', + * lights: true, + * vibration: true, + * importance: Importance.Default, + * visibility: Visibility.Private + * }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function createChannel(channel: Channel): Promise { + return invoke('plugin:notification|create_channel', { ...channel }) +} + +/** + * Removes the channel with the given identifier. + * + * @example + * ```typescript + * import { removeChannel } from '@tauri-apps/api/notification'; + * await removeChannel(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeChannel(id: string): Promise { + return invoke('plugin:notification|delete_channel', { id }) +} + +/** + * Retrieves the list of notification channels. + * + * @example + * ```typescript + * import { channels } from '@tauri-apps/api/notification'; + * const notificationChannels = await channels(); + * ``` + * + * @returns A promise resolving to the list of notification channels. + * + * @since 2.0.0 + */ +async function channels(): Promise { + return invoke('plugin:notification|getActive') +} + +class EventChannel { + id: number + unregisterFn: (channel: EventChannel) => Promise + + constructor(id: number, unregisterFn: (channel: EventChannel) => Promise) { + this.id = id + this.unregisterFn = unregisterFn + } + + toJSON(): string { + return `__CHANNEL__:${this.id}` + } + + async unregister(): Promise { + return this.unregisterFn(this) + } +} + +// TODO: use addPluginListener API on @tauri-apps/api/tauri 2.0.0-alpha.4 +async function onNotificationReceived(cb: (notification: Options) => void): Promise { + const channelId = transformCallback(cb) + const handler = new EventChannel(channelId, (channel) => invoke('plugin:notification|remove_listener', { event: 'notification', channelId: channel.id })) + return invoke('plugin:notification|register_listener', { event: 'notification', handler }).then(() => handler) +} + +// TODO: use addPluginListener API on @tauri-apps/api/tauri 2.0.0-alpha.4 +async function onAction(cb: (notification: Options) => void): Promise { + const channelId = transformCallback(cb) + const handler = new EventChannel(channelId, (channel) => invoke('plugin:notification|remove_listener', { event: 'actionPerformed', channelId: channel.id })) + return invoke('plugin:notification|register_listener', { event: 'actionPerformed', handler }).then(() => handler) +} + + +export type { Attachment, Options, Permission, Action, ActionType, PendingNotification, ActiveNotification, Channel } + +export { + Importance, + Visibility, + sendNotification, + requestPermission, + isPermissionGranted, + registerActionTypes, + pending, + cancel, + cancelAll, + active, + removeActive, + removeAllActive, + createChannel, + removeChannel, + channels, + + onNotificationReceived, + onAction +} diff --git a/plugins/notification/ios/Package.swift b/plugins/notification/ios/Package.swift index ff9991fa9..bfcaf338f 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 000000000..52b1016fc --- /dev/null +++ b/plugins/notification/ios/Sources/Notification.swift @@ -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 + } +} diff --git a/plugins/notification/ios/Sources/NotificationCategory.swift b/plugins/notification/ios/Sources/NotificationCategory.swift new file mode 100644 index 000000000..74a1e194f --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationCategory.swift @@ -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) +} diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift new file mode 100644 index 000000000..1f7cb8ba6 --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -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 + } +} diff --git a/plugins/notification/ios/Sources/NotificationManager.swift b/plugins/notification/ios/Sources/NotificationManager.swift new file mode 100644 index 000000000..857636fb6 --- /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 3d520a929..217c999d1 100644 --- a/plugins/notification/ios/Sources/NotificationPlugin.swift +++ b/plugins/notification/ios/Sources/NotificationPlugin.swift @@ -1,24 +1,209 @@ +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(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"]) - } - - @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() + 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") + } + } @_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 710235c19..4af855857 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 cb63758a3..6e566fe27 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -30,16 +30,6 @@ use desktop::Notification; #[cfg(mobile)] use mobile::Notification; -#[derive(Debug, Default, Serialize)] -struct NotificationData { - /// The notification title. - title: Option, - /// The notification body. - body: Option, - /// The notification icon. - icon: Option, -} - /// The notification builder. #[derive(Debug)] pub struct NotificationBuilder { @@ -47,7 +37,7 @@ pub struct NotificationBuilder { app: AppHandle, #[cfg(mobile)] handle: PluginHandle, - data: NotificationData, + pub(crate) data: NotificationData, } impl NotificationBuilder { @@ -67,6 +57,21 @@ impl NotificationBuilder { } } + /// Sets the notification identifier. + pub fn id(mut self, id: i32) -> Self { + self.data.id = id; + self + } + + /// Identifier of the {@link Channel} that deliveres this notification. + /// + /// If the channel does not exist, the notification won't fire. + /// Make sure the channel exists with {@link listChannels} and {@link createChannel}. + pub fn channel_id(mut self, id: impl Into) -> Self { + self.data.channel_id.replace(id.into()); + self + } + /// Sets the notification title. pub fn title(mut self, title: impl Into) -> Self { self.data.title.replace(title.into()); @@ -79,11 +84,119 @@ impl NotificationBuilder { self } - /// Sets the notification icon. + /// Schedule this notification to fire on a later time or a fixed interval. + pub fn schedule(mut self, schedule: Schedule) -> Self { + self.data.schedule.replace(schedule); + self + } + + /// Multiline text. + /// Changes the notification style to big text. + /// Cannot be used with `inboxLines`. + pub fn large_body(mut self, large_body: impl Into) -> Self { + self.data.large_body.replace(large_body.into()); + self + } + + /// Detail text for the notification with `largeBody`, `inboxLines` or `groupSummary`. + pub fn summary(mut self, summary: impl Into) -> Self { + self.data.summary.replace(summary.into()); + self + } + + /// Defines an action type for this notification. + pub fn action_type_id(mut self, action_type_id: impl Into) -> Self { + self.data.action_type_id.replace(action_type_id.into()); + self + } + + /// Identifier used to group multiple notifications. + /// + /// https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier + pub fn group(mut self, group: impl Into) -> Self { + self.data.group.replace(group.into()); + self + } + + /// Instructs the system that this notification is the summary of a group on Android. + pub fn group_summary(mut self) -> Self { + self.data.group_summary = true; + self + } + + /// The sound resource name. Only available on mobile. + pub fn sound(mut self, sound: impl Into) -> Self { + self.data.sound.replace(sound.into()); + self + } + + /// Append an inbox line to the notification. + /// Changes the notification style to inbox. + /// Cannot be used with `largeBody`. + /// + /// Only supports up to 5 lines. + pub fn inbox_line(mut self, line: impl Into) -> Self { + self.data.inbox_lines.push(line.into()); + self + } + + /// Notification icon. + /// + /// On Android the icon must be placed in the app's `res/drawable` folder. pub fn icon(mut self, icon: impl Into) -> Self { self.data.icon.replace(icon.into()); self } + + /// Notification large icon (Android). + /// + /// The icon must be placed in the app's `res/drawable` folder. + pub fn large_icon(mut self, large_icon: impl Into) -> Self { + self.data.large_icon.replace(large_icon.into()); + self + } + + /// Icon color on Android. + pub fn icon_color(mut self, icon_color: impl Into) -> Self { + self.data.icon_color.replace(icon_color.into()); + self + } + + /// Append an attachment to the notification. + pub fn attachment(mut self, attachment: Attachment) -> Self { + self.data.attachments.push(attachment); + self + } + + /// Adds an extra payload to store in the notification. + pub fn extra(mut self, key: impl Into, value: impl Serialize) -> Self { + self.data + .extra + .insert(key.into(), serde_json::to_value(value).unwrap()); + self + } + + /// If true, the notification cannot be dismissed by the user on Android. + /// + /// An application service must manage the dismissal of the notification. + /// It is typically used to indicate a background task that is pending (e.g. a file download) + /// or the user is engaged with (e.g. playing music). + pub fn ongoing(mut self) -> Self { + self.data.ongoing = true; + self + } + + /// Automatically cancel the notification when the user clicks on it. + pub fn auto_cancel(mut self) -> Self { + self.data.auto_cancel = true; + self + } + + /// Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + pub fn silent(mut self) -> Self { + self.data.silent = true; + self + } } /// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the notification APIs. diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs index abd196ed1..83513eef4 100644 --- a/plugins/notification/src/mobile.rs +++ b/plugins/notification/src/mobile.rs @@ -10,6 +10,8 @@ use tauri::{ use crate::models::*; +use std::collections::HashMap; + #[cfg(target_os = "android")] const PLUGIN_IDENTIFIER: &str = "app.tauri.notification"; @@ -31,7 +33,8 @@ 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(|_| ()) .map_err(Into::into) } } @@ -46,17 +49,121 @@ impl Notification { pub fn request_permission(&self) -> crate::Result { self.0 - .run_mobile_plugin::("requestPermission", ()) + .run_mobile_plugin::("requestPermissions", ()) .map(|r| r.permission_state) .map_err(Into::into) } pub fn permission_state(&self) -> crate::Result { self.0 - .run_mobile_plugin::("permissionState", ()) + .run_mobile_plugin::("checkPermissions", ()) .map(|r| r.permission_state) .map_err(Into::into) } + + pub fn register_action_types(&self, types: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("types", types); + self.0 + .run_mobile_plugin("registerActionTypes", args) + .map_err(Into::into) + } + + pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert( + "notifications", + notifications + .into_iter() + .map(|id| { + let mut notification = HashMap::new(); + notification.insert("id", id); + notification + }) + .collect::>>(), + ); + self.0 + .run_mobile_plugin("removeActive", args) + .map_err(Into::into) + } + + pub fn active(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("getActive", ()) + .map(|r| r.notifications) + .map_err(Into::into) + } + + pub fn remove_all_active(&self) -> crate::Result<()> { + self.0 + .run_mobile_plugin("removeActive", ()) + .map_err(Into::into) + } + + pub fn pending(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("getPending", ()) + .map(|r| r.notifications) + .map_err(Into::into) + } + + /// Cancel pending notifications. + pub fn cancel(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("notifications", notifications); + self.0.run_mobile_plugin("cancel", args).map_err(Into::into) + } + + /// Cancel all pending notifications. + pub fn cancel_all(&self) -> crate::Result<()> { + self.0.run_mobile_plugin("cancel", ()).map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn create_channel(&self, channel: Channel) -> crate::Result<()> { + self.0 + .run_mobile_plugin("createChannel", channel) + .map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn delete_channel(&self, id: impl Into) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("id", id.into()); + self.0 + .run_mobile_plugin("deleteChannel", args) + .map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn list_channels(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("listChannels", ()) + .map(|r| r.channels) + .map_err(Into::into) + } +} + +#[cfg(target_os = "android")] +#[derive(Deserialize)] +struct ListChannelsResult { + channels: Vec, +} + +#[derive(Deserialize)] +struct PendingResponse { + notifications: Vec, +} + +#[derive(Deserialize)] +struct ActiveResponse { + notifications: Vec, +} + +#[derive(Deserialize)] +struct ShowResponse { + #[allow(dead_code)] + id: i32, } #[derive(Deserialize)] diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index d1cf0e4b7..df2ae5c17 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -2,10 +2,201 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::fmt::Display; +use std::{collections::HashMap, fmt::Display}; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use url::Url; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attachment { + id: String, + url: Url, +} + +impl Attachment { + pub fn new(id: impl Into, url: Url) -> Self { + Self { id: id.into(), url } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleInterval { + pub year: Option, + pub month: Option, + pub day: Option, + pub weekday: Option, + pub hour: Option, + pub minute: Option, + pub second: Option, +} + +#[derive(Debug)] +pub enum ScheduleEvery { + Year, + Month, + TwoWeeks, + Week, + Day, + Hour, + Minute, + Second, +} + +impl Display for ScheduleEvery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Year => "Year", + Self::Month => "Month", + Self::TwoWeeks => "TwoWeeks", + Self::Week => "Week", + Self::Day => "Day", + Self::Hour => "Hour", + Self::Minute => "Minute", + Self::Second => "Second", + } + ) + } +} + +impl Serialize for ScheduleEvery { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +impl<'de> Deserialize<'de> for ScheduleEvery { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "year" => Ok(Self::Year), + "month" => Ok(Self::Month), + "twoweeks" => Ok(Self::TwoWeeks), + "week" => Ok(Self::Week), + "day" => Ok(Self::Day), + "hour" => Ok(Self::Hour), + "minute" => Ok(Self::Minute), + "second" => Ok(Self::Second), + _ => Err(DeError::custom(format!("unknown every kind '{s}'"))), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum Schedule { + At { + #[serde( + serialize_with = "iso8601::serialize", + deserialize_with = "time::serde::iso8601::deserialize" + )] + date: time::OffsetDateTime, + #[serde(default)] + repeating: bool, + }, + Interval(ScheduleInterval), + Every { + interval: ScheduleEvery, + }, +} + +// custom ISO-8601 serialization that does not use 6 digits for years. +mod iso8601 { + use serde::{ser::Error as _, Serialize, Serializer}; + use time::{ + format_description::well_known::iso8601::{Config, EncodedConfig}, + format_description::well_known::Iso8601, + OffsetDateTime, + }; + + const SERDE_CONFIG: EncodedConfig = Config::DEFAULT.encode(); + + pub fn serialize( + datetime: &OffsetDateTime, + serializer: S, + ) -> Result { + datetime + .format(&Iso8601::) + .map_err(S::Error::custom)? + .serialize(serializer) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationData { + #[serde(default = "default_id")] + pub(crate) id: i32, + pub(crate) channel_id: Option, + pub(crate) title: Option, + pub(crate) body: Option, + pub(crate) schedule: Option, + pub(crate) large_body: Option, + pub(crate) summary: Option, + pub(crate) action_type_id: Option, + pub(crate) group: Option, + #[serde(default)] + pub(crate) group_summary: bool, + pub(crate) sound: Option, + #[serde(default)] + pub(crate) inbox_lines: Vec, + pub(crate) icon: Option, + pub(crate) large_icon: Option, + pub(crate) icon_color: Option, + #[serde(default)] + pub(crate) attachments: Vec, + #[serde(default)] + pub(crate) extra: HashMap, + #[serde(default)] + pub(crate) ongoing: bool, + #[serde(default)] + pub(crate) auto_cancel: bool, + #[serde(default)] + pub(crate) silent: bool, +} + +fn default_id() -> i32 { + rand::random() +} + +impl Default for NotificationData { + fn default() -> Self { + Self { + id: default_id(), + channel_id: None, + title: None, + body: None, + schedule: None, + large_body: None, + summary: None, + action_type_id: None, + group: None, + group_summary: false, + sound: None, + inbox_lines: Vec::new(), + icon: None, + large_icon: None, + icon_color: None, + attachments: Vec::new(), + extra: Default::default(), + ongoing: false, + auto_cancel: false, + silent: false, + } + } +} + /// Permission state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionState { @@ -13,6 +204,8 @@ pub enum PermissionState { Granted, /// Permission access has been denied. Denied, + /// Unknown state. Must request permission. + Unknown, } impl Display for PermissionState { @@ -20,6 +213,7 @@ impl Display for PermissionState { match self { Self::Granted => write!(f, "granted"), Self::Denied => write!(f, "denied"), + Self::Unknown => write!(f, "Unknown"), } } } @@ -42,7 +236,274 @@ 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}'"))), } } } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingNotification { + id: i32, + title: Option, + body: Option, + schedule: Schedule, +} + +impl PendingNotification { + pub fn id(&self) -> i32 { + self.id + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + pub fn schedule(&self) -> &Schedule { + &self.schedule + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveNotification { + id: i32, + tag: Option, + title: Option, + body: Option, + group: Option, + #[serde(default)] + group_summary: bool, + #[serde(default)] + data: HashMap, + #[serde(default)] + extra: HashMap, + #[serde(default)] + attachments: Vec, + action_type_id: Option, + schedule: Option, + sound: Option, +} + +impl ActiveNotification { + pub fn id(&self) -> i32 { + self.id + } + + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + pub fn group_summary(&self) -> bool { + self.group_summary + } + + pub fn data(&self) -> &HashMap { + &self.data + } + + pub fn extra(&self) -> &HashMap { + &self.extra + } + + pub fn attachments(&self) -> &[Attachment] { + &self.attachments + } + + pub fn action_type_id(&self) -> Option<&str> { + self.action_type_id.as_deref() + } + + pub fn schedule(&self) -> Option<&Schedule> { + self.schedule.as_ref() + } + + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionType { + id: String, + actions: Vec, + hidden_previews_body_placeholder: Option, + custom_dismiss_action: bool, + allow_in_car_play: bool, + hidden_previews_show_title: bool, + hidden_previews_show_subtitle: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Action { + id: String, + title: String, + requires_authentication: bool, + foreground: bool, + destructive: bool, + input: bool, + input_button_title: Option, + input_placeholder: Option, +} + +#[cfg(target_os = "android")] +pub use android::*; + +#[cfg(target_os = "android")] +mod android { + use serde::{Deserialize, Serialize}; + use serde_repr::{Deserialize_repr, Serialize_repr}; + + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(u8)] + pub enum Importance { + None = 0, + Min = 1, + Low = 2, + Default = 3, + High = 4, + } + + impl Default for Importance { + fn default() -> Self { + Self::Default + } + } + + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(i8)] + pub enum Visibility { + Secret = -1, + Private = 0, + Public = 1, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Channel { + id: String, + name: String, + description: Option, + sound: Option, + lights: bool, + light_color: Option, + vibration: bool, + importance: Importance, + visibility: Option, + } + + #[derive(Debug)] + pub struct ChannelBuilder(Channel); + + impl Channel { + pub fn builder(id: impl Into, name: impl Into) -> ChannelBuilder { + ChannelBuilder(Self { + id: id.into(), + name: name.into(), + description: None, + sound: None, + lights: false, + light_color: None, + vibration: false, + importance: Default::default(), + visibility: None, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } + + pub fn lights(&self) -> bool { + self.lights + } + + pub fn light_color(&self) -> Option<&str> { + self.light_color.as_deref() + } + + pub fn vibration(&self) -> bool { + self.vibration + } + + pub fn importance(&self) -> Importance { + self.importance + } + + pub fn visibility(&self) -> Option { + self.visibility + } + } + + impl ChannelBuilder { + pub fn description(mut self, description: impl Into) -> Self { + self.0.description.replace(description.into()); + self + } + + pub fn sound(mut self, sound: impl Into) -> Self { + self.0.sound.replace(sound.into()); + self + } + + pub fn lights(mut self, lights: bool) -> Self { + self.0.lights = lights; + self + } + + pub fn light_color(mut self, color: impl Into) -> Self { + self.0.light_color.replace(color.into()); + self + } + + pub fn vibration(mut self, vibration: bool) -> Self { + self.0.vibration = vibration; + self + } + + pub fn importance(mut self, importance: Importance) -> Self { + self.0.importance = importance; + self + } + + pub fn visibility(mut self, visibility: Visibility) -> Self { + self.0.visibility.replace(visibility); + self + } + + pub fn build(self) -> Channel { + self.0 + } + } +}