snapshot of https://github.com/tauri-apps/plugins-workspace.git (v2, 2026-09-23; blobs > 131072B omitted)
Some checks failed
integration tests / run-integration-tests (windows-latest) (push) Has been cancelled
Test Rust / test (push) Has been cancelled
test plugins e2e (mobile) / ios (push) Has been cancelled
test plugins e2e / test (map[name:Windows os:windows-latest]) (push) Has been cancelled
test plugins e2e / test (map[name:macOS os:macos-latest]) (push) Has been cancelled
integration tests / run-integration-tests (macos-latest) (push) Has been cancelled
version or publish / version-or-publish (push) Failing after 0s
integration tests / run-integration-tests (ubuntu-22.04) (push) Failing after 0s
Lint JavaScript / eslint (push) Failing after 0s
Sync / sync-to-mirrors (push) Failing after 0s
test plugins e2e (mobile) / android (push) Failing after 0s
test plugins e2e / test (map[name:Linux os:ubuntu-latest]) (push) Failing after 0s
Test Rust / changes (push) Failing after 0s
Lint Rust / changes (push) Failing after 0s
Lint Rust / clippy (push) Has been skipped
Audit Rust / audit-rust (push) Failing after 1s
Audit JavaScript / audit-js (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-23 17:17:10 +05:00
commit 9ad64ec987
1469 changed files with 115067 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
/build
/.tauri

View File

@@ -0,0 +1,36 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "app.tauri.notification"
compileSdk = 36
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation(project(":tauri-android"))
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,2 @@
include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')

View File

@@ -0,0 +1,28 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.notification
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("app.tauri.notification", appContext.packageName)
}
}

View File

@@ -0,0 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver android:name="app.tauri.notification.TimedNotificationPublisher" />
<receiver android:name="app.tauri.notification.NotificationDismissReceiver" />
<receiver
android:name="app.tauri.notification.LocalNotificationRestoreReceiver"
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" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View File

@@ -0,0 +1,29 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
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
}
}
}

View File

@@ -0,0 +1,151 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
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 app.tauri.Logger
import app.tauri.annotation.InvokeArg
import app.tauri.plugin.Invoke
import com.fasterxml.jackson.annotation.JsonValue
enum class Importance(@JsonValue val value: Int) {
None(0),
Min(1),
Low(2),
Default(3),
High(4);
}
enum class Visibility(@JsonValue val value: Int) {
Secret(-1),
Private(0),
Public(1);
}
@InvokeArg
class Channel {
lateinit var id: String
lateinit var name: String
var description: String? = null
var sound: String? = null
var lights: Boolean? = null
var lightsColor: String? = null
var vibration: Boolean? = null
var importance: Importance? = null
var visibility: Visibility? = null
}
@InvokeArg
class DeleteChannelArgs {
lateinit var id: String
}
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 = invoke.parseArgs(Channel::class.java)
createChannel(channel)
invoke.resolve()
} else {
invoke.reject("channel not available")
}
}
private fun createChannel(channel: Channel) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
channel.id,
channel.name,
(channel.importance ?: Importance.Default).value
)
notificationChannel.description = channel.description
notificationChannel.lockscreenVisibility = (channel.visibility ?: Visibility.Private).value
notificationChannel.enableVibration(channel.vibration ?: false)
notificationChannel.enableLights(channel.lights ?: false)
val lightColor = channel.lightsColor ?: ""
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.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 args = invoke.parseArgs(DeleteChannelArgs::class.java)
notificationManager?.deleteNotificationChannel(args.id)
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 = mutableListOf<Channel>()
for (notificationChannel in notificationChannels) {
val channel = Channel()
channel.id = notificationChannel.id
channel.name = notificationChannel.name.toString()
channel.description = notificationChannel.description
channel.sound = notificationChannel.sound.toString()
channel.lights = notificationChannel.shouldShowLights()
String.format(
"#%06X",
0xFFFFFF and notificationChannel.lightColor
)
channel.vibration = notificationChannel.shouldVibrate()
channel.importance = Importance.values().firstOrNull { it.value == notificationChannel.importance }
channel.visibility = Visibility.values().firstOrNull { it.value == notificationChannel.lockscreenVisibility }
channels.add(channel)
}
invoke.resolveObject(channels)
} else {
invoke.reject("channel not available")
}
}
}

View File

