react-native-notify-kit 10.4.7 → 10.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -0
  2. package/android/src/main/kotlin/io/invertase/notifee/NotifeeReactUtils.kt +679 -7
  3. package/android/src/test/java/io/invertase/notifee/NotifeeReactUtilsPendingEventsTest.java +1025 -0
  4. package/dist/NotifeeApiModule.d.ts +7 -2
  5. package/dist/NotifeeApiModule.js +34 -15
  6. package/dist/NotifeeApiModule.js.map +1 -1
  7. package/dist/fcm/reconstructNotification.js +3 -2
  8. package/dist/fcm/reconstructNotification.js.map +1 -1
  9. package/dist/fcm/types.d.ts +5 -4
  10. package/dist/types/Module.d.ts +11 -2
  11. package/dist/version.d.ts +1 -1
  12. package/dist/version.js +1 -1
  13. package/package.json +1 -1
  14. package/src/NotifeeApiModule.ts +44 -17
  15. package/src/fcm/reconstructNotification.ts +5 -3
  16. package/src/fcm/types.ts +5 -4
  17. package/src/types/Module.ts +12 -2
  18. package/src/version.ts +1 -1
  19. package/server/dist/android.d.ts +0 -8
  20. package/server/dist/android.d.ts.map +0 -1
  21. package/server/dist/android.js +0 -20
  22. package/server/dist/android.js.map +0 -1
  23. package/server/dist/buildPayload.d.ts +0 -3
  24. package/server/dist/buildPayload.d.ts.map +0 -1
  25. package/server/dist/buildPayload.js +0 -82
  26. package/server/dist/buildPayload.js.map +0 -1
  27. package/server/dist/index.d.ts +0 -6
  28. package/server/dist/index.d.ts.map +0 -1
  29. package/server/dist/index.js +0 -12
  30. package/server/dist/index.js.map +0 -1
  31. package/server/dist/ios.d.ts +0 -11
  32. package/server/dist/ios.d.ts.map +0 -1
  33. package/server/dist/ios.js +0 -61
  34. package/server/dist/ios.js.map +0 -1
  35. package/server/dist/serialize.d.ts +0 -11
  36. package/server/dist/serialize.d.ts.map +0 -1
  37. package/server/dist/serialize.js +0 -32
  38. package/server/dist/serialize.js.map +0 -1
  39. package/server/dist/types.d.ts +0 -2
  40. package/server/dist/types.d.ts.map +0 -1
  41. package/server/dist/types.js +0 -3
  42. package/server/dist/types.js.map +0 -1
  43. package/server/dist/validation.d.ts +0 -3
  44. package/server/dist/validation.d.ts.map +0 -1
  45. package/server/dist/validation.js +0 -101
  46. package/server/dist/validation.js.map +0 -1
@@ -5,42 +5,350 @@
5
5
  package io.invertase.notifee
6
6
 
7
7
  import android.annotation.SuppressLint
8
+ import android.content.Context
9
+ import android.content.pm.ApplicationInfo
10
+ import android.content.pm.PackageManager
8
11
  import android.os.Bundle
12
+ import android.os.SystemClock
9
13
  import android.util.Log
10
14
  import androidx.lifecycle.Lifecycle
11
15
  import androidx.lifecycle.ProcessLifecycleOwner
12
16
  import app.notifee.core.EventSubscriber
13
17
  import com.facebook.react.bridge.Arguments
14
18
  import com.facebook.react.bridge.Promise
19
+ import com.facebook.react.bridge.ReadableMap
20
+ import com.facebook.react.bridge.ReadableType
21
+ import com.facebook.react.bridge.ReactContext
15
22
  import com.facebook.react.bridge.WritableArray
23
+ import com.facebook.react.bridge.WritableMap
16
24
  import com.facebook.react.modules.core.DeviceEventManagerModule
17
25
  import java.lang.reflect.Method
26
+ import java.util.IdentityHashMap
27
+ import java.util.concurrent.atomic.AtomicLong
18
28
 
