appambit-push-notifications 0.3.1 → 1.0.0

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 (28) hide show
  1. package/AppAmbitSdkPushNotifications.podspec +14 -4
  2. package/README.md +184 -105
  3. package/android/build.gradle +21 -3
  4. package/android/src/main/AndroidManifest.xml +107 -1
  5. package/android/src/main/java/com/appambitpushnotifications/AppAmbitContextHolder.kt +22 -0
  6. package/android/src/main/java/com/appambitpushnotifications/AppAmbitHeadlessService.kt +177 -0
  7. package/android/src/main/java/com/appambitpushnotifications/AppAmbitInitProvider.kt +73 -0
  8. package/android/src/main/java/com/appambitpushnotifications/AppAmbitMessagingService.kt +12 -0
  9. package/android/src/main/java/com/appambitpushnotifications/AppAmbitNotificationSerializer.kt +88 -0
  10. package/android/src/main/java/com/appambitpushnotifications/AppAmbitPayloadUtils.kt +59 -0
  11. package/android/src/main/java/com/appambitpushnotifications/AppAmbitPushEventEmitter.kt +100 -0
  12. package/android/src/main/java/com/appambitpushnotifications/AppAmbitRNServiceExtension.kt +75 -0
  13. package/android/src/main/java/com/appambitpushnotifications/AppAmbitRemoteMessageStore.kt +26 -0
  14. package/android/src/main/java/com/appambitpushnotifications/AppambitPushNotificationsModule.kt +377 -76
  15. package/ios/AppAmbitNotificationSwizzler.m +290 -0
  16. package/ios/AppAmbitPushWrapper.swift +165 -25
  17. package/ios/AppAmbitRNNotificationService.swift +46 -0
  18. package/ios/AppambitPushNotifications.mm +264 -10
  19. package/lib/module/NativeAppambitPushNotifications.js.map +1 -1
  20. package/lib/module/index.js +46 -10
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/typescript/src/NativeAppambitPushNotifications.d.ts +2 -1
  23. package/lib/typescript/src/NativeAppambitPushNotifications.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +32 -6
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/NativeAppambitPushNotifications.ts +7 -1
  28. package/src/index.tsx +93 -20
@@ -1,93 +1,394 @@
1
1
  package com.appambitpushnotifications
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.pm.PackageManager
7
+ import android.net.ConnectivityManager
8
+ import android.net.Network
9
+ import android.net.NetworkCapabilities
10
+ import android.net.NetworkRequest
11
+ import android.os.Build
12
+ import android.util.Log
4
13
  import androidx.activity.ComponentActivity
5
- import com.facebook.react.bridge.ReactApplicationContext
14
+ import com.appambit.sdk.AppAmbit
15
+ import com.appambit.sdk.PushKernel
16
+ import com.appambit.sdk.PushNotifications
17
+ import com.facebook.react.bridge.ActivityEventListener
18
+ import com.facebook.react.bridge.LifecycleEventListener
6
19
  import com.facebook.react.bridge.Promise
20
+ import com.facebook.react.bridge.ReactApplicationContext
7
21
  import com.facebook.react.module.annotations.ReactModule
8
- import com.appambit.sdk.PushNotifications
9
- import com.facebook.react.bridge.Arguments
10
- import com.facebook.react.bridge.WritableMap
11
- import com.facebook.react.modules.core.DeviceEventManagerModule
12
22
 
23
+ /**
24
+ * AppambitPushNotificationsModule
25
+ *
26
+ * The Turbo Native Module that exposes AppAmbit Push SDK functionality to JavaScript.
27
+ */
13
28
  @ReactModule(name = AppambitPushNotificationsModule.NAME)
14
29
  class AppambitPushNotificationsModule(reactContext: ReactApplicationContext) :