@@ -0,0 +1,95 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.notification
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.tauri.annotation.InvokeArg
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import org.json.JSONException
import org.json.JSONObject
@InvokeArg
class Notification {
var id: Int = 0
var title: String? = null
var body: String? = null
var largeBody: String? = null
var summary: String? = null
var sound: String? = null
var icon: String? = null
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 sourceJson: String? = 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 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 (icon != null) {
resId = AssetUtils.getResourceID(context, icon, "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
}
companion object {
fun buildNotificationPendingList(notifications: List<Notification>): List<PendingNotification> {
val pendingNotifications = mutableListOf<PendingNotification>()
for (notification in notifications) {
val pendingNotification = PendingNotification(notification.id, notification.title, notification.body, notification.schedule, notification.extra)
pendingNotifications.add(pendingNotification)
}
return pendingNotifications
}
}
}
class PendingNotification(val id: Int, val title: String?, val body: String?, val schedule: NotificationSchedule?, val extra: JSObject?)

View File

@@ -0,0 +1,52 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
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
}
}
}

View File

@@ -0,0 +1,286 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.notification
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.webkit.WebView
import app.tauri.PermissionState
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
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
const val LOCAL_NOTIFICATIONS = "permissionState"
@InvokeArg
class PluginConfig {
var icon: String? = null
var sound: String? = null
var iconColor: String? = null
}
@InvokeArg
class BatchArgs {
lateinit var notifications: List<Notification>
}
@InvokeArg
class CancelArgs {
// `cancelAll()` sends no list: cancel every pending notification
var notifications: List<Int>? = null
}
@InvokeArg
class NotificationAction {
lateinit var id: String
var title: String? = null
var input: Boolean? = null
}
@InvokeArg
class ActionType {
lateinit var id: String
lateinit var actions: List<NotificationAction>
}
@InvokeArg
class RegisterActionTypesArgs {
lateinit var types: List<ActionType>
}
@InvokeArg
class ActiveNotification {
var id: Int = 0
var tag: String? = null
}
@InvokeArg
class RemoveActiveArgs {
var notifications: List<ActiveNotification> = listOf()
}
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState")
]
)
class NotificationPlugin(private val activity: Activity): Plugin(activity) {
private var webView: WebView? = null
private lateinit var manager: TauriNotificationManager
private lateinit var notificationManager: NotificationManager
private lateinit var notificationStorage: NotificationStorage
private var channelManager = ChannelManager(activity)
companion object {
var instance: NotificationPlugin? = null
fun triggerNotification(notification: Notification) {
instance?.triggerObject("notification", notification)
}
}
override fun load(webView: WebView) {
instance = this
super.load(webView)
this.webView = webView
notificationStorage = NotificationStorage(activity, jsonMapper())
val manager = TauriNotificationManager(
notificationStorage,
activity,
activity,
getConfig(PluginConfig::class.java)
)
manager.createNotificationChannel()
this.manager = manager
notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val intent = activity.intent
intent?.let {
onIntent(it)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
onIntent(intent)
}
fun onIntent(intent: 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 = invoke.parseArgs(Notification::class.java)
val id = manager.schedule(notification)
invoke.resolveObject(id)
}
@Command
fun batch(invoke: Invoke) {
val args = invoke.parseArgs(BatchArgs::class.java)
val ids = manager.schedule(args.notifications)
notificationStorage.appendNotifications(args.notifications)
invoke.resolveObject(ids)
}
@Command
fun cancel(invoke: Invoke) {
val args = invoke.parseArgs(CancelArgs::class.java)
manager.cancel(
args.notifications ?: notificationStorage.getSavedNotificationIds().mapNotNull { it.toIntOrNull() }
)
invoke.resolve()
}
@Command
fun removeActive(invoke: Invoke) {
val args = invoke.parseArgs(RemoveActiveArgs::class.java)
if (args.notifications.isEmpty()) {
notificationManager.cancelAll()
invoke.resolve()
} else {
for (notification in args.notifications) {
if (notification.tag == null) {
notificationManager.cancel(notification.id)
} else {
notificationManager.cancel(notification.tag, notification.id)
}
}
invoke.resolve()
}
}
@Command
fun getPending(invoke: Invoke) {
val notifications= notificationStorage.getSavedNotifications()
val result = Notification.buildNotificationPendingList(notifications)
invoke.resolveObject(result)
}
@Command
fun registerActionTypes(invoke: Invoke) {
val args = invoke.parseArgs(RegisterActionTypesArgs::class.java)
notificationStorage.writeActionGroup(args.types)
invoke.resolve()
}
@SuppressLint("ObsoleteSdkInt")
@Command
fun getActive(invoke: Invoke) {
// plain lists and maps: until tauri serializes org.json values natively,
// `resolveObject` turns a `JSArray` into `{ "values": [...] }`
val notifications = mutableListOf<Map<String, Any?>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activeNotifications = notificationManager.activeNotifications
for (activeNotification in activeNotifications) {
val jsNotification = mutableMapOf<String, Any?>(
"id" to activeNotification.id,
"tag" to activeNotification.tag
)
val notification = activeNotification.notification
if (notification != null) {
jsNotification["title"] = notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE)?.toString()
jsNotification["body"] = notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)?.toString()
jsNotification["group"] = notification.group
jsNotification["groupSummary"] =
0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY
val extras = mutableMapOf<String, String?>()
for (key in notification.extras.keySet()) {
extras[key] = notification.extras.getString(key)
}
jsNotification["data"] = extras
}
notifications.add(jsNotification)
}
}
invoke.resolveObject(notifications)
}
@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("permissionState", getPermissionState())
invoke.resolve(permissionsResultJSON)
}
private fun getPermissionState(): String {
return if (manager.areNotificationsEnabled()) {
"granted"
} else {
"denied"
}
}
}

