react-native-push-signal 0.1.1 → 0.1.3

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 (42) hide show
  1. package/README.md +14 -3
  2. package/README.ru.md +14 -3
  3. package/android/build.gradle +1 -0
  4. package/android/consumer-rules.pro +4 -0
  5. package/android/src/main/java/com/margelo/nitro/pushsignal/PushSignal.kt +1 -1
  6. package/android/src/main/java/com/margelo/nitro/pushsignal/PushSignalCenter.kt +170 -8
  7. package/android/src/main/java/com/margelo/nitro/pushsignal/PushSignalMessagingService.kt +5 -0
  8. package/ios/PushSignal.swift +1 -1
  9. package/ios/PushSignalCenter.swift +203 -10
  10. package/ios/PushSignalLaunchStore.m +15 -0
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/pushSignal.js.map +1 -1
  13. package/lib/module/pushSignal.native.js +11 -2
  14. package/lib/module/pushSignal.native.js.map +1 -1
  15. package/lib/typescript/src/PushSignal.nitro.d.ts +2 -1
  16. package/lib/typescript/src/PushSignal.nitro.d.ts.map +1 -1
  17. package/lib/typescript/src/index.d.ts +1 -1
  18. package/lib/typescript/src/index.d.ts.map +1 -1
  19. package/lib/typescript/src/pushSignal.d.ts +2 -2
  20. package/lib/typescript/src/pushSignal.d.ts.map +1 -1
  21. package/lib/typescript/src/pushSignal.native.d.ts +2 -2
  22. package/lib/typescript/src/pushSignal.native.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_bool_____PushMessage.hpp +128 -0
  24. package/nitrogen/generated/android/c++/JHybridPushSignalSpec.cpp +5 -4
  25. package/nitrogen/generated/android/c++/JHybridPushSignalSpec.hpp +1 -1
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pushsignal/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_bool_____PushMessage.kt +78 -0
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pushsignal/HybridPushSignalSpec.kt +2 -2
  28. package/nitrogen/generated/android/pushsignalOnLoad.cpp +2 -0
  29. package/nitrogen/generated/ios/PushSignal-Swift-Cxx-Bridge.cpp +25 -0
  30. package/nitrogen/generated/ios/PushSignal-Swift-Cxx-Bridge.hpp +91 -0
  31. package/nitrogen/generated/ios/c++/HybridPushSignalSpecSwift.hpp +1 -1
  32. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_bool_____PushMessage.swift +61 -0
  33. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  34. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_bool__.swift +66 -0
  35. package/nitrogen/generated/ios/swift/HybridPushSignalSpec.swift +1 -1
  36. package/nitrogen/generated/ios/swift/HybridPushSignalSpec_cxx.swift +26 -5
  37. package/nitrogen/generated/shared/c++/HybridPushSignalSpec.hpp +1 -1
  38. package/package.json +1 -1
  39. package/src/PushSignal.nitro.ts +5 -1
  40. package/src/index.tsx +1 -0
  41. package/src/pushSignal.native.tsx +18 -6
  42. package/src/pushSignal.tsx +2 -3
package/README.md CHANGED
@@ -44,6 +44,10 @@ const credentials = await getCredentials();
44
44
 
45
45
  const stopMessages = onMessage((message) => {
46
46
  console.log('incoming', message);
47
+ if (message.data.silent === '1') {
48
+ return;
49
+ }
50
+ return true;
47
51
  });
48
52
 
