feat: add android impl

pull/340/head
Lucas Nogueira 2 years ago
parent 8899297c8f
commit 7b8aa0e5eb
No known key found for this signature in database
GPG Key ID: FFEA6C72E73482F1

@ -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,158 @@
package app.tauri.notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ContentResolver
import android.content.Context
import android.graphics.Color
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import app.tauri.Logger
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
private const val CHANNEL_ID = "id"
private const val CHANNEL_NAME = "name"
private const val CHANNEL_DESCRIPTION = "description"
private const val CHANNEL_IMPORTANCE = "importance"
private const val CHANNEL_VISIBILITY = "visibility"
private const val CHANNEL_SOUND = "sound"
private const val CHANNEL_VIBRATE = "vibration"
private const val CHANNEL_USE_LIGHTS = "lights"
private const val CHANNEL_LIGHT_COLOR = "lightColor"
class ChannelManager(private var context: Context) {
private var notificationManager: NotificationManager? = null
init {
notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?
}
fun createChannel(invoke: Invoke) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = JSObject()
if (invoke.getString(CHANNEL_ID) != null) {
channel.put(CHANNEL_ID, invoke.getString(CHANNEL_ID))
} else {
invoke.reject("Channel missing identifier")
return
}
if (invoke.getString(CHANNEL_NAME) != null) {
channel.put(CHANNEL_NAME, invoke.getString(CHANNEL_NAME))
} else {
invoke.reject("Channel missing name")
return
}
channel.put(
CHANNEL_IMPORTANCE,
invoke.getInt(CHANNEL_IMPORTANCE, NotificationManager.IMPORTANCE_DEFAULT)
)
channel.put(CHANNEL_DESCRIPTION, invoke.getString(CHANNEL_DESCRIPTION, ""))
channel.put(
CHANNEL_VISIBILITY,
invoke.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC)
)
channel.put(CHANNEL_SOUND, invoke.getString(CHANNEL_SOUND))
channel.put(CHANNEL_VIBRATE, invoke.getBoolean(CHANNEL_VIBRATE, false))
channel.put(CHANNEL_USE_LIGHTS, invoke.getBoolean(CHANNEL_USE_LIGHTS, false))
channel.put(CHANNEL_LIGHT_COLOR, invoke.getString(CHANNEL_LIGHT_COLOR))
createChannel(channel)
invoke.resolve()
} else {
invoke.reject("channel not available")
}
}
private fun createChannel(channel: JSObject) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
channel.getString(CHANNEL_ID),
channel.getString(CHANNEL_NAME),
channel.getInteger(CHANNEL_IMPORTANCE)!!
)
notificationChannel.description = channel.getString(CHANNEL_DESCRIPTION)
notificationChannel.lockscreenVisibility = channel.getInteger(CHANNEL_VISIBILITY, android.app.Notification.VISIBILITY_PRIVATE)
notificationChannel.enableVibration(channel.getBoolean(CHANNEL_VIBRATE, false))
notificationChannel.enableLights(channel.getBoolean(CHANNEL_USE_LIGHTS, false))
val lightColor = channel.getString(CHANNEL_LIGHT_COLOR)
if (lightColor.isNotEmpty()) {
try {
notificationChannel.lightColor = Color.parseColor(lightColor)
} catch (ex: IllegalArgumentException) {
Logger.error(
Logger.tags("NotificationChannel"),
"Invalid color provided for light color.",
null
)
}
}
var sound = channel.getString(CHANNEL_SOUND)
if (sound.isNotEmpty()) {
if (sound.contains(".")) {
sound = sound.substring(0, sound.lastIndexOf('.'))
}
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
val soundUri =
Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/" + sound)
notificationChannel.setSound(soundUri, audioAttributes)
}
notificationManager?.createNotificationChannel(notificationChannel)
}
}
fun deleteChannel(invoke: Invoke) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = invoke.getString("id")
notificationManager?.deleteNotificationChannel(channelId)
invoke.resolve()
} else {
invoke.reject("channel not available")
}
}
fun listChannels(invoke: Invoke) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannels: List<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
)
)
Logger.debug(
Logger.tags("NotificationChannel"),
"visibility " + notificationChannel.lockscreenVisibility
)
Logger.debug(
Logger.tags("NotificationChannel"),
"importance " + notificationChannel.importance
)
channels.put(channel)
}
val result = JSObject()
result.put("channels", channels)
invoke.resolve(result)
} else {
invoke.reject("channel not available")
}
}
}

