From 3731c04e1ba09420e3bacdcbed7f5b69a5b2e7e7 Mon Sep 17 00:00:00 2001 From: Michael Kadziela Date: Thu, 19 Feb 2026 21:30:39 +1100 Subject: [PATCH 1/9] Add the ability to create action types and actions with only Rust so you can register them; fix Android incorrectly storing actions in an action group --- .../src/main/java/NotificationStorage.kt | 166 ++++++++-------- plugins/notification/src/models.rs | 178 ++++++++++++++++++ 2 files changed, 262 insertions(+), 82 deletions(-) diff --git a/plugins/notification/android/src/main/java/NotificationStorage.kt b/plugins/notification/android/src/main/java/NotificationStorage.kt index bceb985dbd..15da4e5e21 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,100 @@ 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() - } + 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) - } + 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) + 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 { - 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/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::*; From 71f15e1e8b4112ee2ed7c0a1ffc4fabe832f35ec Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 21 Feb 2026 17:05:49 +0000 Subject: [PATCH 2/9] fix(notification/android): correct action-group storage keying and add round-trip test --- .../java/NotificationStorageActionsTest.kt | 48 +++++++++++++++++++ .../src/main/java/NotificationStorage.kt | 11 ++--- 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 plugins/notification/android/src/androidTest/java/NotificationStorageActionsTest.kt 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/NotificationStorage.kt b/plugins/notification/android/src/main/java/NotificationStorage.kt index 15da4e5e21..ed2d5b0021 100644 --- a/plugins/notification/android/src/main/java/NotificationStorage.kt +++ b/plugins/notification/android/src/main/java/NotificationStorage.kt @@ -75,7 +75,6 @@ class NotificationStorage(private val context: Context, private val jsonMapper: editor.remove(id) editor.apply() } - private fun getStorage(key: String): SharedPreferences { return context.getSharedPreferences(key, Context.MODE_PRIVATE) } @@ -85,11 +84,11 @@ class NotificationStorage(private val context: Context, private val jsonMapper: 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) + // 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() } From f92c660899bbf25ddd9fb3fa707ab0a637fe650c Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 21 Feb 2026 17:05:49 +0000 Subject: [PATCH 3/9] fix(notification): align actionPerformed payload typing and metadata across bridge --- .../src/main/java/TauriNotificationManager.kt | 30 ++++++++++++++++--- plugins/notification/guest-js/index.ts | 10 ++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt index a8912739bb..f14b50ece2 100644 --- a/plugins/notification/android/src/main/java/TauriNotificationManager.kt +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -61,12 +61,11 @@ class TauriNotificationManager( Logger.debug(Logger.tags("Notification"), "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()) @@ -82,7 +81,19 @@ class TauriNotificationManager( } } catch (_: JSONException) { } + if (request == null) { + request = JSObject() + request.put("id", notificationId) + if (savedNotification?.actionTypeId != null) { + request.put("actionTypeId", savedNotification.actionTypeId) + } + } else if (!request.has("id")) { + request.put("id", notificationId) + } dataJson.put("notification", request) + if (isRemovable) { + notificationStorage.deleteNotification(notificationId.toString()) + } return dataJson } @@ -299,7 +310,18 @@ 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()) return intent diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 685c60c203..8e08fa1db6 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -286,6 +286,13 @@ interface ActiveNotification { sound?: string } +interface ActionPerformedNotification { + actionId: string + id?: number + inputValue?: string + notification?: ActiveNotification | null +} + enum Importance { None = 0, Min, @@ -567,7 +574,7 @@ async function onNotificationReceived( } async function onAction( - cb: (notification: Options) => void + cb: (notification: ActionPerformedNotification) => void ): Promise { return await addPluginListener('notification', 'actionPerformed', cb) } @@ -579,6 +586,7 @@ export type { ActionType, PendingNotification, ActiveNotification, + ActionPerformedNotification, Channel, ScheduleInterval } From 76b5e1f53f39d9d343013ebe36ad89993b43fa28 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 21 Feb 2026 17:05:49 +0000 Subject: [PATCH 4/9] feat(notification): add action listener-ready handshake with command permissions --- .../src/main/java/NotificationPlugin.kt | 31 ++++++++++++++++++- plugins/notification/api-iife.js | 2 +- plugins/notification/build.rs | 1 + plugins/notification/guest-js/index.ts | 13 +++++++- .../ios/Sources/NotificationHandler.swift | 13 ++++++++ .../register_action_listener_ready.toml | 13 ++++++++ .../permissions/autogenerated/reference.md | 27 ++++++++++++++++ plugins/notification/permissions/default.toml | 1 + .../permissions/schemas/schema.json | 16 ++++++++-- 9 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 plugins/notification/permissions/autogenerated/commands/register_action_listener_ready.toml diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index 3ead31527b..1660a8b510 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -82,6 +82,8 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private lateinit var notificationManager: NotificationManager private lateinit var notificationStorage: NotificationStorage private var channelManager = ChannelManager(activity) + private val pendingActionEvents = mutableListOf() + private var isActionListenerReady = false companion object { var instance: NotificationPlugin? = null @@ -96,6 +98,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { super.load(webView) this.webView = webView + synchronized(this) { + pendingActionEvents.clear() + isActionListenerReady = false + } notificationStorage = NotificationStorage(activity, jsonMapper()) val manager = TauriNotificationManager( @@ -127,8 +133,18 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) if (dataJson != null) { - trigger("actionPerformed", dataJson) + dispatchActionPerformed(dataJson) + } + } + + private fun dispatchActionPerformed(payload: JSObject) { + synchronized(this) { + if (!isActionListenerReady) { + pendingActionEvents.add(payload) + return + } } + trigger("actionPerformed", payload) } @Command @@ -189,6 +205,19 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve() } + @Command + fun registerActionListenerReady(invoke: Invoke) { + val pending = JSArray() + synchronized(this) { + isActionListenerReady = true + for (event in pendingActionEvents) { + pending.put(event) + } + pendingActionEvents.clear() + } + invoke.resolveObject(pending) + } + @SuppressLint("ObsoleteSdkInt") @Command fun getActive(invoke: Invoke) { diff --git a/plugins/notification/api-iife.js b/plugins/notification/api-iife.js index d8f85c6cf3..63d3c87911 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(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 c="__TAURI_TO_IPC_KEY__";class s{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 c=i.index;if("end"in i)return void(c==t(this,a,"f")?this.cleanupCallback():n(this,r,c));const s=i.message;if(c==t(this,a,"f")){for(t(this,e,"f").call(this,s),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")[c]=s}))}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,c)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[c]()}}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 s(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){const t=await u("notification","actionPerformed",i);try{const t=await f("plugin:notification|register_action_listener_ready");for(const n of t)i(n)}catch{}return t},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__})} 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 8e08fa1db6..01feacdc97 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -576,7 +576,18 @@ async function onNotificationReceived( async function onAction( cb: (notification: ActionPerformedNotification) => void ): Promise { - return await addPluginListener('notification', 'actionPerformed', cb) + const listener = await addPluginListener('notification', 'actionPerformed', cb) + try { + const pending = await invoke( + 'plugin:notification|register_action_listener_ready' + ) + for (const notification of pending) { + cb(notification) + } + } catch { + // Older plugin versions and non-Android targets may not implement this command. + } + return listener } export type { diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift index 1bf134b676..9807c93746 100644 --- a/plugins/notification/ios/Sources/NotificationHandler.swift +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -66,6 +66,19 @@ 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 currently mitigates this with a pending-action queue drained + // by JS after listener registration. + // + // TODO: Consider applying the same queue/drain handshake on iOS for parity. 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`" } ] } From 5d868499fab85f6dfd5a08a876aed55207984804 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 21 Feb 2026 17:06:25 +0000 Subject: [PATCH 5/9] fix(notification/android): persist and dedupe queued action events across reloads --- .../src/main/java/NotificationPlugin.kt | 148 +++++++++++++++++- 1 file changed, 145 insertions(+), 3 deletions(-) diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index 1660a8b510..89a441c734 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -22,8 +22,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,9 +87,126 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private lateinit var notificationManager: NotificationManager private lateinit var notificationStorage: NotificationStorage private var channelManager = ChannelManager(activity) - private val pendingActionEvents = mutableListOf() + 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( + Logger.tags("Notification"), + "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( + Logger.tags("Notification"), + "Skipping duplicate restored actionPerformed event key=$key" + ) + continue + } + + pendingActionEvents.add(PendingActionEvent(key, payload, timestampMs)) + pendingActionEventKeys.add(key) + } + Logger.debug( + Logger.tags("Notification"), + "Restored pending actionPerformed events=${pendingActionEvents.size}" + ) + } catch (error: Throwable) { + Logger.error( + Logger.tags("Notification"), + "Failed to restore pending actionPerformed events", + error + ) + pendingActionEvents.clear() + pendingActionEventKeys.clear() + persistPendingActionEventsLocked() + } + } + companion object { var instance: NotificationPlugin? = null @@ -100,7 +222,9 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { this.webView = webView synchronized(this) { pendingActionEvents.clear() + pendingActionEventKeys.clear() isActionListenerReady = false + restorePendingActionEventsLocked() } notificationStorage = NotificationStorage(activity, jsonMapper()) @@ -140,7 +264,23 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private fun dispatchActionPerformed(payload: JSObject) { synchronized(this) { if (!isActionListenerReady) { - pendingActionEvents.add(payload) + 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( + Logger.tags("Notification"), + "Skipping duplicate queued actionPerformed event key=$key" + ) + return + } + pendingActionEvents.add(PendingActionEvent(key, payload, nowMs())) + pendingActionEventKeys.add(key) + persistPendingActionEventsLocked() + Logger.debug( + Logger.tags("Notification"), + "Queued actionPerformed event; listener not ready (pending=${pendingActionEvents.size})" + ) return } } @@ -211,9 +351,11 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { synchronized(this) { isActionListenerReady = true for (event in pendingActionEvents) { - pending.put(event) + pending.put(event.payload) } pendingActionEvents.clear() + pendingActionEventKeys.clear() + persistPendingActionEventsLocked() } invoke.resolveObject(pending) } From bb74ff555b723956a24d2eadac8c4a6181947ca7 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 21 Feb 2026 17:06:42 +0000 Subject: [PATCH 6/9] fix(notification/js): normalize replay payload variants and preserve callback compatibility --- plugins/notification/api-iife.js | 2 +- plugins/notification/guest-js/index.ts | 239 ++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 5 deletions(-) diff --git a/plugins/notification/api-iife.js b/plugins/notification/api-iife.js index 63d3c87911..16d979269a 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 c="__TAURI_TO_IPC_KEY__";class s{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 c=i.index;if("end"in i)return void(c==t(this,a,"f")?this.cleanupCallback():n(this,r,c));const s=i.message;if(c==t(this,a,"f")){for(t(this,e,"f").call(this,s),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")[c]=s}))}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,c)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[c]()}}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 s(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){const t=await u("notification","actionPerformed",i);try{const t=await f("plugin:notification|register_action_listener_ready");for(const n of t)i(n)}catch{}return t},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,p,y;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 h(t){const n=[],i=new WeakSet,e=new Set,o=t=>{if(!t||"object"!=typeof t||Array.isArray(t))return null;const n=t,i=n.nameValuePairs;return i&&"object"==typeof i?o(i):n},r=t=>{const n=o(t);if(!n)return null;const i=n.actionId;if("string"!=typeof i||0===i.length)return null;const e={actionId:i},r=n.id;if("number"==typeof r)e.id=r;else if("string"==typeof r){const t=Number.parseInt(r,10);Number.isNaN(t)||(e.id=t)}"string"==typeof n.inputValue&&(e.inputValue=n.inputValue);const a=t=>{if(!t||"object"!=typeof t||Array.isArray(t))return{};const n=t,i={};for(const[t,e]of Object.entries(n))"string"==typeof e&&(i[t]=e);return i},s=t=>!t||"object"!=typeof t||Array.isArray(t)?{}:t,c=t=>{const n=o(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:a(n.data),extra:s(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&&"object"==typeof n.schedule&&(e.schedule=n.schedule),e};return"notification"in n&&(e.notification=c(n.notification)),e},a=t=>{if(!t||"object"!=typeof t)return;if(Array.isArray(t)){for(const n of t)a(n);return}const o=t;if(i.has(o))return;i.add(o);const s=t,c=r(s);if(c)return void(t=>{const i=`${t.id??""}|${t.actionId}|${t.inputValue??""}`;e.has(i)||(e.add(i),n.push(t))})(c);const u=s.value;if(void 0!==u&&a(u),"number"==typeof s.length)for(let t=0;tn(t)));try{const t=h(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/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 01feacdc97..43e159ef65 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -573,16 +573,247 @@ async function onNotificationReceived( return await addPluginListener('notification', 'notification', cb) } -async function onAction( +function normalisePendingActions( + pending: unknown +): ActionPerformedNotification[] { + const normalisedActions: ActionPerformedNotification[] = [] + const seenObjects = new WeakSet() + const seenActionKeys = new Set() + + const toRecord = (value: unknown): Record | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + const record = value as Record + const wrapped = record.nameValuePairs + if (wrapped && typeof wrapped === 'object') { + 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 (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + const source = value as Record + const output: Record = {} + for (const [key, item] of Object.entries(source)) { + if (typeof item === 'string') { + output[key] = item + } + } + return output + } + + const toUnknownRecord = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + return value as Record + } + + 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 && + typeof notificationRecord.schedule === 'object' + ) { + activeNotification.schedule = notificationRecord.schedule 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 as object + if (seenObjects.has(objectValue)) { + return + } + seenObjects.add(objectValue) + + const record = value as Record + + 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: ActionPerformedNotification) => void) + | ((notification: Options) => void) ): Promise { - const listener = await addPluginListener('notification', 'actionPerformed', cb) + const actionCallback = cb as (notification: ActionPerformedNotification) => void + const listener = await addPluginListener( + 'notification', + 'actionPerformed', + (notification: ActionPerformedNotification) => actionCallback(notification) + ) try { - const pending = await invoke( + 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) { - cb(notification) + actionCallback(notification) } } catch { // Older plugin versions and non-Android targets may not implement this command. From fee50ec90199d9029f346de6ff5cc6330e898c60 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 21 Feb 2026 17:07:08 +0000 Subject: [PATCH 7/9] chore(notification): expand Android diagnostics and document iOS parity path --- .../src/main/java/NotificationPlugin.kt | 31 +++++++++++++++++++ .../src/main/java/TauriNotificationManager.kt | 18 +++++++++++ .../ios/Sources/NotificationHandler.swift | 13 ++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index 89a441c734..0f63097457 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 @@ -220,6 +221,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { super.load(webView) this.webView = webView + Logger.debug(Logger.tags("Notification"), "Plugin load started") synchronized(this) { pendingActionEvents.clear() pendingActionEventKeys.clear() @@ -239,6 +241,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { this.manager = manager notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Logger.debug(Logger.tags("Notification"), "Plugin load complete; awaiting notification intents") val intent = activity.intent intent?.let { @@ -248,16 +251,21 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + Logger.debug(Logger.tags("Notification"), "onNewIntent received action=${intent.action}") onIntent(intent) } fun onIntent(intent: Intent) { if (Intent.ACTION_MAIN != intent.action) { + Logger.debug(Logger.tags("Notification"), "Ignoring intent action=${intent.action}") return } + Logger.debug(Logger.tags("Notification"), "Processing ACTION_MAIN intent for notification action") val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) if (dataJson != null) { dispatchActionPerformed(dataJson) + } else { + Logger.debug(Logger.tags("Notification"), "No action payload extracted from intent") } } @@ -284,12 +292,17 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { return } } + Logger.debug(Logger.tags("Notification"), "Dispatching actionPerformed event immediately") trigger("actionPerformed", payload) } @Command fun show(invoke: Invoke) { val notification = invoke.parseArgs(Notification::class.java) + Logger.debug( + Logger.tags("Notification"), + "show called id=${notification.id} title=${notification.title} actionTypeId=${notification.actionTypeId} hasSchedule=${notification.schedule != null}" + ) val id = manager.schedule(notification) invoke.resolveObject(id) @@ -298,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( + Logger.tags("Notification"), + "batch called notifications=${args.notifications.size}" + ) val ids = manager.schedule(args.notifications) notificationStorage.appendNotifications(args.notifications) + Logger.debug(Logger.tags("Notification"), "batch scheduled ids=$ids") invoke.resolveObject(ids) } @@ -308,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(Logger.tags("Notification"), "cancel called notifications=${args.notifications}") manager.cancel(args.notifications) invoke.resolve() } @@ -315,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(Logger.tags("Notification"), "removeActive called notifications=${args.notifications.size}") if (args.notifications.isEmpty()) { notificationManager.cancelAll() @@ -334,6 +354,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun getPending(invoke: Invoke) { val notifications= notificationStorage.getSavedNotifications() + Logger.debug(Logger.tags("Notification"), "getPending returning count=${notifications.size}") val result = Notification.buildNotificationPendingList(notifications) invoke.resolveObject(result) } @@ -341,6 +362,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun registerActionTypes(invoke: Invoke) { val args = invoke.parseArgs(RegisterActionTypesArgs::class.java) + Logger.debug( + Logger.tags("Notification"), + "registerActionTypes called types=${args.types.size}" + ) notificationStorage.writeActionGroup(args.types) invoke.resolve() } @@ -348,15 +373,21 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @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( + Logger.tags("Notification"), + "Action listener marked ready; drained pending actionPerformed events=$drainedCount" + ) invoke.resolveObject(pending) } diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt index f14b50ece2..f5c652d9b4 100644 --- a/plugins/notification/android/src/main/java/TauriNotificationManager.kt +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -70,6 +70,10 @@ class TauriNotificationManager( val input = results?.getCharSequence(REMOTE_INPUT_KEY) dataJson.put("inputValue", input?.toString()) val menuAction = data.getStringExtra(ACTION_INTENT_KEY) + Logger.debug( + Logger.tags("Notification"), + "Action performed id=$notificationId actionId=$menuAction removable=$isRemovable" + ) dismissVisibleNotification(notificationId) dataJson.put("actionId", menuAction) var request: JSONObject? = null @@ -87,6 +91,10 @@ class TauriNotificationManager( if (savedNotification?.actionTypeId != null) { request.put("actionTypeId", savedNotification.actionTypeId) } + Logger.debug( + Logger.tags("Notification"), + "Recovered missing notification metadata from storage id=$notificationId actionTypeId=${savedNotification?.actionTypeId}" + ) } else if (!request.has("id")) { request.put("id", notificationId) } @@ -136,17 +144,23 @@ class TauriNotificationManager( fun schedule(notification: Notification): Int { val notificationManager = NotificationManagerCompat.from(context) + Logger.debug( + Logger.tags("Notification"), + "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(Logger.tags("Notification"), "Scheduling batch notifications count=${notifications.size}") for (notification in notifications) { val id = trigger(notificationManager, notification) ids.add(id) } + Logger.debug(Logger.tags("Notification"), "Scheduled batch notification ids=$ids") return ids } @@ -324,6 +338,10 @@ class TauriNotificationManager( intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, notificationJson.toString()) val schedule = notification.schedule intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()) + Logger.debug( + Logger.tags("Notification"), + "Built action intent notificationId=${notification.id} action=$action removable=${schedule == null || schedule.isRemovable()}" + ) return intent } diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift index 9807c93746..8fbdd87900 100644 --- a/plugins/notification/ios/Sources/NotificationHandler.swift +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -75,10 +75,17 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { // 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 currently mitigates this with a pending-action queue drained - // by JS after listener registration. + // missed. Android mitigates this with a pending-action queue and an explicit + // "listener ready" handshake from JS. // - // TODO: Consider applying the same queue/drain handshake on iOS for parity. + // 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( From 5243dc3741c5bf7b0674e0e03dc9ae78cffa1576 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sun, 22 Feb 2026 01:33:10 +0000 Subject: [PATCH 8/9] refactor(notification): centralize Android log tags and tighten pending action typing --- .../android/src/main/java/ChannelManager.kt | 2 +- .../android/src/main/java/Logging.kt | 10 ++++ .../src/main/java/NotificationPlugin.kt | 42 +++++++-------- .../src/main/java/TauriNotificationManager.kt | 26 ++++----- plugins/notification/api-iife.js | 2 +- plugins/notification/guest-js/index.ts | 54 +++++++++++++------ 6 files changed, 85 insertions(+), 51 deletions(-) create mode 100644 plugins/notification/android/src/main/java/Logging.kt 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 0f63097457..0a47a290cc 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -138,7 +138,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { if (droppedExpired > 0) { rebuildPendingActionEventKeysLocked() Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Dropped expired pending actionPerformed events=$droppedExpired" ) } @@ -183,7 +183,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { val key = event.optString("key").ifEmpty { buildActionEventKey(payload) } if (pendingActionEventKeys.contains(key)) { Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Skipping duplicate restored actionPerformed event key=$key" ) continue @@ -193,12 +193,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { pendingActionEventKeys.add(key) } Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Restored pending actionPerformed events=${pendingActionEvents.size}" ) } catch (error: Throwable) { Logger.error( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Failed to restore pending actionPerformed events", error ) @@ -221,7 +221,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { super.load(webView) this.webView = webView - Logger.debug(Logger.tags("Notification"), "Plugin load started") + Logger.debug(NOTIFICATION_LOG_TAGS, "Plugin load started") synchronized(this) { pendingActionEvents.clear() pendingActionEventKeys.clear() @@ -241,7 +241,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { this.manager = manager notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - Logger.debug(Logger.tags("Notification"), "Plugin load complete; awaiting notification intents") + Logger.debug(NOTIFICATION_LOG_TAGS, "Plugin load complete; awaiting notification intents") val intent = activity.intent intent?.let { @@ -251,21 +251,21 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - Logger.debug(Logger.tags("Notification"), "onNewIntent received action=${intent.action}") + Logger.debug(NOTIFICATION_LOG_TAGS, "onNewIntent received action=${intent.action}") onIntent(intent) } fun onIntent(intent: Intent) { if (Intent.ACTION_MAIN != intent.action) { - Logger.debug(Logger.tags("Notification"), "Ignoring intent action=${intent.action}") + Logger.debug(NOTIFICATION_LOG_TAGS, "Ignoring intent action=${intent.action}") return } - Logger.debug(Logger.tags("Notification"), "Processing ACTION_MAIN intent for notification action") + Logger.debug(NOTIFICATION_LOG_TAGS, "Processing ACTION_MAIN intent for notification action") val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) if (dataJson != null) { dispatchActionPerformed(dataJson) } else { - Logger.debug(Logger.tags("Notification"), "No action payload extracted from intent") + Logger.debug(NOTIFICATION_LOG_TAGS, "No action payload extracted from intent") } } @@ -277,7 +277,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { // Without this key check, the same action can be enqueued twice across reload boundaries. if (pendingActionEventKeys.contains(key)) { Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Skipping duplicate queued actionPerformed event key=$key" ) return @@ -286,13 +286,13 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { pendingActionEventKeys.add(key) persistPendingActionEventsLocked() Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Queued actionPerformed event; listener not ready (pending=${pendingActionEvents.size})" ) return } } - Logger.debug(Logger.tags("Notification"), "Dispatching actionPerformed event immediately") + Logger.debug(NOTIFICATION_LOG_TAGS, "Dispatching actionPerformed event immediately") trigger("actionPerformed", payload) } @@ -300,7 +300,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { fun show(invoke: Invoke) { val notification = invoke.parseArgs(Notification::class.java) Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "show called id=${notification.id} title=${notification.title} actionTypeId=${notification.actionTypeId} hasSchedule=${notification.schedule != null}" ) val id = manager.schedule(notification) @@ -312,13 +312,13 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { fun batch(invoke: Invoke) { val args = invoke.parseArgs(BatchArgs::class.java) Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "batch called notifications=${args.notifications.size}" ) val ids = manager.schedule(args.notifications) notificationStorage.appendNotifications(args.notifications) - Logger.debug(Logger.tags("Notification"), "batch scheduled ids=$ids") + Logger.debug(NOTIFICATION_LOG_TAGS, "batch scheduled ids=$ids") invoke.resolveObject(ids) } @@ -326,7 +326,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun cancel(invoke: Invoke) { val args = invoke.parseArgs(CancelArgs::class.java) - Logger.debug(Logger.tags("Notification"), "cancel called notifications=${args.notifications}") + Logger.debug(NOTIFICATION_LOG_TAGS, "cancel called notifications=${args.notifications}") manager.cancel(args.notifications) invoke.resolve() } @@ -334,7 +334,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun removeActive(invoke: Invoke) { val args = invoke.parseArgs(RemoveActiveArgs::class.java) - Logger.debug(Logger.tags("Notification"), "removeActive called notifications=${args.notifications.size}") + Logger.debug(NOTIFICATION_LOG_TAGS, "removeActive called notifications=${args.notifications.size}") if (args.notifications.isEmpty()) { notificationManager.cancelAll() @@ -354,7 +354,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun getPending(invoke: Invoke) { val notifications= notificationStorage.getSavedNotifications() - Logger.debug(Logger.tags("Notification"), "getPending returning count=${notifications.size}") + Logger.debug(NOTIFICATION_LOG_TAGS, "getPending returning count=${notifications.size}") val result = Notification.buildNotificationPendingList(notifications) invoke.resolveObject(result) } @@ -363,7 +363,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { fun registerActionTypes(invoke: Invoke) { val args = invoke.parseArgs(RegisterActionTypesArgs::class.java) Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "registerActionTypes called types=${args.types.size}" ) notificationStorage.writeActionGroup(args.types) @@ -385,7 +385,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { persistPendingActionEventsLocked() } Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Action listener marked ready; drained pending actionPerformed events=$drainedCount" ) invoke.resolveObject(pending) diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt index f5c652d9b4..bda9aaf382 100644 --- a/plugins/notification/android/src/main/java/TauriNotificationManager.kt +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -54,11 +54,11 @@ 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()) @@ -71,7 +71,7 @@ class TauriNotificationManager( dataJson.put("inputValue", input?.toString()) val menuAction = data.getStringExtra(ACTION_INTENT_KEY) Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Action performed id=$notificationId actionId=$menuAction removable=$isRemovable" ) dismissVisibleNotification(notificationId) @@ -92,7 +92,7 @@ class TauriNotificationManager( request.put("actionTypeId", savedNotification.actionTypeId) } Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Recovered missing notification metadata from storage id=$notificationId actionTypeId=${savedNotification?.actionTypeId}" ) } else if (!request.has("id")) { @@ -145,7 +145,7 @@ class TauriNotificationManager( fun schedule(notification: Notification): Int { val notificationManager = NotificationManagerCompat.from(context) Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Scheduling notification id=${notification.id} actionTypeId=${notification.actionTypeId} hasSchedule=${notification.schedule != null}" ) return trigger(notificationManager, notification) @@ -154,13 +154,13 @@ class TauriNotificationManager( fun schedule(notifications: List): List { val ids = mutableListOf() val notificationManager = NotificationManagerCompat.from(context) - Logger.debug(Logger.tags("Notification"), "Scheduling batch notifications count=${notifications.size}") + 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(Logger.tags("Notification"), "Scheduled batch notification ids=$ids") + Logger.debug(NOTIFICATION_LOG_TAGS, "Scheduled batch notification ids=$ids") return ids } @@ -339,7 +339,7 @@ class TauriNotificationManager( val schedule = notification.schedule intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()) Logger.debug( - Logger.tags("Notification"), + NOTIFICATION_LOG_TAGS, "Built action intent notificationId=${notification.id} action=$action removable=${schedule == null || schedule.isRemovable()}" ) return intent @@ -370,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) { @@ -388,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)) ) } @@ -494,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 = @@ -524,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()) @@ -564,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 16d979269a..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(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,p,y;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 h(t){const n=[],i=new WeakSet,e=new Set,o=t=>{if(!t||"object"!=typeof t||Array.isArray(t))return null;const n=t,i=n.nameValuePairs;return i&&"object"==typeof i?o(i):n},r=t=>{const n=o(t);if(!n)return null;const i=n.actionId;if("string"!=typeof i||0===i.length)return null;const e={actionId:i},r=n.id;if("number"==typeof r)e.id=r;else if("string"==typeof r){const t=Number.parseInt(r,10);Number.isNaN(t)||(e.id=t)}"string"==typeof n.inputValue&&(e.inputValue=n.inputValue);const a=t=>{if(!t||"object"!=typeof t||Array.isArray(t))return{};const n=t,i={};for(const[t,e]of Object.entries(n))"string"==typeof e&&(i[t]=e);return i},s=t=>!t||"object"!=typeof t||Array.isArray(t)?{}:t,c=t=>{const n=o(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:a(n.data),extra:s(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&&"object"==typeof n.schedule&&(e.schedule=n.schedule),e};return"notification"in n&&(e.notification=c(n.notification)),e},a=t=>{if(!t||"object"!=typeof t)return;if(Array.isArray(t)){for(const n of t)a(n);return}const o=t;if(i.has(o))return;i.add(o);const s=t,c=r(s);if(c)return void(t=>{const i=`${t.id??""}|${t.actionId}|${t.inputValue??""}`;e.has(i)||(e.add(i),n.push(t))})(c);const u=s.value;if(void 0!==u&&a(u),"number"==typeof s.length)for(let t=0;tn(t)));try{const t=h(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__})} +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/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 43e159ef65..6bb706961e 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -293,6 +293,20 @@ interface ActionPerformedNotification { 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, @@ -574,20 +588,24 @@ async function onNotificationReceived( } function normalisePendingActions( - pending: unknown + pending: PendingActionsRaw ): ActionPerformedNotification[] { const normalisedActions: ActionPerformedNotification[] = [] const seenObjects = new WeakSet() const seenActionKeys = new Set() - const toRecord = (value: unknown): Record | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + 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 as Record + const record = value const wrapped = record.nameValuePairs - if (wrapped && typeof wrapped === 'object') { + if (isRawPendingRecord(wrapped)) { return toRecord(wrapped) } @@ -639,14 +657,16 @@ function normalisePendingActions( } const toStringRecord = (value: unknown): Record => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isRawPendingRecord(value)) { return {} } - const source = value as Record const output: Record = {} - for (const [key, item] of Object.entries(source)) { + 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 } } @@ -654,10 +674,10 @@ function normalisePendingActions( } const toUnknownRecord = (value: unknown): Record => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isRawPendingRecord(value)) { return {} } - return value as Record + return value } const coerceActiveNotification = ( @@ -706,9 +726,10 @@ function normalisePendingActions( } if ( notificationRecord.schedule && - typeof notificationRecord.schedule === 'object' + isRawPendingRecord(notificationRecord.schedule) ) { - activeNotification.schedule = notificationRecord.schedule as Schedule + activeNotification.schedule = + notificationRecord.schedule as unknown as Schedule } return activeNotification @@ -742,13 +763,16 @@ function normalisePendingActions( return } - const objectValue = value as object + const objectValue = value if (seenObjects.has(objectValue)) { return } seenObjects.add(objectValue) - const record = value as Record + if (!isRawPendingRecord(value)) { + return + } + const record = value const directAction = buildAction(record) if (directAction) { @@ -805,7 +829,7 @@ async function onAction( (notification: ActionPerformedNotification) => actionCallback(notification) ) try { - const pendingResult = await invoke( + const pendingResult = await invoke( 'plugin:notification|register_action_listener_ready' ) const pending = normalisePendingActions(pendingResult) From 93c552af997bbba02ab97af00f8d154fdd28691a Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Sat, 28 Feb 2026 20:28:27 -0500 Subject: [PATCH 9/9] chore(notification): add changefile for action reliability updates --- .changes/change-pr-notification-actions-followup.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changes/change-pr-notification-actions-followup.md 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.