@loyalytics/swan-react-native-sdk 2.3.0 → 2.3.1-beta.1

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 (91) hide show
  1. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +145 -1
  2. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +3 -22
  3. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +7 -0
  4. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +7 -0
  5. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +141 -9
  6. package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +1 -1
  7. package/android/src/main/res/layout/swan_carousel_expanded.xml +1 -1
  8. package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +1 -1
  9. package/ios/SwanAppGroup.m +55 -0
  10. package/ios/SwanNotificationContentExtension/NotificationViewController.swift +90 -28
  11. package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +5 -0
  12. package/ios/SwanNotificationServiceExtension/NotificationService.swift +1 -0
  13. package/lib/commonjs/components/HeaderView.js +0 -1
  14. package/lib/commonjs/components/HeaderView.js.map +1 -1
  15. package/lib/commonjs/index.js +315 -50
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/providers/FirebasePushProvider.js +2 -2
  18. package/lib/commonjs/providers/FirebasePushProvider.js.map +1 -1
  19. package/lib/commonjs/providers/NullPushProvider.js +1 -1
  20. package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
  21. package/lib/commonjs/providers/PushNotificationProvider.js.map +1 -1
  22. package/lib/commonjs/services/PushTokenService.js +2 -2
  23. package/lib/commonjs/services/PushTokenService.js.map +1 -1
  24. package/lib/commonjs/utils/FirebaseNotificationManager.js +9 -5
  25. package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
  26. package/lib/commonjs/utils/NotificationSoundHelper.js +72 -0
  27. package/lib/commonjs/utils/NotificationSoundHelper.js.map +1 -0
  28. package/lib/commonjs/utils/SharedCredentialsManager.js +46 -8
  29. package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
  30. package/lib/commonjs/version.js +1 -1
  31. package/lib/commonjs/version.js.map +1 -1
  32. package/lib/module/components/HeaderView.js +1 -1
  33. package/lib/module/components/HeaderView.js.map +1 -1
  34. package/lib/module/index.js +312 -48
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/providers/FirebasePushProvider.js +2 -2
  37. package/lib/module/providers/FirebasePushProvider.js.map +1 -1
  38. package/lib/module/providers/NullPushProvider.js +1 -1
  39. package/lib/module/providers/NullPushProvider.js.map +1 -1
  40. package/lib/module/providers/PushNotificationProvider.js.map +1 -1
  41. package/lib/module/services/PushTokenService.js +2 -2
  42. package/lib/module/services/PushTokenService.js.map +1 -1
  43. package/lib/module/utils/FirebaseNotificationManager.js +9 -5
  44. package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
  45. package/lib/module/utils/NotificationSoundHelper.js +66 -0
  46. package/lib/module/utils/NotificationSoundHelper.js.map +1 -0
  47. package/lib/module/utils/SharedCredentialsManager.js +48 -9
  48. package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
  49. package/lib/module/version.js +1 -1
  50. package/lib/module/version.js.map +1 -1
  51. package/lib/typescript/commonjs/src/components/HeaderView.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/src/index.d.ts +20 -1
  53. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/src/providers/FirebasePushProvider.d.ts +1 -1
  55. package/lib/typescript/commonjs/src/providers/FirebasePushProvider.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts +1 -1
  57. package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/src/providers/PushNotificationProvider.d.ts +1 -1
  59. package/lib/typescript/commonjs/src/providers/PushNotificationProvider.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/src/services/PushTokenService.d.ts +1 -1
  61. package/lib/typescript/commonjs/src/services/PushTokenService.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts +1 -1
  63. package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/src/utils/NotificationSoundHelper.d.ts +34 -0
  65. package/lib/typescript/commonjs/src/utils/NotificationSoundHelper.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +6 -0
  67. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/src/version.d.ts +1 -1
  69. package/lib/typescript/commonjs/src/version.d.ts.map +1 -1
  70. package/lib/typescript/module/src/components/HeaderView.d.ts.map +1 -1
  71. package/lib/typescript/module/src/index.d.ts +20 -1
  72. package/lib/typescript/module/src/index.d.ts.map +1 -1
  73. package/lib/typescript/module/src/providers/FirebasePushProvider.d.ts +1 -1
  74. package/lib/typescript/module/src/providers/FirebasePushProvider.d.ts.map +1 -1
  75. package/lib/typescript/module/src/providers/NullPushProvider.d.ts +1 -1
  76. package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
  77. package/lib/typescript/module/src/providers/PushNotificationProvider.d.ts +1 -1
  78. package/lib/typescript/module/src/providers/PushNotificationProvider.d.ts.map +1 -1
  79. package/lib/typescript/module/src/services/PushTokenService.d.ts +1 -1
  80. package/lib/typescript/module/src/services/PushTokenService.d.ts.map +1 -1
  81. package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts +1 -1
  82. package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  83. package/lib/typescript/module/src/utils/NotificationSoundHelper.d.ts +34 -0
  84. package/lib/typescript/module/src/utils/NotificationSoundHelper.d.ts.map +1 -0
  85. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +6 -0
  86. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  87. package/lib/typescript/module/src/version.d.ts +1 -1
  88. package/lib/typescript/module/src/version.d.ts.map +1 -1
  89. package/package.json +23 -10
  90. package/scripts/setup-ios-extension.js +61 -41
  91. package/swan-react-native-sdk.podspec +1 -0
