You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tauri-plugins-workspace/plugins/notification/android/src/main/java/TauriNotificationManager.kt

570 lines
21 KiB

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)
}
}