feat(notification): implement Android and iOS APIs (#340)
parent
1397172e95
commit
be1c775b8d
@ -1,3 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
package="app.tauri.notification">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<receiver android:name="app.tauri.notification.TimedNotificationPublisher" />
|
||||||
|
<receiver android:name="app.tauri.notification.NotificationDismissReceiver" />
|
||||||
|
<receiver
|
||||||
|
android:name="app.tauri.notification.NotificationRestoreReceiver"
|
||||||
|
android:directBootAware="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<NotificationChannel> =
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<String>? = null
|
||||||
|
var isGroupSummary = false
|
||||||
|
var isOngoing = false
|
||||||
|
var isAutoCancel = false
|
||||||
|
var extra: JSObject? = null
|
||||||
|
var attachments: List<NotificationAttachment>? = 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<String> = ArrayList()
|
||||||
|
for (i in 0 until inboxLines.length()) {
|
||||||
|
inboxStringList.add(inboxLines.getString(i))
|
||||||
|
}
|
||||||
|
notification.inboxLines = inboxStringList
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildNotificationPendingList(notifications: List<Notification>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<String, List<NotificationAction>> {
|
||||||
|
val actionTypeMap: MutableMap<String, List<NotificationAction>> = HashMap()
|
||||||
|
try {
|
||||||
|
val objects: List<JSONObject> = types.toList()
|
||||||
|
for (obj in objects) {
|
||||||
|
val jsObject = JSObject.fromJSONObject(
|
||||||
|
obj
|
||||||
|
)
|
||||||
|
val actionGroupId = jsObject.getString("id")
|
||||||
|
val actions = jsObject.getJSONArray("actions")
|
||||||
|
val typesArray = mutableListOf<NotificationAction>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<NotificationAttachment> {
|
||||||
|
val attachmentsList: MutableList<NotificationAttachment> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,31 +1,263 @@
|
|||||||
package app.tauri.notification
|
package app.tauri.notification
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
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.Command
|
||||||
|
import app.tauri.annotation.Permission
|
||||||
|
import app.tauri.annotation.PermissionCallback
|
||||||
import app.tauri.annotation.TauriPlugin
|
import app.tauri.annotation.TauriPlugin
|
||||||
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSArray
|
||||||
import app.tauri.plugin.JSObject
|
import app.tauri.plugin.JSObject
|
||||||
import app.tauri.plugin.Plugin
|
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) {
|
class NotificationPlugin(private val activity: Activity): Plugin(activity) {
|
||||||
@Command
|
private var webView: WebView? = null
|
||||||
fun requestPermission(invoke: Invoke) {
|
private lateinit var manager: TauriNotificationManager
|
||||||
val ret = JSObject()
|
private lateinit var notificationManager: NotificationManager
|
||||||
ret.put("permissionState", "granted")
|
private lateinit var notificationStorage: NotificationStorage
|
||||||
invoke.resolve(ret)
|
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<Notification> =
|
||||||
|
ArrayList(notificationArray.length())
|
||||||
|
val notificationsInput: List<JSONObject> = 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
|
val ids = manager.schedule(notifications)
|
||||||
fun permissionState(invoke: Invoke) {
|
notificationStorage.appendNotifications(notifications)
|
||||||
val ret = JSObject()
|
|
||||||
ret.put("permissionState", "granted")
|
val result = JSObject()
|
||||||
invoke.resolve(ret)
|
result.put("notifications", ids)
|
||||||
|
invoke.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun cancel(invoke: Invoke) {
|
||||||
|
val notifications: List<Int> = 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<Any>()) {
|
||||||
|
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
|
private fun getPermissionState(): String {
|
||||||
fun notify(invoke: Invoke) {
|
return if (manager.areNotificationsEnabled()) {
|
||||||
// TODO
|
"granted"
|
||||||
invoke.resolve()
|
} else {
|
||||||
|
"denied"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<Notification>) {
|
||||||
|
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<String> {
|
||||||
|
val storage = getStorage(NOTIFICATION_STORE_ID)
|
||||||
|
val all = storage.all
|
||||||
|
return if (all != null) {
|
||||||
|
ArrayList(all.keys)
|
||||||
|
} else ArrayList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSavedNotifications(): List<Notification> {
|
||||||
|
val storage = getStorage(NOTIFICATION_STORE_ID)
|
||||||
|
val all = storage.all
|
||||||
|
if (all != null) {
|
||||||
|
val notifications = ArrayList<Notification>()
|
||||||
|
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<String, List<NotificationAction>>) {
|
||||||
|
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<NotificationAction?> {
|
||||||
|
val storage = getStorage(ACTION_TYPES_ID + forId)
|
||||||
|
val count = storage.getInt("count", 0)
|
||||||
|
val actions: Array<NotificationAction?> = 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
|
||||||
|
}
|
||||||
|
}
|
@ -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<Notification>): List<Int> {
|
||||||
|
val ids = mutableListOf<Int>()
|
||||||
|
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<Int>) {
|
||||||
|
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<Notification>()
|
||||||
|
val updatedNotifications = mutableListOf<Notification>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="16dp"
|
||||||
|
android:height="16dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@android:color/transparent" />
|
||||||
|
|
||||||
|
</vector>
|
@ -0,0 +1,272 @@
|
|||||||
|
import Tauri
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
enum NotificationError: LocalizedError {
|
||||||
|
case contentNoId
|
||||||
|
case contentNoTitle
|
||||||
|
case contentNoBody
|
||||||
|
case triggerRepeatIntervalTooShort
|
||||||
|
case attachmentNoId
|
||||||
|
case attachmentNoUrl
|
||||||
|
case attachmentFileNotFound(path: String)
|
||||||
|
case attachmentUnableToCreate(String)
|
||||||
|
case pastScheduledTime
|
||||||
|
case invalidDate(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .contentNoId:
|
||||||
|
return "Missing notification identifier"
|
||||||
|
case .contentNoTitle:
|
||||||
|
return "Missing notification title"
|
||||||
|
case .contentNoBody:
|
||||||
|
return "Missing notification body"
|
||||||
|
case .triggerRepeatIntervalTooShort:
|
||||||
|
return "Schedule interval too short, must be a least 1 minute"
|
||||||
|
case .attachmentNoId:
|
||||||
|
return "Missing attachment identifier"
|
||||||
|
case .attachmentNoUrl:
|
||||||
|
return "Missing attachment URL"
|
||||||
|
case .attachmentFileNotFound(let path):
|
||||||
|
return "Unable to find file \(path) for attachment"
|
||||||
|
case .attachmentUnableToCreate(let error):
|
||||||
|
return "Failed to create attachment: \(error)"
|
||||||
|
case .pastScheduledTime:
|
||||||
|
return "Scheduled time must be *after* current time"
|
||||||
|
case .invalidDate(let date):
|
||||||
|
return "Could not parse date \(date)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNotificationContent(_ notification: JSObject) throws -> UNNotificationContent {
|
||||||
|
guard let title = notification["title"] as? String else {
|
||||||
|
throw NotificationError.contentNoTitle
|
||||||
|
}
|
||||||
|
guard let body = notification["body"] as? String else {
|
||||||
|
throw NotificationError.contentNoBody
|
||||||
|
}
|
||||||
|
|
||||||
|
let extra = notification["extra"] as? JSObject ?? [:]
|
||||||
|
let schedule = notification["schedule"] as? JSObject ?? [:]
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = NSString.localizedUserNotificationString(forKey: title, arguments: nil)
|
||||||
|
content.body = NSString.localizedUserNotificationString(
|
||||||
|
forKey: body,
|
||||||
|
arguments: nil)
|
||||||
|
|
||||||
|
content.userInfo = [
|
||||||
|
"__EXTRA__": extra,
|
||||||
|
"__SCHEDULE__": schedule,
|
||||||
|
]
|
||||||
|
|
||||||
|
if let actionTypeId = notification["actionTypeId"] as? String {
|
||||||
|
content.categoryIdentifier = actionTypeId
|
||||||
|
}
|
||||||
|
|
||||||
|
if let threadIdentifier = notification["group"] as? String {
|
||||||
|
content.threadIdentifier = threadIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
if let summaryArgument = notification["summary"] as? String {
|
||||||
|
content.summaryArgument = summaryArgument
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sound = notification["sound"] as? String {
|
||||||
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(sound))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let attachments = notification["attachments"] as? [JSObject] {
|
||||||
|
content.attachments = try makeAttachments(attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAttachments(_ attachments: [JSObject]) throws -> [UNNotificationAttachment] {
|
||||||
|
var createdAttachments = [UNNotificationAttachment]()
|
||||||
|
|
||||||
|
for attachment in attachments {
|
||||||
|
guard let id = attachment["id"] as? String else {
|
||||||
|
throw NotificationError.attachmentNoId
|
||||||
|
}
|
||||||
|
guard let url = attachment["url"] as? String else {
|
||||||
|
throw NotificationError.attachmentNoUrl
|
||||||
|
}
|
||||||
|
guard let urlObject = makeAttachmentUrl(url) else {
|
||||||
|
throw NotificationError.attachmentFileNotFound(path: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = attachment["options"] as? JSObject ?? [:]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let newAttachment = try UNNotificationAttachment(
|
||||||
|
identifier: id, url: urlObject, options: makeAttachmentOptions(options))
|
||||||
|
createdAttachments.append(newAttachment)
|
||||||
|
} catch {
|
||||||
|
throw NotificationError.attachmentUnableToCreate(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdAttachments
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAttachmentUrl(_ path: String) -> URL? {
|
||||||
|
return URL(string: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAttachmentOptions(_ options: JSObject) -> JSObject {
|
||||||
|
var opts: JSObject = [:]
|
||||||
|
|
||||||
|
if let iosUNNotificationAttachmentOptionsTypeHintKey = options[
|
||||||
|
"iosUNNotificationAttachmentOptionsTypeHintKey"] as? String
|
||||||
|
{
|
||||||
|
opts[UNNotificationAttachmentOptionsTypeHintKey] = iosUNNotificationAttachmentOptionsTypeHintKey
|
||||||
|
}
|
||||||
|
if let iosUNNotificationAttachmentOptionsThumbnailHiddenKey = options[
|
||||||
|
"iosUNNotificationAttachmentOptionsThumbnailHiddenKey"] as? String
|
||||||
|
{
|
||||||
|
opts[UNNotificationAttachmentOptionsThumbnailHiddenKey] =
|
||||||
|
iosUNNotificationAttachmentOptionsThumbnailHiddenKey
|
||||||
|
}
|
||||||
|
if let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey = options[
|
||||||
|
"iosUNNotificationAttachmentOptionsThumbnailClippingRectKey"] as? String
|
||||||
|
{
|
||||||
|
opts[UNNotificationAttachmentOptionsThumbnailClippingRectKey] =
|
||||||
|
iosUNNotificationAttachmentOptionsThumbnailClippingRectKey
|
||||||
|
}
|
||||||
|
if let iosUNNotificationAttachmentOptionsThumbnailTimeKey = options[
|
||||||
|
"iosUNNotificationAttachmentOptionsThumbnailTimeKey"] as? String
|
||||||
|
{
|
||||||
|
opts[UNNotificationAttachmentOptionsThumbnailTimeKey] =
|
||||||
|
iosUNNotificationAttachmentOptionsThumbnailTimeKey
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScheduledNotification(_ schedule: JSObject) throws
|
||||||
|
-> UNNotificationTrigger?
|
||||||
|
{
|
||||||
|
let kind = schedule["kind"] as? String ?? ""
|
||||||
|
let payload = schedule["data"] as? JSObject ?? [:]
|
||||||
|
switch kind {
|
||||||
|
case "At":
|
||||||
|
let date = payload["date"] as? String ?? ""
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
||||||
|
|
||||||
|
if let at = dateFormatter.date(from: date) {
|
||||||
|
let repeats = payload["repeats"] as? Bool ?? false
|
||||||
|
|
||||||
|
let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at)
|
||||||
|
|
||||||
|
if dateInfo.date! < Date() {
|
||||||
|
throw NotificationError.pastScheduledTime
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateInterval = DateInterval(start: Date(), end: dateInfo.date!)
|
||||||
|
|
||||||
|
// Notifications that repeat have to be at least a minute between each other
|
||||||
|
if repeats && dateInterval.duration < 60 {
|
||||||
|
throw NotificationError.triggerRepeatIntervalTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNTimeIntervalNotificationTrigger(
|
||||||
|
timeInterval: dateInterval.duration, repeats: repeats)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw NotificationError.invalidDate(date)
|
||||||
|
}
|
||||||
|
case "Interval":
|
||||||
|
let dateComponents = getDateComponents(payload)
|
||||||
|
return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
||||||
|
case "Every":
|
||||||
|
let interval = payload["interval"] as? String ?? ""
|
||||||
|
let count = schedule["count"] as? Int ?? 1
|
||||||
|
|
||||||
|
if let repeatDateInterval = getRepeatDateInterval(interval, count) {
|
||||||
|
// Notifications that repeat have to be at least a minute between each other
|
||||||
|
if repeatDateInterval.duration < 60 {
|
||||||
|
throw NotificationError.triggerRepeatIntervalTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNTimeIntervalNotificationTrigger(
|
||||||
|
timeInterval: repeatDateInterval.duration, repeats: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given our schedule format, return a DateComponents object
|
||||||
|
/// that only contains the components passed in.
|
||||||
|
|
||||||
|
func getDateComponents(_ at: JSObject) -> DateComponents {
|
||||||
|
// var dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: Date())
|
||||||
|
// dateInfo.calendar = Calendar.current
|
||||||
|
var dateInfo = DateComponents()
|
||||||
|
|
||||||
|
if let year = at["year"] as? Int {
|
||||||
|
dateInfo.year = year
|
||||||
|
}
|
||||||
|
if let month = at["month"] as? Int {
|
||||||
|
dateInfo.month = month
|
||||||
|
}
|
||||||
|
if let day = at["day"] as? Int {
|
||||||
|
dateInfo.day = day
|
||||||
|
}
|
||||||
|
if let hour = at["hour"] as? Int {
|
||||||
|
dateInfo.hour = hour
|
||||||
|
}
|
||||||
|
if let minute = at["minute"] as? Int {
|
||||||
|
dateInfo.minute = minute
|
||||||
|
}
|
||||||
|
if let second = at["second"] as? Int {
|
||||||
|
dateInfo.second = second
|
||||||
|
}
|
||||||
|
if let weekday = at["weekday"] as? Int {
|
||||||
|
dateInfo.weekday = weekday
|
||||||
|
}
|
||||||
|
return dateInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the difference between the string representation of a date
|
||||||
|
/// interval and today. For example, if every is "month", then we
|
||||||
|
/// return the interval between today and a month from today.
|
||||||
|
|
||||||
|
func getRepeatDateInterval(_ every: String, _ count: Int) -> DateInterval? {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
switch every {
|
||||||
|
case "Year":
|
||||||
|
let newDate = cal.date(byAdding: .year, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "Month":
|
||||||
|
let newDate = cal.date(byAdding: .month, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "TwoWeeks":
|
||||||
|
let newDate = cal.date(byAdding: .weekOfYear, value: 2 * count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "Week":
|
||||||
|
let newDate = cal.date(byAdding: .weekOfYear, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "Day":
|
||||||
|
let newDate = cal.date(byAdding: .day, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "Hour":
|
||||||
|
let newDate = cal.date(byAdding: .hour, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "Minute":
|
||||||
|
let newDate = cal.date(byAdding: .minute, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
case "Second":
|
||||||
|
let newDate = cal.date(byAdding: .second, value: count, to: now)!
|
||||||
|
return DateInterval(start: now, end: newDate)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,131 @@
|
|||||||
|
import Tauri
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
enum CategoryError: LocalizedError {
|
||||||
|
case noId
|
||||||
|
case noActionId
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noId:
|
||||||
|
return "Action type `id` missing"
|
||||||
|
case .noActionId:
|
||||||
|
return "Action `id` missing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeCategories(_ actionTypes: [JSObject]) throws {
|
||||||
|
var createdCategories = [UNNotificationCategory]()
|
||||||
|
|
||||||
|
let generalCategory = UNNotificationCategory(
|
||||||
|
identifier: "GENERAL",
|
||||||
|
actions: [],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: .customDismissAction)
|
||||||
|
|
||||||
|
createdCategories.append(generalCategory)
|
||||||
|
for type in actionTypes {
|
||||||
|
guard let id = type["id"] as? String else {
|
||||||
|
throw CategoryError.noId
|
||||||
|
}
|
||||||
|
let hiddenBodyPlaceholder = type["hiddenPreviewsBodyPlaceholder"] as? String ?? ""
|
||||||
|
let actions = type["actions"] as? [JSObject] ?? []
|
||||||
|
|
||||||
|
let newActions = try makeActions(actions)
|
||||||
|
|
||||||
|
// Create the custom actions for the TIMER_EXPIRED category.
|
||||||
|
var newCategory: UNNotificationCategory?
|
||||||
|
|
||||||
|
newCategory = UNNotificationCategory(
|
||||||
|
identifier: id,
|
||||||
|
actions: newActions,
|
||||||
|
intentIdentifiers: [],
|
||||||
|
hiddenPreviewsBodyPlaceholder: hiddenBodyPlaceholder,
|
||||||
|
options: makeCategoryOptions(type))
|
||||||
|
|
||||||
|
createdCategories.append(newCategory!)
|
||||||
|
}
|
||||||
|
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.setNotificationCategories(Set(createdCategories))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeActions(_ actions: [JSObject]) throws -> [UNNotificationAction] {
|
||||||
|
var createdActions = [UNNotificationAction]()
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
guard let id = action["id"] as? String else {
|
||||||
|
throw CategoryError.noActionId
|
||||||
|
}
|
||||||
|
let title = action["title"] as? String ?? ""
|
||||||
|
let input = action["input"] as? Bool ?? false
|
||||||
|
|
||||||
|
var newAction: UNNotificationAction
|
||||||
|
if input {
|
||||||
|
let inputButtonTitle = action["inputButtonTitle"] as? String
|
||||||
|
let inputPlaceholder = action["inputPlaceholder"] as? String ?? ""
|
||||||
|
|
||||||
|
if inputButtonTitle != nil {
|
||||||
|
newAction = UNTextInputNotificationAction(
|
||||||
|
identifier: id,
|
||||||
|
title: title,
|
||||||
|
options: makeActionOptions(action),
|
||||||
|
textInputButtonTitle: inputButtonTitle!,
|
||||||
|
textInputPlaceholder: inputPlaceholder)
|
||||||
|
} else {
|
||||||
|
newAction = UNTextInputNotificationAction(
|
||||||
|
identifier: id, title: title, options: makeActionOptions(action))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create the custom actions for the TIMER_EXPIRED category.
|
||||||
|
newAction = UNNotificationAction(
|
||||||
|
identifier: id,
|
||||||
|
title: title,
|
||||||
|
options: makeActionOptions(action))
|
||||||
|
}
|
||||||
|
createdActions.append(newAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdActions
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeActionOptions(_ action: JSObject) -> UNNotificationActionOptions {
|
||||||
|
let foreground = action["foreground"] as? Bool ?? false
|
||||||
|
let destructive = action["destructive"] as? Bool ?? false
|
||||||
|
let requiresAuthentication = action["requiresAuthentication"] as? Bool ?? false
|
||||||
|
|
||||||
|
if foreground {
|
||||||
|
return .foreground
|
||||||
|
}
|
||||||
|
if destructive {
|
||||||
|
return .destructive
|
||||||
|
}
|
||||||
|
if requiresAuthentication {
|
||||||
|
return .authenticationRequired
|
||||||
|
}
|
||||||
|
return UNNotificationActionOptions(rawValue: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCategoryOptions(_ type: JSObject) -> UNNotificationCategoryOptions {
|
||||||
|
let customDismiss = type["customDismissAction"] as? Bool ?? false
|
||||||
|
let carPlay = type["allowInCarPlay"] as? Bool ?? false
|
||||||
|
let hiddenPreviewsShowTitle = type["hiddenPreviewsShowTitle"] as? Bool ?? false
|
||||||
|
let hiddenPreviewsShowSubtitle = type["hiddenPreviewsShowSubtitle"] as? Bool ?? false
|
||||||
|
|
||||||
|
if customDismiss {
|
||||||
|
return .customDismissAction
|
||||||
|
}
|
||||||
|
if carPlay {
|
||||||
|
return .allowInCarPlay
|
||||||
|
}
|
||||||
|
|
||||||
|
if hiddenPreviewsShowTitle {
|
||||||
|
return .hiddenPreviewsShowTitle
|
||||||
|
}
|
||||||
|
if hiddenPreviewsShowSubtitle {
|
||||||
|
return .hiddenPreviewsShowSubtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNNotificationCategoryOptions(rawValue: 0)
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
import Tauri
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
public class NotificationHandler: NSObject, NotificationHandlerProtocol {
|
||||||
|
|
||||||
|
public weak var plugin: Plugin?
|
||||||
|
|
||||||
|
private var notificationsMap = [String: JSObject]()
|
||||||
|
|
||||||
|
public func saveNotification(_ key: String, _ notification: JSObject) {
|
||||||
|
notificationsMap.updateValue(notification, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.requestAuthorization(options: [.badge, .alert, .sound]) { (granted, error) in
|
||||||
|
completion?(granted, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.getNotificationSettings { settings in
|
||||||
|
completion?(settings.authorizationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions {
|
||||||
|
let notificationData = makeNotificationRequestJSObject(notification.request)
|
||||||
|
self.plugin?.trigger("notification", data: notificationData)
|
||||||
|
|
||||||
|
if let options = notificationsMap[notification.request.identifier] {
|
||||||
|
let silent = options["silent"] as? Bool ?? false
|
||||||
|
if silent {
|
||||||
|
return UNNotificationPresentationOptions.init(rawValue: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
.badge,
|
||||||
|
.sound,
|
||||||
|
.alert,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func didReceive(response: UNNotificationResponse) {
|
||||||
|
var data = JSObject()
|
||||||
|
|
||||||
|
let originalNotificationRequest = response.notification.request
|
||||||
|
let actionId = response.actionIdentifier
|
||||||
|
|
||||||
|
// We turn the two default actions (open/dismiss) into generic strings
|
||||||
|
if actionId == UNNotificationDefaultActionIdentifier {
|
||||||
|
data["actionId"] = "tap"
|
||||||
|
} else if actionId == UNNotificationDismissActionIdentifier {
|
||||||
|
data["actionId"] = "dismiss"
|
||||||
|
} else {
|
||||||
|
data["actionId"] = actionId
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the type of action was for an input type, get the value
|
||||||
|
if let inputType = response as? UNTextInputNotificationResponse {
|
||||||
|
data["inputValue"] = inputType.userText
|
||||||
|
}
|
||||||
|
|
||||||
|
data["notification"] = makeNotificationRequestJSObject(originalNotificationRequest)
|
||||||
|
|
||||||
|
self.plugin?.trigger("actionPerformed", data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a UNNotificationRequest into a JSObject to return back to the client.
|
||||||
|
*/
|
||||||
|
func makeNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject {
|
||||||
|
let notificationRequest = notificationsMap[request.identifier] ?? [:]
|
||||||
|
var notification = makePendingNotificationRequestJSObject(request)
|
||||||
|
notification["sound"] = notificationRequest["sound"] ?? ""
|
||||||
|
notification["actionTypeId"] = request.content.categoryIdentifier
|
||||||
|
notification["attachments"] = notificationRequest["attachments"] ?? [JSObject]()
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePendingNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject {
|
||||||
|
var notification: JSObject = [
|
||||||
|
"id": Int(request.identifier) ?? -1,
|
||||||
|
"title": request.content.title,
|
||||||
|
"body": request.content.body,
|
||||||
|
]
|
||||||
|
|
||||||
|
if let userInfo = JSTypes.coerceDictionaryToJSObject(request.content.userInfo) {
|
||||||
|
var extra = userInfo["__EXTRA__"] as? JSObject ?? userInfo
|
||||||
|
|
||||||
|
// check for any dates and convert them to strings
|
||||||
|
for (key, value) in extra {
|
||||||
|
if let date = value as? Date {
|
||||||
|
let dateString = ISO8601DateFormatter().string(from: date)
|
||||||
|
extra[key] = dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notification["extra"] = extra
|
||||||
|
|
||||||
|
if var schedule = userInfo["__SCHEDULE__"] as? JSObject {
|
||||||
|
// convert schedule at date to string
|
||||||
|
if let date = schedule["at"] as? Date {
|
||||||
|
let dateString = ISO8601DateFormatter().string(from: date)
|
||||||
|
schedule["at"] = dateString
|
||||||
|
}
|
||||||
|
|
||||||
|
notification["schedule"] = schedule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@objc public protocol NotificationHandlerProtocol {
|
||||||
|
func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions
|
||||||
|
func didReceive(response: UNNotificationResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
||||||
|
public weak var notificationHandler: NotificationHandlerProtocol?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
var presentationOptions: UNNotificationPresentationOptions? = nil
|
||||||
|
|
||||||
|
if notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true {
|
||||||
|
presentationOptions = notificationHandler?.willPresent(notification: notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler(presentationOptions ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
|
if response.notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true {
|
||||||
|
notificationHandler?.didReceive(response: response)
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
@ -1,24 +1,209 @@
|
|||||||
|
import SwiftRs
|
||||||
|
import Tauri
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
import WebKit
|
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 {
|
class NotificationPlugin: Plugin {
|
||||||
@objc public func requestPermission(_ invoke: Invoke) throws {
|
let notificationHandler = NotificationHandler()
|
||||||
invoke.resolve(["permissionState": "granted"])
|
let notificationManager = NotificationManager()
|
||||||
}
|
|
||||||
|
override init() {
|
||||||
@objc public func permissionState(_ invoke: Invoke) throws {
|
super.init()
|
||||||
invoke.resolve(["permissionState": "granted"])
|
notificationManager.notificationHandler = notificationHandler
|
||||||
}
|
notificationHandler.plugin = self
|
||||||
|
}
|
||||||
@objc public func notify(_ invoke: Invoke) throws {
|
|
||||||
// TODO
|
@objc public func show(_ invoke: Invoke) throws {
|
||||||
invoke.resolve()
|
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")
|
@_cdecl("init_plugin_notification")
|
||||||
func initPlugin(name: SRString, webview: WKWebView?) {
|
func initPlugin() -> Plugin {
|
||||||
Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: NotificationPlugin())
|
return NotificationPlugin()
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in new issue