@ -0,0 +1,301 @@
package app.tauri.notification
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.tauri.Logger
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import org.json.JSONException
import org.json.JSONObject
import java.text.ParseException
class Notification {
var title: String? = null
var body: String? = null
var largeBody: String? = null
var summaryText: String? = null
var id: Int? = null
private var sound: String? = null
private var smallIcon: String? = null
private var largeIcon: String? = null
var iconColor: String? = null
var actionTypeId: String? = null
var group: String? = null
var inboxList: List<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: String? = null
fun getSound(context: Context, defaultSound: Int): String? {
var soundPath: String? = null
var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE
val name = AssetUtils.getResourceBaseName(sound)
if (name != null) {
resId = AssetUtils.getResourceID(context, name, "raw")
}
if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) {
resId = defaultSound
}
if (resId != AssetUtils.RESOURCE_ID_ZERO_VALUE) {
soundPath =
ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + resId
}
return soundPath
}
fun setSound(sound: String?) {
this.sound = sound
}
fun setSmallIcon(smallIcon: String?) {
this.smallIcon = AssetUtils.getResourceBaseName(smallIcon)
}
fun setLargeIcon(largeIcon: String?) {
this.largeIcon = AssetUtils.getResourceBaseName(largeIcon)
}
fun getIconColor(globalColor: String): String {
// use the one defined local before trying for a globally defined color
return iconColor ?: globalColor
}
fun getSmallIcon(context: Context, defaultIcon: Int): Int {
var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE
if (smallIcon != null) {
resId = AssetUtils.getResourceID(context, smallIcon, "drawable")
}
if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) {
resId = defaultIcon
}
return resId
}
fun getLargeIcon(context: Context): Bitmap? {
if (largeIcon != null) {
val resId: Int = AssetUtils.getResourceID(context, largeIcon, "drawable")
return BitmapFactory.decodeResource(context.resources, resId)
}
return null
}
val isScheduled = schedule != null
override fun toString(): String {
return "Notification{" +
"title='" +
title +
'\'' +
", body='" +
body +
'\'' +
", id=" +
id +
", sound='" +
sound +
'\'' +
", smallIcon='" +
smallIcon +
'\'' +
", iconColor='" +
iconColor +
'\'' +
", actionTypeId='" +
actionTypeId +
'\'' +
", group='" +
group +
'\'' +
", extra=" +
extra +
", attachments=" +
attachments +
", schedule=" +
schedule +
", groupSummary=" +
isGroupSummary +
", ongoing=" +
isOngoing +
", autoCancel=" +
isAutoCancel +
'}'
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val that = other as Notification
if (if (title != null) title != that.title else that.title != null) return false
if (if (body != null) body != that.body else that.body != null) return false
if (if (largeBody != null) largeBody != that.largeBody else that.largeBody != null) return false
if (if (id != null) id != that.id else that.id != null) return false
if (if (sound != null) sound != that.sound else that.sound != null) return false
if (if (smallIcon != null) smallIcon != that.smallIcon else that.smallIcon != null) return false
if (if (largeIcon != null) largeIcon != that.largeIcon else that.largeIcon != null) return false
if (if (iconColor != null) iconColor != that.iconColor else that.iconColor != null) return false
if (if (actionTypeId != null) actionTypeId != that.actionTypeId else that.actionTypeId != null) return false
if (if (group != null) group != that.group else that.group != null) return false
if (if (extra != null) extra != that.extra else that.extra != null) return false
if (if (attachments != null) attachments != that.attachments else that.attachments != null) return false
if (if (inboxList != null) inboxList != that.inboxList else that.inboxList != null) return false
if (isGroupSummary != that.isGroupSummary) return false
if (isOngoing != that.isOngoing) return false
if (isAutoCancel != that.isAutoCancel) return false
return if (schedule != null) schedule?.equals(that.schedule) ?: false else that.schedule == null
}
override fun hashCode(): Int {
var result = if (title != null) title.hashCode() else 0
result = 31 * result + if (body != null) body.hashCode() else 0
result = 31 * result + if (id != null) id.hashCode() else 0
result = 31 * result + if (sound != null) sound.hashCode() else 0
result = 31 * result + if (smallIcon != null) smallIcon.hashCode() else 0
result = 31 * result + if (iconColor != null) iconColor.hashCode() else 0
result = 31 * result + if (actionTypeId != null) actionTypeId.hashCode() else 0
result = 31 * result + if (group != null) group.hashCode() else 0
result = 31 * result + java.lang.Boolean.hashCode(isGroupSummary)
result = 31 * result + java.lang.Boolean.hashCode(isOngoing)
result = 31 * result + java.lang.Boolean.hashCode(isAutoCancel)
result = 31 * result + if (extra != null) extra.hashCode() else 0
result = 31 * result + if (attachments != null) attachments.hashCode() else 0
result = 31 * result + if (schedule != null) schedule.hashCode() else 0
return result
}
fun setExtraFromString(extraFromString: String) {
try {
val jsonObject = JSONObject(extraFromString)
extra = JSObject.fromJSONObject(jsonObject)
} catch (e: JSONException) {
Logger.error(Logger.tags("Notification"), "Cannot rebuild extra data", e)
}
}
companion object {
/**
* Build list of the notifications from invoke payload
*/
fun buildNotificationList(invoke: Invoke): List<Notification>? {
val notificationArray = invoke.getArray("notifications")
if (notificationArray == null) {
invoke.reject("Must provide notifications array as notifications option")
return null
}
val resultNotifications: MutableList<Notification> =
ArrayList(notificationArray.length())
val notificationsJson: List<JSONObject> = try {
notificationArray.toList()
} catch (e: JSONException) {
invoke.reject("Provided notification format is invalid")
return null
}
for (jsonNotification in notificationsJson) {
val notification: JSObject = try {
val identifier = jsonNotification.getLong("id")
if (identifier > Int.MAX_VALUE || identifier < Int.MIN_VALUE) {
invoke.reject("The identifier should be a Java int")
return null
}
JSObject.fromJSONObject(jsonNotification)
} catch (e: JSONException) {
invoke.reject("Invalid JSON object sent to Notification plugin", e)
return null
}
try {
val activeNotification = buildNotificationFromJSObject(notification)
resultNotifications.add(activeNotification)
} catch (e: ParseException) {
invoke.reject("Invalid date format sent to Notification plugin", e)
return null
}
}
return resultNotifications
}
fun buildNotificationFromJSObject(jsonObject: JSObject): Notification {
val notification = Notification()
notification.source = jsonObject.toString()
notification.id = jsonObject.getInteger("id")
notification.body = jsonObject.getString("body")
notification.largeBody = jsonObject.getString("largeBody")
notification.summaryText = jsonObject.getString("summaryText")
notification.actionTypeId = jsonObject.getString("actionTypeId")
notification.group = jsonObject.getString("group")
notification.setSound(jsonObject.getString("sound"))
notification.title = jsonObject.getString("title")
notification.setSmallIcon(jsonObject.getString("smallIcon"))
notification.setLargeIcon(jsonObject.getString("largeIcon"))
notification.iconColor = jsonObject.getString("iconColor")
notification.attachments = NotificationAttachment.getAttachments(jsonObject)
notification.isGroupSummary = jsonObject.getBoolean("groupSummary", false)
notification.channelId = jsonObject.getString("channelId")
val schedule = jsonObject.getJSObject("schedule")
if (schedule != null) {
notification.schedule = NotificationSchedule(schedule)
}
notification.extra = jsonObject.getJSObject("extra")
notification.isOngoing = jsonObject.getBoolean("ongoing", false)
notification.isAutoCancel = jsonObject.getBoolean("autoCancel", true)
try {
val inboxList = jsonObject.getJSONArray("inboxList")
val inboxStringList: MutableList<String> = ArrayList()
for (i in 0 until inboxList.length()) {
inboxStringList.add(inboxList.getString(i))
}
notification.inboxList = inboxStringList
} catch (_: Exception) {
}
return notification
}
fun getNotificationPendingList(invoke: Invoke): List<Int>? {
var notifications: List<JSONObject>? = null
try {
notifications = invoke.getArray("notifications", JSArray()).toList()
} catch (_: JSONException) {
}
if (notifications.isNullOrEmpty()) {
invoke.reject("Must provide notifications array as notifications option")
return null
}
val notificationsList: MutableList<Int> = ArrayList(notifications.size)
for (notificationToCancel in notifications) {
try {
notificationsList.add(notificationToCancel.getInt("id"))
} catch (_: JSONException) {
}
}
return notificationsList
}
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"))
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,230 @@
package app.tauri.notification
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.webkit.WebView
import app.tauri.PermissionState
import app.tauri.annotation.Command
import app.tauri.annotation.Permission
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import org.json.JSONException
import org.json.JSONObject
@TauriPlugin
const val LOCAL_NOTIFICATIONS = "permissionState"
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState")
]
)
class NotificationPlugin(private val activity: Activity): Plugin(activity) {
private var webView: WebView? = null
private var manager: TauriNotificationManager? = null
var notificationManager: NotificationManager? = null
private var notificationStorage: NotificationStorage? = null
private var channelManager = ChannelManager(activity)
override fun load(webView: WebView) {
super.load(webView)
this.webView = webView
notificationStorage = NotificationStorage(activity)
val manager = TauriNotificationManager(
notificationStorage!!,
activity,
activity,
getConfig()
)
manager.createNotificationChannel()
this.manager = manager
notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?
}
/*private fun handleOnNewIntent(data: Intent) {
super.handleOnNewIntent(data)
if (Intent.ACTION_MAIN != data.action) {
return
}
val dataJson = manager.handleNotificationActionPerformed(data, notificationStorage)
if (dataJson != null) {
// notifyListeners("localNotificationActionPerformed", dataJson, true)
}
}*/
/**
* Schedule a notification invoke from JavaScript
* Creates local notification in system.
*/
@Command
fun requestPermission(invoke: Invoke) {
val ret = JSObject()
ret.put("permissionState", "granted")
invoke.resolve(ret)
fun schedule(invoke: Invoke) {
val localNotifications = Notification.buildNotificationList(invoke)
?: return
val ids = manager!!.schedule(invoke, localNotifications)
if (ids != null) {
notificationStorage?.appendNotifications(localNotifications)
val result = JSObject()
val jsArray = JSArray()
for (i in 0 until ids.length()) {
try {
val notification = JSObject().put("id", ids.getInt(i))
jsArray.put(notification)
} catch (_: Exception) {
}
}
result.put("notifications", jsArray)
invoke.resolve(result)
}
}
@Command
fun permissionState(invoke: Invoke) {
val ret = JSObject()
ret.put("permissionState", "granted")
invoke.resolve(ret)
fun cancel(invoke: Invoke) {
val notifications = invoke.getArray("notifications")
if (notifications == null) {
manager?.cancel(invoke)
} else {
try {
for (o in notifications.toList<Any>()) {
if (o is JSONObject) {
val notification = JSObject.fromJSONObject((o))
val tag = notification.getString("tag")
val id = notification.getInteger("id")
if (tag.isEmpty()) {
notificationManager!!.cancel(id!!)
} else {
notificationManager!!.cancel(tag, id!!)
}
} else {
invoke.reject("Unexpected notification type")
}
}
} catch (e: JSONException) {
invoke.reject(e.message)
}
invoke.resolve()
}
}
@Command
fun getPending(invoke: Invoke) {
val notifications= notificationStorage!!.getSavedNotifications()
val result = Notification.buildNotificationPendingList(notifications)
invoke.resolve(result)
}
@Command
fun notify(invoke: Invoke) {
// TODO
fun registerActionTypes(invoke: Invoke) {
val types = invoke.getArray("types", JSArray())
val typesArray = NotificationAction.buildTypes(types)
notificationStorage?.writeActionGroup(typesArray)
invoke.resolve()
}
@SuppressLint("ObsoleteSdkInt")
@Command
fun getDeliveredNotifications(invoke: Invoke) {
val notifications = JSArray()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activeNotifications = notificationManager!!.activeNotifications
for (activeNotification in activeNotifications) {
val jsNotification = JSObject()
jsNotification.put("id", activeNotification.id)
jsNotification.put("tag", activeNotification.tag)
val notification = activeNotification.notification
if (notification != null) {
jsNotification.put("title", notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE))
jsNotification.put("body", notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT))
jsNotification.put("group", notification.group)
jsNotification.put(
"groupSummary",
0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY
)
val extras = JSObject()
for (key in notification.extras.keySet()) {
extras.put(key!!, notification.extras.getString(key))
}
jsNotification.put("data", extras)
}
notifications.put(jsNotification)
}
}
val result = JSObject()
result.put("notifications", notifications)
invoke.resolve(result)
}
@Command
fun cancelAll(invoke: Invoke) {
notificationManager!!.cancelAll()
invoke.resolve()
}
@Command
fun createChannel(invoke: Invoke) {
channelManager.createChannel(invoke)
}
@Command
fun deleteChannel(invoke: Invoke) {
channelManager.deleteChannel(invoke)
}
@Command
fun listChannels(invoke: Invoke) {
channelManager.listChannels(invoke)
}
@Command
override fun checkPermissions(invoke: Invoke) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
val permissionsResultJSON = JSObject()
permissionsResultJSON.put("permissionState", getPermissionState())
invoke.resolve(permissionsResultJSON)
} else {
super.checkPermissions(invoke)
}
}
@Command
override fun requestPermissions(invoke: Invoke) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
permissionState(invoke)
} else {
if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) {
requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "permissionsCallback")
}
}
}
@Command
fun permissionState(invoke: Invoke) {
val permissionsResultJSON = JSObject()
permissionsResultJSON.put("permissionState", getPermissionState())
invoke.resolve(permissionsResultJSON)
}
@PermissionCallback
private fun permissionsCallback(invoke: Invoke) {
val permissionsResultJSON = JSObject()
permissionsResultJSON.put("display", getPermissionState())
invoke.resolve(permissionsResultJSON)
}
private fun getPermissionState(): String {
return if (manager!!.areNotificationsEnabled()) {
"granted"
} else {
"denied"
}
}
}

