appsonair-react-native-apppush 0.0.1-alpha

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/LICENSE +21 -0
  2. package/README.md +540 -0
  3. package/android/build.gradle +177 -0
  4. package/android/gradle.properties +15 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/AndroidManifestNew.xml +2 -0
  7. package/android/src/main/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModuleImpl.kt +767 -0
  8. package/android/src/main/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushPackage.kt +43 -0
  9. package/android/src/newarch/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModule.kt +199 -0
  10. package/android/src/oldarch/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModule.kt +249 -0
  11. package/appsonair-react-native-apppush.podspec +77 -0
  12. package/ios/AppsonairReactNativeApppush-Bridging-Header.h +12 -0
  13. package/ios/AppsonairReactNativeApppush.h +28 -0
  14. package/ios/AppsonairReactNativeApppush.mm +494 -0
  15. package/ios/AppsonairReactNativeApppushImpl.swift +594 -0
  16. package/lib/commonjs/NativeAppsonairApppush.js +48 -0
  17. package/lib/commonjs/NativeAppsonairApppush.js.map +1 -0
  18. package/lib/commonjs/index.js +686 -0
  19. package/lib/commonjs/index.js.map +1 -0
  20. package/lib/commonjs/types.js +2 -0
  21. package/lib/commonjs/types.js.map +1 -0
  22. package/lib/module/NativeAppsonairApppush.js +47 -0
  23. package/lib/module/NativeAppsonairApppush.js.map +1 -0
  24. package/lib/module/index.js +612 -0
  25. package/lib/module/index.js.map +1 -0
  26. package/lib/module/types.js +2 -0
  27. package/lib/module/types.js.map +1 -0
  28. package/lib/typescript/commonjs/package.json +1 -0
  29. package/lib/typescript/commonjs/src/NativeAppsonairApppush.d.ts +134 -0
  30. package/lib/typescript/commonjs/src/NativeAppsonairApppush.d.ts.map +1 -0
  31. package/lib/typescript/commonjs/src/index.d.ts +392 -0
  32. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/src/types.d.ts +221 -0
  34. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  35. package/lib/typescript/module/package.json +1 -0
  36. package/lib/typescript/module/src/NativeAppsonairApppush.d.ts +134 -0
  37. package/lib/typescript/module/src/NativeAppsonairApppush.d.ts.map +1 -0
  38. package/lib/typescript/module/src/index.d.ts +392 -0
  39. package/lib/typescript/module/src/index.d.ts.map +1 -0
  40. package/lib/typescript/module/src/types.d.ts +221 -0
  41. package/lib/typescript/module/src/types.d.ts.map +1 -0
  42. package/package.json +119 -0
  43. package/react-native.config.js +14 -0
  44. package/src/NativeAppsonairApppush.ts +188 -0
  45. package/src/index.tsx +745 -0
  46. package/src/types.ts +284 -0
