From 7b8aa0e5ebd1501d3af1d4a4256913d7988d22eb Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Thu, 27 Apr 2023 14:43:52 -0300 Subject: [PATCH] feat: add android impl --- .../android/src/main/java/AssetUtils.kt | 25 + .../android/src/main/java/ChannelManager.kt | 158 +++++ .../android/src/main/java/Notification.kt | 301 ++++++++++ .../src/main/java/NotificationAction.kt | 47 ++ .../src/main/java/NotificationAttachment.kt | 48 ++ .../src/main/java/NotificationPlugin.kt | 231 +++++++- .../src/main/java/NotificationSchedule.kt | 304 ++++++++++ .../src/main/java/NotificationStorage.kt | 152 +++++ .../src/main/java/TauriNotificationManager.kt | 553 ++++++++++++++++++ .../src/main/res/drawable/ic_transparent.xml | 12 + plugins/notification/src/mobile.rs | 4 +- 11 files changed, 1817 insertions(+), 18 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 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 00000000..c97cd528 --- /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 00000000..df3a3f36 --- /dev/null +++ b/plugins/notification/android/src/main/java/ChannelManager.kt @@ -0,0 +1,158 @@ +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 + ) + ) + Logger.debug( + Logger.tags("NotificationChannel"), + "visibility " + notificationChannel.lockscreenVisibility + ) + Logger.debug( + Logger.tags("NotificationChannel"), + "importance " + notificationChannel.importance + ) + 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 00000000..42a221fa --- /dev/null +++ b/plugins/notification/android/src/main/java/Notification.kt @@ -0,0 +1,301 @@ +package app.tauri.notification + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import app.tauri.Logger +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.json.JSONException +import org.json.JSONObject +import java.text.ParseException + +class Notification { + var title: String? = null + var body: String? = null + var largeBody: String? = null + var summaryText: String? = null + var id: Int? = null + 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 inboxList: 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: String? = 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 + + override fun toString(): String { + return "Notification{" + + "title='" + + title + + '\'' + + ", body='" + + body + + '\'' + + ", id=" + + id + + ", sound='" + + sound + + '\'' + + ", smallIcon='" + + smallIcon + + '\'' + + ", iconColor='" + + iconColor + + '\'' + + ", actionTypeId='" + + actionTypeId + + '\'' + + ", group='" + + group + + '\'' + + ", extra=" + + extra + + ", attachments=" + + attachments + + ", schedule=" + + schedule + + ", groupSummary=" + + isGroupSummary + + ", ongoing=" + + isOngoing + + ", autoCancel=" + + isAutoCancel + + '}' + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as Notification + if (if (title != null) title != that.title else that.title != null) return false + if (if (body != null) body != that.body else that.body != null) return false + if (if (largeBody != null) largeBody != that.largeBody else that.largeBody != null) return false + if (if (id != null) id != that.id else that.id != null) return false + if (if (sound != null) sound != that.sound else that.sound != null) return false + if (if (smallIcon != null) smallIcon != that.smallIcon else that.smallIcon != null) return false + if (if (largeIcon != null) largeIcon != that.largeIcon else that.largeIcon != null) return false + if (if (iconColor != null) iconColor != that.iconColor else that.iconColor != null) return false + if (if (actionTypeId != null) actionTypeId != that.actionTypeId else that.actionTypeId != null) return false + if (if (group != null) group != that.group else that.group != null) return false + if (if (extra != null) extra != that.extra else that.extra != null) return false + if (if (attachments != null) attachments != that.attachments else that.attachments != null) return false + if (if (inboxList != null) inboxList != that.inboxList else that.inboxList != null) return false + if (isGroupSummary != that.isGroupSummary) return false + if (isOngoing != that.isOngoing) return false + if (isAutoCancel != that.isAutoCancel) return false + return if (schedule != null) schedule?.equals(that.schedule) ?: false else that.schedule == null + } + + override fun hashCode(): Int { + var result = if (title != null) title.hashCode() else 0 + result = 31 * result + if (body != null) body.hashCode() else 0 + result = 31 * result + if (id != null) id.hashCode() else 0 + result = 31 * result + if (sound != null) sound.hashCode() else 0 + result = 31 * result + if (smallIcon != null) smallIcon.hashCode() else 0 + result = 31 * result + if (iconColor != null) iconColor.hashCode() else 0 + result = 31 * result + if (actionTypeId != null) actionTypeId.hashCode() else 0 + result = 31 * result + if (group != null) group.hashCode() else 0 + result = 31 * result + java.lang.Boolean.hashCode(isGroupSummary) + result = 31 * result + java.lang.Boolean.hashCode(isOngoing) + result = 31 * result + java.lang.Boolean.hashCode(isAutoCancel) + result = 31 * result + if (extra != null) extra.hashCode() else 0 + result = 31 * result + if (attachments != null) attachments.hashCode() else 0 + result = 31 * result + if (schedule != null) schedule.hashCode() else 0 + return result + } + + fun setExtraFromString(extraFromString: String) { + try { + val jsonObject = JSONObject(extraFromString) + extra = JSObject.fromJSONObject(jsonObject) + } catch (e: JSONException) { + Logger.error(Logger.tags("Notification"), "Cannot rebuild extra data", e) + } + } + + companion object { + /** + * Build list of the notifications from invoke payload + */ + fun buildNotificationList(invoke: Invoke): List? { + val notificationArray = invoke.getArray("notifications") + if (notificationArray == null) { + invoke.reject("Must provide notifications array as notifications option") + return null + } + val resultNotifications: MutableList = + ArrayList(notificationArray.length()) + val notificationsJson: List = try { + notificationArray.toList() + } catch (e: JSONException) { + invoke.reject("Provided notification format is invalid") + return null + } + for (jsonNotification in notificationsJson) { + val notification: JSObject = try { + val identifier = jsonNotification.getLong("id") + if (identifier > Int.MAX_VALUE || identifier < Int.MIN_VALUE) { + invoke.reject("The identifier should be a Java int") + return null + } + JSObject.fromJSONObject(jsonNotification) + } catch (e: JSONException) { + invoke.reject("Invalid JSON object sent to Notification plugin", e) + return null + } + try { + val activeNotification = buildNotificationFromJSObject(notification) + resultNotifications.add(activeNotification) + } catch (e: ParseException) { + invoke.reject("Invalid date format sent to Notification plugin", e) + return null + } + } + return resultNotifications + } + + fun buildNotificationFromJSObject(jsonObject: JSObject): Notification { + val notification = Notification() + notification.source = jsonObject.toString() + notification.id = jsonObject.getInteger("id") + notification.body = jsonObject.getString("body") + notification.largeBody = jsonObject.getString("largeBody") + notification.summaryText = jsonObject.getString("summaryText") + notification.actionTypeId = jsonObject.getString("actionTypeId") + notification.group = jsonObject.getString("group") + notification.setSound(jsonObject.getString("sound")) + notification.title = jsonObject.getString("title") + notification.setSmallIcon(jsonObject.getString("smallIcon")) + notification.setLargeIcon(jsonObject.getString("largeIcon")) + notification.iconColor = jsonObject.getString("iconColor") + notification.attachments = NotificationAttachment.getAttachments(jsonObject) + notification.isGroupSummary = jsonObject.getBoolean("groupSummary", false) + notification.channelId = jsonObject.getString("channelId") + 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) + try { + val inboxList = jsonObject.getJSONArray("inboxList") + val inboxStringList: MutableList = ArrayList() + for (i in 0 until inboxList.length()) { + inboxStringList.add(inboxList.getString(i)) + } + notification.inboxList = inboxStringList + } catch (_: Exception) { + } + return notification + } + + fun getNotificationPendingList(invoke: Invoke): List? { + var notifications: List? = null + try { + notifications = invoke.getArray("notifications", JSArray()).toList() + } catch (_: JSONException) { + } + if (notifications.isNullOrEmpty()) { + invoke.reject("Must provide notifications array as notifications option") + return null + } + val notificationsList: MutableList = ArrayList(notifications.size) + for (notificationToCancel in notifications) { + try { + notificationsList.add(notificationToCancel.getInt("id")) + } catch (_: JSONException) { + } + } + return notificationsList + } + + 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")) + 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 00000000..c1a964b4 --- /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 00000000..1cc35e89 --- /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 ab6c9df7..d47414f6 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -1,31 +1,230 @@ 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.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 var manager: TauriNotificationManager? = null + var notificationManager: NotificationManager? = null + private var notificationStorage: NotificationStorage? = null + private var channelManager = ChannelManager(activity) + + override fun load(webView: WebView) { + 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? + } + + /*private fun handleOnNewIntent(data: Intent) { + super.handleOnNewIntent(data) + if (Intent.ACTION_MAIN != data.action) { + return + } + val dataJson = manager.handleNotificationActionPerformed(data, notificationStorage) + if (dataJson != null) { + // notifyListeners("localNotificationActionPerformed", dataJson, true) } + }*/ + + /** + * Schedule a notification invoke from JavaScript + * Creates local notification in system. + */ + @Command + fun schedule(invoke: Invoke) { + val localNotifications = Notification.buildNotificationList(invoke) + ?: return + val ids = manager!!.schedule(invoke, localNotifications) + if (ids != null) { + notificationStorage?.appendNotifications(localNotifications) + val result = JSObject() + val jsArray = JSArray() + for (i in 0 until ids.length()) { + try { + val notification = JSObject().put("id", ids.getInt(i)) + jsArray.put(notification) + } catch (_: Exception) { + } + } + result.put("notifications", jsArray) + invoke.resolve(result) + } + } + + @Command + fun cancel(invoke: Invoke) { + val notifications = invoke.getArray("notifications") + if (notifications == null) { + manager?.cancel(invoke) + } else { + try { + for (o in notifications.toList()) { + if (o is JSONObject) { + val notification = JSObject.fromJSONObject((o)) + val tag = notification.getString("tag") + val id = notification.getInteger("id") + if (tag.isEmpty()) { + notificationManager!!.cancel(id!!) + } else { + notificationManager!!.cancel(tag, id!!) + } + } else { + invoke.reject("Unexpected notification type") + } + } + } 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() + } - @Command - fun permissionState(invoke: Invoke) { - val ret = JSObject() - ret.put("permissionState", "granted") - invoke.resolve(ret) + @SuppressLint("ObsoleteSdkInt") + @Command + fun getDeliveredNotifications(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 cancelAll(invoke: Invoke) { + notificationManager!!.cancelAll() + invoke.resolve() + } + + @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 00000000..caed28c1 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationSchedule.kt @@ -0,0 +1,304 @@ +package app.tauri.notification + +import android.text.format.DateUtils +import app.tauri.plugin.JSObject +import java.text.ParseException +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(val date: Date, val repeating: Boolean): ScheduleKind() + class Interval(val interval: DateMatch): ScheduleKind() + class Every(val interval: NotificationInterval, val count: Int): ScheduleKind() +} + +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 00000000..60385358 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationStorage.kt @@ -0,0 +1,152 @@ +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" +private const val ID_KEY = "notificationIds" + +class NotificationStorage(private val context: Context) { + /** + * Persist the id of currently scheduled notification + */ + 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) + } + } + 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.buildNotificationFromJSObject(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.buildNotificationFromJSObject(jsNotification) + } catch (ex: ParseException) { + return null + } + return notification + } + + /** + * Remove the stored notifications + */ + fun deleteNotification(id: String?) { + val editor = getStorage(NOTIFICATION_STORE_ID).edit() + editor.remove(id) + editor.apply() + } + + /** + * Shared private preferences for the application. + */ + private fun getStorage(key: String): SharedPreferences { + return context.getSharedPreferences(key, Context.MODE_PRIVATE) + } + + /** + * Writes new action types (actions that being displayed in notification) to storage. + * Write will override previous data. + * + * @param typesMap - map with groupId and actionArray assigned to group + */ + 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() + } + } + + /** + * Retrieve array of notification actions per ActionTypeId + * + * @param forId - id of the group + */ + 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 00000000..c61a1a94 --- /dev/null +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -0,0 +1,553 @@ +package app.tauri.notification + +import android.Manifest +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.content.pm.PackageManager +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import app.tauri.Logger +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import org.json.JSONArray +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 + + /** + * Method extecuted when notification is launched by user from the notification bar. + */ + 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 (Build.VERSION.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) + } + } + + fun schedule(invoke: Invoke?, notifications: List): JSONArray? { + val ids = JSONArray() + val notificationManager = NotificationManagerCompat.from( + context + ) + val notificationsEnabled = notificationManager.areNotificationsEnabled() + if (!notificationsEnabled) { + invoke?.reject("Notifications not enabled on this device") + return null + } + for (notification in notifications) { + val id = notification.id + if (id == null) { + invoke?.reject("Notification missing identifier") + return null + } + dismissVisibleNotification(id) + cancelTimerForNotification(id) + buildNotification(notificationManager, notification, invoke) + ids.put(id) + } + return ids + } + + // TODO Progressbar support + // TODO System categories (DO_NOT_DISTURB etc.) + // TODO control visibility by flag Notification.VISIBILITY_PRIVATE + // TODO Group notifications (setGroup, setGroupSummary, setNumber) + // TODO use NotificationCompat.MessagingStyle for latest API + // TODO expandable notification NotificationCompat.MessagingStyle + // TODO media style notification support NotificationCompat.MediaStyle + // TODO custom small/large icons + @SuppressLint("MissingPermission") + private fun buildNotification( + notificationManager: NotificationManagerCompat, + notification: Notification, + invoke: Invoke? + ) { + var channelId = DEFAULT_NOTIFICATION_CHANNEL_ID + if (notification.channelId != null) { + channelId = notification.channelId!! + } + 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.summaryText) + ) + } + if (notification.inboxList != null) { + val inboxStyle = NotificationCompat.InboxStyle() + for (line in notification.inboxList ?: listOf()) { + inboxStyle.addLine(line) + } + inboxStyle.setBigContentTitle(notification.title) + inboxStyle.setSummaryText(notification.summaryText) + 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.summaryText) + } + } + mBuilder.setVisibility(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) { + invoke?.reject("Invalid color provided. Must be a hex string (ex: #ff0000") + return + } + } + 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 { + try { + // TODO notify + // val notificationJson = JSObject(notification.source ?: "") + } catch (_: JSONException) { + } + if (ActivityCompat.checkSelfPermission( + activity, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + notificationManager.notify(notification.id ?: 0, buildNotification) + } + } + + // Create intents for open/dissmis 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getActivity(context, notification.id ?: 0, 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 ?: 0) + 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val deleteIntent = + PendingIntent.getBroadcast(context, notification.id ?: 0, dissmissIntent, flags) + mBuilder.setDeleteIntent(deleteIntent) + } + + private fun buildIntent(notification: Notification, action: String?): Intent { + val intent = Intent(context, activity.javaClass) + 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) + 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 + 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + var pendingIntent = + PendingIntent.getBroadcast(context, request.id ?: 0, 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 ?: 0, 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle == true) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle == true) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + } + } + + fun cancel(invoke: Invoke) { + val notificationsToCancel = Notification.getNotificationPendingList(invoke) + if (notificationsToCancel != null) { + for (id in notificationsToCancel) { + dismissVisibleNotification(id) + cancelTimerForNotification(id) + storage.deleteNotification(id.toString()) + } + } + invoke.resolve() + } + + private fun cancelTimerForNotification(notificationId: Int) { + val intent = Intent(context, TimedNotificationPublisher::class.java) + var flags = 0 + if (Build.VERSION.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("smallIcon")) + 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) + // TODO notify + // val notificationJson = storage.getSavedNotificationAsJSObject(id.toString()) + 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") + 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getBroadcast(context, id, clone, flags) + if (Build.VERSION.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" + } +} \ No newline at end of file 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 00000000..fc1779e2 --- /dev/null +++ b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs index abd196ed..f8485cb4 100644 --- a/plugins/notification/src/mobile.rs +++ b/plugins/notification/src/mobile.rs @@ -46,14 +46,14 @@ 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) }