49
53
  const stopPress = onNotificationPress((message) => {
@@ -70,7 +74,7 @@ Keep Apple `.p8` keys and the Firebase service account on the server. The app ne
70
74
  2. Enable **Background Modes → Remote notifications**.
71
75
  3. Use a physical device. The simulator cannot register with APNs.
72
76
 
73
- The library hooks `UNUserNotificationCenter` and APNs token callbacks, so the host `AppDelegate` does not need extra code.
77
+ The library hooks `UNUserNotificationCenter` at launch (before JS starts) and APNs token callbacks, so the host `AppDelegate` does not need extra code. Foreground pushes only reach JS if this delegate is installed before launch finishes.
74
78
 
75
79
  ## Android setup
76
80
 
@@ -112,13 +116,20 @@ If the host app already declares its own `FirebaseMessagingService`, only one se
112
116
 
113
117
  ## Incoming messages vs taps
114
118
 
115
- - `onMessage` — the push arrived while the app is in the foreground (and Android data messages while the process is alive). The library does not draw a banner.
119
+ | App state | iOS | Android (notification payload) | Android (data-only) |
120
+ | --- | --- | --- | --- |
121
+ | Foreground | `onMessage`. Banner if a listener returns `true` | `onMessage`. Banner if a listener returns `true` | `onMessage`. Tray only if a listener returns `true` and the payload has title or body |
122
+ | Background / killed | System banner. Tap → `onNotificationPress` | System banner. Tap → `onNotificationPress` | No banner. `onMessage` if the process is alive |
123
+
124
+ - `onMessage` — the push arrived while the app is in the foreground (and Android data messages while the process is alive). Return `true` to show the system banner as usual. No return, `false`, or a throw means no banner. If several listeners are registered, the banner shows when any of them returns `true`.
116
125
  - `onNotificationPress` — the user opened the notification, including a cold start.
117
126
  - On iOS, a visible push received in the background or when the app is killed is delivered on tap, not through `onMessage`. That is an OS limit.
118
127
 
128
+ If `onMessage` throws or takes longer than about 2 seconds, the library does not show a banner.
129
+
119
130
  ## Web
120
131
 
121
- `initialize()` resolves. `getCredentials()` throws. Listeners are no-ops.
132
+ `initialize()` resolves. `getCredentials()` throws. Listeners (`onMessage`, `onNotificationPress`) are no-ops.
122
133
 
123
134
  ## Contributing
124
135
 
package/README.ru.md CHANGED
@@ -44,6 +44,10 @@ const credentials = await getCredentials();
44
44
 
45
45
  const stopMessages = onMessage((message) => {
46
46
  console.log('incoming', message);
47
+ if (message.data.silent === '1') {
48
+ return;
49
+ }
50
+ return true;
47
51
  });
48
52
 
49
53
  const stopPress = onNotificationPress((message) => {
@@ -70,7 +74,7 @@ const stopPress = onNotificationPress((message) => {
70
74
  2. Включите **Background Modes → Remote notifications**.
71
75
  3. Проверяйте на физическом устройстве. Симулятор не умеет регистрироваться в APNs.
72
76
 
73
- Библиотека сама подписывается на `UNUserNotificationCenter` и колбеки APNs-токена. В `AppDelegate` хоста ничего дописывать не нужно.
77
+ Библиотека сама ставит делегат `UNUserNotificationCenter` на запуске (до JS) и подписывается на колбеки APNs-токена. В `AppDelegate` хоста ничего дописывать не нужно. Без делегата до конца запуска iOS не вызывает `willPresent`, и пуш в foreground не доходит до JS.
74
78
 
75
79
  ## Настройка Android
76
80
 
@@ -112,13 +116,20 @@ apply plugin: "com.google.gms.google-services"
112
116
 
113
117
  ## Входящее сообщение и тап
114
118
 
115
- - `onMessage` — пуш пришёл, пока приложение на переднем плане (и Android data-message, пока процесс жив). Баннер библиотека не рисует.
119
+ | Состояние | iOS | Android (notification payload) | Android (data-only) |
120
+ | --- | --- | --- | --- |
121
+ | Foreground | `onMessage`. Баннер, если слушатель вернул `true` | `onMessage`. Баннер, если слушатель вернул `true` | `onMessage`. Tray только если слушатель вернул `true` и в payload есть title или body |
122
+ | Background / killed | Системный баннер. Тап → `onNotificationPress` | Системный баннер. Тап → `onNotificationPress` | Баннера нет. `onMessage`, если процесс жив |
123
+
124
+ - `onMessage` — пуш пришёл, пока приложение на переднем плане (и Android data-message, пока процесс жив). Верните `true`, чтобы показать системный баннер как обычно. Без return, `false` или ошибка — баннера нет. Если слушателей несколько, баннер показывается, когда любой вернул `true`.
116
125
  - `onNotificationPress` — пользователь открыл уведомление, в том числе при холодном старте.
117
126
  - На iOS видимый пуш в фоне или при убитом приложении приходит только в тап, не в `onMessage`. Это ограничение ОС.
118
127
 
128
+ Если `onMessage` бросил ошибку или не ответил примерно за 2 секунды, баннер не показывается.
129
+
119
130
  ## Web
120
131
 
121
- `initialize()` резолвится. `getCredentials()` бросает ошибку. Слушатели ничего не делают.
132
+ `initialize()` резолвится. `getCredentials()` бросает ошибку. Слушатели (`onMessage`, `onNotificationPress`) ничего не делают.
122
133
 
123
134
  ## Contributing
124
135
 
@@ -43,6 +43,7 @@ android {
43
43
 
44
44
  defaultConfig {
45
45
  minSdkVersion getExtOrDefault("minSdkVersion")
46
+ consumerProguardFiles "consumer-rules.pro"
46
47
 
47
48
  externalNativeBuild {
48
49
  cmake {
@@ -0,0 +1,4 @@
1
+ -keep class com.margelo.nitro.pushsignal.PushSignalMessagingService { *; }
2
+ -keep class com.margelo.nitro.pushsignal.PushSignalInitProvider { *; }
3
+ -keep class com.margelo.nitro.pushsignal.PushSignalCenter { *; }
4
+ -keep class com.google.firebase.messaging.** { *; }
@@ -28,7 +28,7 @@ class PushSignal : HybridPushSignalSpec() {
28
28
  }
29
29
  }
30
30
 
31
- override fun setOnMessage(callback: (message: PushMessage) -> Unit) {
31
+ override fun setOnMessage(callback: (message: PushMessage) -> Promise<Promise<Boolean>>) {
32
32
  PushSignalCenter.setOnMessage(callback)
33
33
  }
34
34
 
@@ -2,29 +2,44 @@ package com.margelo.nitro.pushsignal
2
2
 
3
3
  import android.app.Activity
4
4
  import android.app.Application
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.app.PendingIntent
5
8
  import android.content.Context
6
9
  import android.content.Intent
10
+ import android.os.Build
7
11
  import android.os.Bundle
12
+ import android.os.Handler
13
+ import android.os.Looper
8
14
  import androidx.activity.ComponentActivity
15
+ import androidx.core.app.NotificationCompat
9
16
  import com.google.firebase.FirebaseApp
10
17
  import com.google.firebase.FirebaseOptions
11
18
  import com.google.firebase.messaging.FirebaseMessaging
12
19
  import com.google.android.gms.tasks.Tasks
13
20
  import com.google.firebase.messaging.RemoteMessage
21
+ import com.margelo.nitro.core.Promise
14
22
  import java.util.Collections
23
+ import java.util.UUID
15
24
  import java.util.WeakHashMap
16
25
  import java.util.concurrent.CopyOnWriteArrayList
26
+ import java.util.concurrent.atomic.AtomicBoolean
17
27
 
18
28
  internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
19
29
  private const val EXTRA_HANDLED = "pushsignal.handled"
30
+ private const val CHANNEL_ID = "push_signal_default"
31
+ private const val FOREGROUND_TIMEOUT_MS = 2_000L
20
32
 
21
33
  private val lock = Any()
34
+ private val mainHandler = Handler(Looper.getMainLooper())
22
35
  @Volatile private var application: Application? = null
23
36
  @Volatile private var currentActivity: Activity? = null
24
- @Volatile private var onMessage: ((PushMessage) -> Unit)? = null
37
+ @Volatile private var onMessage: ((PushMessage) -> Promise<Promise<Boolean>>)? = null
25
38
  @Volatile private var onNotificationPress: ((PushMessage) -> Unit)? = null
26
39
  @Volatile private var pendingPress: PushMessage? = null
40
+ private val pendingMessages = CopyOnWriteArrayList<PushMessage>()
27
41
  private val registeredActivities = Collections.newSetFromMap(WeakHashMap<Activity, Boolean>())
42
+ @Volatile private var startedActivityCount = 0
28
43
  @Volatile private var pendingFirebaseConfig: AndroidFirebaseConfig? = null
29
44
  private val initializeWaiters = CopyOnWriteArrayList<(Exception?) -> Unit>()
30
45
 
@@ -63,8 +78,24 @@ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
63
78
  onDone(applyFirebaseConfig(context, config))
64
79
  }
65
80
 
66
- fun setOnMessage(callback: (PushMessage) -> Unit) {
81
+ fun setOnMessage(callback: (PushMessage) -> Promise<Promise<Boolean>>) {
67
82
  onMessage = callback
83
+ val queued = pendingMessages.toList()
84
+ pendingMessages.clear()
85
+ queued.forEach { message ->
86
+ runOnMain {
87
+ val inForeground = startedActivityCount > 0 || currentActivity != null
88
+ resolveShouldShowBanner(message) { shouldShow ->
89
+ if (
90
+ shouldShow &&
91
+ inForeground &&
92
+ (!message.title.isNullOrEmpty() || !message.body.isNullOrEmpty())
93
+ ) {
94
+ postForegroundNotification(message)
95
+ }
96
+ }
97
+ }
98
+ }
68
99
  }
69
100
 
70
101
  fun setOnNotificationPress(callback: (PushMessage) -> Unit) {
@@ -112,12 +143,133 @@ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
112
143
  return token
113
144
  }
114
145
 
115
- fun emitMessage(message: PushMessage) {
116
- onMessage?.invoke(message)
146
+ fun emitMessage(remoteMessage: RemoteMessage) {
147
+ val message = remoteMessage.toPushMessage()
148
+ runOnMain {
149
+ val callback = onMessage
150
+ if (callback == null) {
151
+ pendingMessages.add(message)
152
+ return@runOnMain
153
+ }
154
+
155
+ val inForeground = startedActivityCount > 0 || currentActivity != null
156
+ resolveShouldShowBanner(message) { shouldShow ->
157
+ if (
158
+ shouldShow &&
159
+ inForeground &&
160
+ (!message.title.isNullOrEmpty() || !message.body.isNullOrEmpty())
161
+ ) {
162
+ postForegroundNotification(message)
163
+ }
164
+ }
165
+ }
117
166
  }
118
167
 
119
- fun emitMessage(remoteMessage: RemoteMessage) {
120
- emitMessage(remoteMessage.toPushMessage())
168
+ private fun runOnMain(block: () -> Unit) {
169
+ if (Looper.myLooper() == Looper.getMainLooper()) {
170
+ block()
171
+ } else {
172
+ mainHandler.post(block)
173
+ }
174
+ }
175
+
176
+ private fun resolveShouldShowBanner(
177
+ message: PushMessage,
178
+ onDone: (Boolean) -> Unit
179
+ ) {
180
+ val callback = onMessage
181
+ if (callback == null) {
182
+ onDone(false)
183
+ return
184
+ }
185
+
186
+ val delivered = AtomicBoolean(false)
187
+ val timeout = Runnable {
188
+ if (delivered.compareAndSet(false, true)) {
189
+ onDone(true)
190
+ }
191
+ }
192
+ mainHandler.postDelayed(timeout, FOREGROUND_TIMEOUT_MS)
193
+
194
+ val finish = { shouldShow: Boolean ->
195
+ if (delivered.compareAndSet(false, true)) {
196
+ mainHandler.removeCallbacks(timeout)
197
+ onDone(shouldShow)
198
+ }
199
+ }
200
+
201
+ try {
202
+ callback(message)
203
+ .then { inner ->
204
+ inner
205
+ .then { shouldShow -> finish(shouldShow) }
206
+ .catch { finish(true) }
207
+ }
208
+ .catch { finish(true) }
209
+ } catch (_: Throwable) {
210
+ finish(true)
211
+ }
212
+ }
213
+
214
+ private fun postForegroundNotification(message: PushMessage) {
215
+ val context = application ?: return
216
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
217
+ ?: return
218
+
219
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
220
+ val existing = manager.getNotificationChannel(CHANNEL_ID)
221
+ if (existing == null) {
222
+ manager.createNotificationChannel(
223
+ NotificationChannel(
224
+ CHANNEL_ID,
225
+ "Notifications",
226
+ NotificationManager.IMPORTANCE_HIGH
227
+ )
228
+ )
229
+ }
230
+ }
231
+
232
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
233
+ ?: return
234
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
235
+ launchIntent.putExtra("google.message_id", message.id ?: UUID.randomUUID().toString())
236
+ message.title?.let { launchIntent.putExtra("gcm.notification.title", it) }
237
+ message.body?.let { launchIntent.putExtra("gcm.notification.body", it) }
238
+ message.data.forEach { (key, value) ->
239
+ launchIntent.putExtra(key, value)
240
+ }
241
+
242
+ val requestCode = (message.id ?: message.title ?: "push").hashCode()
243
+ val pendingIntent = PendingIntent.getActivity(
244
+ context,
245
+ requestCode,
246
+ launchIntent,
247
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
248
+ )
249
+
250
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
251
+ .setSmallIcon(smallIcon(context))
252
+ .setContentTitle(message.title.orEmpty())
253
+ .setContentText(message.body.orEmpty())
254
+ .setContentIntent(pendingIntent)
255
+ .setAutoCancel(true)
256
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
257
+ .setDefaults(NotificationCompat.DEFAULT_SOUND)
258
+ .setNumber(1)
259
+
260
+ manager.notify(requestCode, builder.build())
261
+ }
262
+
263
+ private fun smallIcon(context: Context): Int {
264
+ val named = context.resources.getIdentifier("ic_notification", "drawable", context.packageName)
265
+ if (named != 0) {
266
+ return named
267
+ }
268
+ val appIcon = context.applicationInfo.icon
269
+ if (appIcon != 0) {
270
+ return appIcon
271
+ }
272
+ return android.R.drawable.stat_notify_more
121
273
  }
122
274
 
123
275
  override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
@@ -126,7 +278,10 @@ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
126
278
  handleIntent(activity.intent)
127
279
  }
128
280
 
129
- override fun onActivityStarted(activity: Activity) = Unit
281
+ override fun onActivityStarted(activity: Activity) {
282
+ currentActivity = activity
283
+ startedActivityCount += 1
284
+ }
130
285
 
131
286
  override fun onActivityResumed(activity: Activity) {
132
287
  currentActivity = activity
@@ -139,7 +294,12 @@ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
139
294
  }
140
295
  }
141
296
 
142
- override fun onActivityStopped(activity: Activity) = Unit
297
+ override fun onActivityStopped(activity: Activity) {
298
+ startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
299
+ if (currentActivity === activity) {
300
+ currentActivity = null
301
+ }
302
+ }
143
303
 
144
304
  override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
145
305
 
@@ -171,6 +331,7 @@ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
171
331
 
172
332
  private fun applyFirebaseConfig(context: Context, config: AndroidFirebaseConfig): Exception? {
173
333
  if (FirebaseApp.getApps(context).isNotEmpty()) {
334
+ FirebaseMessaging.getInstance().isAutoInitEnabled = true
174
335
  return null
175
336
  }
176
337
 
@@ -187,6 +348,7 @@ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
187
348
  .setGcmSenderId(config.project_number!!.trim())
188
349
  .build()
189
350
  FirebaseApp.initializeApp(context, options)
351
+ FirebaseMessaging.getInstance().isAutoInitEnabled = true
190
352
  null
191
353
  } catch (error: Exception) {
192
354
  error
@@ -9,7 +9,12 @@ class PushSignalMessagingService : FirebaseMessagingService() {
9
9
  PushSignalCenter.attach(applicationContext)
10
10
  }
11
11
 
12
+ override fun onNewToken(token: String) {
13
+ PushSignalCenter.attach(applicationContext)
14
+ }
15
+
12
16
  override fun onMessageReceived(message: RemoteMessage) {
17
+ PushSignalCenter.attach(applicationContext)
13
18
  PushSignalCenter.emitMessage(message)
14
19
  }
15
20
  }
@@ -19,7 +19,7 @@ class PushSignal: HybridPushSignalSpec {
19
19
  }
20
20
  }
21
21
 
22
- func setOnMessage(callback: @escaping (PushMessage) -> Void) throws {
22
+ func setOnMessage(callback: @escaping (PushMessage) -> Promise<Promise<Bool>>) throws {
23
23
  PushSignalCenter.shared.onMessage = callback
24
24
  }
25
25
 
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import NitroModules
2
3
  import ObjectiveC
3
4
  import UIKit
4
5
  import UserNotifications
@@ -12,16 +13,31 @@ final class PushSignalCenter: NSObject, UNUserNotificationCenterDelegate {
12
13
  shared.install()
13
14
  }
14
15
 
16
+ /// Called from ObjC `+load` / launch notifications so the UNUserNotificationCenter
17
+ /// delegate is set before the app finishes launching. If this happens after JS loads,
18
+ /// iOS never calls `willPresent` and foreground pushes never reach JS.
19
+ @objc static func installEarly() {
20
+ shared.install()
21
+ }
22
+
15
23
  private let lock = NSLock()
16
24
  private var didInstall = false
25
+ private var didSwizzleNotificationCenter = false
17
26
  private var deviceToken: String?
18
27
  private var registrationError: Error?
19
28
  private var tokenWaiters: [(Result<String, Error>) -> Void] = []
20
29
  private var pendingPress: PushMessage?
30
+ private var pendingMessages: [PushMessage] = []
21
31
  private var pendingLaunchOptions: [AnyHashable: Any]?
22
32
  private var didCaptureLaunchNotification = false
33
+ private var recentMessageIds: [String: Date] = [:]
34
+ private weak var forwardingDelegate: UNUserNotificationCenterDelegate?
23
35
 
24
- var onMessage: ((PushMessage) -> Void)?
36
+ var onMessage: ((PushMessage) -> Promise<Promise<Bool>>)? {
37
+ didSet {
38
+ flushPendingMessages()
39
+ }
40
+ }
25
41
  var onNotificationPress: ((PushMessage) -> Void)? {
26
42
  didSet {
27
43
  flushPendingPress()
@@ -29,13 +45,21 @@ final class PushSignalCenter: NSObject, UNUserNotificationCenterDelegate {
29
45
  }
30
46
 
31
47
  func install() {
32
- DispatchQueue.main.async {
33
- self.installOnMain()
48
+ if Thread.isMainThread {
49
+ installOnMain()
50
+ } else {
51
+ DispatchQueue.main.async {
52
+ self.installOnMain()
53
+ }
34
54
  }
35
55
  }
36
56
 
37
57
  private func installOnMain() {
58
+ swizzleNotificationCenterDelegateIfNeeded()
59
+
38
60
  guard !didInstall else {
61
+ UNUserNotificationCenter.current().delegate = self
62
+ swizzleAppDelegate()
39
63
  captureLaunchNotificationIfNeeded()
40
64
  return
41
65
  }
@@ -46,6 +70,12 @@ final class PushSignalCenter: NSObject, UNUserNotificationCenterDelegate {
46
70
  captureLaunchNotificationIfNeeded()
47
71
  }
48
72
 
73
+ func rememberForwardingDelegate(_ delegate: UNUserNotificationCenterDelegate?) {
74
+ if let delegate, delegate !== self {
75
+ forwardingDelegate = delegate
76
+ }
77
+ }
78
+
49
79
  func fetchCredentials() async throws -> PushCredentials {
50
80
  await MainActor.run {
51
81
  self.installOnMain()
@@ -69,22 +99,39 @@ final class PushSignalCenter: NSObject, UNUserNotificationCenterDelegate {
69
99
  finishRegistration(result: .failure(error))
70
100
  }
71
101
 
72
- func userNotificationCenter(
102
+ @objc func userNotificationCenter(
73
103
  _ center: UNUserNotificationCenter,
74
104
  willPresent notification: UNNotification,
75
105
  withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
76
106
  ) {
77
- emitMessage(Self.message(from: notification))
78
- completionHandler([])
107
+ let message = Self.message(from: notification)
108
+ Task {
109
+ let shouldShow = await self.deliverMessage(message)
110
+ let ours: UNNotificationPresentationOptions = shouldShow ? Self.foregroundPresentationOptions : []
111
+ await MainActor.run {
112
+ self.forwardWillPresent(
113
+ center,
114
+ notification: notification,
115
+ ourOptions: ours,
116
+ completionHandler: completionHandler
117
+ )
118
+ }
119
+ }
79
120
  }
80
121
 
81
- func userNotificationCenter(
122
+ @objc func userNotificationCenter(
82
123
  _ center: UNUserNotificationCenter,
83
124
  didReceive response: UNNotificationResponse,
84
125
  withCompletionHandler completionHandler: @escaping () -> Void
85
126
  ) {
86
127
  emitPress(Self.message(from: response.notification))
87
- completionHandler()
128
+ if forwardingDelegate?.responds(
129
+ to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:))
130
+ ) == true {
131
+ forwardingDelegate?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler)
132
+ } else {
133
+ completionHandler()
134
+ }
88
135
  }
89
136
 
90
137
  private func waitForToken() async throws -> String {
@@ -152,10 +199,125 @@ final class PushSignalCenter: NSObject, UNUserNotificationCenterDelegate {
152
199
  waiters.forEach { $0(result) }
153
200
  }
154
201
 
155
- private func emitMessage(_ message: PushMessage) {
202
+ private func synchronized<T>(_ body: () -> T) -> T {
203
+ lock.lock()
204
+ defer { lock.unlock() }
205
+ return body()
206
+ }
207
+
208
+ private func deliverMessage(_ message: PushMessage) async -> Bool {
209
+ guard shouldEmit(message) else {
210
+ return false
211
+ }
212
+
213
+ let callback = synchronized { onMessage }
214
+
215
+ guard let callback else {
216
+ synchronized { pendingMessages.append(message) }
217
+ // Still show the system banner so a visible push is not swallowed
218
+ // before JS has subscribed.
219
+ return true
220
+ }
221
+
222
+ return await invokeOnMessage(callback, message: message)
223
+ }
224
+
225
+ private func invokeOnMessage(
226
+ _ callback: @escaping (PushMessage) -> Promise<Promise<Bool>>,
227
+ message: PushMessage
228
+ ) async -> Bool {
229
+ await withCheckedContinuation { continuation in
230
+ let resumeLock = NSLock()
231
+ var resumed = false
232
+ let finish: (Bool) -> Void = { value in
233
+ resumeLock.lock()
234
+ defer { resumeLock.unlock() }
235
+ guard !resumed else {
236
+ return
237
+ }
238
+ resumed = true
239
+ continuation.resume(returning: value)
240
+ }
241
+
242
+ DispatchQueue.main.async {
243
+ Task {
244
+ do {
245
+ let inner = try await callback(message).await()
246
+ let shouldShow = try await inner.await()
247
+ finish(shouldShow)
248
+ } catch {
249
+ finish(true)
250
+ }
251
+ }
252
+ }
253
+
254
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
255
+ finish(true)
256
+ }
257
+ }
258
+ }
259
+
260
+ private func flushPendingMessages() {
156
261
  DispatchQueue.main.async {
157
- self.onMessage?(message)
262
+ let callback = self.synchronized { self.onMessage }
263
+ let queued = self.synchronized { () -> [PushMessage] in
264
+ let messages = self.pendingMessages
265
+ self.pendingMessages.removeAll()
266
+ return messages
267
+ }
268
+ guard let callback, !queued.isEmpty else {
269
+ return
270
+ }
271
+
272
+ for message in queued {
273
+ Task {
274
+ _ = await self.invokeOnMessage(callback, message: message)
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ private func shouldEmit(_ message: PushMessage) -> Bool {
281
+ let key = message.id ?? "\(message.title ?? "")|\(message.body ?? "")"
282
+ return synchronized {
283
+ let now = Date()
284
+ recentMessageIds = recentMessageIds.filter { now.timeIntervalSince($0.value) < 5 }
285
+ if let last = recentMessageIds[key], now.timeIntervalSince(last) < 2 {
286
+ return false
287
+ }
288
+ recentMessageIds[key] = now
289
+ return true
290
+ }
291
+ }
292
+
293
+ private func forwardWillPresent(
294
+ _ center: UNUserNotificationCenter,
295
+ notification: UNNotification,
296
+ ourOptions: UNNotificationPresentationOptions,
297
+ completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
298
+ ) {
299
+ let selector = #selector(
300
+ UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:)
301
+ )
302
+ guard let forwardingDelegate, forwardingDelegate.responds(to: selector) else {
303
+ completionHandler(ourOptions)
304
+ return
158
305
  }
306
+
307
+ forwardingDelegate.userNotificationCenter?(
308
+ center,
309
+ willPresent: notification,
310
+ withCompletionHandler: { forwarded in
311
+ completionHandler(ourOptions.union(forwarded))
312
+ }
313
+ )
314
+ }
315
+
316
+ private static var foregroundPresentationOptions: UNNotificationPresentationOptions {
317
+ if #available(iOS 14.0, *) {
318
+ return [.banner, .list, .sound, .badge]
319
+ }
320
+ return [.alert, .sound, .badge]
159
321
  }
160
322
 
161
323
  private func emitPress(_ message: PushMessage) {
@@ -333,6 +495,25 @@ final class PushSignalCenter: NSObject, UNUserNotificationCenterDelegate {
333
495
  )
334
496
  }
335
497
 
498
+ private func swizzleNotificationCenterDelegateIfNeeded() {
499
+ guard !didSwizzleNotificationCenter else {
500
+ return
501
+ }
502
+ didSwizzleNotificationCenter = true
503
+
504
+ let target: AnyClass = UNUserNotificationCenter.self
505
+ let original = #selector(setter: UNUserNotificationCenter.delegate)
506
+ let replacement = #selector(UNUserNotificationCenter.pushSignal_setDelegate(_:))
507
+
508
+ guard let originalMethod = class_getInstanceMethod(target, original),
509
+ let replacementMethod = class_getInstanceMethod(target, replacement)
510
+ else {
511
+ return
512
+ }
513
+
514
+ method_exchangeImplementations(originalMethod, replacementMethod)
515
+ }
516
+
336
517
  private func swizzle(target: AnyClass, original: Selector, replacement: Selector, source: AnyClass) {
337
518
  guard let replacementMethod = class_getInstanceMethod(source, replacement) else {
338
519
  return
@@ -393,3 +574,15 @@ private final class PushSignalAppDelegateHook: NSObject {
393
574
  unsafeBitCast(method_getImplementation(method), to: Fn.self)(self, selector, application, error as NSError)
394
575
  }
395
576
  }
577
+
578
+ extension UNUserNotificationCenter {
579
+ @objc func pushSignal_setDelegate(_ delegate: UNUserNotificationCenterDelegate?) {
580
+ if let delegate, delegate is PushSignalCenter {
581
+ pushSignal_setDelegate(delegate)
582
+ return
583
+ }
584
+
585
+ PushSignalCenter.shared.rememberForwardingDelegate(delegate)
586
+ pushSignal_setDelegate(PushSignalCenter.shared)
587
+ }
588
+ }