@@ -0,0 +1,767 @@
1
+ package com.appsonairreactnativeapppush
2
+
3
+ import android.app.Activity
4
+ import android.app.NotificationManager
5
+ import android.content.Intent
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.util.Log
9
+ import com.appsonair.apppush.AppPushService
10
+ import com.appsonair.apppush.INotificationClickListener
11
+ import com.appsonair.apppush.INotificationLifecycleListener
12
+ import com.appsonair.apppush.INotificationPermissionObserver
13
+ import com.appsonair.apppush.IPushSubscriptionObserver
14
+ import com.appsonair.apppush.IUserStateObserver
15
+ import com.appsonair.apppush.LogLevel
16
+ import com.appsonair.apppush.NotificationClickEvent
17
+ import com.appsonair.apppush.NotificationWillDisplayEvent
18
+ import com.appsonair.apppush.PushDebug
19
+ import com.appsonair.apppush.PushError
20
+ import com.appsonair.apppush.PushListener
21
+ import com.appsonair.apppush.PushNotification
22
+ import com.appsonair.apppush.PushNotifications
23
+ import com.appsonair.apppush.PushSubscriptionChangedState
24
+ import com.appsonair.apppush.PushUser
25
+ import com.appsonair.apppush.UserChangedState
26
+ import com.facebook.react.bridge.ActivityEventListener
27
+ import com.facebook.react.bridge.Arguments
28
+ import com.facebook.react.bridge.LifecycleEventListener
29
+ import com.facebook.react.bridge.Promise
30
+ import com.facebook.react.bridge.ReactApplicationContext
31
+ import com.facebook.react.bridge.ReadableArray
32
+ import com.facebook.react.bridge.ReadableMap
33
+ import com.facebook.react.bridge.WritableMap
34
+ import com.facebook.react.modules.core.DeviceEventManagerModule
35
+ import org.json.JSONArray
36
+ import org.json.JSONObject
37
+ import java.util.concurrent.ConcurrentHashMap
38
+ import java.util.concurrent.CountDownLatch
39
+ import java.util.concurrent.TimeUnit
40
+ import java.util.concurrent.atomic.AtomicBoolean
41
+
42
+ /**
43
+ * The whole Android bridge implementation.
44
+ *
45
+ * Both architectures share this class verbatim -- the New and Old Architecture
46
+ * module classes in src/newarch and src/oldarch are thin subclasses that differ
47
+ * only in what they extend. Keeping every behaviour here is what makes "supports
48
+ * both architectures" a build-configuration detail rather than two codebases.
49
+ *
50
+ * Threading: the native SDK posts its callbacks to the main thread already, and
51
+ * every method below is either a plain property read or a call the SDK itself
52
+ * marshals, so this class does no dispatching of its own -- except for the one
53
+ * documented latch in [onWillDisplay].
54
+ */
55
+ class AppsonairReactNativeApppushModuleImpl(
56
+ private val reactContext: ReactApplicationContext
57
+ ) {
58
+
59
+ companion object {
60
+ const val NAME = "AppsonairReactNativeApppush"
61
+
62
+ // Event names. These strings are duplicated in src/index.tsx and in the iOS
63
+ // bridge -- a rename has to land in all three at once.
64
+ private const val EVENT_TOKEN_UPDATED = "AppsonairPush:onTokenUpdated"
65
+ private const val EVENT_NOTIFICATION_RECEIVED = "AppsonairPush:onNotificationReceived"
66
+ private const val EVENT_NOTIFICATION_OPENED = "AppsonairPush:onNotificationOpened"
67
+ private const val EVENT_NOTIFICATION_WILL_DISPLAY = "AppsonairPush:onNotificationWillDisplay"
68
+ private const val EVENT_PERMISSION_CHANGED = "AppsonairPush:onPermissionChanged"
69
+ private const val EVENT_SUBSCRIPTION_CHANGED = "AppsonairPush:onSubscriptionChanged"
70
+ private const val EVENT_USER_STATE_CHANGED = "AppsonairPush:onUserStateChanged"
71
+ private const val EVENT_SILENT_NOTIFICATION = "AppsonairPush:onSilentNotification"
72
+ private const val EVENT_INSTALLATION_ID_UPDATED = "AppsonairPush:onInstallationIdUpdated"
73
+ private const val EVENT_ERROR = "AppsonairPush:onError"
74
+
75
+ /**
76
+ * How long [onWillDisplay] blocks waiting for JS to answer.
77
+ *
78
+ * The SDK calls foreground lifecycle listeners on FCM's background thread,
79
+ * whose process window is about 10 seconds, so a bounded wait well inside
80
+ * that is safe. On timeout the notification displays -- failing open, because
81
+ * a dropped notification is worse than an unsuppressed one.
82
+ */
83
+ private const val WILL_DISPLAY_TIMEOUT_MS = 2_000L
84
+ }
85
+
86
+ private val mainHandler = Handler(Looper.getMainLooper())
87
+
88
+ /** Guards against double-registering SDK listeners if initialize() is called twice. */
89
+ private val listenersRegistered = AtomicBoolean(false)
90
+
91
+ // MARK: - preventDefault plumbing
92
+ //
93
+ // Parity F7: the SDK expects preventDefault() to be called synchronously inside
94
+ // onWillDisplay, but the JS handler is an async bridge hop away. Each pending
95
+ // notification therefore parks its native thread on a latch that JS releases
96
+ // via completeNotificationWillDisplay().
97
+
98
+ private val pendingWillDisplay = ConcurrentHashMap<String, CountDownLatch>()
99
+ private val willDisplayDecision = ConcurrentHashMap<String, Boolean>()
100
+
101
+ // MARK: - Installation ID
102
+ //
103
+ // The SDK's getInstallationId() returns Unit and delivers the value through
104
+ // PushListener.onInstallationIdUpdated, so promises park here until it lands.
105
+
106
+ private val pendingInstallationIdPromises = mutableListOf<Promise>()
107
+ private var cachedInstallationId: String? = null
108
+
109
+ // MARK: - Events
110
+
111
+ private fun emit(eventName: String, params: WritableMap?) {
112
+ if (!reactContext.hasActiveReactInstance()) return
113
+ reactContext
114
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
115
+ .emit(eventName, params)
116
+ }
117
+
118
+ // MARK: - Lifecycle
119
+
120
+ fun initialize(config: ReadableMap?, promise: Promise) {
121
+ try {
122
+ val debug = config?.takeIf { it.hasKey("debug") }?.getBoolean("debug") ?: false
123
+
124
+ // Parity A1/A2: Android needs a Context that JS must never see, and has no
125
+ // appGroupId concept -- that key is read only by the iOS bridge.
126
+ AppPushService.initialize(reactContext.applicationContext, debug)
127
+
128
+ registerSdkListeners()
129
+ registerActivityHooks()
130
+
131
+ // Parity A3: a cold start delivers the tap through the launch Intent. The
132
+ // SDK's own ActivityLifecycleCallbacks cover onActivityCreated, but the
133
+ // React Activity is typically already created by the time JS calls
134
+ // initialize(), so replay the current Intent here. handleNotificationTapIntent
135
+ // is documented idempotent, so a double delivery is not possible.
136
+ reactContext.currentActivity?.intent?.let {
137
+ AppPushService.handleNotificationTapIntent(it)
138
+ }
139
+
140
+ promise.resolve(null)
141
+ } catch (e: Throwable) {
142
+ promise.reject("initializeFailed", e.message, e)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Parity A3: without this the host app would have to call
148
+ * handleNotificationTapIntent() from MainActivity.onNewIntent() by hand, and a
149
+ * warm-start tap would be silently lost if it forgot. Registering RN's own
150
+ * ActivityEventListener absorbs that requirement into the wrapper.
151
+ */
152
+ private fun registerActivityHooks() {
153
+ reactContext.addActivityEventListener(object : ActivityEventListener {
154
+ override fun onActivityResult(
155
+ activity: Activity?,
156
+ requestCode: Int,
157
+ resultCode: Int,
158
+ data: Intent?
159
+ ) = Unit
160
+
161
+ override fun onNewIntent(intent: Intent?) {
162
+ AppPushService.handleNotificationTapIntent(intent)
163
+ }
164
+ })
165
+ }
166
+
167
+ private fun registerSdkListeners() {
168
+ if (!listenersRegistered.compareAndSet(false, true)) return
169
+
170
+ AppPushService.setListener(object : PushListener {
171
+ override fun onTokenUpdated(token: String) {
172
+ emit(EVENT_TOKEN_UPDATED, Arguments.createMap().apply {
173
+ putString("token", token)
174
+ // Parity B1/C8: FCM routes for you, so there is no sandbox/production
175
+ // split to report. iOS fills this in.
176
+ putNull("environment")
177
+ })
178
+ }
179
+
180
+ override fun onInstallationIdUpdated(id: String) {
181
+ cachedInstallationId = id
182
+ synchronized(pendingInstallationIdPromises) {
183
+ pendingInstallationIdPromises.forEach { it.resolve(id) }
184
+ pendingInstallationIdPromises.clear()
185
+ }
186
+ emit(EVENT_INSTALLATION_ID_UPDATED, Arguments.createMap().apply {
187
+ putString("id", id)
188
+ })
189
+ }
190
+
191
+ override fun onNotificationReceived(notification: PushNotification) {
192
+ emit(EVENT_NOTIFICATION_RECEIVED, Arguments.createMap().apply {
193
+ putMap("notification", notification.toWritableMap())
194
+ })
195
+ }
196
+
197
+ override fun onNotificationOpened(notification: PushNotification) {
198
+ emit(EVENT_NOTIFICATION_OPENED, Arguments.createMap().apply {
199
+ putMap("notification", notification.toWritableMap())
200
+ putNull("actionId")
201
+ putString("url", notification.data["url"])
202
+ })
203
+ }
204
+
205
+ override fun onError(error: PushError) {
206
+ emit(EVENT_ERROR, error.toWritableMap())
207
+ }
208
+ })
209
+
210
+ // The click listener carries the action button id, which PushListener.onNotificationOpened
211
+ // does not. Both fire for a tap, so this one wins for action-button taps and
212
+ // the plain listener above covers body taps on SDK paths that skip click listeners.
213
+ PushNotifications.addClickListener(object : INotificationClickListener {
214
+ override fun onClick(event: NotificationClickEvent) {
215
+ emit(EVENT_NOTIFICATION_OPENED, Arguments.createMap().apply {
216
+ putMap("notification", event.notification.toWritableMap())
217
+ putString("actionId", event.result.actionId)
218
+ putString("url", event.result.url)
219
+ })
220
+ }
221
+ })
222
+
223
+ PushNotifications.addForegroundLifecycleListener(
224
+ object : INotificationLifecycleListener {
225
+ override fun onWillDisplay(event: NotificationWillDisplayEvent) =
226
+ this@AppsonairReactNativeApppushModuleImpl.onWillDisplay(event)
227
+ }
228
+ )
229
+
230
+ PushNotifications.addPermissionObserver(object : INotificationPermissionObserver {
231
+ override fun onNotificationPermissionDidChange(permission: Boolean) {
232
+ emit(EVENT_PERMISSION_CHANGED, Arguments.createMap().apply {
233
+ putBoolean("granted", permission)
234
+ })
235
+ }
236
+ })
237
+
238
+ PushUser.pushSubscription.addObserver(object : IPushSubscriptionObserver {
239
+ override fun onPushSubscriptionDidChange(state: PushSubscriptionChangedState) {
240
+ emit(EVENT_SUBSCRIPTION_CHANGED, Arguments.createMap().apply {
241
+ putMap("previous", Arguments.createMap().apply {
242
+ putString("id", AppPushService.subscriptionId)
243
+ putString("token", state.previous.token)
244
+ putBoolean("optedIn", state.previous.optedIn)
245
+ })
246
+ putMap("current", Arguments.createMap().apply {
247
+ putString("id", AppPushService.subscriptionId)
248
+ putString("token", state.current.token)
249
+ putBoolean("optedIn", state.current.optedIn)
250
+ })
251
+ })
252
+ }
253
+ })
254
+
255
+ PushUser.addObserver(object : IUserStateObserver {
256
+ override fun onUserStateDidChange(state: UserChangedState) {
257
+ emit(EVENT_USER_STATE_CHANGED, Arguments.createMap().apply {
258
+ putMap("current", Arguments.createMap().apply {
259
+ putString("externalId", state.current.externalId)
260
+ putString("appsOnAirId", state.current.appsOnAirId)
261
+ })
262
+ })
263
+ }
264
+ })
265
+
266
+ // Parity F5: Android renders every data push as a visible notification, so
267
+ // this fires only for payloads the SDK recognises as silent. Wired anyway
268
+ // because the hook exists and costs nothing.
269
+ AppPushService.onSilentPushReceived = { data ->
270
+ emit(EVENT_SILENT_NOTIFICATION, Arguments.createMap().apply {
271
+ putMap("data", Arguments.createMap().apply {
272
+ data.forEach { (k, v) -> putString(k, v) }
273
+ })
274
+ })
275
+ }
276
+ }
277
+
278
+ private fun onWillDisplay(event: NotificationWillDisplayEvent) {
279
+ val id = event.notification.id
280
+ if (id == null) {
281
+ // Nothing to correlate the JS answer against, so do not block -- just
282
+ // inform JS and let the notification display.
283
+ emit(EVENT_NOTIFICATION_WILL_DISPLAY, Arguments.createMap().apply {
284
+ putMap("notification", event.notification.toWritableMap())
285
+ })
286
+ return
287
+ }
288
+
289
+ val latch = CountDownLatch(1)
290
+ pendingWillDisplay[id] = latch
291
+
292
+ emit(EVENT_NOTIFICATION_WILL_DISPLAY, Arguments.createMap().apply {
293
+ putMap("notification", event.notification.toWritableMap())
294
+ })
295
+
296
+ val answered = try {
297
+ latch.await(WILL_DISPLAY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
298
+ } catch (e: InterruptedException) {
299
+ Thread.currentThread().interrupt()
300
+ false
301
+ }
302
+
303
+ val shouldDisplay = if (answered) willDisplayDecision[id] ?: true else true
304
+ pendingWillDisplay.remove(id)
305
+ willDisplayDecision.remove(id)
306
+
307
+ if (!shouldDisplay) {
308
+ event.preventDefault()
309
+ }
310
+ }
311
+
312
+ fun completeNotificationWillDisplay(
313
+ notificationId: String,
314
+ display: Boolean,
315
+ promise: Promise
316
+ ) {
317
+ willDisplayDecision[notificationId] = display
318
+ pendingWillDisplay[notificationId]?.countDown()
319
+ promise.resolve(null)
320
+ }
321
+
322
+ // MARK: - Identity
323
+
324
+ fun getDeviceId(promise: Promise) = resolving(promise) { AppPushService.getDeviceId() }
325
+
326
+ fun getSubscriptionId(promise: Promise) = resolving(promise) { AppPushService.subscriptionId }
327
+
328
+ fun setSubscriptionId(id: String, promise: Promise) =
329
+ resolvingUnit(promise) { AppPushService.setSubscriptionId(id) }
330
+
331
+ fun getExternalId(promise: Promise) = resolving(promise) { PushUser.externalId }
332
+
333
+ fun login(externalId: String, promise: Promise) =
334
+ resolvingUnit(promise) { AppPushService.login(externalId) }
335
+
336
+ fun logout(promise: Promise) = resolvingUnit(promise) { AppPushService.logout() }
337
+
338
+ // MARK: - Token
339
+
340
+ fun getToken(promise: Promise) = resolving(promise) { PushUser.pushSubscription.token }
341
+
342
+ fun refreshToken(promise: Promise) = resolvingUnit(promise) { AppPushService.refreshFcmToken() }
343
+
344
+ fun getInstallationId(promise: Promise) {
345
+ cachedInstallationId?.let { promise.resolve(it); return }
346
+ synchronized(pendingInstallationIdPromises) { pendingInstallationIdPromises.add(promise) }
347
+ try {
348
+ // Fires PushListener.onInstallationIdUpdated, which drains the list above.
349
+ AppPushService.getInstallationId()
350
+ } catch (e: Throwable) {
351
+ synchronized(pendingInstallationIdPromises) {
352
+ pendingInstallationIdPromises.remove(promise)
353
+ }
354
+ promise.reject("installationIdFetchFailed", e.message, e)
355
+ }
356
+ }
357
+
358
+ /** Parity C8: an APNs concept with no FCM equivalent. */
359
+ fun getApnsEnvironment(promise: Promise) = promise.resolve(null)
360
+
361
+ // MARK: - Permissions
362
+
363
+ fun requestPermission(fallbackToSettings: Boolean, promise: Promise) {
364
+ val activity = reactContext.currentActivity
365
+ if (activity == null) {
366
+ // Parity E1: the native call would throw without an Activity. Reject with
367
+ // something actionable instead of crashing the app.
368
+ promise.reject(
369
+ "noActivity",
370
+ "requestPermission() needs a foreground Activity. Call it after the app is visible."
371
+ )
372
+ return
373
+ }
374
+
375
+ // The SDK's request is fire-and-forget: the outcome arrives either through the
376
+ // permission observer or, if the user dismissed without changing anything, not
377
+ // at all. Resolving on the next host resume covers both -- the permission
378
+ // dialog always resumes the Activity when it closes.
379
+ val settled = AtomicBoolean(false)
380
+ fun settle() {
381
+ if (settled.compareAndSet(false, true)) {
382
+ promise.resolve(PushNotifications.permission(reactContext))
383
+ }
384
+ }
385
+
386
+ val resumeListener = object : LifecycleEventListener {
387
+ override fun onHostResume() {
388
+ reactContext.removeLifecycleEventListener(this)
389
+ // One frame of slack so the OS has written the new grant state before it
390
+ // is read back.
391
+ mainHandler.post { settle() }
392
+ }
393
+
394
+ override fun onHostPause() = Unit
395
+ override fun onHostDestroy() = Unit
396
+ }
397
+ reactContext.addLifecycleEventListener(resumeListener)
398
+
399
+ try {
400
+ PushNotifications.requestPermission(activity, fallbackToSettings)
401
+ } catch (e: Throwable) {
402
+ reactContext.removeLifecycleEventListener(resumeListener)
403
+ if (settled.compareAndSet(false, true)) {
404
+ promise.reject("permissionRequestFailed", e.message, e)
405
+ }
406
+ }
407
+ }
408
+
409
+ fun getPermission(promise: Promise) =
410
+ resolving(promise) { PushNotifications.permission(reactContext) }
411
+
412
+ /**
413
+ * Parity C7: Android has no granular permission type, so only two of the five
414
+ * cross-platform values are reachable here.
415
+ */
416
+ fun getPermissionStatus(promise: Promise) = resolving(promise) {
417
+ if (PushNotifications.permission(reactContext)) "authorized" else "denied"
418
+ }
419
+
420
+ fun canRequestPermission(promise: Promise) =
421
+ resolving(promise) { PushNotifications.canRequestPermission(reactContext) }
422
+
423
+ /** Parity E5: iOS provisional authorization has no Android equivalent. */
424
+ fun registerForProvisionalAuthorization(promise: Promise) = promise.resolve(null)
425
+
426
+ // MARK: - Notifications
427
+
428
+ fun clearAllNotifications(promise: Promise) =
429
+ resolvingUnit(promise) { PushNotifications.clearAllNotifications(reactContext) }
430
+
431
+ fun removeNotification(notificationId: String, promise: Promise) =
432
+ resolvingUnit(promise) {
433
+ PushNotifications.removeNotification(reactContext, notificationId)
434
+ }
435
+
436
+ /** Parity F3: iOS removes a list natively; Android has no bulk call, so loop. */
437
+ fun removeNotifications(notificationIds: ReadableArray, promise: Promise) =
438
+ resolvingUnit(promise) {
439
+ for (i in 0 until notificationIds.size()) {
440
+ notificationIds.getString(i)?.let {
441
+ PushNotifications.removeNotification(reactContext, it)
442
+ }
443
+ }
444
+ }
445
+
446
+ fun removeNotificationGroup(groupKey: String, promise: Promise) =
447
+ resolvingUnit(promise) {
448
+ PushNotifications.removeGroupedNotifications(reactContext, groupKey)
449
+ }
450
+
451
+ fun createNotificationChannel(config: ReadableMap, promise: Promise) = resolvingUnit(promise) {
452
+ val id = config.getString("id")
453
+ ?: throw IllegalArgumentException("channel id is required")
454
+ val name = config.getString("name")
455
+ ?: throw IllegalArgumentException("channel name is required")
456
+
457
+ PushNotifications.createNotificationChannel(
458
+ reactContext,
459
+ id,
460
+ name,
461
+ importanceFromString(config.getString("importance")),
462
+ config.getString("description") ?: "",
463
+ config.getString("sound")
464
+ )
465
+ }
466
+
467
+ fun deleteNotificationChannel(channelId: String, promise: Promise) =
468
+ resolvingUnit(promise) {
469
+ PushNotifications.deleteNotificationChannel(reactContext, channelId)
470
+ }
471
+
472
+ private fun importanceFromString(value: String?): Int = when (value) {
473
+ "none" -> NotificationManager.IMPORTANCE_NONE
474
+ "min" -> NotificationManager.IMPORTANCE_MIN
475
+ "low" -> NotificationManager.IMPORTANCE_LOW
476
+ "default" -> NotificationManager.IMPORTANCE_DEFAULT
477
+ "max" -> NotificationManager.IMPORTANCE_MAX
478
+ // The native default is IMPORTANCE_HIGH; keep that for "high" and for anything
479
+ // unrecognised so a typo does not silence a channel.
480
+ else -> NotificationManager.IMPORTANCE_HIGH
481
+ }
482
+
483
+ // MARK: - Badges
484
+
485
+ fun getBadgeCount(promise: Promise) = resolving(promise) { AppPushService.getBadgeCount() }
486
+
487
+ fun setBadgeCount(count: Int, promise: Promise) =
488
+ resolvingUnit(promise) { AppPushService.setBadgeCount(reactContext, count) }
489
+
490
+ /**
491
+ * Parity G2: Android has no native increment, so read -> add -> set. The read
492
+ * is the SDK's own persisted value, which is the same value set() writes, so
493
+ * the arithmetic is consistent even where the launcher ignores the broadcast.
494
+ */
495
+ fun incrementBadgeCount(delta: Int, promise: Promise) = resolving(promise) {
496
+ val next = (AppPushService.getBadgeCount() + delta).coerceAtLeast(0)
497
+ AppPushService.setBadgeCount(reactContext, next)
498
+ next
499
+ }
500
+
501
+ fun clearBadgeCount(promise: Promise) =
502
+ resolvingUnit(promise) { AppPushService.clearBadgeCount(reactContext) }
503
+
504
+ /** Parity G4: an iOS-only behaviour. */
505
+ fun setAutoClearBadgeOnForeground(enabled: Boolean, promise: Promise) = promise.resolve(null)
506
+
507
+ /**
508
+ * iOS-only. APNs registration can be deferred; FCM registration cannot -- the
509
+ * Firebase SDK obtains a token on its own schedule regardless.
510
+ */
511
+ fun setAutoRegisterForRemoteNotifications(enabled: Boolean, promise: Promise) =
512
+ promise.resolve(null)
513
+
514
+ // MARK: - User
515
+
516
+ fun addTag(key: String, value: String, promise: Promise) =
517
+ resolvingUnit(promise) { PushUser.addTag(key, value) }
518
+
519
+ fun addTags(tags: ReadableMap, promise: Promise) =
520
+ resolvingUnit(promise) { PushUser.addTags(tags.toStringMap()) }
521
+
522
+ fun removeTag(key: String, promise: Promise) =
523
+ resolvingUnit(promise) { PushUser.removeTag(key) }
524
+
525
+ fun removeTags(keys: ReadableArray, promise: Promise) =
526
+ resolvingUnit(promise) { PushUser.removeTags(keys.toStringList()) }
527
+
528
+ fun getTags(promise: Promise) {
529
+ // Not resolving(): getTags() fetches from the backend, so the tags arrive in a callback
530
+ // rather than as a return value and the promise has to be resolved from inside it.
531
+ try {
532
+ PushUser.getTags { tags ->
533
+ promise.resolve(Arguments.createMap().apply {
534
+ tags.forEach { (k, v) -> putString(k, v) }
535
+ })
536
+ }
537
+ } catch (e: Throwable) {
538
+ promise.reject(e.errorCode(), e.message, e)
539
+ }
540
+ }
541
+
542
+ fun addAlias(label: String, id: String, promise: Promise) =
543
+ resolvingUnit(promise) { PushUser.addAlias(label, id) }
544
+
545
+ fun addAliases(aliases: ReadableMap, promise: Promise) =
546
+ resolvingUnit(promise) { PushUser.addAliases(aliases.toStringMap()) }
547
+
548
+ fun removeAlias(label: String, promise: Promise) =
549
+ resolvingUnit(promise) { PushUser.removeAlias(label) }
550
+
551
+ fun removeAliases(labels: ReadableArray, promise: Promise) =
552
+ resolvingUnit(promise) { PushUser.removeAliases(labels.toStringList()) }
553
+
554
+ fun addEmail(address: String, promise: Promise) =
555
+ resolvingUnit(promise) { PushUser.addEmail(address) }
556
+
557
+ fun removeEmail(address: String, promise: Promise) =
558
+ resolvingUnit(promise) { PushUser.removeEmail(address) }
559
+
560
+ fun setLanguage(languageCode: String, promise: Promise) =
561
+ resolvingUnit(promise) { PushUser.setLanguage(languageCode) }
562
+
563
+ fun getLanguage(promise: Promise) = resolving(promise) { PushUser.language }
564
+
565
+ fun getPushSubscription(promise: Promise) = resolving(promise) {
566
+ Arguments.createMap().apply {
567
+ putString("id", PushUser.pushSubscription.id)
568
+ putString("token", PushUser.pushSubscription.token)
569
+ putBoolean("optedIn", PushUser.pushSubscription.optedIn)
570
+ }
571
+ }
572
+
573
+ fun optIn(promise: Promise) = resolvingUnit(promise) { PushUser.pushSubscription.optIn() }
574
+
575
+ fun optOut(promise: Promise) = resolvingUnit(promise) { PushUser.pushSubscription.optOut() }
576
+
577
+ /**
578
+ * Backend-truth opted-in state, unlike the local `optedIn` in [getPushSubscription].
579
+ * Callback-based for the same reason as [getTags], and short-circuits to the local
580
+ * value when the device has no subscriptionId yet.
581
+ */
582
+ fun getOptedIn(promise: Promise) {
583
+ try {
584
+ PushUser.pushSubscription.getOptedIn { optedIn -> promise.resolve(optedIn) }
585
+ } catch (e: Throwable) {
586
+ promise.reject(e.errorCode(), e.message, e)
587
+ }
588
+ }
589
+
590
+ // MARK: - Consent
591
+
592
+ fun setConsentRequired(required: Boolean, promise: Promise) =
593
+ resolvingUnit(promise) { AppPushService.consentRequired = required }
594
+
595
+ fun getConsentRequired(promise: Promise) = resolving(promise) { AppPushService.consentRequired }
596
+
597
+ fun setConsentGiven(given: Boolean, promise: Promise) =
598
+ resolvingUnit(promise) { AppPushService.consentGiven = given }
599
+
600
+ fun getConsentGiven(promise: Promise) = resolving(promise) { AppPushService.consentGiven }
601
+
602
+ // MARK: - Test device
603
+
604
+ fun setTestDevice(enabled: Boolean, promise: Promise) =
605
+ resolvingUnit(promise) { AppPushService.isTestDevice = enabled }
606
+
607
+ fun isTestDevice(promise: Promise) = resolving(promise) { AppPushService.isTestDevice }
608
+
609
+ // MARK: - Background sync
610
+
611
+ /**
612
+ * Parity H1: the Android Push SDK has no AppsOnAirBackgroundSync -- the type
613
+ * exists on iOS only. Resolving as a no-op keeps cross-platform calling code
614
+ * working; it is documented as iOS-only in the README rather than faked here.
615
+ */
616
+ fun scheduleBackgroundSync(options: ReadableMap?, promise: Promise) {
617
+ Log.i(NAME, "scheduleBackgroundSync() is iOS-only; ignored on Android.")
618
+ promise.resolve(null)
619
+ }
620
+
621
+ fun cancelBackgroundSync(promise: Promise) = promise.resolve(null)
622
+
623
+ // MARK: - Debug
624
+
625
+ fun setLogLevel(level: String, promise: Promise) = resolvingUnit(promise) {
626
+ PushDebug.setLogLevel(
627
+ when (level) {
628
+ "none" -> LogLevel.NONE
629
+ "fatal" -> LogLevel.FATAL
630
+ "error" -> LogLevel.ERROR
631
+ "warn" -> LogLevel.WARN
632
+ "info" -> LogLevel.INFO
633
+ "debug" -> LogLevel.DEBUG
634
+ "verbose" -> LogLevel.VERBOSE
635
+ else -> throw IllegalArgumentException("Unknown log level: $level")
636
+ }
637
+ )
638
+ }
639
+
640
+ // MARK: - Promise helpers
641
+ //
642
+ // Parity I1/I2: the SDK throws IllegalStateException before initialize() and
643
+ // IllegalArgumentException on empty input. Every native call goes through one
644
+ // of these so such a throw becomes a rejected promise instead of a crash. The
645
+ // JS layer guards too; this is the backstop for a race the JS guard cannot see.
646
+
647
+ private inline fun resolving(promise: Promise, block: () -> Any?) {
648
+ try {
649
+ promise.resolve(block())
650
+ } catch (e: Throwable) {
651
+ promise.reject(e.errorCode(), e.message, e)
652
+ }
653
+ }
654
+
655
+ private inline fun resolvingUnit(promise: Promise, block: () -> Unit) {
656
+ try {
657
+ block()
658
+ promise.resolve(null)
659
+ } catch (e: Throwable) {
660
+ promise.reject(e.errorCode(), e.message, e)
661
+ }
662
+ }
663
+
664
+ private fun Throwable.errorCode(): String = when (this) {
665
+ is IllegalStateException -> "notInitialized"
666
+ is IllegalArgumentException -> "invalidArgument"
667
+ else -> "unknown"
668
+ }
669
+
670
+ // MARK: - Conversions
671
+
672
+ private fun ReadableMap.toStringMap(): Map<String, String> {
673
+ val out = mutableMapOf<String, String>()
674
+ val iterator = keySetIterator()
675
+ while (iterator.hasNextKey()) {
676
+ val key = iterator.nextKey()
677
+ getString(key)?.let { out[key] = it }
678
+ }
679
+ return out
680
+ }
681
+
682
+ private fun ReadableArray.toStringList(): List<String> =
683
+ (0 until size()).mapNotNull { getString(it) }
684
+
685
+ /**
686
+ * Parity C1: the cross-platform notification shape.
687
+ *
688
+ * The native Android model carries 6 fields against iOS's 14. The parity audit's
689
+ * recommendation is that Android parse the remaining keys out of the FCM data
690
+ * payload, which is what the block below does -- so a notification looks the
691
+ * same to JS on both platforms, with nulls only where the payload genuinely
692
+ * lacked the key.
693
+ */
694
+ private fun PushNotification.toWritableMap(): WritableMap = Arguments.createMap().apply {
695
+ putString("id", id)
696
+ putString("title", title)
697
+ putString("body", body)
698
+
699
+ putString("campaignId", data["campaign_id"])
700
+ putString("templateId", data["template_id"])
701
+ putString("sentAt", data["sent_at"])
702
+ putString("subtitle", data["subtitle"])
703
+ putString("launchUrl", data["url"])
704
+ putString("imageUrl", imageUrl)
705
+ putString("sound", sound)
706
+ putString("channelId", data["channel_id"])
707
+
708
+ // iOS reads collapse_id; Android's payload contract calls it collapse_key.
709
+ putString("collapseId", data["collapse_id"] ?: data["collapse_key"])
710
+
711
+ val badgeIncrement = data["badge_increment"]?.toIntOrNull()
712
+ if (badgeIncrement != null) putInt("badgeIncrement", badgeIncrement) else putNull("badgeIncrement")
713
+
714
+ // Parity C1: iOS emits attachments; Android's payload has at most an image,
715
+ // so synthesize the single-entry array iOS would have produced.
716
+ putArray("attachments", Arguments.createArray().apply {
717
+ imageUrl?.let {
718
+ pushMap(Arguments.createMap().apply {
719
+ putNull("id")
720
+ putString("url", it)
721
+ })
722
+ }
723
+ })
724
+
725
+ putArray("actionButtons", parseActionButtons(data["actions"]))
726
+
727
+ putMap("data", Arguments.createMap().apply {
728
+ data.forEach { (k, v) -> putString(k, v) }
729
+ })
730
+
731
+ // Android's raw payload is the same flat string map, unlike the nested APNs
732
+ // userInfo on iOS. Both are the untouched payload, which is what the field promises.
733
+ putMap("rawPayload", Arguments.createMap().apply {
734
+ data.forEach { (k, v) -> putString(k, v) }
735
+ })
736
+ }
737
+
738
+ private fun parseActionButtons(raw: String?) = Arguments.createArray().apply {
739
+ if (raw.isNullOrBlank()) return@apply
740
+ val array = runCatching { JSONArray(raw) }.getOrNull() ?: return@apply
741
+ for (i in 0 until array.length()) {
742
+ val obj = array.optJSONObject(i) ?: continue
743
+ val id = obj.optString("id").takeIf { it.isNotEmpty() } ?: continue
744
+ pushMap(Arguments.createMap().apply {
745
+ putString("id", id)
746
+ putString("title", obj.optString("title"))
747
+ })
748
+ }
749
+ }
750
+
751
+ /** Parity C5: one casing and one membership set across both platforms. */
752
+ private fun PushError.toWritableMap(): WritableMap = Arguments.createMap().apply {
753
+ putString(
754
+ "code",
755
+ when (code) {
756
+ PushError.Code.NOT_INITIALIZED -> "notInitialized"
757
+ PushError.Code.PERMISSION_DENIED -> "permissionDenied"
758
+ PushError.Code.FIREBASE_NOT_CONFIGURED -> "firebaseNotConfigured"
759
+ PushError.Code.TOKEN_FETCH_FAILED -> "tokenRegistrationFailed"
760
+ PushError.Code.INSTALLATION_ID_FETCH_FAILED -> "installationIdFetchFailed"
761
+ PushError.Code.UNKNOWN -> "unknown"
762
+ }
763
+ )
764
+ putString("message", message)
765
+ // `cause` is deliberately dropped -- a Throwable does not cross the bridge.
766
+ }
767
+ }