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.
- package/LICENSE +21 -0
- package/README.md +540 -0
- package/android/build.gradle +177 -0
- package/android/gradle.properties +15 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/AndroidManifestNew.xml +2 -0
- package/android/src/main/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModuleImpl.kt +767 -0
- package/android/src/main/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushPackage.kt +43 -0
- package/android/src/newarch/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModule.kt +199 -0
- package/android/src/oldarch/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModule.kt +249 -0
- package/appsonair-react-native-apppush.podspec +77 -0
- package/ios/AppsonairReactNativeApppush-Bridging-Header.h +12 -0
- package/ios/AppsonairReactNativeApppush.h +28 -0
- package/ios/AppsonairReactNativeApppush.mm +494 -0
- package/ios/AppsonairReactNativeApppushImpl.swift +594 -0
- package/lib/commonjs/NativeAppsonairApppush.js +48 -0
- package/lib/commonjs/NativeAppsonairApppush.js.map +1 -0
- package/lib/commonjs/index.js +686 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/NativeAppsonairApppush.js +47 -0
- package/lib/module/NativeAppsonairApppush.js.map +1 -0
- package/lib/module/index.js +612 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/NativeAppsonairApppush.d.ts +134 -0
- package/lib/typescript/commonjs/src/NativeAppsonairApppush.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +392 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +221 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/NativeAppsonairApppush.d.ts +134 -0
- package/lib/typescript/module/src/NativeAppsonairApppush.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +392 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +221 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +119 -0
- package/react-native.config.js +14 -0
- package/src/NativeAppsonairApppush.ts +188 -0
- package/src/index.tsx +745 -0
- 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
|
+
}
|