@ -0,0 +1,304 @@
package app.tauri.notification
import android.text.format.DateUtils
import app.tauri.plugin.JSObject
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
const val JS_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
enum class NotificationInterval {
Year, Month, TwoWeeks, Week, Day, Hour, Minute, Second
}
fun getIntervalTime(interval: NotificationInterval, count: Int): Long {
return when (interval) {
// This case is just approximation as not all years have the same number of days
NotificationInterval.Year -> count * DateUtils.WEEK_IN_MILLIS * 52
// This case is just approximation as months have different number of days
NotificationInterval.Month -> count * 30 * DateUtils.DAY_IN_MILLIS
NotificationInterval.TwoWeeks -> count * 2 * DateUtils.WEEK_IN_MILLIS
NotificationInterval.Week -> count * DateUtils.WEEK_IN_MILLIS
NotificationInterval.Day -> count * DateUtils.DAY_IN_MILLIS
NotificationInterval.Hour -> count * DateUtils.HOUR_IN_MILLIS
NotificationInterval.Minute -> count * DateUtils.MINUTE_IN_MILLIS
NotificationInterval.Second -> count * DateUtils.SECOND_IN_MILLIS
}
}
sealed class ScheduleKind {
// At specific moment of time (with repeating option)
class At(val date: Date, val repeating: Boolean): ScheduleKind()
class Interval(val interval: DateMatch): ScheduleKind()
class Every(val interval: NotificationInterval, val count: Int): ScheduleKind()
}
class NotificationSchedule(val scheduleObj: JSObject) {
val kind: ScheduleKind
// Schedule this notification to fire even if app is idled (Doze)
var whileIdle: Boolean = false
init {
val payload = scheduleObj.getJSObject("data", JSObject())
when (val scheduleKind = scheduleObj.getString("kind", "")) {
"at" -> {
val dateString = payload.getString("date")
if (dateString.isNotEmpty()) {
val sdf = SimpleDateFormat(JS_DATE_FORMAT)
sdf.timeZone = TimeZone.getTimeZone("UTC")
val at = sdf.parse(dateString)
if (at == null) {
throw Exception("could not parse `at` date")
} else {
kind = ScheduleKind.At(at, payload.getBoolean("repeating"))
}
} else {
throw Exception("`at` date cannot be empty")
}
}
"interval" -> {
val dateMatch = onFromJson(payload)
kind = ScheduleKind.Interval(dateMatch)
}
"every" -> {
val interval = NotificationInterval.valueOf(payload.getString("interval"))
kind = ScheduleKind.Every(interval, payload.getInteger("count", 1))
}
else -> {
throw Exception("Unknown schedule kind $scheduleKind")
}
}
whileIdle = scheduleObj.getBoolean("allowWhileIdle", false)
}
private fun onFromJson(onJson: JSObject): DateMatch {
val match = DateMatch()
match.year = onJson.getInteger("year")
match.month = onJson.getInteger("month")
match.day = onJson.getInteger("day")
match.weekday = onJson.getInteger("weekday")
match.hour = onJson.getInteger("hour")
match.minute = onJson.getInteger("minute")
match.second = onJson.getInteger("second")
return match
}
fun isRemovable(): Boolean {
return when (kind) {
is ScheduleKind.At -> !kind.repeating
else -> false
}
}
}
class DateMatch {
var year: Int? = null
var month: Int? = null
var day: Int? = null
var weekday: Int? = null
var hour: Int? = null
var minute: Int? = null
var second: Int? = null
// Unit used to save the last used unit for a trigger.
// One of the Calendar constants values
var unit: Int? = -1
/**
* Gets a calendar instance pointing to the specified date.
*
* @param date The date to point.
*/
private fun buildCalendar(date: Date): Calendar {
val cal: Calendar = Calendar.getInstance()
cal.time = date
cal.set(Calendar.MILLISECOND, 0)
return cal
}
/**
* Calculates next trigger date for
*
* @param date base date used to calculate trigger
* @return next trigger timestamp
*/
fun nextTrigger(date: Date): Long {
val current: Calendar = buildCalendar(date)
val next: Calendar = buildNextTriggerTime(date)
return postponeTriggerIfNeeded(current, next)
}
/**
* Postpone trigger if first schedule matches the past
*/
private fun postponeTriggerIfNeeded(current: Calendar, next: Calendar): Long {
if (next.timeInMillis <= current.timeInMillis && unit != -1) {
var incrementUnit = -1
if (unit == Calendar.YEAR || unit == Calendar.MONTH) {
incrementUnit = Calendar.YEAR
} else if (unit == Calendar.DAY_OF_MONTH) {
incrementUnit = Calendar.MONTH
} else if (unit == Calendar.DAY_OF_WEEK) {
incrementUnit = Calendar.WEEK_OF_MONTH
} else if (unit == Calendar.HOUR_OF_DAY) {
incrementUnit = Calendar.DAY_OF_MONTH
} else if (unit == Calendar.MINUTE) {
incrementUnit = Calendar.HOUR_OF_DAY
} else if (unit == Calendar.SECOND) {
incrementUnit = Calendar.MINUTE
}
if (incrementUnit != -1) {
next.set(incrementUnit, next.get(incrementUnit) + 1)
}
}
return next.timeInMillis
}
private fun buildNextTriggerTime(date: Date): Calendar {
val next: Calendar = buildCalendar(date)
if (year != null) {
next.set(Calendar.YEAR, year ?: 0)
if (unit == -1) unit = Calendar.YEAR
}
if (month != null) {
next.set(Calendar.MONTH, month ?: 0)
if (unit == -1) unit = Calendar.MONTH
}
if (day != null) {
next.set(Calendar.DAY_OF_MONTH, day ?: 0)
if (unit == -1) unit = Calendar.DAY_OF_MONTH
}
if (weekday != null) {
next.set(Calendar.DAY_OF_WEEK, weekday ?: 0)
if (unit == -1) unit = Calendar.DAY_OF_WEEK
}
if (hour != null) {
next.set(Calendar.HOUR_OF_DAY, hour ?: 0)
if (unit == -1) unit = Calendar.HOUR_OF_DAY
}
if (minute != null) {
next.set(Calendar.MINUTE, minute ?: 0)
if (unit == -1) unit = Calendar.MINUTE
}
if (second != null) {
next.set(Calendar.SECOND, second ?: 0)
if (unit == -1) unit = Calendar.SECOND
}
return next
}
override fun toString(): String {
return "DateMatch{" +
"year=" +
year +
", month=" +
month +
", day=" +
day +
", weekday=" +
weekday +
", hour=" +
hour +
", minute=" +
minute +
", second=" +
second +
'}'
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val dateMatch = other as DateMatch
if (if (year != null) year != dateMatch.year else dateMatch.year != null) return false
if (if (month != null) month != dateMatch.month else dateMatch.month != null) return false
if (if (day != null) day != dateMatch.day else dateMatch.day != null) return false
if (if (weekday != null) weekday != dateMatch.weekday else dateMatch.weekday != null) return false
if (if (hour != null) hour != dateMatch.hour else dateMatch.hour != null) return false
if (if (minute != null) minute != dateMatch.minute else dateMatch.minute != null) return false
return if (second != null) second == dateMatch.second else dateMatch.second == null
}
override fun hashCode(): Int {
var result = if (year != null) year.hashCode() else 0
result = 31 * result + if (month != null) month.hashCode() else 0
result = 31 * result + if (day != null) day.hashCode() else 0
result = 31 * result + if (weekday != null) weekday.hashCode() else 0
result = 31 * result + if (hour != null) hour.hashCode() else 0
result = 31 * result + if (minute != null) minute.hashCode() else 0
result += 31 + if (second != null) second.hashCode() else 0
return result
}
/**
* Transform DateMatch object to CronString
*
* @return
*/
fun toMatchString(): String {
val matchString = year.toString() +
separator +
month +
separator +
day +
separator +
weekday +
separator +
hour +
separator +
minute +
separator +
second +
separator +
unit
return matchString.replace("null", "*")
}
companion object {
private const val separator = " "
/**
* Create DateMatch object from stored string
*
* @param matchString
* @return
*/
fun fromMatchString(matchString: String): DateMatch {
val date = DateMatch()
val split = matchString.split(separator.toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray()
if (split.size == 7) {
date.year = getValueFromCronElement(split[0])
date.month = getValueFromCronElement(split[1])
date.day = getValueFromCronElement(split[2])
date.weekday = getValueFromCronElement(split[3])
date.hour = getValueFromCronElement(split[4])
date.minute = getValueFromCronElement(split[5])
date.unit = getValueFromCronElement(split[6])
}
if (split.size == 8) {
date.year = getValueFromCronElement(split[0])
date.month = getValueFromCronElement(split[1])
date.day = getValueFromCronElement(split[2])
date.weekday = getValueFromCronElement(split[3])
date.hour = getValueFromCronElement(split[4])
date.minute = getValueFromCronElement(split[5])
date.second = getValueFromCronElement(split[6])
date.unit = getValueFromCronElement(split[7])
}
return date
}
private fun getValueFromCronElement(token: String): Int? {
return try {
token.toInt()
} catch (e: NumberFormatException) {
null
}
}
}
}

@ -0,0 +1,152 @@
package app.tauri.notification
import android.content.Context
import android.content.SharedPreferences
import app.tauri.plugin.JSObject
import org.json.JSONException
import java.text.ParseException
// Key for private preferences
private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE"
// Key used to save action types
private const val ACTION_TYPES_ID = "ACTION_TYPE_STORE"
private const val ID_KEY = "notificationIds"
class NotificationStorage(private val context: Context) {
/**
* Persist the id of currently scheduled notification
*/
fun appendNotifications(localNotifications: List<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)
}
}
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.buildNotificationFromJSObject(jsNotification)
notifications.add(notification)
} catch (_: ParseException) {
}
}
}
return notifications
}
return ArrayList()
}
private fun getNotificationFromJSONString(notificationString: String?): JSObject? {
if (notificationString == null) {
return null
}
val jsNotification = try {
JSObject(notificationString)
} catch (ex: JSONException) {
return null
}
return jsNotification
}
fun getSavedNotificationAsJSObject(key: String?): JSObject? {
val storage = getStorage(NOTIFICATION_STORE_ID)
val notificationString = try {
storage.getString(key, null)
} catch (ex: ClassCastException) {
return null
} ?: return null
val jsNotification = try {
JSObject(notificationString)
} catch (ex: JSONException) {
return null
}
return jsNotification
}
fun getSavedNotification(key: String?): Notification? {
val jsNotification = getSavedNotificationAsJSObject(key) ?: return null
val notification = try {
Notification.buildNotificationFromJSObject(jsNotification)
} catch (ex: ParseException) {
return null
}
return notification
}
/**
* Remove the stored notifications
*/
fun deleteNotification(id: String?) {
val editor = getStorage(NOTIFICATION_STORE_ID).edit()
editor.remove(id)
editor.apply()
}
/**
* Shared private preferences for the application.
*/
private fun getStorage(key: String): SharedPreferences {
return context.getSharedPreferences(key, Context.MODE_PRIVATE)
}
/**
* Writes new action types (actions that being displayed in notification) to storage.
* Write will override previous data.
*
* @param typesMap - map with groupId and actionArray assigned to group
*/
fun writeActionGroup(typesMap: Map<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()
}
}
/**
* Retrieve array of notification actions per ActionTypeId
*
* @param forId - id of the group
*/
fun getActionGroup(forId: String): Array<NotificationAction?> {
val storage = getStorage(ACTION_TYPES_ID + forId)
val count = storage.getInt("count", 0)
val actions: Array<NotificationAction?> = arrayOfNulls<NotificationAction>(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,553 @@
package app.tauri.notification
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import app.tauri.Logger
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
// Action constants
const val NOTIFICATION_INTENT_KEY = "NotificationId"
const val NOTIFICATION_OBJ_INTENT_KEY = "LocalNotficationObject"
const val ACTION_INTENT_KEY = "NotificationUserAction"
const val NOTIFICATION_IS_REMOVABLE_KEY = "NotificationRepeating"
const val REMOTE_INPUT_KEY = "NotificationRemoteInput"
const val DEFAULT_NOTIFICATION_CHANNEL_ID = "default"
const val DEFAULT_PRESS_ACTION = "tap"
class TauriNotificationManager(
private val storage: NotificationStorage,
private val activity: Activity,
private val context: Context,
private val config: JSObject
) {
private var defaultSoundID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE
private var defaultSmallIconID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE
/**
* Method extecuted when notification is launched by user from the notification bar.
*/
fun handleNotificationActionPerformed(
data: Intent,
notificationStorage: NotificationStorage
): JSObject? {
Logger.debug(Logger.tags("Notification"), "Notification received: " + data.dataString)
val notificationId =
data.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE)
if (notificationId == Int.MIN_VALUE) {
Logger.debug(Logger.tags("Notification"), "Activity started without notification attached")
return null
}
val isRemovable =
data.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true)
if (isRemovable) {
notificationStorage.deleteNotification(notificationId.toString())
}
val dataJson = JSObject()
val results = RemoteInput.getResultsFromIntent(data)
val input = results?.getCharSequence(REMOTE_INPUT_KEY)
dataJson.put("inputValue", input?.toString())
val menuAction = data.getStringExtra(ACTION_INTENT_KEY)
dismissVisibleNotification(notificationId)
dataJson.put("actionId", menuAction)
var request: JSONObject? = null
try {
val notificationJsonString =
data.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY)
if (notificationJsonString != null) {
request = JSObject(notificationJsonString)
}
} catch (_: JSONException) {
}
dataJson.put("notification", request)
return dataJson
}
/**
* Create notification channel
*/
fun createNotificationChannel() {
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = "Default"
val description = "Default"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL_ID, name, importance)
channel.description = description
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
val soundUri = getDefaultSoundUrl(context)
if (soundUri != null) {
channel.setSound(soundUri, audioAttributes)
}
// Register the channel with the system; you can't change the importance
// or other notification behaviors after this
val notificationManager = context.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(channel)
}
}
fun schedule(invoke: Invoke?, notifications: List<Notification>): JSONArray? {
val ids = JSONArray()
val notificationManager = NotificationManagerCompat.from(
context
)
val notificationsEnabled = notificationManager.areNotificationsEnabled()
if (!notificationsEnabled) {
invoke?.reject("Notifications not enabled on this device")
return null
}
for (notification in notifications) {
val id = notification.id
if (id == null) {
invoke?.reject("Notification missing identifier")
return null
}
dismissVisibleNotification(id)
cancelTimerForNotification(id)
buildNotification(notificationManager, notification, invoke)
ids.put(id)
}
return ids
}
// TODO Progressbar support
// TODO System categories (DO_NOT_DISTURB etc.)
// TODO control visibility by flag Notification.VISIBILITY_PRIVATE
// TODO Group notifications (setGroup, setGroupSummary, setNumber)
// TODO use NotificationCompat.MessagingStyle for latest API
// TODO expandable notification NotificationCompat.MessagingStyle
// TODO media style notification support NotificationCompat.MediaStyle
// TODO custom small/large icons
@SuppressLint("MissingPermission")
private fun buildNotification(
notificationManager: NotificationManagerCompat,
notification: Notification,
invoke: Invoke?
) {
var channelId = DEFAULT_NOTIFICATION_CHANNEL_ID
if (notification.channelId != null) {
channelId = notification.channelId!!
}
val mBuilder = NotificationCompat.Builder(
context, channelId
)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setAutoCancel(notification.isAutoCancel)
.setOngoing(notification.isOngoing)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setGroupSummary(notification.isGroupSummary)
if (notification.largeBody != null) {
// support multiline text
mBuilder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(notification.largeBody)
.setSummaryText(notification.summaryText)
)
}
if (notification.inboxList != null) {
val inboxStyle = NotificationCompat.InboxStyle()
for (line in notification.inboxList ?: listOf()) {
inboxStyle.addLine(line)
}
inboxStyle.setBigContentTitle(notification.title)
inboxStyle.setSummaryText(notification.summaryText)
mBuilder.setStyle(inboxStyle)
}
val sound = notification.getSound(context, getDefaultSound(context))
if (sound != null) {
val soundUri = Uri.parse(sound)
// Grant permission to use sound
context.grantUriPermission(
"com.android.systemui",
soundUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
mBuilder.setSound(soundUri)
mBuilder.setDefaults(android.app.Notification.DEFAULT_VIBRATE or android.app.Notification.DEFAULT_LIGHTS)
} else {
mBuilder.setDefaults(android.app.Notification.DEFAULT_ALL)
}
val group = notification.group
if (group != null) {
mBuilder.setGroup(group)
if (notification.isGroupSummary) {
mBuilder.setSubText(notification.summaryText)
}
}
mBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
mBuilder.setOnlyAlertOnce(true)
mBuilder.setSmallIcon(notification.getSmallIcon(context, getDefaultSmallIcon(context)))
mBuilder.setLargeIcon(notification.getLargeIcon(context))
val iconColor = notification.getIconColor(config.getString("iconColor"))
if (iconColor.isNotEmpty()) {
try {
mBuilder.color = Color.parseColor(iconColor)
} catch (ex: IllegalArgumentException) {
invoke?.reject("Invalid color provided. Must be a hex string (ex: #ff0000")
return
}
}
createActionIntents(notification, mBuilder)
// notificationId is a unique int for each notification that you must define
val buildNotification = mBuilder.build()
if (notification.isScheduled) {
triggerScheduledNotification(buildNotification, notification)
} else {
try {
// TODO notify
// val notificationJson = JSObject(notification.source ?: "")
} catch (_: JSONException) {
}
if (ActivityCompat.checkSelfPermission(
activity,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager.notify(notification.id ?: 0, buildNotification)
}
}
// Create intents for open/dissmis actions
private fun createActionIntents(
notification: Notification,
mBuilder: NotificationCompat.Builder
) {
// Open intent
val intent = buildIntent(notification, DEFAULT_PRESS_ACTION)
var flags = PendingIntent.FLAG_CANCEL_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
val pendingIntent = PendingIntent.getActivity(context, notification.id ?: 0, intent, flags)
mBuilder.setContentIntent(pendingIntent)
// Build action types
val actionTypeId = notification.actionTypeId
if (actionTypeId != null) {
val actionGroup = storage.getActionGroup(actionTypeId)
for (notificationAction in actionGroup) {
// TODO Add custom icons to actions
val actionIntent = buildIntent(notification, notificationAction!!.id)
val actionPendingIntent = PendingIntent.getActivity(
context,
(notification.id ?: 0) + notificationAction.id.hashCode(),
actionIntent,
flags
)
val actionBuilder: NotificationCompat.Action.Builder = NotificationCompat.Action.Builder(
R.drawable.ic_transparent,
notificationAction.title,
actionPendingIntent
)
if (notificationAction.input) {
val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel(
notificationAction.title
).build()
actionBuilder.addRemoteInput(remoteInput)
}
mBuilder.addAction(actionBuilder.build())
}
}
// Dismiss intent
val dissmissIntent = Intent(
context,
NotificationDismissReceiver::class.java
)
dissmissIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
dissmissIntent.putExtra(NOTIFICATION_INTENT_KEY, notification.id)
dissmissIntent.putExtra(ACTION_INTENT_KEY, "dismiss")
val schedule = notification.schedule
dissmissIntent.putExtra(
NOTIFICATION_IS_REMOVABLE_KEY,
schedule == null || schedule.isRemovable()
)
flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = PendingIntent.FLAG_MUTABLE
}
val deleteIntent =
PendingIntent.getBroadcast(context, notification.id ?: 0, dissmissIntent, flags)
mBuilder.setDeleteIntent(deleteIntent)
}
private fun buildIntent(notification: Notification, action: String?): Intent {
val intent = Intent(context, activity.javaClass)
intent.action = Intent.ACTION_MAIN
intent.addCategory(Intent.CATEGORY_LAUNCHER)
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
intent.putExtra(NOTIFICATION_INTENT_KEY, notification.id)
intent.putExtra(ACTION_INTENT_KEY, action)
intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, notification.source)
val schedule = notification.schedule
intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable())
return intent
}
/**
* Build a notification trigger, such as triggering each N seconds, or
* on a certain date "shape" (such as every first of the month)
*/
// TODO support different AlarmManager.RTC modes depending on priority
private fun triggerScheduledNotification(notification: android.app.Notification, request: Notification) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val schedule = request.schedule
val notificationIntent = Intent(
context,
TimedNotificationPublisher::class.java
)
notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.id)
notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification)
var flags = PendingIntent.FLAG_CANCEL_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
var pendingIntent =
PendingIntent.getBroadcast(context, request.id ?: 0, notificationIntent, flags)
when (val scheduleKind = schedule?.kind) {
is ScheduleKind.At -> {
val at = scheduleKind.date
if (at.time < Date().time) {
Logger.error(Logger.tags("Notification"), "Scheduled time must be *after* current time", null)
return
}
if (scheduleKind.repeating) {
val interval: Long = at.time - Date().time
alarmManager.setRepeating(AlarmManager.RTC, at.time, interval, pendingIntent)
} else {
setExactIfPossible(alarmManager, schedule, at.time, pendingIntent)
}
}
is ScheduleKind.Interval -> {
val trigger = scheduleKind.interval.nextTrigger(Date())
notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, scheduleKind.interval.toMatchString())
pendingIntent =
PendingIntent.getBroadcast(context, request.id ?: 0, notificationIntent, flags)
setExactIfPossible(alarmManager, schedule, trigger, pendingIntent)
val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss")
Logger.debug(
Logger.tags("Notification"),
"notification " + request.id + " will next fire at " + sdf.format(Date(trigger))
)
}
is ScheduleKind.Every -> {
val everyInterval = getIntervalTime(scheduleKind.interval, scheduleKind.count)
val startTime: Long = Date().time + everyInterval
alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent)
}
else -> {}
}
}
@SuppressLint("ObsoleteSdkInt", "MissingPermission")
private fun setExactIfPossible(
alarmManager: AlarmManager,
schedule: NotificationSchedule,
trigger: Long,
pendingIntent: PendingIntent
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle == true) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent)
} else {
alarmManager[AlarmManager.RTC, trigger] = pendingIntent
}
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle == true) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent)
} else {
alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent)
}
}
}
fun cancel(invoke: Invoke) {
val notificationsToCancel = Notification.getNotificationPendingList(invoke)
if (notificationsToCancel != null) {
for (id in notificationsToCancel) {
dismissVisibleNotification(id)
cancelTimerForNotification(id)
storage.deleteNotification(id.toString())
}
}
invoke.resolve()
}
private fun cancelTimerForNotification(notificationId: Int) {
val intent = Intent(context, TimedNotificationPublisher::class.java)
var flags = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = PendingIntent.FLAG_MUTABLE
}
val pi = PendingIntent.getBroadcast(context, notificationId, intent, flags)
if (pi != null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.cancel(pi)
}
}
private fun dismissVisibleNotification(notificationId: Int) {
val notificationManager = NotificationManagerCompat.from(
context
)
notificationManager.cancel(notificationId)
}
fun areNotificationsEnabled(): Boolean {
val notificationManager = NotificationManagerCompat.from(
context
)
return notificationManager.areNotificationsEnabled()
}
private fun getDefaultSoundUrl(context: Context): Uri? {
val soundId = getDefaultSound(context)
return if (soundId != AssetUtils.RESOURCE_ID_ZERO_VALUE) {
Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + soundId)
} else null
}
private fun getDefaultSound(context: Context): Int {
if (defaultSoundID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSoundID
var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE
val soundConfigResourceName = AssetUtils.getResourceBaseName(config.getString("sound"))
if (soundConfigResourceName != null) {
resId = AssetUtils.getResourceID(context, soundConfigResourceName, "raw")
}
defaultSoundID = resId
return resId
}
private fun getDefaultSmallIcon(context: Context): Int {
if (defaultSmallIconID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSmallIconID
var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE
val smallIconConfigResourceName = AssetUtils.getResourceBaseName(config.getString("smallIcon"))
if (smallIconConfigResourceName != null) {
resId = AssetUtils.getResourceID(context, smallIconConfigResourceName, "drawable")
}
if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) {
resId = android.R.drawable.ic_dialog_info
}
defaultSmallIconID = resId
return resId
}
}
class NotificationDismissReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val intExtra =
intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE)
if (intExtra == Int.MIN_VALUE) {
Logger.error(Logger.tags("Notification"), "Invalid notification dismiss operation", null)
return
}
val isRemovable =
intent.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true)
if (isRemovable) {
val notificationStorage = NotificationStorage(context)
notificationStorage.deleteNotification(intExtra.toString())
}
}
}
class TimedNotificationPublisher : BroadcastReceiver() {
/**
* Restore and present notification
*/
override fun onReceive(context: Context, intent: Intent) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(
NOTIFICATION_KEY,
android.app.Notification::class.java
)
} else {
getParcelableExtraLegacy(intent, NOTIFICATION_KEY)
}
notification?.`when` = System.currentTimeMillis()
val id = intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE)
if (id == Int.MIN_VALUE) {
Logger.error(Logger.tags("Notification"), "No valid id supplied", null)
}
val storage = NotificationStorage(context)
// TODO notify
// val notificationJson = storage.getSavedNotificationAsJSObject(id.toString())
notificationManager.notify(id, notification)
if (!rescheduleNotificationIfNeeded(context, intent, id)) {
storage.deleteNotification(id.toString())
}
}
@Suppress("DEPRECATION")
private fun getParcelableExtraLegacy(intent: Intent, string: String): android.app.Notification? {
return intent.getParcelableExtra(string)
}
@SuppressLint("MissingPermission")
private fun rescheduleNotificationIfNeeded(context: Context, intent: Intent, id: Int): Boolean {
val dateString = intent.getStringExtra(CRON_KEY)
if (dateString != null) {
val date = DateMatch.fromMatchString(dateString)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val trigger = date.nextTrigger(Date())
val clone = intent.clone() as Intent
var flags = PendingIntent.FLAG_CANCEL_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
val pendingIntent = PendingIntent.getBroadcast(context, id, clone, flags)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
alarmManager[AlarmManager.RTC, trigger] = pendingIntent
} else {
alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent)
}
val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss")
Logger.debug(
Logger.tags("Notification"),
"notification " + id + " will next fire at " + sdf.format(Date(trigger))
)
return true
}
return false
}
companion object {
var NOTIFICATION_KEY = "NotificationPublisher.notification"
var CRON_KEY = "NotificationPublisher.cron"
}
}

@ -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>

@ -46,14 +46,14 @@ impl<R: Runtime> Notification<R> {
pub fn request_permission(&self) -> crate::Result<PermissionState> {
self.0
.run_mobile_plugin::<PermissionResponse>("requestPermission", ())
.run_mobile_plugin::<PermissionResponse>("requestPermissions", ())
.map(|r| r.permission_state)
.map_err(Into::into)
}
pub fn permission_state(&self) -> crate::Result<PermissionState> {
self.0
.run_mobile_plugin::<PermissionResponse>("permissionState", ())
.run_mobile_plugin::<PermissionResponse>("checkPermissions", ())
.map(|r| r.permission_state)
.map_err(Into::into)
}

Loading…
Cancel
Save