View File

@@ -0,0 +1,369 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.notification
import android.annotation.SuppressLint
import android.text.format.DateUtils
import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import java.io.IOException
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 {
@JsonProperty("year")
Year,
@JsonProperty("month")
Month,
@JsonProperty("twoWeeks")
TwoWeeks,
@JsonProperty("week")
Week,
@JsonProperty("day")
Day,
@JsonProperty("hour")
Hour,
@JsonProperty("minute")
Minute,
@JsonProperty("second")
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
}
}
@JsonDeserialize(using = NotificationScheduleDeserializer::class)
@JsonSerialize(using = NotificationScheduleSerializer::class)
sealed class NotificationSchedule {
// At specific moment of time (with repeating option)
@JsonDeserialize
class At: NotificationSchedule() {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = JS_DATE_FORMAT)
lateinit var date: Date
var repeating: Boolean = false
var allowWhileIdle: Boolean = false
}
@JsonDeserialize
class Interval: NotificationSchedule() {
lateinit var interval: DateMatch
var allowWhileIdle: Boolean = false
}
@JsonDeserialize
class Every: NotificationSchedule() {
lateinit var interval: NotificationInterval
var count: Int = 0
var allowWhileIdle: Boolean = false
}
fun isRemovable(): Boolean {
return when (this) {
is At -> !repeating
else -> false
}
}
fun allowWhileIdle(): Boolean {
return when (this) {
is At -> allowWhileIdle
is Interval -> allowWhileIdle
is Every -> allowWhileIdle
else -> false
}
}
}
internal class NotificationScheduleSerializer @JvmOverloads constructor(t: Class<NotificationSchedule>? = null) :
StdSerializer<NotificationSchedule>(t) {
@SuppressLint("SimpleDateFormat")
@Throws(IOException::class, JsonProcessingException::class)
override fun serialize(
value: NotificationSchedule, jgen: JsonGenerator, provider: SerializerProvider
) {
jgen.writeStartObject()
when (value) {
is NotificationSchedule.At -> {
jgen.writeObjectFieldStart("at")
val sdf = SimpleDateFormat(JS_DATE_FORMAT)
sdf.timeZone = TimeZone.getTimeZone("UTC")
jgen.writeStringField("date", sdf.format(value.date))
jgen.writeBooleanField("repeating", value.repeating)
jgen.writeEndObject()
}
is NotificationSchedule.Interval -> {
jgen.writeObjectFieldStart("interval")
jgen.writeObjectField("interval", value.interval)
jgen.writeEndObject()
}
is NotificationSchedule.Every -> {
jgen.writeObjectFieldStart("every")
jgen.writeObjectField("interval", value.interval)
jgen.writeNumberField("count", value.count)
jgen.writeEndObject()
}
}
jgen.writeEndObject()
}
}
internal class NotificationScheduleDeserializer: JsonDeserializer<NotificationSchedule>() {
override fun deserialize(
jsonParser: JsonParser,
deserializationContext: DeserializationContext
): NotificationSchedule {
val node: JsonNode = jsonParser.codec.readTree(jsonParser)
node.get("at")?.let {
return jsonParser.codec.treeToValue(it, NotificationSchedule.At::class.java)
}
node.get("interval")?.let {
return jsonParser.codec.treeToValue(it, NotificationSchedule.Interval::class.java)
}
node.get("every")?.let {
return jsonParser.codec.treeToValue(it, NotificationSchedule.Every::class.java)
}
throw Error("unknown schedule kind $node")
}
}
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
}
}
}
}

