@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.
- package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +145 -1
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +3 -22
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +7 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +7 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +141 -9
- package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +1 -1
- package/android/src/main/res/layout/swan_carousel_expanded.xml +1 -1
- package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +1 -1
- package/ios/SwanAppGroup.m +55 -0
- package/ios/SwanNotificationContentExtension/NotificationViewController.swift +90 -28
- package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +5 -0
- package/ios/SwanNotificationServiceExtension/NotificationService.swift +1 -0
- package/lib/commonjs/components/HeaderView.js +0 -1
- package/lib/commonjs/components/HeaderView.js.map +1 -1
- package/lib/commonjs/index.js +315 -50
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/providers/FirebasePushProvider.js +2 -2
- package/lib/commonjs/providers/FirebasePushProvider.js.map +1 -1
- package/lib/commonjs/providers/NullPushProvider.js +1 -1
- package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
- package/lib/commonjs/providers/PushNotificationProvider.js.map +1 -1
- package/lib/commonjs/services/PushTokenService.js +2 -2
- package/lib/commonjs/services/PushTokenService.js.map +1 -1
- package/lib/commonjs/utils/FirebaseNotificationManager.js +9 -5
- package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
- package/lib/commonjs/utils/NotificationSoundHelper.js +72 -0
- package/lib/commonjs/utils/NotificationSoundHelper.js.map +1 -0
- package/lib/commonjs/utils/SharedCredentialsManager.js +46 -8
- package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/commonjs/version.js.map +1 -1
- package/lib/module/components/HeaderView.js +1 -1
- package/lib/module/components/HeaderView.js.map +1 -1
- package/lib/module/index.js +312 -48
- package/lib/module/index.js.map +1 -1
- package/lib/module/providers/FirebasePushProvider.js +2 -2
- package/lib/module/providers/FirebasePushProvider.js.map +1 -1
- package/lib/module/providers/NullPushProvider.js +1 -1
- package/lib/module/providers/NullPushProvider.js.map +1 -1
- package/lib/module/providers/PushNotificationProvider.js.map +1 -1
- package/lib/module/services/PushTokenService.js +2 -2
- package/lib/module/services/PushTokenService.js.map +1 -1
- package/lib/module/utils/FirebaseNotificationManager.js +9 -5
- package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
- package/lib/module/utils/NotificationSoundHelper.js +66 -0
- package/lib/module/utils/NotificationSoundHelper.js.map +1 -0
- package/lib/module/utils/SharedCredentialsManager.js +48 -9
- package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/module/version.js.map +1 -1
- package/lib/typescript/commonjs/src/components/HeaderView.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +20 -1
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/providers/FirebasePushProvider.d.ts +1 -1
- package/lib/typescript/commonjs/src/providers/FirebasePushProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts +1 -1
- package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/providers/PushNotificationProvider.d.ts +1 -1
- package/lib/typescript/commonjs/src/providers/PushNotificationProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/services/PushTokenService.d.ts +1 -1
- package/lib/typescript/commonjs/src/services/PushTokenService.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts +1 -1
- package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/NotificationSoundHelper.d.ts +34 -0
- package/lib/typescript/commonjs/src/utils/NotificationSoundHelper.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +6 -0
- package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/version.d.ts +1 -1
- package/lib/typescript/commonjs/src/version.d.ts.map +1 -1
- package/lib/typescript/module/src/components/HeaderView.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +20 -1
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/providers/FirebasePushProvider.d.ts +1 -1
- package/lib/typescript/module/src/providers/FirebasePushProvider.d.ts.map +1 -1
- package/lib/typescript/module/src/providers/NullPushProvider.d.ts +1 -1
- package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
- package/lib/typescript/module/src/providers/PushNotificationProvider.d.ts +1 -1
- package/lib/typescript/module/src/providers/PushNotificationProvider.d.ts.map +1 -1
- package/lib/typescript/module/src/services/PushTokenService.d.ts +1 -1
- package/lib/typescript/module/src/services/PushTokenService.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts +1 -1
- package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/NotificationSoundHelper.d.ts +34 -0
- package/lib/typescript/module/src/utils/NotificationSoundHelper.d.ts.map +1 -0
- package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +6 -0
- package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
- package/lib/typescript/module/src/version.d.ts +1 -1
- package/lib/typescript/module/src/version.d.ts.map +1 -1
- package/package.json +23 -10
- package/scripts/setup-ios-extension.js +61 -41
- package/swan-react-native-sdk.podspec +1 -0
package/lib/module/index.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3588
|
+
Logger.log('[SwanSDK] Background handler - messageId:', messageId);
|
|
3360
3589
|
|
|
3361
3590
|
// Extract notification data from data payload (data-only messages)
|
|
3362
|
-
|
|
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
|
-
|
|
3682
|
+
Logger.log('[SwanSDK] STEP 2: Sending delivery ACK...');
|
|
3444
3683
|
const isSDKReady = sdkInstance && sdkInstance.isReady();
|
|
3445
3684
|
if (isSDKReady) {
|
|
3446
|
-
|
|
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
|
-
|
|
3692
|
+
Logger.log('[SwanSDK] Direct delivery ACK failed:', err);
|
|
3454
3693
|
});
|
|
3455
3694
|
}
|
|
3456
|
-
|
|
3695
|
+
Logger.log('[SwanSDK] ✅ Background handler completed');
|
|
3457
3696
|
} catch (error) {
|
|
3458
|
-
|
|
3459
|
-
|
|
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
|
-
//
|
|
3481
|
-
//
|
|
3482
|
-
//
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4035
|
+
Logger.log('[SwanSDK] Payload:', JSON.stringify(payload));
|
|
3772
4036
|
const controller = new AbortController();
|
|
3773
4037
|
const timeoutId = setTimeout(() => {
|
|
3774
|
-
|
|
4038
|
+
Logger.log('[SwanSDK] ACK request timeout triggered (10s)');
|
|
3775
4039
|
controller.abort();
|
|
3776
4040
|
}, 10000);
|
|
3777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|