19
29
  object NotifeeReactUtils {
20
30
 
21
31
  val headlessTaskManager = HeadlessTask()
22
32
 
23
33
  private const val MAX_PENDING_EVENTS = 10
24
- private val pendingEvents = mutableListOf<Pair<String, com.facebook.react.bridge.WritableMap>>()
34
+ private const val PENDING_DIAGNOSTICS_TAG = "NOTIFEE_PENDING_DIAG"
35
+ private const val PENDING_DIAGNOSTICS_ENABLED_METADATA =
36
+ "notifee_pending_events_diagnostics_enabled"
37
+ private const val PENDING_DIAGNOSTICS_DELAY_METADATA =
38
+ "notifee_pending_events_delay_after_snapshot_ms"
39
+ private const val MAX_PENDING_DIAGNOSTIC_DELAY_MS = 10_000
40
+ private const val PENDING_DIAGNOSTIC_SCENARIO_ID_KEY = "__notifeePendingDiagScenarioId"
41
+ private const val PENDING_DIAGNOSTIC_EVENT_ID_KEY = "__notifeePendingDiagEventId"
42
+ private const val MAX_PENDING_DIAGNOSTIC_SCENARIO_ID_LENGTH = 48
43
+ private const val MAX_PENDING_DIAGNOSTIC_EVENT_ID_LENGTH = 80
44
+
45
+ @Volatile
46
+ private var pendingDiagnosticsState: PendingDiagnosticsState? = null
47
+
48
+ private val pendingEvents = mutableListOf<Pair<String, WritableMap>>()
49
+
50
+ private data class PendingDiagnosticIdentity(
51
+ val scenarioId: String,
52
+ val eventId: String,
53
+ val eventType: Int?,
54
+ )
55
+
56
+ private data class QueuedPendingEventDiagnostic(
57
+ val eventSeq: Long,
58
+ val identity: PendingDiagnosticIdentity?,
59
+ )
60
+
61
+ private data class PendingDiagnosticEvent(
62
+ val eventSeq: Long,
63
+ val pendingEvent: Pair<String, WritableMap>,
64
+ val identity: PendingDiagnosticIdentity?,
65
+ ) {
66
+ val eventName: String
67
+ get() = pendingEvent.first
68
+
69
+ val eventBody: WritableMap
70
+ get() = pendingEvent.second
71
+ }
72
+
73
+ private data class PendingEventToRequeue(
74
+ val pendingEvent: Pair<String, WritableMap>,
75
+ val diagnostic: QueuedPendingEventDiagnostic? = null,
76
+ val reason: String? = null,
77
+ )
78
+
79
+ private data class PendingEventMergeResult(
80
+ val queueSizeBefore: Int,
81
+ val requeuedCount: Int,
82
+ val newArrivalsCount: Int,
83
+ val queueSizeAfter: Int,
84
+ val droppedEvents: List<PendingEventToRequeue>,
85
+ val survivingRequeuedEvents: List<PendingEventToRequeue>,
86
+ )
87
+
88
+ private data class PendingDiagnosticsConfig(
89
+ val enabled: Boolean,
90
+ val delayAfterSnapshotMs: Long,
91
+ )
92
+
93
+ private class PendingDiagnosticsState(val delayAfterSnapshotMs: Long) {
94
+ val eventSequence = AtomicLong(0)
95
+ val flushSequence = AtomicLong(0)
96
+ val queuedEventDiagnostics =
97
+ IdentityHashMap<Pair<String, WritableMap>, QueuedPendingEventDiagnostic>()
98
+ }
25
99
 
26
100
  fun flushPendingEvents() {
27
- val eventsToSend: List<Pair<String, com.facebook.react.bridge.WritableMap>>
101
+ val diagnosticsState = pendingDiagnosticsState
102
+ if (diagnosticsState != null) {
103
+ flushPendingEventsDiagnostics(diagnosticsState)
104
+ return
105
+ }
106
+
107
+ val eventsToSend: List<Pair<String, WritableMap>>
28
108
  synchronized(pendingEvents) {
29
109
  eventsToSend = pendingEvents.toList()
30
110
  pendingEvents.clear()
31
111
  }
32
- for ((name, map) in eventsToSend) {
112
+ val eventsToRequeue = mutableListOf<PendingEventToRequeue>()
113
+ for (pendingEvent in eventsToSend) {
114
+ val (name, map) = pendingEvent
33
115
  try {
34
116
  val reactContext = HeadlessTask.getReactContext(EventSubscriber.getContext())
35
- if (reactContext != null && reactContext.hasActiveReactInstance()) {
36
- reactContext
37
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
38
- .emit(name, map)
117
+ val hasActiveReactInstance = reactContext?.hasActiveReactInstance()
118
+ if (reactContext == null || hasActiveReactInstance != true) {
119
+ eventsToRequeue.add(
120
+ PendingEventToRequeue(
121
+ pendingEvent = pendingEvent,
122
+ reason = if (reactContext == null) "context_null" else "context_inactive",
123
+ ),
124
+ )
125
+ continue
126
+ }
127
+ reactContext
128
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
129
+ .emit(name, map)
130
+ } catch (e: Exception) {
131
+ Log.e("SEND_EVENT", "flush failed", e)
132
+ }
133
+ }
134
+ mergeUndeliveredPendingEvents(eventsToRequeue, null)
135
+ }
136
+
137
+ private fun flushPendingEventsDiagnostics(diagnosticsState: PendingDiagnosticsState) {
138
+ val flushId = diagnosticsState.flushSequence.incrementAndGet()
139
+ val queueSizeAtRequest = currentPendingEventCount()
140
+ logPendingDiagnostic(
141
+ marker = "FLUSH_REQUESTED",
142
+ flushId = flushId,
143
+ queueSizeBefore = queueSizeAtRequest,
144
+ queueSizeAfter = queueSizeAtRequest,
145
+ result = "requested",
146
+ )
147
+
148
+ val eventsToSend = snapshotAndClearPendingEventsDiagnostics(diagnosticsState)
149
+ logPendingDiagnostic(
150
+ marker = "FLUSH_SNAPSHOT",
151
+ flushId = flushId,
152
+ queueSizeBefore = eventsToSend.size,
153
+ queueSizeAfter = eventsToSend.size,
154
+ result = "snapshot_captured",
155
+ )
156
+ eventsToSend.forEach { event ->
157
+ logPendingDiagnostic(
158
+ marker = "FLUSH_SNAPSHOT_ITEM",
159
+ eventSeq = event.eventSeq,
160
+ flushId = flushId,
161
+ eventName = event.eventName,
162
+ queueSizeBefore = eventsToSend.size,
163
+ queueSizeAfter = 0,
164
+ diagnosticIdentity = event.identity,
165
+ result = "snapshot_item",
166
+ )
167
+ }
168
+ logPendingDiagnostic(
169
+ marker = "FLUSH_QUEUE_CLEARED",
170
+ flushId = flushId,
171
+ queueSizeBefore = eventsToSend.size,
172
+ queueSizeAfter = 0,
173
+ result = "queue_cleared",
174
+ )
175
+
176
+ if (
177
+ eventsToSend.isNotEmpty() &&
178
+ diagnosticsState.delayAfterSnapshotMs > 0
179
+ ) {
180
+ val queueSizeBeforeDelay = currentPendingEventCount()
181
+ logPendingDiagnostic(
182
+ marker = "FLUSH_DIAGNOSTIC_DELAY_BEGIN",
183
+ flushId = flushId,
184
+ queueSizeBefore = queueSizeBeforeDelay,
185
+ queueSizeAfter = queueSizeBeforeDelay,
186
+ result = "sleep_${diagnosticsState.delayAfterSnapshotMs}_ms",
187
+ )
188
+ SystemClock.sleep(diagnosticsState.delayAfterSnapshotMs)
189
+ val queueSizeAfterDelay = currentPendingEventCount()
190
+ logPendingDiagnostic(
191
+ marker = "FLUSH_DIAGNOSTIC_DELAY_END",
192
+ flushId = flushId,
193
+ queueSizeBefore = queueSizeBeforeDelay,
194
+ queueSizeAfter = queueSizeAfterDelay,
195
+ result = "delay_complete",
196
+ )
197
+ }
198
+
199
+ var nativeReturns = 0
200
+ var skippedNoContext = 0
201
+ var exceptions = 0
202
+ val eventsToRequeue = mutableListOf<PendingEventToRequeue>()
203
+ for (event in eventsToSend) {
204
+ var reactContext: ReactContext? = null
205
+ var hasActiveReactInstance: Boolean? = null
206
+ try {
207
+ reactContext = HeadlessTask.getReactContext(EventSubscriber.getContext())
208
+ hasActiveReactInstance = reactContext?.hasActiveReactInstance()
209
+ logPendingDiagnostic(
210
+ marker = "FLUSH_EVENT_CONTEXT_CHECK",
211
+ eventSeq = event.eventSeq,
212
+ flushId = flushId,
213
+ eventName = event.eventName,
214
+ diagnosticIdentity = event.identity,
215
+ reactContext = reactContext,
216
+ hasActiveReactInstance = hasActiveReactInstance,
217
+ result = when {
218
+ reactContext == null -> "context_null"
219
+ hasActiveReactInstance != true -> "context_inactive"
220
+ else -> "context_active"
221
+ },
222
+ )
223
+
224
+ val activeReactContext = reactContext
225
+ if (activeReactContext == null || hasActiveReactInstance != true) {
226
+ skippedNoContext += 1
227
+ val requeueReason =
228
+ if (activeReactContext == null) "context_null" else "context_inactive"
229
+ logPendingDiagnostic(
230
+ marker = "FLUSH_EVENT_SKIPPED_NO_CONTEXT",
231
+ eventSeq = event.eventSeq,
232
+ flushId = flushId,
233
+ eventName = event.eventName,
234
+ diagnosticIdentity = event.identity,
235
+ reactContext = reactContext,
236
+ hasActiveReactInstance = hasActiveReactInstance,
237
+ result = if (activeReactContext == null) {
238
+ "skipped_context_null"
239
+ } else {
240
+ "skipped_context_inactive"
241
+ },
242
+ )
243
+ eventsToRequeue.add(
244
+ PendingEventToRequeue(
245
+ pendingEvent = event.pendingEvent,
246
+ diagnostic =
247
+ QueuedPendingEventDiagnostic(event.eventSeq, event.identity),
248
+ reason = requeueReason,
249
+ ),
250
+ )
251
+ continue
39
252
  }
253
+
254
+ logPendingDiagnostic(
255
+ marker = "FLUSH_EVENT_EMIT_BEGIN",
256
+ eventSeq = event.eventSeq,
257
+ flushId = flushId,
258
+ eventName = event.eventName,
259
+ diagnosticIdentity = event.identity,
260
+ reactContext = activeReactContext,
261
+ hasActiveReactInstance = hasActiveReactInstance,
262
+ result = "native_call_begin",
263
+ )
264
+ activeReactContext
265
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
266
+ .emit(event.eventName, event.eventBody)
267
+ nativeReturns += 1
268
+ logPendingDiagnostic(
269
+ marker = "FLUSH_EVENT_EMIT_RETURN",
270
+ eventSeq = event.eventSeq,
271
+ flushId = flushId,
272
+ eventName = event.eventName,
273
+ diagnosticIdentity = event.identity,
274
+ reactContext = activeReactContext,
275
+ hasActiveReactInstance = hasActiveReactInstance,
276
+ result = "native_call_returned_no_js_ack",
277
+ )
40
278
  } catch (e: Exception) {
279
+ exceptions += 1
280
+ logPendingDiagnostic(
281
+ marker = "FLUSH_EVENT_EMIT_EXCEPTION",
282
+ eventSeq = event.eventSeq,
283
+ flushId = flushId,
284
+ eventName = event.eventName,
285
+ diagnosticIdentity = event.identity,
286
+ reactContext = reactContext,
287
+ hasActiveReactInstance = hasActiveReactInstance,
288
+ result = "exception_${e.javaClass.simpleName}",
289
+ exception = e,
290
+ )
41
291
  Log.e("SEND_EVENT", "flush failed", e)
42
292
  }
43
293
  }
294
+
295
+ val mergeResult =
296
+ mergeUndeliveredPendingEvents(eventsToRequeue, diagnosticsState)
297
+ mergeResult.survivingRequeuedEvents.forEach { event ->
298
+ logPendingDiagnostic(
299
+ marker = "FLUSH_EVENT_REQUEUED",
300
+ eventSeq = event.diagnostic?.eventSeq ?: -1,
301
+ flushId = flushId,
302
+ eventName = event.pendingEvent.first,
303
+ diagnosticIdentity = event.diagnostic?.identity,
304
+ queueSizeBefore = mergeResult.queueSizeBefore,
305
+ queueSizeAfter = mergeResult.queueSizeAfter,
306
+ reason = event.reason ?: "context_unavailable",
307
+ requeuedCount = mergeResult.requeuedCount,
308
+ newArrivalsCount = mergeResult.newArrivalsCount,
309
+ droppedCount = mergeResult.droppedEvents.size,
310
+ result = "requeued_${event.reason ?: "context_unavailable"}",
311
+ )
312
+ }
313
+ mergeResult.droppedEvents.forEach { event ->
314
+ logPendingDiagnostic(
315
+ marker = "FLUSH_REQUEUE_DROP",
316
+ eventSeq = event.diagnostic?.eventSeq ?: -1,
317
+ flushId = flushId,
318
+ eventName = event.pendingEvent.first,
319
+ diagnosticIdentity = event.diagnostic?.identity,
320
+ queueSizeBefore = mergeResult.queueSizeBefore,
321
+ queueSizeAfter = mergeResult.queueSizeAfter,
322
+ reason = event.reason ?: "new_arrival",
323
+ requeuedCount = mergeResult.requeuedCount,
324
+ newArrivalsCount = mergeResult.newArrivalsCount,
325
+ droppedCount = mergeResult.droppedEvents.size,
326
+ result = "drop_oldest_after_requeue_merge",
327
+ )
328
+ }
329
+ if (eventsToRequeue.isNotEmpty()) {
330
+ logPendingDiagnostic(
331
+ marker = "FLUSH_REQUEUE_MERGE",
332
+ flushId = flushId,
333
+ queueSizeBefore = mergeResult.queueSizeBefore,
334
+ queueSizeAfter = mergeResult.queueSizeAfter,
335
+ reason = "context_unavailable",
336
+ requeuedCount = mergeResult.requeuedCount,
337
+ newArrivalsCount = mergeResult.newArrivalsCount,
338
+ droppedCount = mergeResult.droppedEvents.size,
339
+ result = "requeued_before_new_arrivals_cap_applied",
340
+ )
341
+ }
342
+
343
+ val queueSizeAtCompletion = currentPendingEventCount()
344
+ logPendingDiagnostic(
345
+ marker = "FLUSH_COMPLETED",
346
+ flushId = flushId,
347
+ queueSizeBefore = eventsToSend.size,
348
+ queueSizeAfter = queueSizeAtCompletion,
349
+ result =
350
+ "native_returns_${nativeReturns}_skipped_${skippedNoContext}_exceptions_$exceptions",
351
+ )
44
352
  }
45
353
 
46
354
  fun promiseResolver(promise: Promise, e: Exception?, bundle: Bundle?) {
@@ -104,6 +412,12 @@ object NotifeeReactUtils {
104
412
  }
105
413
 
106
414
  fun sendEvent(eventName: String, eventMap: com.facebook.react.bridge.WritableMap) {
415
+ val diagnosticsState = pendingDiagnosticsState
416
+ if (diagnosticsState != null) {
417
+ sendEventDiagnostics(diagnosticsState, eventName, eventMap)
418
+ return
419
+ }
420
+
107
421
  try {
108
422
  val reactContext = HeadlessTask.getReactContext(EventSubscriber.getContext())
109
423
  if (reactContext == null || !reactContext.hasActiveReactInstance()) {
@@ -124,6 +438,364 @@ object NotifeeReactUtils {
124
438
  }
125
439
  }
126
440
 
441
+ private fun sendEventDiagnostics(
442
+ diagnosticsState: PendingDiagnosticsState,
443
+ eventName: String,
444
+ eventMap: WritableMap,
445
+ ) {
446
+ val eventSeq = diagnosticsState.eventSequence.incrementAndGet()
447
+ val diagnosticIdentity = extractPendingDiagnosticIdentity(eventMap)
448
+ val queueSizeAtReceipt = currentPendingEventCount()
449
+ logPendingDiagnostic(
450
+ marker = "EVENT_RECEIVED",
451
+ eventSeq = eventSeq,
452
+ eventName = eventName,
453
+ diagnosticIdentity = diagnosticIdentity,
454
+ queueSizeBefore = queueSizeAtReceipt,
455
+ queueSizeAfter = queueSizeAtReceipt,
456
+ result = "received",
457
+ )
458
+
459
+ var reactContext: ReactContext? = null
460
+ var hasActiveReactInstance: Boolean? = null
461
+ try {
462
+ reactContext = HeadlessTask.getReactContext(EventSubscriber.getContext())
463
+ hasActiveReactInstance = reactContext?.hasActiveReactInstance()
464
+ val activeReactContext = reactContext
465
+ if (activeReactContext == null || hasActiveReactInstance != true) {
466
+ val pendingEvent = Pair(eventName, eventMap)
467
+ val queueSizeBefore: Int
468
+ val queueSizeAfterDrop: Int
469
+ val queueSizeAfter: Int
470
+ var droppedEvent: Pair<String, WritableMap>? = null
471
+ var droppedEventDiagnostic: QueuedPendingEventDiagnostic? = null
472
+ synchronized(pendingEvents) {
473
+ queueSizeBefore = pendingEvents.size
474
+ if (pendingEvents.size >= MAX_PENDING_EVENTS) {
475
+ droppedEvent = pendingEvents.removeAt(0)
476
+ droppedEventDiagnostic =
477
+ diagnosticsState.queuedEventDiagnostics.remove(droppedEvent)
478
+ }
479
+ queueSizeAfterDrop = pendingEvents.size
480
+ pendingEvents.add(pendingEvent)
481
+ diagnosticsState.queuedEventDiagnostics[pendingEvent] =
482
+ QueuedPendingEventDiagnostic(eventSeq, diagnosticIdentity)
483
+ queueSizeAfter = pendingEvents.size
484
+ }
485
+
486
+ droppedEvent?.let { dropped ->
487
+ logPendingDiagnostic(
488
+ marker = "EVENT_OVERFLOW_DROPPED",
489
+ eventSeq = droppedEventDiagnostic?.eventSeq ?: -1,
490
+ eventName = dropped.first,
491
+ diagnosticIdentity = droppedEventDiagnostic?.identity,
492
+ queueSizeBefore = queueSizeBefore,
493
+ queueSizeAfter = queueSizeAfterDrop,
494
+ reactContext = reactContext,
495
+ hasActiveReactInstance = hasActiveReactInstance,
496
+ result = "drop_oldest",
497
+ )
498
+ }
499
+ logPendingDiagnostic(
500
+ marker = "EVENT_ENQUEUED",
501
+ eventSeq = eventSeq,
502
+ eventName = eventName,
503
+ diagnosticIdentity = diagnosticIdentity,
504
+ queueSizeBefore = queueSizeAfterDrop,
505
+ queueSizeAfter = queueSizeAfter,
506
+ reactContext = reactContext,
507
+ hasActiveReactInstance = hasActiveReactInstance,
508
+ result = if (activeReactContext == null) {
509
+ "enqueued_context_null"
510
+ } else {
511
+ "enqueued_context_inactive"
512
+ },
513
+ )
514
+ return
515
+ }
516
+
517
+ val directQueueSize = currentPendingEventCount()
518
+ logPendingDiagnostic(
519
+ marker = "EVENT_EMIT_DIRECT_BEGIN",
520
+ eventSeq = eventSeq,
521
+ eventName = eventName,
522
+ diagnosticIdentity = diagnosticIdentity,
523
+ queueSizeBefore = directQueueSize,
524
+ queueSizeAfter = directQueueSize,
525
+ reactContext = activeReactContext,
526
+ hasActiveReactInstance = hasActiveReactInstance,
527
+ result = "native_call_begin",
528
+ )
529
+ activeReactContext
530
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
531
+ .emit(eventName, eventMap)
532
+ logPendingDiagnostic(
533
+ marker = "EVENT_EMIT_DIRECT_RETURN",
534
+ eventSeq = eventSeq,
535
+ eventName = eventName,
536
+ diagnosticIdentity = diagnosticIdentity,
537
+ reactContext = activeReactContext,
538
+ hasActiveReactInstance = hasActiveReactInstance,
539
+ result = "native_call_returned_no_js_ack",
540
+ )
541
+ } catch (e: Exception) {
542
+ logPendingDiagnostic(
543
+ marker = "EVENT_EMIT_DIRECT_EXCEPTION",
544
+ eventSeq = eventSeq,
545
+ eventName = eventName,
546
+ diagnosticIdentity = diagnosticIdentity,
547
+ reactContext = reactContext,
548
+ hasActiveReactInstance = hasActiveReactInstance,
549
+ result = "exception_${e.javaClass.simpleName}",
550
+ exception = e,
551
+ )
552
+ Log.e("SEND_EVENT", "", e)
553
+ }
554
+ }
555
+
556
+ private fun snapshotAndClearPendingEventsDiagnostics(
557
+ diagnosticsState: PendingDiagnosticsState,
558
+ ): List<PendingDiagnosticEvent> {
559
+ synchronized(pendingEvents) {
560
+ val eventsToSend =
561
+ pendingEvents.map { event ->
562
+ val eventDiagnostic =
563
+ diagnosticsState.queuedEventDiagnostics.remove(event)
564
+ PendingDiagnosticEvent(
565
+ eventDiagnostic?.eventSeq ?: -1,
566
+ event,
567
+ eventDiagnostic?.identity,
568
+ )
569
+ }
570
+ pendingEvents.clear()
571
+ return eventsToSend
572
+ }
573
+ }
574
+
575
+ private fun mergeUndeliveredPendingEvents(
576
+ eventsToRequeue: List<PendingEventToRequeue>,
577
+ diagnosticsState: PendingDiagnosticsState?,
578
+ ): PendingEventMergeResult {
579
+ synchronized(pendingEvents) {
580
+ val newArrivals =
581
+ pendingEvents.map { pendingEvent ->
582
+ PendingEventToRequeue(
583
+ pendingEvent = pendingEvent,
584
+ diagnostic = diagnosticsState?.queuedEventDiagnostics?.get(pendingEvent),
585
+ )
586
+ }
587
+ val mergedEvents = eventsToRequeue + newArrivals
588
+ val droppedCount = (mergedEvents.size - MAX_PENDING_EVENTS).coerceAtLeast(0)
589
+ val droppedEvents = mergedEvents.take(droppedCount)
590
+ val survivingEvents = mergedEvents.drop(droppedCount)
591
+ val droppedRequeuedCount = droppedCount.coerceAtMost(eventsToRequeue.size)
592
+ val survivingRequeuedEvents = eventsToRequeue.drop(droppedRequeuedCount)
593
+
594
+ pendingEvents.clear()
595
+ pendingEvents.addAll(survivingEvents.map { it.pendingEvent })
596
+ diagnosticsState?.queuedEventDiagnostics?.apply {
597
+ clear()
598
+ survivingEvents.forEach { event ->
599
+ event.diagnostic?.let { diagnostic -> put(event.pendingEvent, diagnostic) }
600
+ }
601
+ }
602
+
603
+ return PendingEventMergeResult(
604
+ queueSizeBefore = mergedEvents.size,
605
+ requeuedCount = eventsToRequeue.size,
606
+ newArrivalsCount = newArrivals.size,
607
+ queueSizeAfter = survivingEvents.size,
608
+ droppedEvents = droppedEvents,
609
+ survivingRequeuedEvents = survivingRequeuedEvents,
610
+ )
611
+ }
612
+ }
613
+
614
+ private fun currentPendingEventCount(): Int = synchronized(pendingEvents) { pendingEvents.size }
615
+
616
+ private fun extractPendingDiagnosticIdentity(
617
+ eventMap: WritableMap,
618
+ ): PendingDiagnosticIdentity? {
619
+ return try {
620
+ val detail = readPendingDiagnosticMap(eventMap, "detail") ?: return null
621
+ val notification = readPendingDiagnosticMap(detail, "notification") ?: return null
622
+ val data = readPendingDiagnosticMap(notification, "data") ?: return null
623
+ val scenarioId =
624
+ readPendingDiagnosticString(data, PENDING_DIAGNOSTIC_SCENARIO_ID_KEY)
625
+ ?: return null
626
+ val eventId =
627
+ readPendingDiagnosticString(data, PENDING_DIAGNOSTIC_EVENT_ID_KEY) ?: return null
628
+
629
+ if (
630
+ !isValidPendingDiagnosticScenarioId(scenarioId) ||
631
+ !isValidPendingDiagnosticEventId(scenarioId, eventId)
632
+ ) {
633
+ null
634
+ } else {
635
+ PendingDiagnosticIdentity(
636
+ scenarioId,
637
+ eventId,
638
+ readPendingDiagnosticInt(eventMap, "type"),
639
+ )
640
+ }
641
+ } catch (_: Exception) {
642
+ null
643
+ }
644
+ }
645
+
646
+ private fun readPendingDiagnosticMap(map: ReadableMap, key: String): ReadableMap? {
647
+ if (!map.hasKey(key) || map.isNull(key) || map.getType(key) != ReadableType.Map) {
648
+ return null
649
+ }
650
+ return map.getMap(key)
651
+ }
652
+
653
+ private fun readPendingDiagnosticString(map: ReadableMap, key: String): String? {
654
+ if (!map.hasKey(key) || map.isNull(key) || map.getType(key) != ReadableType.String) {
655
+ return null
656
+ }
657
+ return map.getString(key)
658
+ }
659
+
660
+ private fun readPendingDiagnosticInt(map: ReadableMap, key: String): Int? {
661
+ if (!map.hasKey(key) || map.isNull(key) || map.getType(key) != ReadableType.Number) {
662
+ return null
663
+ }
664
+
665
+ val numericValue =
666
+ try {
667
+ map.getDouble(key)
668
+ } catch (_: Exception) {
669
+ return try {
670
+ map.getInt(key)
671
+ } catch (_: Exception) {
672
+ null
673
+ }
674
+ }
675
+ val integerValue = numericValue.toInt()
676
+ return integerValue.takeIf { it.toDouble() == numericValue }
677
+ }
678
+
679
+ private fun isValidPendingDiagnosticScenarioId(value: String): Boolean =
680
+ value.length in 1..MAX_PENDING_DIAGNOSTIC_SCENARIO_ID_LENGTH &&
681
+ value.all { character ->
682
+ character in 'A'..'Z' ||
683
+ character in 'a'..'z' ||
684
+ character in '0'..'9' ||
685
+ character == '.' ||
686
+ character == '_' ||
687
+ character == '-'
688
+ }
689
+
690
+ private fun isValidPendingDiagnosticEventId(
691
+ scenarioId: String,
692
+ eventId: String,
693
+ ): Boolean {
694
+ if (eventId.length !in 1..MAX_PENDING_DIAGNOSTIC_EVENT_ID_LENGTH) return false
695
+
696
+ val prefix = "pending-diag:$scenarioId:"
697
+ if (!eventId.startsWith(prefix)) return false
698
+
699
+ val suffix = eventId.substring(prefix.length)
700
+ if (suffix == "A" || suffix == "B") return true
701
+ if (!suffix.startsWith("overflow:")) return false
702
+
703
+ val overflowIndex = suffix.substring("overflow:".length)
704
+ if (overflowIndex.length != 2 || overflowIndex.any { it !in '0'..'9' }) return false
705
+ return overflowIndex.toIntOrNull()?.let { it in 1..11 } == true
706
+ }
707
+
708
+ // Invoked reflectively only by the smoke app's debug-only reload receiver. Keeping this method
709
+ // private avoids adding a library API while ensuring metadata work never runs in an event path.
710
+ @Synchronized
711
+ private fun initializePendingDiagnostics(context: Context): Boolean {
712
+ if (pendingDiagnosticsState != null) return true
713
+
714
+ val config = loadPendingDiagnosticsConfig(context)
715
+ if (!config.enabled) return false
716
+
717
+ pendingDiagnosticsState = PendingDiagnosticsState(config.delayAfterSnapshotMs)
718
+ return true
719
+ }
720
+
721
+ @Suppress("DEPRECATION")
722
+ private fun loadPendingDiagnosticsConfig(context: Context): PendingDiagnosticsConfig {
723
+ return try {
724
+ val applicationInfo =
725
+ context.packageManager.getApplicationInfo(
726
+ context.packageName,
727
+ PackageManager.GET_META_DATA,
728
+ )
729
+ val metadata = applicationInfo.metaData
730
+ val enabled =
731
+ metadata?.getBoolean(PENDING_DIAGNOSTICS_ENABLED_METADATA, false) == true &&
732
+ applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
733
+ val delayAfterSnapshotMs =
734
+ if (enabled) {
735
+ metadata
736
+ .getInt(PENDING_DIAGNOSTICS_DELAY_METADATA, 0)
737
+ .coerceIn(0, MAX_PENDING_DIAGNOSTIC_DELAY_MS)
738
+ .toLong()
739
+ } else {
740
+ 0
741
+ }
742
+ PendingDiagnosticsConfig(enabled, delayAfterSnapshotMs)
743
+ } catch (_: Exception) {
744
+ PendingDiagnosticsConfig(false, 0)
745
+ }
746
+ }
747
+
748
+ private fun logPendingDiagnostic(
749
+ marker: String,
750
+ eventSeq: Long = -1,
751
+ flushId: Long = -1,
752
+ eventName: String? = null,
753
+ diagnosticIdentity: PendingDiagnosticIdentity? = null,
754
+ queueSizeBefore: Int = -1,
755
+ queueSizeAfter: Int = -1,
756
+ reason: String = "not_applicable",
757
+ requeuedCount: Int = -1,
758
+ newArrivalsCount: Int = -1,
759
+ droppedCount: Int = -1,
760
+ reactContext: ReactContext? = null,
761
+ hasActiveReactInstance: Boolean? = null,
762
+ result: String,
763
+ exception: Exception? = null,
764
+ ) {
765
+ val contextIdentity =
766
+ reactContext?.let { "0x${Integer.toHexString(System.identityHashCode(it))}" } ?: "null"
767
+ val message =
768
+ "$PENDING_DIAGNOSTICS_TAG marker=$marker " +
769
+ "eventSeq=$eventSeq flushId=$flushId " +
770
+ "eventName=${sanitizePendingDiagnosticValue(eventName)} " +
771
+ "diagScenarioId=${diagnosticIdentity?.scenarioId ?: "unavailable"} " +
772
+ "diagEventId=${diagnosticIdentity?.eventId ?: "unavailable"} " +
773
+ "eventType=${diagnosticIdentity?.eventType ?: "unavailable"} " +
774
+ "thread=${sanitizePendingDiagnosticValue(Thread.currentThread().name)} " +
775
+ "elapsedRealtime=${SystemClock.elapsedRealtime()} " +
776
+ "queueSizeBefore=$queueSizeBefore queueSizeAfter=$queueSizeAfter " +
777
+ "reason=${sanitizePendingDiagnosticValue(reason)} " +
778
+ "requeuedCount=$requeuedCount newArrivalsCount=$newArrivalsCount " +
779
+ "droppedCount=$droppedCount " +
780
+ "contextIdentity=$contextIdentity reactHostIdentity=unavailable " +
781
+ "hasActiveReactInstance=${hasActiveReactInstance ?: "not_checked"} " +
782
+ "result=${sanitizePendingDiagnosticValue(result)}"
783
+ if (exception == null) {
784
+ Log.i(PENDING_DIAGNOSTICS_TAG, message)
785
+ } else {
786
+ Log.e(PENDING_DIAGNOSTICS_TAG, message, exception)
787
+ }
788
+ }
789
+
790
+ private fun sanitizePendingDiagnosticValue(value: String?): String =
791
+ value
792
+ ?.replace(' ', '_')
793
+ ?.replace('\t', '_')
794
+ ?.replace('\r', '_')
795
+ ?.replace('\n', '_')
796
+ ?.take(120)
797
+ ?: "null"
798
+
127
799
  fun isAppInForeground(): Boolean {
128
800
  return ProcessLifecycleOwner.get()
129
801
  .lifecycle