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
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:
2
plugins/notification/android/.gitignore
vendored
Normal file
2
plugins/notification/android/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/.tauri
|
||||
36
plugins/notification/android/build.gradle.kts
Normal file
36
plugins/notification/android/build.gradle.kts
Normal 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"))
|
||||
}
|
||||
21
plugins/notification/android/consumer-rules.pro
Normal file
21
plugins/notification/android/consumer-rules.pro
Normal 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
|
||||
2
plugins/notification/android/settings.gradle
Normal file
2
plugins/notification/android/settings.gradle
Normal file
@@ -0,0 +1,2 @@
|
||||
include ':tauri-android'
|
||||
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
20
plugins/notification/android/src/main/AndroidManifest.xml
Normal file
20
plugins/notification/android/src/main/AndroidManifest.xml
Normal 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>
|
||||
29
plugins/notification/android/src/main/java/AssetUtils.kt
Normal file
29
plugins/notification/android/src/main/java/AssetUtils.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
151
plugins/notification/android/src/main/java/ChannelManager.kt
Normal file
151
plugins/notification/android/src/main/java/ChannelManager.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
95
plugins/notification/android/src/main/java/Notification.kt
Normal file
95
plugins/notification/android/src/main/java/Notification.kt
Normal 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?)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
286
plugins/notification/android/src/main/java/NotificationPlugin.kt
Normal file
286
plugins/notification/android/src/main/java/NotificationPlugin.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
|
||||
<path
|
||||
android:width="1dp"
|
||||
android:color="@android:color/transparent" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user