Skip to content

Commit 7b17693

Browse files
committed
fix(notification/android): persist and dedupe queued action events across reloads
1 parent e63b8f9 commit 7b17693

1 file changed

Lines changed: 145 additions & 3 deletions

File tree

plugins/notification/android/src/main/java/NotificationPlugin.kt

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ import app.tauri.plugin.Invoke
2222
import app.tauri.plugin.JSArray
2323
import app.tauri.plugin.JSObject
2424
import app.tauri.plugin.Plugin
25+
import org.json.JSONArray
26+
import org.json.JSONObject
2527

2628
const 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
2934
class 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

Comments
 (0)