diff --git a/.changes/change-pr-notification-actions-followup.md b/.changes/change-pr-notification-actions-followup.md new file mode 100644 index 0000000000..f960d3b07f --- /dev/null +++ b/.changes/change-pr-notification-actions-followup.md @@ -0,0 +1,8 @@ +--- +"notification": minor +"notification-js": minor +--- + +Improve notification actions across Rust and Android by adding Rust support for defining/registering action types and actions, and by fixing Android action-group storage consistency. + +Extend reliability and API consistency by adding listener-ready queue/replay handling for cold-start action events and making `onAction` payloads consistent for both immediate delivery and queued replay on Android. diff --git a/plugins/notification/android/src/androidTest/java/NotificationStorageActionsTest.kt b/plugins/notification/android/src/androidTest/java/NotificationStorageActionsTest.kt new file mode 100644 index 0000000000..db2ff54ba3 --- /dev/null +++ b/plugins/notification/android/src/androidTest/java/NotificationStorageActionsTest.kt @@ -0,0 +1,48 @@ +// 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.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NotificationStorageActionsTest { + @Test + fun actionGroup_roundTrip() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val storage = NotificationStorage(context, ObjectMapper()) + + val reply = NotificationAction().apply { + id = "reply" + title = "Reply" + input = true + } + val markRead = NotificationAction().apply { + id = "mark-read" + title = "Mark Read" + input = false + } + + val type = ActionType().apply { + id = "chat-actions" + actions = listOf(reply, markRead) + } + + storage.writeActionGroup(listOf(type)) + val restored = storage.getActionGroup("chat-actions") + + assertEquals(2, restored.size) + assertEquals("reply", restored[0]!!.id) + assertEquals("Reply", restored[0]!!.title) + assertTrue(restored[0]!!.input == true) + assertEquals("mark-read", restored[1]!!.id) + assertEquals("Mark Read", restored[1]!!.title) + } +} diff --git a/plugins/notification/android/src/main/java/ChannelManager.kt b/plugins/notification/android/src/main/java/ChannelManager.kt index 206f340ab4..b9be7d4250 100644 --- a/plugins/notification/android/src/main/java/ChannelManager.kt +++ b/plugins/notification/android/src/main/java/ChannelManager.kt @@ -84,7 +84,7 @@ class ChannelManager(private var context: Context) { notificationChannel.lightColor = Color.parseColor(lightColor) } catch (ex: IllegalArgumentException) { Logger.error( - Logger.tags("NotificationChannel"), + NOTIFICATION_CHANNEL_LOG_TAGS, "Invalid color provided for light color.", null ) diff --git a/plugins/notification/android/src/main/java/Logging.kt b/plugins/notification/android/src/main/java/Logging.kt new file mode 100644 index 0000000000..c5b2708ee1 --- /dev/null +++ b/plugins/notification/android/src/main/java/Logging.kt @@ -0,0 +1,10 @@ +// 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.Logger + +val NOTIFICATION_LOG_TAGS = Logger.tags("Notification") +val NOTIFICATION_CHANNEL_LOG_TAGS = Logger.tags("NotificationChannel") diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index 3ead31527b..0a47a290cc 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -12,6 +12,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.webkit.WebView +import app.tauri.Logger import app.tauri.PermissionState import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg @@ -22,8 +23,13 @@ import app.tauri.plugin.Invoke import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin +import org.json.JSONArray +import org.json.JSONObject const val LOCAL_NOTIFICATIONS = "permissionState" +private const val PREFS_NAME = "tauri_notification_plugin" +private const val PREF_KEY_PENDING_ACTION_EVENTS = "pending_action_events" +private const val PENDING_ACTION_EVENT_TTL_MS = 24 * 60 * 60 * 1000L @InvokeArg class PluginConfig { @@ -82,6 +88,125 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private lateinit var notificationManager: NotificationManager private lateinit var notificationStorage: NotificationStorage private var channelManager = ChannelManager(activity) + private data class PendingActionEvent( + val key: String, + val payload: JSObject, + val timestampMs: Long + ) + + private val pendingActionEvents = mutableListOf() + private val pendingActionEventKeys = mutableSetOf() + private var isActionListenerReady = false + + private fun nowMs(): Long = System.currentTimeMillis() + + private fun isEventExpired(timestampMs: Long): Boolean { + return nowMs() - timestampMs > PENDING_ACTION_EVENT_TTL_MS + } + + private fun buildActionEventKey(payload: JSObject): String { + val notification = payload.optJSONObject("notification") + val notificationId = notification?.opt("id") ?: payload.opt("id") + val actionId = payload.optString("actionId") + val inputValue = payload.optString("inputValue") + + if (notificationId != null && actionId.isNotEmpty()) { + return "$notificationId|$actionId|$inputValue" + } + + // Fallback for malformed payloads so we can still dedupe identical events. + return "payload:${payload.toString()}" + } + + private fun rebuildPendingActionEventKeysLocked() { + pendingActionEventKeys.clear() + for (event in pendingActionEvents) { + pendingActionEventKeys.add(event.key) + } + } + + private fun persistPendingActionEventsLocked() { + val iterator = pendingActionEvents.iterator() + var droppedExpired = 0 + while (iterator.hasNext()) { + val event = iterator.next() + if (isEventExpired(event.timestampMs)) { + iterator.remove() + droppedExpired += 1 + } + } + if (droppedExpired > 0) { + rebuildPendingActionEventKeysLocked() + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Dropped expired pending actionPerformed events=$droppedExpired" + ) + } + + val events = JSONArray() + for (event in pendingActionEvents) { + try { + val wrappedEvent = JSONObject() + wrappedEvent.put("key", event.key) + wrappedEvent.put("timestampMs", event.timestampMs) + wrappedEvent.put("payload", JSONObject(event.payload.toString())) + events.put(wrappedEvent) + } catch (_: Throwable) { + events.put(event.payload) + } + } + activity + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(PREF_KEY_PENDING_ACTION_EVENTS, events.toString()) + .apply() + } + + private fun restorePendingActionEventsLocked() { + val prefs = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val serializedEvents = prefs.getString(PREF_KEY_PENDING_ACTION_EVENTS, null) ?: return + + try { + val events = JSONArray(serializedEvents) + for (index in 0 until events.length()) { + val event = events.optJSONObject(index) ?: continue + val wrappedPayload = event.optJSONObject("payload") + val payloadObject = wrappedPayload ?: event + val payload = JSObject(payloadObject.toString()) + + val timestampMs = + if (wrappedPayload != null) event.optLong("timestampMs", nowMs()) else nowMs() + if (isEventExpired(timestampMs)) { + continue + } + + val key = event.optString("key").ifEmpty { buildActionEventKey(payload) } + if (pendingActionEventKeys.contains(key)) { + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Skipping duplicate restored actionPerformed event key=$key" + ) + continue + } + + pendingActionEvents.add(PendingActionEvent(key, payload, timestampMs)) + pendingActionEventKeys.add(key) + } + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Restored pending actionPerformed events=${pendingActionEvents.size}" + ) + } catch (error: Throwable) { + Logger.error( + NOTIFICATION_LOG_TAGS, + "Failed to restore pending actionPerformed events", + error + ) + pendingActionEvents.clear() + pendingActionEventKeys.clear() + persistPendingActionEventsLocked() + } + } companion object { var instance: NotificationPlugin? = null @@ -96,6 +221,13 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { super.load(webView) this.webView = webView + Logger.debug(NOTIFICATION_LOG_TAGS, "Plugin load started") + synchronized(this) { + pendingActionEvents.clear() + pendingActionEventKeys.clear() + isActionListenerReady = false + restorePendingActionEventsLocked() + } notificationStorage = NotificationStorage(activity, jsonMapper()) val manager = TauriNotificationManager( @@ -109,6 +241,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { this.manager = manager notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Logger.debug(NOTIFICATION_LOG_TAGS, "Plugin load complete; awaiting notification intents") val intent = activity.intent intent?.let { @@ -118,22 +251,58 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + Logger.debug(NOTIFICATION_LOG_TAGS, "onNewIntent received action=${intent.action}") onIntent(intent) } fun onIntent(intent: Intent) { if (Intent.ACTION_MAIN != intent.action) { + Logger.debug(NOTIFICATION_LOG_TAGS, "Ignoring intent action=${intent.action}") return } + Logger.debug(NOTIFICATION_LOG_TAGS, "Processing ACTION_MAIN intent for notification action") val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) if (dataJson != null) { - trigger("actionPerformed", dataJson) + dispatchActionPerformed(dataJson) + } else { + Logger.debug(NOTIFICATION_LOG_TAGS, "No action payload extracted from intent") } } + private fun dispatchActionPerformed(payload: JSObject) { + synchronized(this) { + if (!isActionListenerReady) { + val key = buildActionEventKey(payload) + // `load()` restores persisted pending events before processing the current activity intent. + // Without this key check, the same action can be enqueued twice across reload boundaries. + if (pendingActionEventKeys.contains(key)) { + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Skipping duplicate queued actionPerformed event key=$key" + ) + return + } + pendingActionEvents.add(PendingActionEvent(key, payload, nowMs())) + pendingActionEventKeys.add(key) + persistPendingActionEventsLocked() + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Queued actionPerformed event; listener not ready (pending=${pendingActionEvents.size})" + ) + return + } + } + Logger.debug(NOTIFICATION_LOG_TAGS, "Dispatching actionPerformed event immediately") + trigger("actionPerformed", payload) + } + @Command fun show(invoke: Invoke) { val notification = invoke.parseArgs(Notification::class.java) + Logger.debug( + NOTIFICATION_LOG_TAGS, + "show called id=${notification.id} title=${notification.title} actionTypeId=${notification.actionTypeId} hasSchedule=${notification.schedule != null}" + ) val id = manager.schedule(notification) invoke.resolveObject(id) @@ -142,9 +311,14 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun batch(invoke: Invoke) { val args = invoke.parseArgs(BatchArgs::class.java) + Logger.debug( + NOTIFICATION_LOG_TAGS, + "batch called notifications=${args.notifications.size}" + ) val ids = manager.schedule(args.notifications) notificationStorage.appendNotifications(args.notifications) + Logger.debug(NOTIFICATION_LOG_TAGS, "batch scheduled ids=$ids") invoke.resolveObject(ids) } @@ -152,6 +326,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun cancel(invoke: Invoke) { val args = invoke.parseArgs(CancelArgs::class.java) + Logger.debug(NOTIFICATION_LOG_TAGS, "cancel called notifications=${args.notifications}") manager.cancel(args.notifications) invoke.resolve() } @@ -159,6 +334,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun removeActive(invoke: Invoke) { val args = invoke.parseArgs(RemoveActiveArgs::class.java) + Logger.debug(NOTIFICATION_LOG_TAGS, "removeActive called notifications=${args.notifications.size}") if (args.notifications.isEmpty()) { notificationManager.cancelAll() @@ -178,6 +354,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun getPending(invoke: Invoke) { val notifications= notificationStorage.getSavedNotifications() + Logger.debug(NOTIFICATION_LOG_TAGS, "getPending returning count=${notifications.size}") val result = Notification.buildNotificationPendingList(notifications) invoke.resolveObject(result) } @@ -185,10 +362,35 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun registerActionTypes(invoke: Invoke) { val args = invoke.parseArgs(RegisterActionTypesArgs::class.java) + Logger.debug( + NOTIFICATION_LOG_TAGS, + "registerActionTypes called types=${args.types.size}" + ) notificationStorage.writeActionGroup(args.types) invoke.resolve() } + @Command + fun registerActionListenerReady(invoke: Invoke) { + val pending = JSArray() + var drainedCount = 0 + synchronized(this) { + isActionListenerReady = true + for (event in pendingActionEvents) { + pending.put(event.payload) + } + drainedCount = pendingActionEvents.size + pendingActionEvents.clear() + pendingActionEventKeys.clear() + persistPendingActionEventsLocked() + } + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Action listener marked ready; drained pending actionPerformed events=$drainedCount" + ) + invoke.resolveObject(pending) + } + @SuppressLint("ObsoleteSdkInt") @Command fun getActive(invoke: Invoke) { diff --git a/plugins/notification/android/src/main/java/NotificationStorage.kt b/plugins/notification/android/src/main/java/NotificationStorage.kt index bceb985dbd..ed2d5b0021 100644 --- a/plugins/notification/android/src/main/java/NotificationStorage.kt +++ b/plugins/notification/android/src/main/java/NotificationStorage.kt @@ -7,8 +7,8 @@ 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 +import org.json.JSONException // Key for private preferences private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE" @@ -16,98 +16,99 @@ private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE" private const val ACTION_TYPES_ID = "ACTION_TYPE_STORE" class NotificationStorage(private val context: Context, private val jsonMapper: ObjectMapper) { - fun appendNotifications(localNotifications: List) { - 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()) - } + fun appendNotifications(localNotifications: List) { + 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() } - editor.apply() - } - fun getSavedNotificationIds(): List { - val storage = getStorage(NOTIFICATION_STORE_ID) - val all = storage.all - return if (all != null) { - ArrayList(all.keys) - } else ArrayList() - } + fun getSavedNotificationIds(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + return if (all != null) { + ArrayList(all.keys) + } else ArrayList() + } - fun getSavedNotifications(): List { - val storage = getStorage(NOTIFICATION_STORE_ID) - val all = storage.all - if (all != null) { - val notifications = ArrayList() - 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 + fun getSavedNotifications(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + if (all != null) { + val notifications = ArrayList() + 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() } - 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 + 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 + 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 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) { - for (type in actions) { - val i = type.id - val editor = getStorage(ACTION_TYPES_ID + type.id).edit() - editor.clear() - editor.putInt("count", type.actions.size) - for (action in type.actions) { - editor.putString("id$i", action.id) - editor.putString("title$i", action.title) - editor.putBoolean("input$i", action.input ?: false) - } - editor.apply() + fun writeActionGroup(actions: List) { + for (type in actions) { + val editor = getStorage(ACTION_TYPES_ID + type.id).edit() + editor.clear() + editor.putInt("count", type.actions.size) + // Store actions by numeric index to match getActionGroup() retrieval keys. + for ((index, action) in type.actions.withIndex()) { + editor.putString("id$index", action.id) + editor.putString("title$index", action.title) + editor.putBoolean("input$index", action.input ?: false) + } + editor.apply() + } } - } - fun getActionGroup(forId: String): Array { - val storage = getStorage(ACTION_TYPES_ID + forId) - val count = storage.getInt("count", 0) - val actions: Array = 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) + fun getActionGroup(forId: String): Array { + val storage = getStorage(ACTION_TYPES_ID + forId) + val count = storage.getInt("count", 0) + val actions: Array = 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 + val action = NotificationAction() + action.id = id ?: "" + action.title = title + action.input = input + actions[i] = action + } + return actions } - return actions - } -} \ No newline at end of file +} diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt index a8912739bb..bda9aaf382 100644 --- a/plugins/notification/android/src/main/java/TauriNotificationManager.kt +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -54,23 +54,26 @@ class TauriNotificationManager( data: Intent, notificationStorage: NotificationStorage ): JSObject? { - Logger.debug(Logger.tags("Notification"), "Notification received: " + data.dataString) + Logger.debug(NOTIFICATION_LOG_TAGS, "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") + Logger.debug(NOTIFICATION_LOG_TAGS, "Activity started without notification attached") return null } + val savedNotification = notificationStorage.getSavedNotification(notificationId.toString()) val isRemovable = data.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) - if (isRemovable) { - notificationStorage.deleteNotification(notificationId.toString()) - } val dataJson = JSObject() + dataJson.put("id", notificationId) val results = RemoteInput.getResultsFromIntent(data) val input = results?.getCharSequence(REMOTE_INPUT_KEY) dataJson.put("inputValue", input?.toString()) val menuAction = data.getStringExtra(ACTION_INTENT_KEY) + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Action performed id=$notificationId actionId=$menuAction removable=$isRemovable" + ) dismissVisibleNotification(notificationId) dataJson.put("actionId", menuAction) var request: JSONObject? = null @@ -82,7 +85,23 @@ class TauriNotificationManager( } } catch (_: JSONException) { } + if (request == null) { + request = JSObject() + request.put("id", notificationId) + if (savedNotification?.actionTypeId != null) { + request.put("actionTypeId", savedNotification.actionTypeId) + } + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Recovered missing notification metadata from storage id=$notificationId actionTypeId=${savedNotification?.actionTypeId}" + ) + } else if (!request.has("id")) { + request.put("id", notificationId) + } dataJson.put("notification", request) + if (isRemovable) { + notificationStorage.deleteNotification(notificationId.toString()) + } return dataJson } @@ -125,17 +144,23 @@ class TauriNotificationManager( fun schedule(notification: Notification): Int { val notificationManager = NotificationManagerCompat.from(context) + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Scheduling notification id=${notification.id} actionTypeId=${notification.actionTypeId} hasSchedule=${notification.schedule != null}" + ) return trigger(notificationManager, notification) } fun schedule(notifications: List): List { val ids = mutableListOf() val notificationManager = NotificationManagerCompat.from(context) + Logger.debug(NOTIFICATION_LOG_TAGS, "Scheduling batch notifications count=${notifications.size}") for (notification in notifications) { val id = trigger(notificationManager, notification) ids.add(id) } + Logger.debug(NOTIFICATION_LOG_TAGS, "Scheduled batch notification ids=$ids") return ids } @@ -299,9 +324,24 @@ class TauriNotificationManager( 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 notificationJson = JSObject() + notificationJson.put("id", notification.id) + notification.actionTypeId?.let { actionTypeId -> + notificationJson.put("actionTypeId", actionTypeId) + } + notification.title?.let { title -> + notificationJson.put("title", title) + } + notification.body?.let { body -> + notificationJson.put("body", body) + } + intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, notificationJson.toString()) val schedule = notification.schedule intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()) + Logger.debug( + NOTIFICATION_LOG_TAGS, + "Built action intent notificationId=${notification.id} action=$action removable=${schedule == null || schedule.isRemovable()}" + ) return intent } @@ -330,7 +370,7 @@ class TauriNotificationManager( when (schedule) { is NotificationSchedule.At -> { if (schedule.date.time < Date().time) { - Logger.error(Logger.tags("Notification"), "Scheduled time must be *after* current time", null) + Logger.error(NOTIFICATION_LOG_TAGS, "Scheduled time must be *after* current time", null) return } if (schedule.repeating) { @@ -348,7 +388,7 @@ class TauriNotificationManager( setExactIfPossible(alarmManager, schedule, trigger, pendingIntent) val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "notification " + request.id + " will next fire at " + sdf.format(Date(trigger)) ) } @@ -454,7 +494,7 @@ class NotificationDismissReceiver : BroadcastReceiver() { 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) + Logger.error(NOTIFICATION_LOG_TAGS, "Invalid notification dismiss operation", null) return } val isRemovable = @@ -484,7 +524,7 @@ class TimedNotificationPublisher : BroadcastReceiver() { 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) + Logger.error(NOTIFICATION_LOG_TAGS, "No valid id supplied", null) } val storage = NotificationStorage(context, ObjectMapper()) @@ -524,7 +564,7 @@ class TimedNotificationPublisher : BroadcastReceiver() { } val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "notification " + id + " will next fire at " + sdf.format(Date(trigger)) ) return true diff --git a/plugins/notification/api-iife.js b/plugins/notification/api-iife.js index d8f85c6cf3..74cfe6bd50 100644 --- a/plugins/notification/api-iife.js +++ b/plugins/notification/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(i){"use strict";function t(i,t,n,e){if("function"==typeof t?i!==t||!e:!t.has(i))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?e:"a"===n?e.call(i):e?e.value:t.get(i)}function n(i,t,n,e,a){if("function"==typeof t||!t.has(i))throw new TypeError("Cannot write private member to an object whose class did not declare it");return t.set(i,n),n}var e,a,o,r;"function"==typeof SuppressedError&&SuppressedError;const s="__TAURI_TO_IPC_KEY__";class c{constructor(i){e.set(this,void 0),a.set(this,0),o.set(this,[]),r.set(this,void 0),n(this,e,i||(()=>{})),this.id=function(i,t=!1){return window.__TAURI_INTERNALS__.transformCallback(i,t)}((i=>{const s=i.index;if("end"in i)return void(s==t(this,a,"f")?this.cleanupCallback():n(this,r,s));const c=i.message;if(s==t(this,a,"f")){for(t(this,e,"f").call(this,c),n(this,a,t(this,a,"f")+1);t(this,a,"f")in t(this,o,"f");){const i=t(this,o,"f")[t(this,a,"f")];t(this,e,"f").call(this,i),delete t(this,o,"f")[t(this,a,"f")],n(this,a,t(this,a,"f")+1)}t(this,a,"f")===t(this,r,"f")&&this.cleanupCallback()}else t(this,o,"f")[s]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(i){n(this,e,i)}get onmessage(){return t(this,e,"f")}[(e=new WeakMap,a=new WeakMap,o=new WeakMap,r=new WeakMap,s)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[s]()}}class l{constructor(i,t,n){this.plugin=i,this.event=t,this.channelId=n}async unregister(){return f(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function u(i,t,n){const e=new c(n);try{return await f(`plugin:${i}|register_listener`,{event:t,handler:e}),new l(i,t,e.id)}catch{return await f(`plugin:${i}|registerListener`,{event:t,handler:e}),new l(i,t,e.id)}}async function f(i,t={},n){return window.__TAURI_INTERNALS__.invoke(i,t,n)}var h,d,w;i.ScheduleEvery=void 0,(h=i.ScheduleEvery||(i.ScheduleEvery={})).Year="year",h.Month="month",h.TwoWeeks="twoWeeks",h.Week="week",h.Day="day",h.Hour="hour",h.Minute="minute",h.Second="second";return i.Importance=void 0,(d=i.Importance||(i.Importance={}))[d.None=0]="None",d[d.Min=1]="Min",d[d.Low=2]="Low",d[d.Default=3]="Default",d[d.High=4]="High",i.Visibility=void 0,(w=i.Visibility||(i.Visibility={}))[w.Secret=-1]="Secret",w[w.Private=0]="Private",w[w.Public=1]="Public",i.Schedule=class{static at(i,t=!1,n=!1){return{at:{date:i,repeating:t,allowWhileIdle:n},interval:void 0,every:void 0}}static interval(i,t=!1){return{at:void 0,interval:{interval:i,allowWhileIdle:t},every:void 0}}static every(i,t,n=!1){return{at:void 0,interval:void 0,every:{interval:i,count:t,allowWhileIdle:n}}}},i.active=async function(){return await f("plugin:notification|get_active")},i.cancel=async function(i){await f("plugin:notification|cancel",{notifications:i})},i.cancelAll=async function(){await f("plugin:notification|cancel")},i.channels=async function(){return await f("plugin:notification|listChannels")},i.createChannel=async function(i){await f("plugin:notification|create_channel",{...i})},i.isPermissionGranted=async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):await f("plugin:notification|is_permission_granted")},i.onAction=async function(i){return await u("notification","actionPerformed",i)},i.onNotificationReceived=async function(i){return await u("notification","notification",i)},i.pending=async function(){return await f("plugin:notification|get_pending")},i.registerActionTypes=async function(i){await f("plugin:notification|register_action_types",{types:i})},i.removeActive=async function(i){await f("plugin:notification|remove_active",{notifications:i})},i.removeAllActive=async function(){await f("plugin:notification|remove_active")},i.removeChannel=async function(i){await f("plugin:notification|delete_channel",{id:i})},i.requestPermission=async function(){return await window.Notification.requestPermission()},i.sendNotification=function(i){"string"==typeof i?new window.Notification(i):new window.Notification(i.title,i)},i}({});Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(t){"use strict";function n(t,n,i,e){if("function"==typeof n?t!==n||!e:!n.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===i?e:"a"===i?e.call(t):e?e.value:n.get(t)}function i(t,n,i,e,o){if("function"==typeof n||!n.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return n.set(t,i),i}var e,o,r,a;"function"==typeof SuppressedError&&SuppressedError;const s="__TAURI_TO_IPC_KEY__";class c{constructor(t){e.set(this,void 0),o.set(this,0),r.set(this,[]),a.set(this,void 0),i(this,e,t||(()=>{})),this.id=function(t,n=!1){return window.__TAURI_INTERNALS__.transformCallback(t,n)}((t=>{const s=t.index;if("end"in t)return void(s==n(this,o,"f")?this.cleanupCallback():i(this,a,s));const c=t.message;if(s==n(this,o,"f")){for(n(this,e,"f").call(this,c),i(this,o,n(this,o,"f")+1);n(this,o,"f")in n(this,r,"f");){const t=n(this,r,"f")[n(this,o,"f")];n(this,e,"f").call(this,t),delete n(this,r,"f")[n(this,o,"f")],i(this,o,n(this,o,"f")+1)}n(this,o,"f")===n(this,a,"f")&&this.cleanupCallback()}else n(this,r,"f")[s]=c}))}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){i(this,e,t)}get onmessage(){return n(this,e,"f")}[(e=new WeakMap,o=new WeakMap,r=new WeakMap,a=new WeakMap,s)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[s]()}}class u{constructor(t,n,i){this.plugin=t,this.event=n,this.channelId=i}async unregister(){return f(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function l(t,n,i){const e=new c(i);try{return await f(`plugin:${t}|register_listener`,{event:n,handler:e}),new u(t,n,e.id)}catch{return await f(`plugin:${t}|registerListener`,{event:n,handler:e}),new u(t,n,e.id)}}async function f(t,n={},i){return window.__TAURI_INTERNALS__.invoke(t,n,i)}var d,h,p;t.ScheduleEvery=void 0,(d=t.ScheduleEvery||(t.ScheduleEvery={})).Year="year",d.Month="month",d.TwoWeeks="twoWeeks",d.Week="week",d.Day="day",d.Hour="hour",d.Minute="minute",d.Second="second";function y(t){const n=[],i=new WeakSet,e=new Set,o=t=>!!t&&"object"==typeof t&&!Array.isArray(t),r=t=>{if(!o(t))return null;const n=t,i=n.nameValuePairs;return o(i)?r(i):n},a=t=>{const n=r(t);if(!n)return null;const i=n.actionId;if("string"!=typeof i||0===i.length)return null;const e={actionId:i},a=n.id;if("number"==typeof a)e.id=a;else if("string"==typeof a){const t=Number.parseInt(a,10);Number.isNaN(t)||(e.id=t)}"string"==typeof n.inputValue&&(e.inputValue=n.inputValue);const s=t=>{if(!o(t))return{};const n={};for(const[i,e]of Object.entries(t))"string"==typeof e&&(n[i]=e);return n},c=t=>o(t)?t:{},u=t=>{const n=r(t);if(!n)return null;const i=(t=>{if("number"==typeof t&&Number.isFinite(t))return t;if("string"==typeof t){const n=Number.parseInt(t,10);if(!Number.isNaN(n))return n}return null})(n.id);if(null===i)return null;const e={id:i,groupSummary:"boolean"==typeof n.groupSummary&&n.groupSummary,data:s(n.data),extra:c(n.extra),attachments:Array.isArray(n.attachments)?n.attachments:[]};return"string"==typeof n.tag&&(e.tag=n.tag),"string"==typeof n.title&&(e.title=n.title),"string"==typeof n.body&&(e.body=n.body),"string"==typeof n.group&&(e.group=n.group),"string"==typeof n.actionTypeId&&(e.actionTypeId=n.actionTypeId),"string"==typeof n.sound&&(e.sound=n.sound),n.schedule&&o(n.schedule)&&(e.schedule=n.schedule),e};return"notification"in n&&(e.notification=u(n.notification)),e},s=t=>{if(!t||"object"!=typeof t)return;if(Array.isArray(t)){for(const n of t)s(n);return}const r=t;if(i.has(r))return;if(i.add(r),!o(t))return;const c=t,u=a(c);if(u)return void(t=>{const i=`${t.id??""}|${t.actionId}|${t.inputValue??""}`;e.has(i)||(e.add(i),n.push(t))})(u);const l=c.value;if(void 0!==l&&s(l),"number"==typeof c.length)for(let t=0;tn(t)));try{const t=y(await f("plugin:notification|register_action_listener_ready"));console.debug(`[NotificationPlugin] register_action_listener_ready replay count=${t.length}`);for(const i of t)n(i)}catch{}return i},t.onNotificationReceived=async function(t){return await l("notification","notification",t)},t.pending=async function(){return await f("plugin:notification|get_pending")},t.registerActionTypes=async function(t){await f("plugin:notification|register_action_types",{types:t})},t.removeActive=async function(t){await f("plugin:notification|remove_active",{notifications:t})},t.removeAllActive=async function(){await f("plugin:notification|remove_active")},t.removeChannel=async function(t){await f("plugin:notification|delete_channel",{id:t})},t.requestPermission=async function(){return await window.Notification.requestPermission()},t.sendNotification=function(t){"string"==typeof t?new window.Notification(t):new window.Notification(t.title,t)},t}({});Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})} diff --git a/plugins/notification/build.rs b/plugins/notification/build.rs index 4b24c755e2..f5a3d77c0c 100644 --- a/plugins/notification/build.rs +++ b/plugins/notification/build.rs @@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[ "is_permission_granted", "register_action_types", "register_listener", + "register_action_listener_ready", "cancel", "get_pending", "remove_active", diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 685c60c203..6bb706961e 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -286,6 +286,27 @@ interface ActiveNotification { sound?: string } +interface ActionPerformedNotification { + actionId: string + id?: number + inputValue?: string + notification?: ActiveNotification | null +} + +type RawPendingRecord = Record & { + nameValuePairs?: unknown + value?: unknown + length?: number +} + +type PendingActionsRaw = + | ActionPerformedNotification + | ActionPerformedNotification[] + | RawPendingRecord + | RawPendingRecord[] + | null + | undefined + enum Importance { None = 0, Min, @@ -566,10 +587,262 @@ async function onNotificationReceived( return await addPluginListener('notification', 'notification', cb) } +function normalisePendingActions( + pending: PendingActionsRaw +): ActionPerformedNotification[] { + const normalisedActions: ActionPerformedNotification[] = [] + const seenObjects = new WeakSet() + const seenActionKeys = new Set() + + const isRawPendingRecord = (value: unknown): value is RawPendingRecord => { + return !!value && typeof value === 'object' && !Array.isArray(value) + } + + const toRecord = (value: unknown): RawPendingRecord | null => { + if (!isRawPendingRecord(value)) { + return null + } + + const record = value + const wrapped = record.nameValuePairs + if (isRawPendingRecord(wrapped)) { + return toRecord(wrapped) + } + + return record + } + + const buildAction = ( + candidate: unknown + ): ActionPerformedNotification | null => { + const record = toRecord(candidate) + if (!record) { + return null + } + + const actionId = record.actionId + if (typeof actionId !== 'string' || actionId.length === 0) { + return null + } + + const action: ActionPerformedNotification = { + actionId + } + + const rawId = record.id + if (typeof rawId === 'number') { + action.id = rawId + } else if (typeof rawId === 'string') { + const parsedId = Number.parseInt(rawId, 10) + if (!Number.isNaN(parsedId)) { + action.id = parsedId + } + } + + if (typeof record.inputValue === 'string') { + action.inputValue = record.inputValue + } + + const toNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10) + if (!Number.isNaN(parsed)) { + return parsed + } + } + return null + } + + const toStringRecord = (value: unknown): Record => { + if (!isRawPendingRecord(value)) { + return {} + } + + const output: Record = {} + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'string') { + // Dynamic key passthrough is intentional here: this function only normalises + // already-received bridge payload objects into a plain string record. + // eslint-disable-next-line security/detect-object-injection + output[key] = item + } + } + return output + } + + const toUnknownRecord = (value: unknown): Record => { + if (!isRawPendingRecord(value)) { + return {} + } + return value + } + + const coerceActiveNotification = ( + value: unknown + ): ActiveNotification | null => { + const notificationRecord = toRecord(value) + if (!notificationRecord) { + return null + } + + const id = toNumber(notificationRecord.id) + if (id === null) { + return null + } + + const activeNotification: ActiveNotification = { + id, + groupSummary: + typeof notificationRecord.groupSummary === 'boolean' + ? notificationRecord.groupSummary + : false, + data: toStringRecord(notificationRecord.data), + extra: toUnknownRecord(notificationRecord.extra), + attachments: Array.isArray(notificationRecord.attachments) + ? (notificationRecord.attachments as Attachment[]) + : [] + } + + if (typeof notificationRecord.tag === 'string') { + activeNotification.tag = notificationRecord.tag + } + if (typeof notificationRecord.title === 'string') { + activeNotification.title = notificationRecord.title + } + if (typeof notificationRecord.body === 'string') { + activeNotification.body = notificationRecord.body + } + if (typeof notificationRecord.group === 'string') { + activeNotification.group = notificationRecord.group + } + if (typeof notificationRecord.actionTypeId === 'string') { + activeNotification.actionTypeId = notificationRecord.actionTypeId + } + if (typeof notificationRecord.sound === 'string') { + activeNotification.sound = notificationRecord.sound + } + if ( + notificationRecord.schedule && + isRawPendingRecord(notificationRecord.schedule) + ) { + activeNotification.schedule = + notificationRecord.schedule as unknown as Schedule + } + + return activeNotification + } + + if ('notification' in record) { + action.notification = coerceActiveNotification(record.notification) + } + + return action + } + + const addAction = (action: ActionPerformedNotification): void => { + const key = `${action.id ?? ''}|${action.actionId}|${action.inputValue ?? ''}` + if (seenActionKeys.has(key)) { + return + } + seenActionKeys.add(key) + normalisedActions.push(action) + } + + const walk = (value: unknown): void => { + if (!value || typeof value !== 'object') { + return + } + + if (Array.isArray(value)) { + for (const entry of value) { + walk(entry) + } + return + } + + const objectValue = value + if (seenObjects.has(objectValue)) { + return + } + seenObjects.add(objectValue) + + if (!isRawPendingRecord(value)) { + return + } + const record = value + + const directAction = buildAction(record) + if (directAction) { + addAction(directAction) + return + } + + const wrappedValue = record.value + if (wrappedValue !== undefined) { + walk(wrappedValue) + } + + // Some host bridges return array-like objects (`{ 0: ..., length: N }`). + if (typeof record.length === 'number') { + for (let index = 0; index < record.length; index += 1) { + walk(record[String(index)]) + } + } + + for (const entry of Object.values(record)) { + walk(entry) + } + } + + walk(pending) + + return normalisedActions +} + +/** + * Registers a listener for notification action events. + * + * @since 2.0.0 + */ +function onAction( + cb: (notification: ActionPerformedNotification) => void +): Promise +/** + * Registers a listener for notification action events. + * + * @deprecated Use the `ActionPerformedNotification` callback type. + * @since 2.0.0 + */ +function onAction(cb: (notification: Options) => void): Promise async function onAction( - cb: (notification: Options) => void + cb: + | ((notification: ActionPerformedNotification) => void) + | ((notification: Options) => void) ): Promise { - return await addPluginListener('notification', 'actionPerformed', cb) + const actionCallback = cb as (notification: ActionPerformedNotification) => void + const listener = await addPluginListener( + 'notification', + 'actionPerformed', + (notification: ActionPerformedNotification) => actionCallback(notification) + ) + try { + const pendingResult = await invoke( + 'plugin:notification|register_action_listener_ready' + ) + const pending = normalisePendingActions(pendingResult) + console.debug( + `[NotificationPlugin] register_action_listener_ready replay count=${pending.length}` + ) + for (const notification of pending) { + actionCallback(notification) + } + } catch { + // Older plugin versions and non-Android targets may not implement this command. + } + return listener } export type { @@ -579,6 +852,7 @@ export type { ActionType, PendingNotification, ActiveNotification, + ActionPerformedNotification, Channel, ScheduleInterval } diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift index 1bf134b676..8fbdd87900 100644 --- a/plugins/notification/ios/Sources/NotificationHandler.swift +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -66,6 +66,26 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { inputValue = inputType.userText } + // Payload contract note: + // Keep this structure aligned with Android for `actionPerformed`: + // - `actionId` + // - `inputValue` (optional) + // - `notification` (with at least `id`, optionally `actionTypeId`) + // + // Delivery note: + // This event is emitted as soon as the OS callback arrives. If the JS side has + // not attached `onAction` yet (for example during cold start), the event may be + // missed. Android mitigates this with a pending-action queue and an explicit + // "listener ready" handshake from JS. + // + // iOS parity guidance: + // 1. Queue `ReceivedNotification` payloads at this boundary when listeners are not ready. + // 2. Expose a small command that JS calls after `onAction` registration to drain the queue. + // 3. Consider persistence (for example `UserDefaults`) so queued actions survive process restarts. + // 4. Keep the payload contract aligned with Android (`actionId`, `inputValue`, `notification`). + // + // Important: queue payloads should be built from `UNNotificationResponse` fields directly + // where possible, since `notificationsMap` is in-memory and may be unavailable after restart. try? self.plugin?.trigger( "actionPerformed", data: ReceivedNotification( diff --git a/plugins/notification/permissions/autogenerated/commands/register_action_listener_ready.toml b/plugins/notification/permissions/autogenerated/commands/register_action_listener_ready.toml new file mode 100644 index 0000000000..cd2fbc53fb --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/register_action_listener_ready.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-register-action-listener-ready" +description = "Enables the register_action_listener_ready command without any pre-configured scope." +commands.allow = ["register_action_listener_ready"] + +[[permission]] +identifier = "deny-register-action-listener-ready" +description = "Denies the register_action_listener_ready command without any pre-configured scope." +commands.deny = ["register_action_listener_ready"] diff --git a/plugins/notification/permissions/autogenerated/reference.md b/plugins/notification/permissions/autogenerated/reference.md index b0652545ac..8fabd357a1 100644 --- a/plugins/notification/permissions/autogenerated/reference.md +++ b/plugins/notification/permissions/autogenerated/reference.md @@ -14,6 +14,7 @@ It allows all notification related features. - `allow-notify` - `allow-register-action-types` - `allow-register-listener` +- `allow-register-action-listener-ready` - `allow-cancel` - `allow-get-pending` - `allow-remove-active` @@ -324,6 +325,32 @@ Denies the permission_state command without any pre-configured scope. +`notification:allow-register-action-listener-ready` + + + + +Enables the register_action_listener_ready command without any pre-configured scope. + + + + + + + +`notification:deny-register-action-listener-ready` + + + + +Denies the register_action_listener_ready command without any pre-configured scope. + + + + + + + `notification:allow-register-action-types` diff --git a/plugins/notification/permissions/default.toml b/plugins/notification/permissions/default.toml index 00b4e1d0ce..6107fd3cc6 100644 --- a/plugins/notification/permissions/default.toml +++ b/plugins/notification/permissions/default.toml @@ -16,6 +16,7 @@ permissions = [ "allow-notify", "allow-register-action-types", "allow-register-listener", + "allow-register-action-listener-ready", "allow-cancel", "allow-get-pending", "allow-remove-active", diff --git a/plugins/notification/permissions/schemas/schema.json b/plugins/notification/permissions/schemas/schema.json index 26703a8a1e..1770241c44 100644 --- a/plugins/notification/permissions/schemas/schema.json +++ b/plugins/notification/permissions/schemas/schema.json @@ -426,6 +426,18 @@ "const": "deny-permission-state", "markdownDescription": "Denies the permission_state command without any pre-configured scope." }, + { + "description": "Enables the register_action_listener_ready command without any pre-configured scope.", + "type": "string", + "const": "allow-register-action-listener-ready", + "markdownDescription": "Enables the register_action_listener_ready command without any pre-configured scope." + }, + { + "description": "Denies the register_action_listener_ready command without any pre-configured scope.", + "type": "string", + "const": "deny-register-action-listener-ready", + "markdownDescription": "Denies the register_action_listener_ready command without any pre-configured scope." + }, { "description": "Enables the register_action_types command without any pre-configured scope.", "type": "string", @@ -487,10 +499,10 @@ "markdownDescription": "Denies the show command without any pre-configured scope." }, { - "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", + "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-register-action-listener-ready`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "type": "string", "const": "default", - "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" + "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-register-action-listener-ready`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" } ] } diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index 02134e4dbc..e93b1e0783 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -320,6 +320,95 @@ pub struct ActionType { hidden_previews_show_subtitle: bool, } +#[cfg(mobile)] +#[derive(Debug)] +pub struct ActionTypeBuilder(ActionType); + +#[cfg(mobile)] +impl ActionType { + pub fn builder(id: impl Into) -> ActionTypeBuilder { + ActionTypeBuilder(Self { + id: id.into(), + actions: Vec::new(), + hidden_previews_body_placeholder: None, + custom_dismiss_action: false, + allow_in_car_play: false, + hidden_previews_show_title: false, + hidden_previews_show_subtitle: false, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn actions(&self) -> &[Action] { + &self.actions + } + + pub fn hidden_previews_body_placeholder(&self) -> Option<&str> { + self.hidden_previews_body_placeholder.as_deref() + } + + pub fn custom_dismiss_action(&self) -> bool { + self.custom_dismiss_action + } + + pub fn allow_in_car_play(&self) -> bool { + self.allow_in_car_play + } + + pub fn hidden_previews_show_title(&self) -> bool { + self.hidden_previews_show_title + } + + pub fn hidden_previews_show_subtitle(&self) -> bool { + self.hidden_previews_show_subtitle + } +} + +#[cfg(mobile)] +impl ActionTypeBuilder { + pub fn actions(mut self, actions: Vec) -> Self { + self.0.actions = actions; + self + } + + pub fn hidden_previews_body_placeholder( + mut self, + hidden_previews_body_placeholder: impl Into, + ) -> Self { + self.0 + .hidden_previews_body_placeholder + .replace(hidden_previews_body_placeholder.into()); + self + } + + pub fn custom_dismiss_action(mut self, custom_dismiss_action: bool) -> Self { + self.0.custom_dismiss_action = custom_dismiss_action; + self + } + + pub fn allow_in_car_play(mut self, allow_in_car_play: bool) -> Self { + self.0.allow_in_car_play = allow_in_car_play; + self + } + + pub fn hidden_previews_show_title(mut self, hidden_previews_show_title: bool) -> Self { + self.0.hidden_previews_show_title = hidden_previews_show_title; + self + } + + pub fn hidden_previews_show_subtitle(mut self, hidden_previews_show_subtitle: bool) -> Self { + self.0.hidden_previews_show_subtitle = hidden_previews_show_subtitle; + self + } + + pub fn build(self) -> ActionType { + self.0 + } +} + #[cfg(mobile)] #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -334,6 +423,95 @@ pub struct Action { input_placeholder: Option, } +#[cfg(mobile)] +#[derive(Debug)] +pub struct ActionBuilder(Action); + +#[cfg(mobile)] +impl Action { + pub fn builder(id: impl Into, title: impl Into) -> ActionBuilder { + ActionBuilder(Self { + id: id.into(), + title: title.into(), + requires_authentication: false, + foreground: false, + destructive: false, + input: false, + input_button_title: None, + input_placeholder: None, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn requires_authentication(&self) -> bool { + self.requires_authentication + } + + pub fn foreground(&self) -> bool { + self.foreground + } + + pub fn destructive(&self) -> bool { + self.destructive + } + + pub fn input(&self) -> bool { + self.input + } + + pub fn input_button_title(&self) -> Option<&str> { + self.input_button_title.as_deref() + } + + pub fn input_placeholder(&self) -> Option<&str> { + self.input_placeholder.as_deref() + } +} + +#[cfg(mobile)] +impl ActionBuilder { + pub fn requires_authentication(mut self, requires_authentication: bool) -> Self { + self.0.requires_authentication = requires_authentication; + self + } + + pub fn foreground(mut self, foreground: bool) -> Self { + self.0.foreground = foreground; + self + } + + pub fn destructive(mut self, destructive: bool) -> Self { + self.0.destructive = destructive; + self + } + + pub fn input(mut self, input: bool) -> Self { + self.0.input = input; + self + } + + pub fn input_button_title(mut self, input_button_title: impl Into) -> Self { + self.0.input_button_title.replace(input_button_title.into()); + self + } + + pub fn input_placeholder(mut self, input_placeholder: impl Into) -> Self { + self.0.input_placeholder.replace(input_placeholder.into()); + self + } + + pub fn build(self) -> Action { + self.0 + } +} + #[cfg(target_os = "android")] pub use android::*;