@@ -22,8 +22,13 @@ import app.tauri.plugin.Invoke
2222import app.tauri.plugin.JSArray
2323import app.tauri.plugin.JSObject
2424import app.tauri.plugin.Plugin
25+ import org.json.JSONArray
26+ import org.json.JSONObject
2527
2628const val LOCAL_NOTIFICATIONS = " permissionState"
29+ private const val PREFS_NAME = " tauri_notification_plugin"
30+ private const val PREF_KEY_PENDING_ACTION_EVENTS = " pending_action_events"
31+ private const val PENDING_ACTION_EVENT_TTL_MS = 24 * 60 * 60 * 1000L
2732
2833@InvokeArg
2934class PluginConfig {
@@ -82,9 +87,126 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
8287 private lateinit var notificationManager: NotificationManager
8388 private lateinit var notificationStorage: NotificationStorage
8489 private var channelManager = ChannelManager (activity)
85- private val pendingActionEvents = mutableListOf<JSObject >()
90+ private data class PendingActionEvent (
91+ val key : String ,
92+ val payload : JSObject ,
93+ val timestampMs : Long
94+ )
95+
96+ private val pendingActionEvents = mutableListOf<PendingActionEvent >()
97+ private val pendingActionEventKeys = mutableSetOf<String >()
8698 private var isActionListenerReady = false
8799
100+ private fun nowMs (): Long = System .currentTimeMillis()
101+
102+ private fun isEventExpired (timestampMs : Long ): Boolean {
103+ return nowMs() - timestampMs > PENDING_ACTION_EVENT_TTL_MS
104+ }
105+
106+ private fun buildActionEventKey (payload : JSObject ): String {
107+ val notification = payload.optJSONObject(" notification" )
108+ val notificationId = notification?.opt(" id" ) ? : payload.opt(" id" )
109+ val actionId = payload.optString(" actionId" )
110+ val inputValue = payload.optString(" inputValue" )
111+
112+ if (notificationId != null && actionId.isNotEmpty()) {
113+ return " $notificationId |$actionId |$inputValue "
114+ }
115+
116+ // Fallback for malformed payloads so we can still dedupe identical events.
117+ return " payload:${payload.toString()} "
118+ }
119+
120+ private fun rebuildPendingActionEventKeysLocked () {
121+ pendingActionEventKeys.clear()
122+ for (event in pendingActionEvents) {
123+ pendingActionEventKeys.add(event.key)
124+ }
125+ }
126+
127+ private fun persistPendingActionEventsLocked () {
128+ val iterator = pendingActionEvents.iterator()
129+ var droppedExpired = 0
130+ while (iterator.hasNext()) {
131+ val event = iterator.next()
132+ if (isEventExpired(event.timestampMs)) {
133+ iterator.remove()
134+ droppedExpired + = 1
135+ }
136+ }
137+ if (droppedExpired > 0 ) {
138+ rebuildPendingActionEventKeysLocked()
139+ Logger .debug(
140+ Logger .tags(" Notification" ),
141+ " Dropped expired pending actionPerformed events=$droppedExpired "
142+ )
143+ }
144+
145+ val events = JSONArray ()
146+ for (event in pendingActionEvents) {
147+ try {
148+ val wrappedEvent = JSONObject ()
149+ wrappedEvent.put(" key" , event.key)
150+ wrappedEvent.put(" timestampMs" , event.timestampMs)
151+ wrappedEvent.put(" payload" , JSONObject (event.payload.toString()))
152+ events.put(wrappedEvent)
153+ } catch (_: Throwable ) {
154+ events.put(event.payload)
155+ }
156+ }
157+ activity
158+ .getSharedPreferences(PREFS_NAME , Context .MODE_PRIVATE )
159+ .edit()
160+ .putString(PREF_KEY_PENDING_ACTION_EVENTS , events.toString())
161+ .apply ()
162+ }
163+
164+ private fun restorePendingActionEventsLocked () {
165+ val prefs = activity.getSharedPreferences(PREFS_NAME , Context .MODE_PRIVATE )
166+ val serializedEvents = prefs.getString(PREF_KEY_PENDING_ACTION_EVENTS , null ) ? : return
167+
168+ try {
169+ val events = JSONArray (serializedEvents)
170+ for (index in 0 until events.length()) {
171+ val event = events.optJSONObject(index) ? : continue
172+ val wrappedPayload = event.optJSONObject(" payload" )
173+ val payloadObject = wrappedPayload ? : event
174+ val payload = JSObject (payloadObject.toString())
175+
176+ val timestampMs =
177+ if (wrappedPayload != null ) event.optLong(" timestampMs" , nowMs()) else nowMs()
178+ if (isEventExpired(timestampMs)) {
179+ continue
180+ }
181+
182+ val key = event.optString(" key" ).ifEmpty { buildActionEventKey(payload) }
183+ if (pendingActionEventKeys.contains(key)) {
184+ Logger .debug(
185+ Logger .tags(" Notification" ),
186+ " Skipping duplicate restored actionPerformed event key=$key "
187+ )
188+ continue
189+ }
190+
191+ pendingActionEvents.add(PendingActionEvent (key, payload, timestampMs))
192+ pendingActionEventKeys.add(key)
193+ }
194+ Logger .debug(
195+ Logger .tags(" Notification" ),
196+ " Restored pending actionPerformed events=${pendingActionEvents.size} "
197+ )
198+ } catch (error: Throwable ) {
199+ Logger .error(
200+ Logger .tags(" Notification" ),
201+ " Failed to restore pending actionPerformed events" ,
202+ error
203+ )
204+ pendingActionEvents.clear()
205+ pendingActionEventKeys.clear()
206+ persistPendingActionEventsLocked()
207+ }
208+ }
209+
88210 companion object {
89211 var instance: NotificationPlugin ? = null
90212
@@ -100,7 +222,9 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
100222 this .webView = webView
101223 synchronized(this ) {
102224 pendingActionEvents.clear()
225+ pendingActionEventKeys.clear()
103226 isActionListenerReady = false
227+ restorePendingActionEventsLocked()
104228 }
105229 notificationStorage = NotificationStorage (activity, jsonMapper())
106230
@@ -140,7 +264,23 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
140264 private fun dispatchActionPerformed (payload : JSObject ) {
141265 synchronized(this ) {
142266 if (! isActionListenerReady) {
143- pendingActionEvents.add(payload)
267+ val key = buildActionEventKey(payload)
268+ // `load()` restores persisted pending events before processing the current activity intent.
269+ // Without this key check, the same action can be enqueued twice across reload boundaries.
270+ if (pendingActionEventKeys.contains(key)) {
271+ Logger .debug(
272+ Logger .tags(" Notification" ),
273+ " Skipping duplicate queued actionPerformed event key=$key "
274+ )
275+ return
276+ }
277+ pendingActionEvents.add(PendingActionEvent (key, payload, nowMs()))
278+ pendingActionEventKeys.add(key)
279+ persistPendingActionEventsLocked()
280+ Logger .debug(
281+ Logger .tags(" Notification" ),
282+ " Queued actionPerformed event; listener not ready (pending=${pendingActionEvents.size} )"
283+ )
144284 return
145285 }
146286 }
@@ -211,9 +351,11 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
211351 synchronized(this ) {
212352 isActionListenerReady = true
213353 for (event in pendingActionEvents) {
214- pending.put(event)
354+ pending.put(event.payload )
215355 }
216356 pendingActionEvents.clear()
357+ pendingActionEventKeys.clear()
358+ persistPendingActionEventsLocked()
217359 }
218360 invoke.resolveObject(pending)
219361 }
0 commit comments