@@ -14,7 +14,7 @@
14
14
  import AsyncStorage from '@react-native-async-storage/async-storage';
15
15
  import Base64 from 'react-native-base64';
16
16
  import uuid from 'react-native-uuid';
17
- import { StyleSheet, View, Modal, PermissionsAndroid, Platform, AppState, Linking } from 'react-native';
17
+ import { StyleSheet, View, Modal, NativeModules, DeviceEventEmitter, PermissionsAndroid, Platform, AppState, Linking } from 'react-native';
18
18
  import { SDK_VERSION as PACKAGE_VERSION } from "./version.js";
19
19
  import PopUpView from "./components/PopUpView.js";
20
20
  import HeaderView from "./components/HeaderView.js";
@@ -23,7 +23,6 @@ import FullScreenView from "./components/FullScreenView.js";
23
23
  import { useState } from 'react';
24
24
  import SQLite from 'react-native-sqlite-2';
25
25
  import DeviceInfo from 'react-native-device-info';
26
- import Geolocation from '@react-native-community/geolocation';
27
26
  import Logger from "./utils/Logger.js";
28
27
  import { EventQueueManager } from "./core/EventQueueManager.js";
29
28
  import { FlushManager } from "./core/FlushManager.js";
@@ -39,6 +38,7 @@ import { DeviceRegistrationService } from "./services/DeviceRegistrationService.
39
38
  import { PushTokenService } from "./services/PushTokenService.js";
40
39
  import { NullPushProvider } from "./providers/NullPushProvider.js";
41
40
  import { SharedCredentialsManager } from "./utils/SharedCredentialsManager.js";
41
+ import { resolveSoundFromPayload, buildIosSound, buildAndroidSound } from "./utils/NotificationSoundHelper.js";
42
42
  import URLS from "./constants/ApiUrls.js";
43
43
 
44
44
  // Predefined Notification Channels
@@ -110,6 +110,28 @@ function parseKeyValuePairs(data) {
110
110
  }
111
111
  }
112
112
 
