@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
@@ -1,20 +1,164 @@
1
1
  package com.loyalytics.swan
2
2
 
3
+ import android.app.Activity
4
+ import android.app.NotificationManager
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.util.Log
3
8
  import com.facebook.react.bridge.*
9
+ import com.facebook.react.modules.core.DeviceEventManagerModule
4
10
  import com.loyalytics.swan.templates.SwanTemplateRegistry
5
11
  import com.loyalytics.swan.templates.carousel.CarouselTemplate
12
+ import com.loyalytics.swan.templates.common.NotificationBitmapCache
13
+ import com.loyalytics.swan.templates.common.NotificationStateManager
6
14
  import kotlinx.coroutines.CoroutineScope
7
15
  import kotlinx.coroutines.Dispatchers
8
16
  import kotlinx.coroutines.launch
17
+ import org.json.JSONObject
9
18
 
10
19
  class SwanNotificationModule(reactContext: ReactApplicationContext) :
11
- ReactContextBaseJavaModule(reactContext) {
20
+ ReactContextBaseJavaModule(reactContext), ActivityEventListener {
12
21
 
13
22
  override fun getName(): String = "SwanNotificationModule"
14
23
 
24
+ companion object {
25
+ private const val TAG = "SwanNotificationModule"
26
+ private const val EVENT_CAROUSEL_CLICK = "swanCarouselClick"
27
+ }
28
+
15
29
  init {
16
30
  // Register built-in templates
17
31
  SwanTemplateRegistry.register(CarouselTemplate())
32
+
33
+ // Listen for new intents (e.g., when PendingIntent.getActivity() delivers
34
+ // carousel click data to an already-running Activity via onNewIntent)
35
+ reactContext.addActivityEventListener(this)
36
+
37
+ // Register click listener so carousel clicks emit a DeviceEventEmitter event
38
+ // to the JS layer. This covers the foreground-click case where AppState
39
+ // stays 'active' and the polling approach never triggers.
40
+ CarouselTemplate.clickListener = { clickData ->
41
+ try {
42
+ val map = Arguments.createMap().apply {
43
+ putString("messageId", clickData.optString("messageId"))
44
+ putString("route", clickData.optString("route"))
45
+ putInt("itemIndex", clickData.optInt("itemIndex"))
46
+ putString("title", clickData.optString("title"))
47
+ putString("body", clickData.optString("body"))
48
+ }
49
+ reactContext
50
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
51
+ .emit(EVENT_CAROUSEL_CLICK, map)
52
+ } catch (e: Exception) {
53
+ Log.d(TAG, "React bridge not available for click event, will use polling fallback")
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Called when the Activity receives a new intent via onNewIntent().
60
+ * On Android 12+, carousel clicks use PendingIntent.getActivity() which delivers
61
+ * click data as intent extras. We extract the data here and save it to the static
62
+ * variable so getPendingCarouselClick() can find it.
63
+ */
64
+ override fun onNewIntent(intent: Intent?) {
65
+ if (intent == null) return
66
+ if (!intent.getBooleanExtra(CarouselTemplate.EXTRA_SWAN_CAROUSEL_CLICK, false)) return
67
+
68
+ val messageId = intent.getStringExtra(CarouselTemplate.EXTRA_SWAN_MESSAGE_ID) ?: ""
69
+ val route = intent.getStringExtra(CarouselTemplate.EXTRA_SWAN_ROUTE) ?: ""
70
+ val itemIndex = intent.getIntExtra(CarouselTemplate.EXTRA_SWAN_ITEM_INDEX, 0)
71
+ val notificationId = intent.getIntExtra(CarouselTemplate.EXTRA_SWAN_NOTIFICATION_ID, 0)
72
+
73
+ // Get title/body from notification state (saved during display)
74
+ val activity = currentActivity
75
+ val state = if (activity != null) NotificationStateManager.get(activity, notificationId) else null
76
+ val title = state?.optString("title", "") ?: ""
77
+ val body = state?.optString("body", "") ?: ""
78
+
79
+ // Save click data to static variable for JS SDK to consume
80
+ val clickData = JSONObject().apply {
81
+ put("messageId", messageId)
82
+ put("route", route)
83
+ put("itemIndex", itemIndex)
84
+ put("title", title)
85
+ put("body", body)
86
+ }
87
+ CarouselTemplate.savePendingClick(clickData)
88
+
89
+ // Clean up: cancel notification, clear bitmap cache, remove state
90
+ if (activity != null) {
91
+ NotificationBitmapCache.clear(notificationId)
92
+ NotificationStateManager.remove(activity, notificationId)
93
+ val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
94
+ nm.cancel(notificationId)
95
+ }
96
+ }
97
+
98
+ override fun onActivityResult(activity: Activity?, requestCode: Int, resultCode: Int, data: Intent?) {
99
+ // Not used
100
+ }
101
+
102
+ /**
103
+ * Check for a pending carousel click.
104
+ * Called by the JS SDK on AppState 'active' to bridge native carousel
105
+ * clicks into the JS NOTIFICATION_OPENED event system.
106
+ *
107
+ * Checks two sources:
108
+ * 1. Static variable (set by BroadcastReceiver on pre-Android 12, or by onNewIntent on Android 12+)
109
+ * 2. Activity intent extras (covers fresh app launch from killed state on Android 12+)
110
+ */
111
+ @ReactMethod
112
+ fun getPendingCarouselClick(promise: Promise) {
113
+ // Source 1: Static variable (primary path for both pre-12 and 12+)
114
+ val click = CarouselTemplate.consumePendingClick()
115
+ if (click != null) {
116
+ val map = Arguments.createMap().apply {
117
+ putString("messageId", click.optString("messageId"))
118
+ putString("route", click.optString("route"))
119
+ putInt("itemIndex", click.optInt("itemIndex"))
120
+ putString("title", click.optString("title"))
121
+ putString("body", click.optString("body"))
122
+ }
123
+ promise.resolve(map)
124
+ return
125
+ }
126
+
127
+ // Source 2: Activity intent extras (covers fresh launch from killed state)
128
+ val activity = currentActivity
129
+ val intent = activity?.intent
130
+ if (intent != null && intent.getBooleanExtra(CarouselTemplate.EXTRA_SWAN_CAROUSEL_CLICK, false)) {
131
+ val messageId = intent.getStringExtra(CarouselTemplate.EXTRA_SWAN_MESSAGE_ID) ?: ""
132
+ val route = intent.getStringExtra(CarouselTemplate.EXTRA_SWAN_ROUTE) ?: ""
133
+ val itemIndex = intent.getIntExtra(CarouselTemplate.EXTRA_SWAN_ITEM_INDEX, 0)
134
+ val notificationId = intent.getIntExtra(CarouselTemplate.EXTRA_SWAN_NOTIFICATION_ID, 0)
135
+
136
+ // Get title/body from notification state (saved during display)
137
+ val state = NotificationStateManager.get(activity, notificationId)
138
+ val title = state?.optString("title", "") ?: ""
139
+ val body = state?.optString("body", "") ?: ""
140
+
141
+ // Clear the intent flag to prevent re-consumption
142
+ intent.removeExtra(CarouselTemplate.EXTRA_SWAN_CAROUSEL_CLICK)
143
+
144
+ // Clean up
145
+ NotificationBitmapCache.clear(notificationId)
146
+ NotificationStateManager.remove(activity, notificationId)
147
+ val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
148
+ nm.cancel(notificationId)
149
+
150
+ val map = Arguments.createMap().apply {
151
+ putString("messageId", messageId)
152
+ putString("route", route)
153
+ putInt("itemIndex", itemIndex)
154
+ putString("title", title)
155
+ putString("body", body)
156
+ }
157
+ promise.resolve(map)
158
+ return
159
+ }
160
+
161
+ promise.resolve(null)
18
162
  }
19
163
 
20
164
  @ReactMethod
@@ -2,13 +2,9 @@ package com.loyalytics.swan.templates.carousel
2
2
 
3
3
  import android.app.PendingIntent
4
4
  import android.content.Context
5
- import android.content.Intent
6
5
  import android.graphics.Bitmap
7
- import android.os.Build
8
- import android.os.Bundle
9
6
  import android.widget.RemoteViews
10
7
  import com.loyalytics.swan.R
11
- import com.loyalytics.swan.templates.SwanNotificationActionReceiver
12
8
 
13
9
  /**
14
10
  * Builds RemoteViews for auto-carousel using ViewFlipper.
@@ -80,24 +76,9 @@ object CarouselAutoRemoteViews {
80
76
  messageId: String,
81
77
  route: String
82
78
  ): PendingIntent {
83
- val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
84
- action = "com.loyalytics.swan.${CarouselTemplate.ACTION_CLICK}_$notificationId"
85
- putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, CarouselTemplate.TYPE)
86
- putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_ACTION, CarouselTemplate.ACTION_CLICK)
87
- putExtra(SwanNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
88
- putExtra(CarouselTemplate.EXTRA_MESSAGE_ID, messageId)
89
- putExtra(CarouselTemplate.EXTRA_ITEM_INDEX, 0)
90
- putExtra(CarouselTemplate.EXTRA_ITEM_ROUTE, route)
91
- }
92
-
93
- val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
94
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
95
- } else {
96
- PendingIntent.FLAG_UPDATE_CURRENT
97
- }
98
-
99
- return PendingIntent.getBroadcast(
100
- context, "${notificationId}AUTO_CLICK".hashCode(), intent, flags
79
+ // Use PendingIntent.getActivity() to avoid Android 12+ trampoline restriction
80
+ return CarouselTemplate.createClickActivityPendingIntent(
81
+ context, notificationId, messageId, 0, route
101
82
  )
102
83
  }
103
84
  }
@@ -109,6 +109,13 @@ object CarouselFilmstripRemoteViews {
109
109
  itemIndex: Int,
110
110
  itemRoute: String
111
111
  ): PendingIntent {
112
+ // For CLICK, use PendingIntent.getActivity() to avoid Android 12+ trampoline restriction
113
+ if (action == CarouselTemplate.ACTION_CLICK) {
114
+ return CarouselTemplate.createClickActivityPendingIntent(
115
+ context, notificationId, messageId, itemIndex, itemRoute
116
+ )
117
+ }
118
+
112
119
  val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
113
120
  this.action = "com.loyalytics.swan.${action}_$notificationId"
114
121
  putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, CarouselTemplate.TYPE)
@@ -106,6 +106,13 @@ object CarouselRemoteViews {
106
106
  itemIndex: Int,
107
107
  itemRoute: String
108
108
  ): PendingIntent {
109
+ // For CLICK, use PendingIntent.getActivity() to avoid Android 12+ trampoline restriction
110
+ if (action == CarouselTemplate.ACTION_CLICK) {
111
+ return CarouselTemplate.createClickActivityPendingIntent(
112
+ context, notificationId, messageId, itemIndex, itemRoute
113
+ )
114
+ }
115
+
109
116
  val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
110
117
  this.action = "com.loyalytics.swan.${action}_$notificationId"
111
118
  putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, CarouselTemplate.TYPE)
@@ -36,6 +36,81 @@ class CarouselTemplate : SwanNotificationTemplate {
36
36
  const val EXTRA_ITEM_INDEX = "itemIndex"
37
37
  const val EXTRA_ITEM_ROUTE = "itemRoute"
38
38
  const val EXTRA_MESSAGE_ID = "messageId"
39
+
40
+ // Activity intent extras (prefixed to avoid conflicts)
41
+ const val EXTRA_SWAN_CAROUSEL_CLICK = "swanCarouselClick"
42
+ const val EXTRA_SWAN_MESSAGE_ID = "swanMessageId"
43
+ const val EXTRA_SWAN_ROUTE = "swanRoute"
44
+ const val EXTRA_SWAN_ITEM_INDEX = "swanItemIndex"
45
+ const val EXTRA_SWAN_NOTIFICATION_ID = "swanNotificationId"
46
+ const val EXTRA_SWAN_TITLE = "swanTitle"
47
+ const val EXTRA_SWAN_BODY = "swanBody"
48
+
49
+ /**
50
+ * Pending click data for the JS bridge.
51
+ * When a carousel item is clicked, the click data is stored here
52
+ * so the JS SDK can read it via SwanNotificationModule.getPendingCarouselClick()
53
+ * and emit the appropriate NOTIFICATION_OPENED event.
54
+ */
55
+ @Volatile
56
+ private var pendingClick: JSONObject? = null
57
+
58
+ /**
59
+ * Optional listener invoked immediately when a carousel item is clicked.
60
+ * SwanNotificationModule registers this to emit a DeviceEventEmitter event
61
+ * to the JS layer, covering the foreground-click case where AppState
62
+ * doesn't change (so the polling approach never triggers).
63
+ */
64
+ var clickListener: ((JSONObject) -> Unit)? = null
65
+
66
+ fun savePendingClick(data: JSONObject) {
67
+ pendingClick = data
68
+ clickListener?.invoke(data)
69
+ }
70
+
71
+ fun consumePendingClick(): JSONObject? {
72
+ val click = pendingClick
73
+ pendingClick = null
74
+ return click
75
+ }
76
+
77
+ /**
78
+ * Create a PendingIntent that directly launches the app's main Activity.
79
+ * Android 12+ blocks BroadcastReceiver → startActivity() ("notification trampolines"),
80
+ * so CLICK actions must use PendingIntent.getActivity() instead of getBroadcast().
81
+ */
82
+ fun createClickActivityPendingIntent(
83
+ context: Context,
84
+ notificationId: Int,
85
+ messageId: String,
86
+ itemIndex: Int,
87
+ itemRoute: String
88
+ ): PendingIntent {
89
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
90
+ ?: Intent(Intent.ACTION_MAIN).apply {
91
+ addCategory(Intent.CATEGORY_LAUNCHER)
92
+ setPackage(context.packageName)
93
+ }
94
+
95
+ launchIntent.apply {
96
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
97
+ putExtra(EXTRA_SWAN_CAROUSEL_CLICK, true)
98
+ putExtra(EXTRA_SWAN_MESSAGE_ID, messageId)
99
+ putExtra(EXTRA_SWAN_ROUTE, itemRoute)
100
+ putExtra(EXTRA_SWAN_ITEM_INDEX, itemIndex)
101
+ putExtra(EXTRA_SWAN_NOTIFICATION_ID, notificationId)
102
+ }
103
+
104
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
105
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
106
+ } else {
107
+ PendingIntent.FLAG_UPDATE_CURRENT
108
+ }
109
+
110
+ return PendingIntent.getActivity(
111
+ context, "$notificationId${ACTION_CLICK}$itemIndex".hashCode(), launchIntent, flags
112
+ )
113
+ }
39
114
  }
40
115
 
41
116
  override fun canHandle(notificationType: String): Boolean = notificationType == TYPE
@@ -142,6 +217,11 @@ class CarouselTemplate : SwanNotificationTemplate {
142
217
  )
143
218
  }
144
219
 
220
+ /**
221
+ * Handle carousel item click from BroadcastReceiver.
222
+ * On Android 12+ (API 31+), CLICK actions use PendingIntent.getActivity() directly,
223
+ * so this method is only called on older Android versions.
224
+ */
145
225
  private fun handleItemClick(context: Context, extras: Bundle, notificationId: Int) {
146
226
  NotificationBitmapCache.clear(notificationId)
147
227
 
@@ -149,16 +229,35 @@ class CarouselTemplate : SwanNotificationTemplate {
149
229
  val itemRoute = extras.getString(EXTRA_ITEM_ROUTE, "")
150
230
  val itemIndex = extras.getInt(EXTRA_ITEM_INDEX, 0)
151
231
 
232
+ // Get notification metadata from state for the JS event
233
+ val state = NotificationStateManager.get(context, notificationId)
234
+ val title = state?.optString("title", "") ?: ""
235
+ val body = state?.optString("body", "") ?: ""
236
+ val defaultRoute = state?.optString("defaultRoute", "") ?: ""
237
+
152
238
  Log.d(TAG, "Carousel item clicked: messageId=$messageId, index=$itemIndex, route=$itemRoute")
153
239
 
154
- val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
155
- if (launchIntent != null) {
156
- launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
157
- launchIntent.putExtra("swanTemplateClick", true)
158
- launchIntent.putExtra("messageId", messageId)
159
- launchIntent.putExtra("route", itemRoute)
160
- launchIntent.putExtra("itemIndex", itemIndex)
161
- context.startActivity(launchIntent)
240
+ // Save click data for JS SDK to consume via getPendingCarouselClick()
241
+ val clickData = JSONObject().apply {
242
+ put("messageId", messageId)
243
+ put("route", itemRoute.ifEmpty { defaultRoute })
244
+ put("itemIndex", itemIndex)
245
+ put("title", title)
246
+ put("body", body)
247
+ }
248
+ savePendingClick(clickData)
249
+
250
+ // Launch the app — on Android 12+, this is handled by PendingIntent.getActivity() instead
251
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
252
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
253
+ if (launchIntent != null) {
254
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
255
+ launchIntent.putExtra(EXTRA_SWAN_CAROUSEL_CLICK, true)
256
+ launchIntent.putExtra(EXTRA_SWAN_MESSAGE_ID, messageId)
257
+ launchIntent.putExtra(EXTRA_SWAN_ROUTE, itemRoute)
258
+ launchIntent.putExtra(EXTRA_SWAN_ITEM_INDEX, itemIndex)
259
+ context.startActivity(launchIntent)
260
+ }
162
261
  }
163
262
 
164
263
  val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -249,6 +348,18 @@ class CarouselTemplate : SwanNotificationTemplate {
249
348
  context, notificationId, ACTION_DISMISS, Bundle()
250
349
  ))
251
350
 
351
+ // Set contentIntent so tapping the notification (collapsed or expanded text area) opens the app
352
+ val defaultRoute = state?.optString("defaultRoute", "") ?: ""
353
+ val contentRoute = itemRoute.ifEmpty { defaultRoute }
354
+ val contentClickExtras = Bundle().apply {
355
+ putString(EXTRA_MESSAGE_ID, messageId)
356
+ putString(EXTRA_ITEM_ROUTE, contentRoute)
357
+ putInt(EXTRA_ITEM_INDEX, currentIndex)
358
+ }
359
+ builder.setContentIntent(createActionPendingIntent(
360
+ context, notificationId, ACTION_CLICK, contentClickExtras
361
+ ))
362
+
252
363
  // DecoratedCustomViewStyle on Android 12+ (same as CleverTap)
253
364
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
254
365
  builder.setStyle(NotificationCompat.DecoratedCustomViewStyle())
@@ -301,12 +412,22 @@ class CarouselTemplate : SwanNotificationTemplate {
301
412
  .setCustomBigContentView(views.expanded)
302
413
  .setCustomContentView(views.collapsed)
303
414
  .setOngoing(false)
304
- .setAutoCancel(false)
415
+ .setAutoCancel(true)
305
416
  .setOnlyAlertOnce(true)
306
417
  .setDeleteIntent(createActionPendingIntent(
307
418
  context, notificationId, ACTION_DISMISS, Bundle()
308
419
  ))
309
420
 
421
+ // Set contentIntent so tapping the notification opens the app with the default route
422
+ val contentClickExtras = Bundle().apply {
423
+ putString(EXTRA_MESSAGE_ID, messageId)
424
+ putString(EXTRA_ITEM_ROUTE, route)
425
+ putInt(EXTRA_ITEM_INDEX, 0)
426
+ }
427
+ builder.setContentIntent(createActionPendingIntent(
428
+ context, notificationId, ACTION_CLICK, contentClickExtras
429
+ ))
430
+
310
431
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
311
432
  builder.setStyle(NotificationCompat.DecoratedCustomViewStyle())
312
433
  }
@@ -321,6 +442,17 @@ class CarouselTemplate : SwanNotificationTemplate {
321
442
  action: String,
322
443
  extras: Bundle
323
444
  ): PendingIntent {
445
+ // For CLICK, use PendingIntent.getActivity() to avoid Android 12+ trampoline restriction
446
+ if (action == ACTION_CLICK) {
447
+ return createClickActivityPendingIntent(
448
+ context,
449
+ notificationId,
450
+ extras.getString(EXTRA_MESSAGE_ID, ""),
451
+ extras.getInt(EXTRA_ITEM_INDEX, 0),
452
+ extras.getString(EXTRA_ITEM_ROUTE, "")
453
+ )
454
+ }
455
+
324
456
  val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
325
457
  this.action = "com.loyalytics.swan.${action}_$notificationId"
326
458
  putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, TYPE)
@@ -41,7 +41,7 @@
41
41
  <ViewFlipper
42
42
  android:id="@+id/swan_carousel_flipper"
43
43
  android:layout_width="match_parent"
44
- android:layout_height="196dp"
44
+ android:layout_height="144dp"
45
45
  android:layout_below="@id/swan_carousel_auto_header"
46
46
  android:inAnimation="@anim/swan_slide_in_right"
47
47
  android:outAnimation="@anim/swan_slide_out_left"
@@ -43,7 +43,7 @@
43
43
  android:layout_width="match_parent"
44
44
  android:layout_height="0dp"
45
45
  android:layout_weight="1"
46
- android:minHeight="196dp">
46
+ android:minHeight="144dp">
47
47
 
48
48
  <!-- Center ViewFlipper (all images pre-loaded) -->
49
49
  <ViewFlipper
@@ -23,7 +23,7 @@
23
23
  <!-- 3-image filmstrip: left preview | center | right preview -->
24
24
  <LinearLayout
25
25
  android:layout_width="match_parent"
26
- android:layout_height="196dp"
26
+ android:layout_height="144dp"
27
27
  android:orientation="horizontal"
28
28
  android:gravity="center_vertical">
29
29
 
@@ -0,0 +1,55 @@
1
+ //
2
+ // SwanAppGroup.m
3
+ // swan-react-native-sdk
4
+ //
5
+ // Native bridge for iOS App Group UserDefaults.
6
+ // Replaces react-native-shared-group-preferences to fix cross-process
7
+ // caching bug: the third-party library caches the NSUserDefaults instance
8
+ // and misses writes from the Notification Content Extension process.
9
+ //
10
+ // This module creates a fresh NSUserDefaults instance on every call,
11
+ // ensuring reads always reflect the latest data from any process.
12
+ //
13
+
14
+ #import <React/RCTBridgeModule.h>
15
+
16
+ @interface SwanAppGroup : NSObject <RCTBridgeModule>
17
+ @end
18
+
19
+ @implementation SwanAppGroup
20
+
21
+ RCT_EXPORT_MODULE();
22
+
23
+ RCT_EXPORT_METHOD(getItem:(NSString *)key
24
+ suiteName:(NSString *)suiteName
25
+ resolver:(RCTPromiseResolveBlock)resolve
26
+ rejecter:(RCTPromiseRejectBlock)reject)
27
+ {
28
+ NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
29
+ if (!defaults) {
30
+ reject(@"ERR_APP_GROUP", @"Failed to access App Group", nil);
31
+ return;
32
+ }
33
+
34
+ NSString *value = [defaults stringForKey:key];
35
+ resolve(value);
36
+ }
37
+
38
+ RCT_EXPORT_METHOD(setItem:(NSString *)key
39
+ value:(NSString *)value
40
+ suiteName:(NSString *)suiteName
41
+ resolver:(RCTPromiseResolveBlock)resolve
42
+ rejecter:(RCTPromiseRejectBlock)reject)
43
+ {
44
+ NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
45
+ if (!defaults) {
46
+ reject(@"ERR_APP_GROUP", @"Failed to access App Group", nil);
47
+ return;
48
+ }
49
+
50
+ [defaults setObject:value forKey:key];
51
+ [defaults synchronize];
52
+ resolve(nil);
53
+ }
54
+
55
+ @end