15
- NativeAppambitPushNotificationsSpec(reactContext) {
16
-
17
- override fun getName(): String {
18
- return NAME
19
- }
20
-
21
- override fun start() {
22
- PushNotifications.start(reactApplicationContext.applicationContext)
23
- }
24
-
25
- override fun requestNotificationPermission() {
26
- val activity: Activity? = currentActivity
27
- if (activity is ComponentActivity) {
28
- PushNotifications.requestNotificationPermission(activity)
29
- }
30
- }
31
-
32
- override fun requestNotificationPermissionWithResult(promise: Promise) {
33
- val activity: Activity? = currentActivity
34
- if (activity is ComponentActivity) {
35
- PushNotifications.requestNotificationPermission(activity) { isGranted ->
36
- promise.resolve(isGranted)
37
- }
38
- } else {
39
- promise.resolve(false)
40
- }
41
- }
42
-
43
- override fun setNotificationsEnabled(enabled: Boolean) {
44
- PushNotifications.setNotificationsEnabled(
45
- reactApplicationContext.applicationContext,
46
- enabled
47
- )
48
- }
49
-
50
- override fun isNotificationsEnabled(promise: Promise) {
51
- val enabled = PushNotifications.isNotificationsEnabled(
52
- reactApplicationContext.applicationContext
53
- )
54
- promise.resolve(enabled)
55
- }
56
-
57
- override fun setNotificationCustomizer() {
58
- PushNotifications.setNotificationCustomizer { _, _, notification ->
59
- val params = Arguments.createMap()
60
-
61
- val notificationMap = Arguments.createMap()
62
- notificationMap.putString("title", notification.title)
63
- notificationMap.putString("body", notification.body)
64
-
65
- params.putMap("notification", notificationMap)
66
-
67
- notification.data?.let { data ->
68
- val dataMap = Arguments.createMap()
69
- for ((key, value) in data) {
70
- dataMap.putString(key, value)
30
+ NativeAppambitPushNotificationsSpec(reactContext), ActivityEventListener, LifecycleEventListener {
31
+
32
+ companion object {
33
+ const val NAME = "AppambitPushNotifications"
34
+ private const val TAG = "AppAmbitPushModule"
35
+
36
+ // SharedPreferences for UI state persistence across restarts.
37
+ private const val PUSH_PREFS = "appambit_push_prefs"
38
+ private const val KEY_ENABLED_STATE = "appambit_push_enabled_state"
39
+ private const val KEY_HAS_ENABLED_STATE = "appambit_push_has_enabled_state"
40
+ // Pending consumer-sync intent: last toggle not yet confirmed online.
41
+ private const val KEY_PENDING = "appambit_push_pending_sync"
42
+ private const val KEY_PENDING_VALUE = "appambit_push_pending_value"
43
+ }
44
+
45
+ private var hasRegisteredOpenedListener = false
46
+ private var lastProcessedIntent: Intent? = null
47
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
48
+
49
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
50
+
51
+ override fun getName(): String = NAME
52
+
53
+ override fun initialize() {
54
+ super.initialize()
55
+ Log.d(TAG, "Module initialized")
56
+ AppAmbitContextHolder.set(reactApplicationContext)
57
+ AppAmbitPushEventEmitter.attach(reactApplicationContext)
58
+
59
+ reactApplicationContext.addActivityEventListener(this)
60
+ reactApplicationContext.addLifecycleEventListener(this)
61
+
62
+ registerNetworkCallback()
63
+
64
+ // Try to check intent immediately if activity exists
65
+ checkInitialIntent()
66
+ }
67
+
68
+ override fun invalidate() {
69
+ AppAmbitPushEventEmitter.detach()
70
+ PushKernel.setOpenedNotificationListener(null)
71
+ reactApplicationContext.removeActivityEventListener(this)
72
+ reactApplicationContext.removeLifecycleEventListener(this)
73
+ unregisterNetworkCallback()
74
+ hasRegisteredOpenedListener = false
75
+ super.invalidate()
76
+ }
77
+
78
+ private fun checkInitialIntent() {
79
+ val activity = currentActivity
80
+ Log.d(TAG, "checkInitialIntent called. activity=$activity")
81
+
82
+ if (activity != null) {
83
+ var intent = activity.intent
84
+ Log.d(TAG, "checkInitialIntent: intent=$intent, lastProcessedIntent=$lastProcessedIntent")
85
+
86
+ if (intent != null && intent != lastProcessedIntent) {
87
+ lastProcessedIntent = intent
88
+
89
+ Log.d(TAG, "checkInitialIntent: original action=${intent.action}")
90
+ val extras = intent.extras
91
+
92
+ if (extras != null && intent.action != "com.appambit.sdk.NOTIFICATION_OPENED") {
93
+ var isPush = extras.containsKey("google.message_id")
94
+ for (key in extras.keySet()) {
95
+ Log.d(TAG, "checkInitialIntent: extra $key = ${extras.get(key)}")
96
+ if (key.startsWith("gcm.")) {
97
+ isPush = true
98
+ }
99
+ }
100
+
101
+ if (isPush) {
102
+ Log.d(TAG, "checkInitialIntent: Detected FCM System Tray Intent. Mutating format...")
103
+ val pushIntent = Intent(intent)
104
+ pushIntent.action = "com.appambit.sdk.NOTIFICATION_OPENED"
105
+
106
+ val title = extras.getString("gcm.notification.title") ?: extras.getString("title")
107
+ val body = extras.getString("gcm.notification.body") ?: extras.getString("body")
108
+ if (title != null) pushIntent.putExtra("appambit_title", title)
109
+ if (body != null) pushIntent.putExtra("appambit_body", body)
110
+
111
+ // imageUrl: gcm.notification.image is filtered from the data loop below,
112
+ // so we extract it explicitly here.
113
+ val imageUrl = extras.getString("gcm.notification.image")
114
+ ?: extras.getString("image_url")
115
+ ?: extras.getString("image")
116
+ if (imageUrl != null) pushIntent.putExtra("appambit_image_url", imageUrl)
117
+
118
+ // color and icon
119
+ val color = extras.getString("gcm.notification.color")
120
+ val icon = extras.getString("gcm.notification.icon")
121
+ if (color != null) pushIntent.putExtra("appambit_color", color)
122
+ if (icon != null) pushIntent.putExtra("appambit_icon", icon)
123
+
124
+ val keysList = mutableListOf<String>()
125
+ val valuesList = mutableListOf<String>()
126
+
127
+ // Inject Android notification display fields as _aa_* data keys so the
128
+ // serializer can promote them into the android sub-object and strip them
129
+ // from the custom data map exposed to JS.
130
+ mapOf(
131
+ "_aa_ticker" to (extras.getString("gcm.notification.ticker")),
132
+ "_aa_sticky" to (extras.getString("gcm.notification.sticky")),
133
+ "_aa_visibility" to (extras.getString("gcm.notification.visibility")),
134
+ "_aa_channel_id" to (extras.getString("gcm.notification.channel_id")),
135
+ "_aa_priority" to (extras.getString("gcm.notification.priority")
136
+ ?: extras.getString("gcm.notification.notification_priority")),
137
+ "_aa_tag" to (extras.getString("gcm.notification.tag")),
138
+ "_aa_sound" to (extras.getString("gcm.notification.sound")),
139
+ "_aa_click_action" to (extras.getString("gcm.notification.click_action")
140
+ ?: extras.getString("gcm.notification.clickAction"))
141
+ ).forEach { (key, value) ->
142
+ if (value != null) { keysList.add(key); valuesList.add(value) }
143
+ }
144
+
145
+ // Custom data keys from the FCM data payload
146
+ for (key in extras.keySet()) {
147
+ if (!key.startsWith("google.") && !key.startsWith("gcm.") && !key.startsWith("android.") &&
148
+ key != "from" && key != "collapse_key" && key != "profile") {
149
+ keysList.add(key)
150
+ valuesList.add(extras.get(key).toString())
151
+ }
152
+ }
153
+
154
+ if (keysList.isNotEmpty()) {
155
+ pushIntent.putExtra("appambit_data_keys", keysList.toTypedArray())
156
+ pushIntent.putExtra("appambit_data_keys_values", valuesList.toTypedArray())
157
+ }
158
+
159
+ intent = pushIntent
160
+ }
161
+ } else if (extras == null) {
162
+ Log.d(TAG, "checkInitialIntent: no extras")
163
+ }
164
+
165
+ PushNotifications.handleNotificationOpened(reactApplicationContext.applicationContext, intent)
166
+ }
167
+ }
168
+ }
169
+
170
+ // ── LifecycleEventListener ────────────────────────────────────────────────
171
+
172
+ override fun onHostResume() {
173
+ checkInitialIntent()
174
+ // Replay any deferred consumer sync (covers "toggle offline → reopen online").
175
+ flushPendingConsumerSync()
176
+ }
177
+
178
+ override fun onHostPause() {
179
+ // No-op
180
+ }
181
+
182
+ override fun onHostDestroy() {
183
+ // No-op
184
+ }
185
+
186
+ // ── ActivityEventListener ─────────────────────────────────────────────────
187
+
188
+ override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
189
+ // No-op
190
+ }
191
+
192
+ override fun onNewIntent(intent: Intent) {
193
+ if (intent != lastProcessedIntent) {
194
+ lastProcessedIntent = intent
195
+ Log.d(TAG, "onNewIntent: action=${intent.action}")
196
+ PushNotifications.handleNotificationOpened(reactApplicationContext.applicationContext, intent)
197
+ }
198
+ }
199
+
200
+ // ── JS API ────────────────────────────────────────────────────────────────
201
+
202
+ override fun start() {
203
+ checkInitialIntent()
204
+ PushNotifications.start(reactApplicationContext.applicationContext)
205
+ // start() is invoked from JS right after AppAmbit.start() (Core init), so this
206
+ // is the first reliable point where a deferred consumer sync can succeed.
207
+ // onHostResume / the network callback may fire before Core is initialized.
208
+ flushPendingConsumerSync()
209
+ }
210
+
211
+ override fun requestNotificationPermission() {
212
+ val activity: Activity? = currentActivity
213
+ if (activity is ComponentActivity) {
214
+ PushNotifications.requestNotificationPermission(activity)
215
+ } else {
216
+ Log.w(TAG, "requestNotificationPermission: currentActivity is not a ComponentActivity")
217
+ }
218
+ }
219
+
220
+ override fun requestNotificationPermissionWithResult(promise: Promise) {
221
+ val activity: Activity? = currentActivity
222
+ if (activity is ComponentActivity) {
223
+ PushNotifications.requestNotificationPermission(activity) { isGranted ->
224
+ promise.resolve(isGranted)
225
+ }
226
+ } else {
227
+ promise.resolve(false)
228
+ }
229
+ }
230
+
231
+ override fun setNotificationsEnabled(enabled: Boolean) {
232
+ val context = reactApplicationContext.applicationContext
233
+ val prefs = context.getSharedPreferences(PUSH_PREFS, Context.MODE_PRIVATE)
234
+ // 1. Persist user intent for UI state consistency across restarts and
235
+ // 2. record a pending consumer-sync intent (cleared once delivered).
236
+ prefs.edit()
237
+ .putBoolean(KEY_ENABLED_STATE, enabled)
238
+ .putBoolean(KEY_HAS_ENABLED_STATE, true)
239
+ .putBoolean(KEY_PENDING_VALUE, enabled)
240
+ .putBoolean(KEY_PENDING, true)
241
+ .apply()
242
+ // 3. Update the SDK's local enabled flag (no network) so cold-start token
243
+ // sync knows the user's intent.
244
+ PushKernel.setNotificationsEnabled(context, enabled)
245
+ // 4. Push to the backend when online; otherwise defer until connectivity
246
+ // returns (replayed from the network callback / onHostResume).
247
+ flushPendingConsumerSync()
248
+ }
249
+
250
+ override fun isNotificationsEnabled(promise: Promise) {
251
+ val context = reactApplicationContext.applicationContext
252
+ val prefs = context.getSharedPreferences(PUSH_PREFS, Context.MODE_PRIVATE)
253
+ if (prefs.getBoolean(KEY_HAS_ENABLED_STATE, false)) {
254
+ // Return our own persisted state — always the last value the user explicitly set,
255
+ // survives cold restarts and SDK state inconsistencies.
256
+ promise.resolve(prefs.getBoolean(KEY_ENABLED_STATE, false))
257
+ } else {
258
+ // First-ever launch: no stored state yet, ask the SDK.
259
+ promise.resolve(
260
+ PushNotifications.isNotificationsEnabled(context)
261
+ )
262
+ }
263
+ }
264
+
265
+ override fun hasNotificationPermission(promise: Promise) {
266
+ val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
267
+ reactApplicationContext.checkSelfPermission(
268
+ android.Manifest.permission.POST_NOTIFICATIONS
269
+ ) == PackageManager.PERMISSION_GRANTED
270
+ } else {
271
+ true
272
+ }
273
+ promise.resolve(granted)
274
+ }
275
+
276
+ override fun backgroundHandlerCompleted() {
277
+ // Android background execution is managed by the headless JS task registered with
278
+ // BACKGROUND_NOTIFICATION_TASK — no native completion signal needed here.
279
+ }
280
+
281
+ // ── Required by NativeEventEmitter ───────────────────────────────────────
282
+
283
+ override fun addListener(eventName: String?) {
284
+ if (eventName == AppAmbitPushEventEmitter.EVENT_OPENED && !hasRegisteredOpenedListener) {
285
+ hasRegisteredOpenedListener = true
286
+ registerOpenedListener()
71
287
  }
72
- params.putMap("data", dataMap)
73
- }
74
- sendEvent("onNotificationReceived", params)
75
288
  }
76
- }
77
289
 
78
- private fun sendEvent(eventName: String, params: WritableMap?) {
79
- if (reactApplicationContext.hasActiveReactInstance()) {
80
- reactApplicationContext
81
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
82
- .emit(eventName, params)
290
+ override fun removeListeners(count: Double) {
291
+ // No-op
83
292
  }
84
- }
85
293
 
86
- override fun addListener(eventName: String?) {}
294
+ // ── Offline-resilient consumer sync ──────────────────────────────────────
295
+ // The SDK's consumer update is fire-and-forget with no offline retry, so we
296
+ // defer it while offline and replay it when connectivity returns.
87
297
 
88
- override fun removeListeners(count: Double) {}
298
+ private fun registerNetworkCallback() {
299
+ if (networkCallback != null) return
300
+ val cm = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
301
+ as? ConnectivityManager ?: return
302
+ val callback = object : ConnectivityManager.NetworkCallback() {
303
+ override fun onAvailable(network: Network) {
304
+ Log.d(TAG, "NetworkCallback.onAvailable")
305
+ flushPendingConsumerSync()
306
+ }
89
307
 
90
- companion object {
91
- const val NAME = "AppambitPushNotifications"
92
- }
308
+ override fun onCapabilitiesChanged(
309
+ network: Network,
310
+ caps: NetworkCapabilities
311
+ ) {
312
+ // onAvailable can fire before the link is actually usable; the
313
+ // capabilities update is a second chance to replay once the
314
+ // network reports INTERNET.
315
+ if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
316
+ flushPendingConsumerSync()
317
+ }
318
+ }
319
+ }
320
+ val request = NetworkRequest.Builder()
321
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
322
+ .build()
323
+ try {
324
+ cm.registerNetworkCallback(request, callback)
325
+ networkCallback = callback
326
+ } catch (e: Exception) {
327
+ Log.w(TAG, "registerNetworkCallback failed: ${e.message}")
328
+ }
329
+ }
330
+
331
+ private fun unregisterNetworkCallback() {
332
+ val cb = networkCallback ?: return
333
+ val cm = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
334
+ as? ConnectivityManager
335
+ try {
336
+ cm?.unregisterNetworkCallback(cb)
337
+ } catch (e: Exception) {
338
+ Log.w(TAG, "unregisterNetworkCallback failed: ${e.message}")
339
+ }
340
+ networkCallback = null
341
+ }
342
+
343
+ private fun isNetworkAvailable(context: Context): Boolean {
344
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
345
+ as? ConnectivityManager ?: return false
346
+ val network = cm.activeNetwork ?: return false
347
+ val caps = cm.getNetworkCapabilities(network) ?: return false
348
+ // Only require INTERNET capability (matches iOS NWPathMonitor's `.satisfied`).
349
+ // We intentionally do NOT require NET_CAPABILITY_VALIDATED: emulators and
350
+ // freshly-reconnected networks often report a usable connection before (or
351
+ // without ever) flipping VALIDATED, which would otherwise make the deferred
352
+ // consumer sync skip forever.
353
+ return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
354
+ }
355
+
356
+ @Synchronized
357
+ private fun flushPendingConsumerSync() {
358
+ val context = reactApplicationContext.applicationContext
359
+ val prefs = context.getSharedPreferences(PUSH_PREFS, Context.MODE_PRIVATE)
360
+ if (!prefs.getBoolean(KEY_PENDING, false)) return
361
+ if (!isNetworkAvailable(context)) {
362
+ Log.d(TAG, "flushPendingConsumerSync: offline, keeping pending intent")
363
+ return
364
+ }
365
+ // The Core SDK rejects (and logs) any consumer update before AppAmbit.start()
366
+ // has run. onHostResume / the network callback can fire during cold start
367
+ // BEFORE the JS layer initializes Core, so we must keep the pending intent
368
+ // until Core is ready — otherwise the replay is silently dropped.
369
+ if (!AppAmbit.isInitialized()) {
370
+ Log.d(TAG, "flushPendingConsumerSync: Core not initialized yet, keeping pending intent")
371
+ return
372
+ }
373
+ val desired = prefs.getBoolean(KEY_PENDING_VALUE, false)
374
+ Log.d(TAG, "flushPendingConsumerSync: replaying consumer update enabled=$desired")
375
+ // The SDK's consumer update reuses the last stored device token when no
376
+ // live token is available, so this succeeds as long as a token was ever
377
+ // registered (it only skips when nothing has ever been stored).
378
+ PushNotifications.setNotificationsEnabled(context, desired)
379
+ prefs.edit()
380
+ .remove(KEY_PENDING)
381
+ .remove(KEY_PENDING_VALUE)
382
+ .apply()
383
+ }
384
+
385
+ // ── Internal helpers ──────────────────────────────────────────────────────
386
+
387
+ private fun registerOpenedListener() {
388
+ PushNotifications.setOpenedListener { notification ->
389
+ Log.d(TAG, "Notification opened: ${notification.title}")
390
+ val payload = AppAmbitNotificationSerializer.toEventPayload(notification)
391
+ AppAmbitPushEventEmitter.emit(AppAmbitPushEventEmitter.EVENT_OPENED, payload)
392
+ }
393
+ }
93
394
  }