113
+ /**
114
+ * Click deduplication guard.
115
+ * Prevents double click handling if both Firebase (onNotificationOpenedApp) and
116
+ * Notifee handlers fire for the same notification.
117
+ */
118
+ const processedClickIds = new Set();
119
+ const CLICK_ID_TTL_MS = 10_000; // Clear after 10 seconds
120
+
121
+ function markClickProcessed(id) {
122
+ if (processedClickIds.has(id)) {
123
+ return false; // Already processed
124
+ }
125
+ processedClickIds.add(id);
126
+ setTimeout(() => processedClickIds.delete(id), CLICK_ID_TTL_MS);
127
+ return true; // First time processing
128
+ }
129
+
130
+ /** @internal Test-only: reset click deduplication state */
131
+ export function _resetClickDedup() {
132
+ processedClickIds.clear();
133
+ }
134
+
113
135
  /**
114
136
  * Notification deep link payload
115
137
  * Emitted when user clicks on a push notification
@@ -151,6 +173,7 @@ export default class SwanSDK {
151
173
  pendingPushEventListeners = [];
152
174
  appStateSubscription = null;
153
175
  linkingSubscription = null;
176
+ carouselClickSubscription = null;
154
177
 
155
178
  // Track if initial notification was already handled to prevent duplicate ACKs
156
179
  initialNotificationHandled = false;
@@ -728,6 +751,29 @@ export default class SwanSDK {
728
751
  return;
729
752
  }
730
753
 
754
+ // Detect iOS carousel click via URL (Content Extension openURL pattern)
755
+ // URL format: scheme://route?existing=params&swan_carousel=1&swan_comm_id=messageId&swan_item_index=0
756
+ if (parsed.params.swan_carousel === '1') {
757
+ const messageId = parsed.params.swan_comm_id;
758
+ Logger.log('[SwanSDK] Carousel click via URL, route:', parsed.path, 'messageId:', messageId);
759
+ if (messageId) {
760
+ this.sendNotificationAck(messageId, 'clicked');
761
+ // Mark as processed so checkPendingCarouselClick() (App Group path)
762
+ // doesn't emit a duplicate NOTIFICATION_OPENED event
763
+ markClickProcessed(messageId);
764
+ }
765
+
766
+ // Reconstruct route with original query params (exclude swan_ tracking params)
767
+ const originalParams = Object.entries(parsed.params).filter(([key]) => !key.startsWith('swan_')).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&');
768
+ const route = originalParams ? `${parsed.path}?${originalParams}` : parsed.path;
769
+ const payload = {
770
+ route: route || undefined,
771
+ keyValuePairs: {}
772
+ };
773
+ this.emitNotificationOpened(payload);
774
+ return; // Handled — don't process as regular deep link
775
+ }
776
+
731
777
  // Extract swan_ prefixed parameters
732
778
  const swanParams = {};
733
779
  for (const [key, value] of Object.entries(parsed.params)) {
@@ -897,9 +943,16 @@ export default class SwanSDK {
897
943
  Logger.log('[SwanSDK] Tracking initial app launch event...');
898
944
  this.appLaunched();
899
945
 
946
+ // Check for pending carousel click (covers killed/quit → fresh launch)
947
+ this.checkPendingCarouselClick();
948
+
900
949
  // AppState listener setup (always runs)
901
950
  Logger.log('[SwanSDK] Setting up AppState listener...');
902
951
  this.setupAppStateListener();
952
+
953
+ // Listen for native carousel click events (covers foreground click case
954
+ // where AppState stays 'active' and polling never triggers)
955
+ this.setupCarouselClickListener();
903
956
  Logger.log('Event queue system initialized successfully');
904
957
  } catch (error) {
905
958
  Logger.error('Failed to initialize event queue:', error);
@@ -921,10 +974,47 @@ export default class SwanSDK {
921
974
  if (nextAppState === 'active') {
922
975
  Logger.log('[SwanSDK] App came to foreground, tracking appLaunched event');
923
976
  this.appLaunched();
977
+ this.checkPendingCarouselClick();
924
978
  }
925
979
  });
926
980
  Logger.log('[SwanSDK] AppState listener set up successfully');
927
981
  }
982
+
983
+ /**
984
+ * Listen for native carousel click events emitted via DeviceEventEmitter.
985
+ * Covers the foreground-click case where AppState stays 'active'.
986
+ */
987
+ setupCarouselClickListener() {
988
+ if (Platform.OS !== 'android') return;
989
+ if (this.carouselClickSubscription) {
990
+ this.carouselClickSubscription.remove();
991
+ }
992
+ this.carouselClickSubscription = DeviceEventEmitter.addListener('swanCarouselClick', clickData => {
993
+ Logger.log('[SwanSDK] Carousel click event from native:', JSON.stringify(clickData));
994
+
995
+ // Consume the pending click so polling doesn't fire again
996
+ const {
997
+ SwanNotificationModule
998
+ } = NativeModules;
999
+ SwanNotificationModule?.getPendingCarouselClick?.();
1000
+ const {
1001
+ messageId,
1002
+ route,
1003
+ title,
1004
+ body
1005
+ } = clickData;
1006
+ if (messageId) {
1007
+ this.sendNotificationAck(messageId, 'clicked');
1008
+ }
1009
+ const deepLinkPayload = {
1010
+ route: route || undefined,
1011
+ title: title || undefined,
1012
+ body: body || undefined,
1013
+ keyValuePairs: {}
1014
+ };
1015
+ this.emitNotificationOpened(deepLinkPayload);
1016
+ });
1017
+ }
928
1018
  createTable(tableName) {
929
1019
  return new Promise((resolve, reject) => {
930
1020
  if (!this.db) {
@@ -1408,6 +1498,7 @@ export default class SwanSDK {
1408
1498
  // This will trigger the Info.plist strings we discussed
1409
1499
  // It handles the 'Not Determined' -> 'Requested' flow automatically
1410
1500
  try {
1501
+ const Geolocation = require('@react-native-community/geolocation').default;
1411
1502
  Geolocation.requestAuthorization();
1412
1503
  return true; // iOS handles the dialog; if denied, getCurrentPosition will trigger the error callback
1413
1504
  } catch (e) {
@@ -1425,6 +1516,13 @@ export default class SwanSDK {
1425
1516
  * @param checkOnly - If true, only checks for existing permission without requesting (non-blocking)
1426
1517
  */
1427
1518
  async getDeviceLocation(checkOnly = false) {
1519
+ let Geolocation;
1520
+ try {
1521
+ Geolocation = require('@react-native-community/geolocation').default;
1522
+ } catch (e) {
1523
+ Logger.warn('[SwanSDK] @react-native-community/geolocation is not installed, skipping location');
1524
+ return null;
1525
+ }
1428
1526
  const hasPermission = await this.hasLocationPermission(checkOnly);
1429
1527
  if (!hasPermission) {
1430
1528
  Logger.log('[SwanSDK] Location permission not granted (checkOnly mode), skipping location');
@@ -1690,6 +1788,72 @@ export default class SwanSDK {
1690
1788
  this.trackEvent(ECOM_EVENTS.APP_LAUNCHED, data || {});
1691
1789
  }
1692
1790
 
1791
+ /**
1792
+ * Check for a pending carousel click and emit NOTIFICATION_OPENED.
1793
+ * Native carousel clicks bypass Notifee, so the JS event system never fires.
1794
+ *
1795
+ * Android: reads from SwanNotificationModule.getPendingCarouselClick()
1796
+ * iOS: reads from App Group via SharedCredentialsManager.readTemplateClickData()
1797
+ * (Content Extension writes click data to App Group UserDefaults)
1798
+ *
1799
+ * @internal
1800
+ */
1801
+ async checkPendingCarouselClick() {
1802
+ try {
1803
+ let clickData = null;
1804
+ if (Platform.OS === 'android') {
1805
+ const {
1806
+ SwanNotificationModule
1807
+ } = NativeModules;
1808
+ if (!SwanNotificationModule?.getPendingCarouselClick) return;
1809
+ clickData = await SwanNotificationModule.getPendingCarouselClick();
1810
+ } else if (Platform.OS === 'ios') {
1811
+ // Read click data from App Group (written by Content Extension)
1812
+ clickData = await SharedCredentialsManager.readTemplateClickData();
1813
+
1814
+ // If no data yet, retry after a short delay.
1815
+ // The Content Extension may still be writing when the app foregrounds.
1816
+ if (!clickData) {
1817
+ await new Promise(r => setTimeout(r, 500));
1818
+ clickData = await SharedCredentialsManager.readTemplateClickData();
1819
+ }
1820
+ }
1821
+ if (!clickData) return;
1822
+ Logger.log('[SwanSDK] Pending carousel click found:', JSON.stringify(clickData));
1823
+ const {
1824
+ messageId,
1825
+ route,
1826
+ title,
1827
+ body
1828
+ } = clickData;
1829
+
1830
+ // Deduplication: prevent double click handling.
1831
+ // On iOS, multiple handlers can fire for the same carousel tap
1832
+ // (Notifee foreground, Firebase onNotificationOpenedApp, AppState change).
1833
+ // Use the same markClickProcessed() as other handlers to prevent duplicates.
1834
+ if (messageId && !markClickProcessed(messageId)) {
1835
+ Logger.log('[SwanSDK] Carousel click already processed for messageId:', messageId);
1836
+ return;
1837
+ }
1838
+
1839
+ // Send click ACK
1840
+ if (messageId) {
1841
+ this.sendNotificationAck(messageId, 'clicked');
1842
+ }
1843
+
1844
+ // Emit NOTIFICATION_OPENED event (same as Notifee click handler)
1845
+ const deepLinkPayload = {
1846
+ route: route || undefined,
1847
+ title: title || undefined,
1848
+ body: body || undefined,
1849
+ keyValuePairs: {}
1850
+ };
1851
+ this.emitNotificationOpened(deepLinkPayload);
1852
+ } catch (error) {
1853
+ Logger.error('[SwanSDK] Error checking pending carousel click:', error);
1854
+ }
1855
+ }
1856
+
1693
1857
  /**
1694
1858
  * @param { { success: boolean } } data
1695
1859
  */
@@ -2113,6 +2277,11 @@ export default class SwanSDK {
2113
2277
  // Get priority from payload (for Android 7.1 and below)
2114
2278
  const priority = this.getPriorityFromPayload(remoteMessage);
2115
2279
 
2280
+ // Resolve sound from data.sound payload field
2281
+ const soundConfig = resolveSoundFromPayload(remoteMessage?.data?.sound);
2282
+ const androidSound = buildAndroidSound(soundConfig);
2283
+ const iosSound = buildIosSound(soundConfig);
2284
+
2116
2285
  // Build notification config
2117
2286
  const notificationConfig = {
2118
2287
  id: remoteMessage?.messageId || String(Date.now()),
@@ -2143,7 +2312,7 @@ export default class SwanSDK {
2143
2312
  foregroundPresentationOptions: {
2144
2313
  alert: true,
2145
2314
  badge: true,
2146
- sound: true
2315
+ sound: soundConfig.enabled
2147
2316
  },
2148
2317
  // Pass through category for Content Extension (e.g., carousel)
2149
2318
  ...(remoteMessage?.category && {
@@ -2152,6 +2321,14 @@ export default class SwanSDK {
2152
2321
  }
2153
2322
  };
2154
2323
 
2324
+ // Add sound if resolved
2325
+ if (androidSound !== undefined) {
2326
+ notificationConfig.android.sound = androidSound;
2327
+ }
2328
+ if (iosSound !== undefined) {
2329
+ notificationConfig.ios.sound = iosSound;
2330
+ }
2331
+
2155
2332
  // Add image if present
2156
2333
  if (imageUrl) {
2157
2334
  // Dynamically import AndroidStyle to avoid import errors
@@ -2170,8 +2347,7 @@ export default class SwanSDK {
2170
2347
  // Display notification
2171
2348
  Logger.log('[SwanSDK] Displaying notification on channel:', channelId, priority !== undefined ? `with priority: ${priority}` : '(using channel importance)');
2172
2349
  Logger.log('[SwanSDK] Notification config:', JSON.stringify(notificationConfig));
2173
- const notificationId = await notifee.displayNotification(notificationConfig);
2174
- Logger.log('[SwanSDK] Notification displayed successfully with ID:', notificationId);
2350
+ await routeNotificationForDisplay(remoteMessage, notificationConfig, notifee);
2175
2351
  } catch (error) {
2176
2352
  Logger.error('[SwanSDK] Error displaying foreground notification:', error);
2177
2353
  // Emit event so app can handle display on error
@@ -2682,7 +2858,7 @@ export default class SwanSDK {
2682
2858
  if (initialNotification && !this.initialNotificationHandled) {
2683
2859
  const messageId = initialNotification?.notification?.id;
2684
2860
  const notificationData = initialNotification.data || {};
2685
- if (messageId) {
2861
+ if (messageId && markClickProcessed(messageId)) {
2686
2862
  Logger.log('[SwanSDK] App opened from Notifee notification tap:', messageId);
2687
2863
  this.initialNotificationHandled = true;
2688
2864
  await this.sendNotificationAck(messageId, 'clicked');
@@ -2723,13 +2899,17 @@ export default class SwanSDK {
2723
2899
  const messaging = require('@react-native-firebase/messaging').default;
2724
2900
  const initialNotification = await messaging().getInitialNotification();
2725
2901
  if (initialNotification && !this.initialNotificationHandled) {
2726
- Logger.log('[SwanSDK] App opened from Firebase notification (killed state):', initialNotification.messageId);
2902
+ const messageId = initialNotification.messageId;
2903
+
2904
+ // Deduplication: prevent double click handling
2905
+ if (messageId && !markClickProcessed(messageId)) {
2906
+ Logger.log('[SwanSDK] Firebase initial notification click already processed:', messageId);
2907
+ return;
2908
+ }
2909
+ Logger.log('[SwanSDK] App opened from Firebase notification (killed state):', messageId);
2727
2910
 
2728
2911
  // Mark as handled to prevent duplicate ACKs
2729
2912
  this.initialNotificationHandled = true;
2730
-
2731
- // Get messageId and notification data
2732
- const messageId = initialNotification.messageId;
2733
2913
  const notificationData = initialNotification.data || {};
2734
2914
 
2735
2915
  // Extract deep link information
@@ -2925,18 +3105,18 @@ export default class SwanSDK {
2925
3105
  * @param description - User visible description
2926
3106
  * @param channelId - Custom Channel ID (defaults to App ID)
2927
3107
  */
2928
- async createNotificationChannel(channelName = 'General Notifications', importance = 4, description, channelId) {
3108
+ async createNotificationChannel(channelName = 'General Notifications', importance = 4, description, channelId, sound) {
2929
3109
  // Use provided channelId or fallback to appId
2930
3110
  const id = channelId || this.appId;
2931
3111
 
2932
3112
  // Use new PushTokenService if available
2933
3113
  if (this.pushService) {
2934
- return await this.pushService.createNotificationChannel(id, channelName, importance, description);
3114
+ return await this.pushService.createNotificationChannel(id, channelName, importance, description, sound);
2935
3115
  }
2936
3116
 
2937
3117
  // Fallback to firebaseManager for backward compatibility
2938
3118
  if (this.firebaseManager) {
2939
- return await this.firebaseManager.createNotificationChannel(id, channelName, importance, description);
3119
+ return await this.firebaseManager.createNotificationChannel(id, channelName, importance, description, sound);
2940
3120
  }
2941
3121
  Logger.warn('[SwanSDK] Push notifications not initialized');
2942
3122
  return null;
@@ -3257,6 +3437,43 @@ export default class SwanSDK {
3257
3437
  }
3258
3438
  }
3259
3439
 
3440
+ /**
3441
+ * Routes notification display to either the native carousel module (Android)
3442
+ * or Notifee's standard display. Shared between foreground and background handlers.
3443
+ */
3444
+ async function routeNotificationForDisplay(remoteMessage, notificationConfig, notifee) {
3445
+ const isAndroidCarousel = Platform.OS === 'android' && remoteMessage?.data?.notificationType === 'carousel';
3446
+ if (isAndroidCarousel) {
3447
+ Logger.log('[SwanSDK] Routing to native carousel template');
3448
+ const {
3449
+ SwanNotificationModule
3450
+ } = NativeModules;
3451
+ if (SwanNotificationModule) {
3452
+ await SwanNotificationModule.displayTemplateNotification({
3453
+ notificationType: remoteMessage.data.notificationType,
3454
+ messageId: notificationConfig.id,
3455
+ title: notificationConfig.title,
3456
+ body: notificationConfig.body,
3457
+ channelId: notificationConfig.android.channelId,
3458
+ carouselMode: remoteMessage.data.carouselMode || 'manual',
3459
+ carouselVariant: remoteMessage.data.carouselVariant || 'standard',
3460
+ carouselItems: remoteMessage.data.carouselItems || '[]',
3461
+ defaultRoute: remoteMessage.data.defaultRoute || '',
3462
+ ...(remoteMessage.data.carouselInterval && {
3463
+ carouselInterval: parseInt(remoteMessage.data.carouselInterval, 10)
3464
+ })
3465
+ });
3466
+ Logger.log('[SwanSDK] Native carousel displayed successfully');
3467
+ } else {
3468
+ Logger.warn('[SwanSDK] SwanNotificationModule not available, falling back to Notifee');
3469
+ await notifee.displayNotification(notificationConfig);
3470
+ }
3471
+ } else {
3472
+ const notificationId = await notifee.displayNotification(notificationConfig);
3473
+ Logger.log('[SwanSDK] Notification displayed successfully with ID:', notificationId);
3474
+ }
3475
+ }
3476
+
3260
3477
  /**
3261
3478
  * Foreground Message Handler for index.js
3262
3479
  *
@@ -3333,15 +3550,27 @@ export function createBackgroundMessageHandler() {
3333
3550
  return;
3334
3551
  }
3335
3552
 
3553
+ // If iOS displayed the notification natively (aps.alert present),
3554
+ // skip Notifee display to avoid duplicates. Just send delivery ACK.
3555
+ if (Platform.OS === 'ios' && remoteMessage?.notification) {
3556
+ Logger.log('[SwanSDK] iOS native notification (aps.alert), skipping Notifee display');
3557
+ const sdkReady = sdkInstance && sdkInstance.isReady();
3558
+ if (sdkReady) {
3559
+ await sdkInstance.sendNotificationAck(messageId, 'delivered');
3560
+ } else if (messageId) {
3561
+ sendDirectNotificationAck(messageId, 'delivered').catch(err => {
3562
+ Logger.log('[SwanSDK] Direct delivery ACK failed:', err);
3563
+ });
3564
+ }
3565
+ return;
3566
+ }
3567
+
3336
3568
  // Handle silent push (no UI display)
3337
3569
  if (isSilent) {
3338
3570
  console.log('[SwanSDK] Silent push received in background, skipping notification display');
3339
- console.log('[SwanSDK] ✅ Silent push handled');
3571
+ Logger.log('[SwanSDK] ✅ Silent push handled');
3340
3572
  return;
3341
3573
  }
3342
- if (sdkInstance && sdkInstance.isReady()) {
3343
- await sdkInstance.sendNotificationAck(messageId, 'delivered');
3344
- }
3345
3574
  try {
3346
3575
  // Import notifee dynamically to avoid issues if not installed
3347
3576
  let notifee;
@@ -3356,10 +3585,10 @@ export function createBackgroundMessageHandler() {
3356
3585
  // Android may kill headless JS task during network requests
3357
3586
  // So we must show notification before any network calls
3358
3587
  const messageId = remoteMessage?.messageId;
3359
- console.log('[SwanSDK] Background handler - messageId:', messageId);
3588
+ Logger.log('[SwanSDK] Background handler - messageId:', messageId);
3360
3589
 
3361
3590
  // Extract notification data from data payload (data-only messages)
3362
- console.log('[SwanSDK] Extracting notification data from payload...');
3591
+ Logger.log('[SwanSDK] Extracting notification data from payload...');
3363
3592
  const title = remoteMessage.data?.title || 'Notification';
3364
3593
  const body = remoteMessage.data?.body || 'New message';
3365
3594
  const imageUrl = remoteMessage.data?.image || remoteMessage.data?.fcm_options?.image || '';
@@ -3383,6 +3612,11 @@ export function createBackgroundMessageHandler() {
3383
3612
  };
3384
3613
  const priority = getPriority();
3385
3614
 
3615
+ // Resolve sound from data.sound payload field
3616
+ const soundConfig = resolveSoundFromPayload(remoteMessage?.data?.sound);
3617
+ const androidSound = buildAndroidSound(soundConfig);
3618
+ const iosSound = buildIosSound(soundConfig);
3619
+
3386
3620
  // Use messageId as notification ID for click tracking
3387
3621
  const notificationId = messageId || `swan_bg_${Date.now()}`;
3388
3622
  const notificationConfig = {
@@ -3405,9 +3639,18 @@ export function createBackgroundMessageHandler() {
3405
3639
  pressAction: {
3406
3640
  id: 'default'
3407
3641
  }
3408
- }
3642
+ },
3643
+ ios: {}
3409
3644
  };
3410
3645
 
3646
+ // Add sound if resolved
3647
+ if (androidSound !== undefined) {
3648
+ notificationConfig.android.sound = androidSound;
3649
+ }
3650
+ if (iosSound !== undefined) {
3651
+ notificationConfig.ios.sound = iosSound;
3652
+ }
3653
+
3411
3654
  // Add iOS category for Content Extension (e.g., carousel)
3412
3655
  if (remoteMessage?.category) {
3413
3656
  notificationConfig.ios = {
@@ -3433,30 +3676,26 @@ export function createBackgroundMessageHandler() {
3433
3676
  }]
3434
3677
  };
3435
3678
  }
3436
-
3437
- // STEP 1: Display notification FIRST (fast local operation)
3438
- console.log('[SwanSDK] STEP 1: Displaying notification...');
3439
- await notifee.displayNotification(notificationConfig);
3440
- console.log('[SwanSDK] ✅ Notification displayed successfully');
3679
+ await routeNotificationForDisplay(remoteMessage, notificationConfig, notifee);
3441
3680
 
3442
3681
  // STEP 2: Send delivery ACK (network operation - may be killed by OS)
3443
- console.log('[SwanSDK] STEP 2: Sending delivery ACK...');
3682
+ Logger.log('[SwanSDK] STEP 2: Sending delivery ACK...');
3444
3683
  const isSDKReady = sdkInstance && sdkInstance.isReady();
3445
3684
  if (isSDKReady) {
3446
- console.log('[SwanSDK] Using SDK instance for delivery ACK');
3685
+ Logger.log('[SwanSDK] Using SDK instance for delivery ACK');
3447
3686
  await sdkInstance.sendNotificationAck(messageId, 'delivered');
3448
3687
  } else if (messageId) {
3449
3688
  // SDK not fully initialized (app was killed) - send delivery ACK directly via fetch
3450
3689
  console.log('[SwanSDK] SDK not ready, sending delivery ACK via direct fetch');
3451
3690
  // Fire and forget - don't await since OS may kill us
3452
3691
  sendDirectNotificationAck(messageId, 'delivered').catch(err => {
3453
- console.log('[SwanSDK] Direct delivery ACK failed:', err);
3692
+ Logger.log('[SwanSDK] Direct delivery ACK failed:', err);
3454
3693
  });
3455
3694
  }
3456
- console.log('[SwanSDK] ✅ Background handler completed');
3695
+ Logger.log('[SwanSDK] ✅ Background handler completed');
3457
3696
  } catch (error) {
3458
- console.log('[SwanSDK] Error handling background message:', error?.message || error);
3459
- console.log('[SwanSDK] Error stack:', error?.stack);
3697
+ Logger.error('[SwanSDK] Error handling background message:', error?.message || error);
3698
+ Logger.error('[SwanSDK] Error stack:', error?.stack);
3460
3699
  }
3461
3700
  };
3462
3701
  }
@@ -3477,18 +3716,26 @@ export function createNotificationOpenedHandler() {
3477
3716
  const messageId = event?.messageId;
3478
3717
  const notificationData = event?.data || {};
3479
3718
 
3480
- // For iOS carousel notifications, iOS displays the notification (not Notifee),
3481
- // so this handler MUST process the click regardless of NES status.
3482
- // For non-carousel, defer to Notifee click handlers unless NES is active.
3483
- const isIOSCarousel = Platform.OS === 'ios' && notificationData.notificationType === 'carousel';
3484
- if (!isIOSCarousel) {
3485
- const isNESActive = await SharedCredentialsManager.isNotificationServiceExtensionActive();
3486
- if (!isNESActive && Platform.OS === 'ios') {
3487
- Logger.log('[SwanSDK] Non-carousel iOS without NES, deferring to Notifee click handler');
3488
- return;
3489
- }
3719
+ // Use custom messageId (from push payload data) for deduplication.
3720
+ // On iOS, both Firebase onNotificationOpenedApp AND Notifee event handlers
3721
+ // fire for the same notification tap (Content Extension performNotificationDefaultAction).
3722
+ // Firebase uses its own messageId, Notifee uses data.messageId they differ.
3723
+ // Using the custom messageId ensures both handlers use the same dedup key.
3724
+ const customMessageId = notificationData.messageId;
3725
+ const dedupId = customMessageId || messageId;
3726
+
3727
+ // Deduplication: prevent double click handling
3728
+ if (dedupId && !markClickProcessed(dedupId)) {
3729
+ Logger.log('[SwanSDK] Click already processed for messageId:', dedupId);
3730
+ return;
3490
3731
  }
3491
3732
 
3733
+ // If onNotificationOpenedApp fires, it means iOS displayed the notification
3734
+ // natively (aps.alert present). Notifee handlers won't fire for iOS-native
3735
+ // notifications, so this handler MUST process the click.
3736
+ // For data-only messages (no aps.alert), Firebase never calls this handler.
3737
+ const isIOSCarousel = Platform.OS === 'ios' && notificationData.notificationType === 'carousel';
3738
+
3492
3739
  // Extract deep link information
3493
3740
  // For carousel notifications, the default route is in 'defaultRoute' field
3494
3741
  let route = notificationData.route || notificationData.defaultRoute;
@@ -3521,6 +3768,11 @@ export function createNotificationOpenedHandler() {
3521
3768
  // Emit notificationOpened event for host app to handle
3522
3769
  sdkInstance.emitNotificationOpened(deepLinkPayload);
3523
3770
 
3771
+ // Send delivery ACK (background handler may not have fired for aps.alert notifications)
3772
+ if (messageId && Platform.OS === 'ios') {
3773
+ await sdkInstance.sendNotificationAck(messageId, 'delivered');
3774
+ }
3775
+
3524
3776
  // Send click ACK
3525
3777
  if (messageId) {
3526
3778
  await sdkInstance.sendNotificationAck(messageId, 'clicked');
@@ -3577,6 +3829,12 @@ export function createNotifeeForegroundHandler() {
3577
3829
  const messageId = event?.detail?.notification?.data?.messageId;
3578
3830
  const notificationData = event?.detail?.notification?.data || {};
3579
3831
 
3832
+ // Deduplication: prevent double click handling
3833
+ if (messageId && !markClickProcessed(messageId)) {
3834
+ Logger.log('[SwanSDK] Click already processed for messageId:', messageId);
3835
+ return;
3836
+ }
3837
+
3580
3838
  // Extract deep link information
3581
3839
  // Note: route field can contain either a path (/products/123) or full URL (myapp://products/123)
3582
3840
  // For carousel notifications, the default route is in 'defaultRoute' field
@@ -3661,6 +3919,12 @@ export function createNotifeeBackgroundHandler() {
3661
3919
  const messageId = detail?.notification?.data?.messageId;
3662
3920
  const notificationData = detail?.notification?.data || {};
3663
3921
 
3922
+ // Deduplication: prevent double click handling
3923
+ if (messageId && !markClickProcessed(messageId)) {
3924
+ Logger.log('[SwanSDK] Click already processed for messageId:', messageId);
3925
+ return;
3926
+ }
3927
+
3664
3928
  // Extract deep link information
3665
3929
  // Note: route field can contain either a path (/products/123) or full URL (myapp://products/123)
3666
3930
  // For carousel notifications, the default route is in 'defaultRoute' field
@@ -3737,13 +4001,13 @@ async function sendDirectNotificationAck(messageId, event) {
3737
4001
  const Base64Module = require('react-native-base64').default;
3738
4002
 
3739
4003
  // Read credentials from AsyncStorage
3740
- console.log('[SwanSDK] Reading credentials from AsyncStorage...');
4004
+ Logger.log('[SwanSDK] Reading credentials from AsyncStorage...');
3741
4005
  const encoded = await AsyncStorageModule.getItem('swanCredentials');
3742
4006
  if (!encoded) {
3743
4007
  console.log(`[SwanSDK] No credentials found, cannot send ${event} ACK`);
3744
4008
  return;
3745
4009
  }
3746
- console.log('[SwanSDK] Credentials found, decoding...');
4010
+ Logger.log('[SwanSDK] Credentials found, decoding...');
3747
4011
  const credentials = JSON.parse(Base64Module.decode(encoded));
3748
4012
  const {
3749
4013
  appId,
@@ -3768,13 +4032,13 @@ async function sendDirectNotificationAck(messageId, event) {
3768
4032
  };
3769
4033
  const urlType = isProduction === 'PROD' ? 'PROD' : 'STAGE';
3770
4034
  console.log(`[SwanSDK] Sending ${event} ACK to: ${URLS.WEBHOOK_MOBILE_PUSH_URL[urlType]}`);
3771
- console.log('[SwanSDK] Payload:', JSON.stringify(payload));
4035
+ Logger.log('[SwanSDK] Payload:', JSON.stringify(payload));
3772
4036
  const controller = new AbortController();
3773
4037
  const timeoutId = setTimeout(() => {
3774
- console.log('[SwanSDK] ACK request timeout triggered (10s)');
4038
+ Logger.log('[SwanSDK] ACK request timeout triggered (10s)');
3775
4039
  controller.abort();
3776
4040
  }, 10000);
3777
- console.log('[SwanSDK] Starting fetch...');
4041
+ Logger.log('[SwanSDK] Starting fetch...');
3778
4042
  const response = await fetch(URLS.WEBHOOK_MOBILE_PUSH_URL[urlType], {
3779
4043
  method: 'POST',
3780
4044
  headers: {
@@ -3784,7 +4048,7 @@ async function sendDirectNotificationAck(messageId, event) {
3784
4048
  body: JSON.stringify(payload),
3785
4049
  signal: controller.signal
3786
4050
  });
3787
- console.log('[SwanSDK] Fetch completed, status:', response.status);
4051
+ Logger.log('[SwanSDK] Fetch completed, status:', response.status);
3788
4052
  clearTimeout(timeoutId);
3789
4053
  if (response.ok) {
3790
4054
  console.log(`[SwanSDK] ✅ ${event} ACK sent successfully via direct fetch`);
@@ -3793,7 +4057,7 @@ async function sendDirectNotificationAck(messageId, event) {
3793
4057
  console.log(`[SwanSDK] ${event} ACK failed - HTTP ${response.status}: ${responseText}`);
3794
4058
  }
3795
4059
  } catch (error) {
3796
- console.log('[SwanSDK] Caught error in sendDirectNotificationAck');
4060
+ Logger.log('[SwanSDK] Caught error in sendDirectNotificationAck');
3797
4061
  if (error?.name === 'AbortError') {
3798
4062
  console.log(`[SwanSDK] ${event} ACK timed out (AbortError)`);
3799
4063
  } else {