View File

@@ -0,0 +1,113 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.notification
import android.content.Context
import android.content.SharedPreferences
import com.fasterxml.jackson.databind.ObjectMapper
import org.json.JSONException
import java.lang.Exception
// 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, private val jsonMapper: ObjectMapper) {
fun appendNotifications(localNotifications: List<Notification>) {
val storage = getStorage(NOTIFICATION_STORE_ID)
val editor = storage.edit()
for (request in localNotifications) {
if (request.schedule != null) {
val key: String = request.id.toString()
editor.putString(key, request.sourceJson.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?
try {
val notification = jsonMapper.readValue(notificationString, Notification::class.java)
notifications.add(notification)
} catch (_: Exception) { }
}
return notifications
}
return ArrayList()
}
fun getSavedNotification(key: String): Notification? {
val storage = getStorage(NOTIFICATION_STORE_ID)
val notificationString = try {
storage.getString(key, null)
} catch (ex: ClassCastException) {
return null
} ?: return null
return try {
jsonMapper.readValue(notificationString, Notification::class.java)
} catch (ex: JSONException) {
null
}
}
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(actions: List<ActionType>) {
for (type in actions) {
val editor = getStorage(ACTION_TYPES_ID + type.id).edit()
editor.clear()
editor.putInt("count", type.actions.size)
for (i in 0 until type.actions.size) {
val action = type.actions[i]
editor.putString("id$i", action.id)
editor.putString("title$i", action.title)
editor.putBoolean("input$i", action.input ?: false)
}
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)
val action = NotificationAction()
action.id = id ?: ""
action.title = title
action.input = input
actions[i] = action
}
return actions
}
}

View File

@@ -0,0 +1,581 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
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 com.fasterxml.jackson.databind.ObjectMapper
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: PluginConfig?
) {
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?.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.schedule != null) {
triggerScheduledNotification(buildNotification, notification)
} else {
notificationManager.notify(notification.id, buildNotification)
try {
NotificationPlugin.triggerNotification(notification)
} 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 == true) {
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.sourceJson)
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 (schedule) {
is NotificationSchedule.At -> {
if (schedule.date.time < Date().time) {
Logger.error(Logger.tags("Notification"), "Scheduled time must be *after* current time", null)
return
}
if (schedule.repeating) {
val interval: Long = schedule.date.time - Date().time
alarmManager.setRepeating(AlarmManager.RTC, schedule.date.time, interval, pendingIntent)
} else {
setExactIfPossible(alarmManager, schedule, schedule.date.time, pendingIntent)
}
}
is NotificationSchedule.Interval -> {
val trigger = schedule.interval.nextTrigger(Date())
notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, schedule.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 NotificationSchedule.Every -> {
val everyInterval = getIntervalTime(schedule.interval, schedule.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.allowWhileIdle()) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent)
} else {
alarmManager[AlarmManager.RTC, trigger] = pendingIntent
}
} else {
if (SDK_INT >= Build.VERSION_CODES.M && schedule.allowWhileIdle()) {
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?.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?.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, ObjectMapper())
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 (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, ObjectMapper())
val savedNotification = storage.getSavedNotification(id.toString())
if (savedNotification != null) {
NotificationPlugin.triggerNotification(savedNotification)
}
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, ObjectMapper())
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 is NotificationSchedule.At) {
val at: Date = schedule.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.date = Date(newDateTime)
updatedNotifications.add(notification)
}
}
notifications.add(notification)
}
if (updatedNotifications.size > 0) {
storage.appendNotifications(updatedNotifications)
}
var config: PluginConfig? = null
try {
config = PluginManager.loadConfig(context, "notification", PluginConfig::class.java)
} catch (ex: Exception) {
ex.printStackTrace()
}
val notificationManager = TauriNotificationManager(storage, null, context, config)
notificationManager.schedule(notifications)
}
}

View File

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

View File

@@ -0,0 +1,21 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.notification
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}