@loyalytics/swan-react-native-sdk 2.1.3-beta.0 → 2.1.3-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 (89) hide show
  1. package/android/build.gradle +66 -0
  2. package/android/src/main/AndroidManifest.xml +10 -0
  3. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +43 -0
  4. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationPackage.kt +16 -0
  5. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationActionReceiver.kt +49 -0
  6. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationTemplate.kt +20 -0
  7. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanTemplateRegistry.kt +47 -0
  8. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +103 -0
  9. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +132 -0
  10. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +129 -0
  11. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +412 -0
  12. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationBitmapCache.kt +70 -0
  13. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationImageLoader.kt +97 -0
  14. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationStateManager.kt +85 -0
  15. package/android/src/main/res/anim/swan_fade_in.xml +6 -0
  16. package/android/src/main/res/anim/swan_fade_out.xml +6 -0
  17. package/android/src/main/res/anim/swan_slide_in_right.xml +8 -0
  18. package/android/src/main/res/anim/swan_slide_out_left.xml +8 -0
  19. package/android/src/main/res/drawable/swan_ic_chevron_left.xml +11 -0
  20. package/android/src/main/res/drawable/swan_ic_chevron_right.xml +11 -0
  21. package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +51 -0
  22. package/android/src/main/res/layout/swan_carousel_collapsed.xml +31 -0
  23. package/android/src/main/res/layout/swan_carousel_expanded.xml +96 -0
  24. package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +115 -0
  25. package/android/src/main/res/layout/swan_carousel_flipper_item.xml +7 -0
  26. package/android/src/test/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplateTest.kt +125 -0
  27. package/docs/SDK_INDUSTRY_REVIEW_REPORT.md +347 -0
  28. package/docs/Swan_Push_Notifications.postman_collection.json +330 -0
  29. package/docs/deep-link-attribution.md +281 -0
  30. package/ios/SwanNotificationContentExtension/Info.plist +40 -0
  31. package/ios/SwanNotificationContentExtension/MainInterface.storyboard +19 -0
  32. package/ios/SwanNotificationContentExtension/NotificationViewController.swift +190 -0
  33. package/ios/SwanNotificationContentExtension/SwanNotificationContentExtension.entitlements +10 -0
  34. package/ios/SwanNotificationContentExtension/common/ImageDownloader.swift +32 -0
  35. package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +336 -0
  36. package/lib/commonjs/constants/ApiUrls.js.map +1 -1
  37. package/lib/commonjs/index.js +117 -35
  38. package/lib/commonjs/index.js.map +1 -1
  39. package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
  40. package/lib/commonjs/services/DeviceRegistrationService.js.map +1 -1
  41. package/lib/commonjs/state/AuthStateMachine.js.map +1 -1
  42. package/lib/commonjs/state/DeviceStateMachine.js.map +1 -1
  43. package/lib/commonjs/state/PushStateMachine.js.map +1 -1
  44. package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
  45. package/lib/commonjs/utils/Logger.js.map +1 -1
  46. package/lib/commonjs/utils/SharedCredentialsManager.js +28 -0
  47. package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
  48. package/lib/commonjs/version.js +1 -1
  49. package/lib/module/index.js +117 -35
  50. package/lib/module/index.js.map +1 -1
  51. package/lib/module/providers/NullPushProvider.js.map +1 -1
  52. package/lib/module/services/DeviceRegistrationService.js.map +1 -1
  53. package/lib/module/state/AuthStateMachine.js.map +1 -1
  54. package/lib/module/state/DeviceStateMachine.js.map +1 -1
  55. package/lib/module/state/PushStateMachine.js.map +1 -1
  56. package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
  57. package/lib/module/utils/Logger.js.map +1 -1
  58. package/lib/module/utils/SharedCredentialsManager.js +28 -0
  59. package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
  60. package/lib/module/version.js +1 -1
  61. package/lib/typescript/commonjs/src/constants/ApiUrls.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/src/services/DeviceRegistrationService.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/state/AuthStateMachine.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/src/state/DeviceStateMachine.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/state/PushStateMachine.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/src/utils/Logger.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +13 -0
  71. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/src/version.d.ts +1 -1
  73. package/lib/typescript/module/src/constants/ApiUrls.d.ts.map +1 -1
  74. package/lib/typescript/module/src/index.d.ts.map +1 -1
  75. package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
  76. package/lib/typescript/module/src/services/DeviceRegistrationService.d.ts.map +1 -1
  77. package/lib/typescript/module/src/state/AuthStateMachine.d.ts.map +1 -1
  78. package/lib/typescript/module/src/state/DeviceStateMachine.d.ts.map +1 -1
  79. package/lib/typescript/module/src/state/PushStateMachine.d.ts.map +1 -1
  80. package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  81. package/lib/typescript/module/src/utils/Logger.d.ts.map +1 -1
  82. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +13 -0
  83. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  84. package/lib/typescript/module/src/version.d.ts +1 -1
  85. package/package.json +7 -3
  86. package/react-native.config.json +12 -0
  87. package/scripts/setup-ios-extension.js +100 -20
  88. package/scripts/test-carousel-push.js +266 -0
  89. package/swan-react-native-sdk.podspec +18 -0
@@ -0,0 +1,412 @@
1
+ package com.loyalytics.swan.templates.carousel
2
+
3
+ import android.app.NotificationManager
4
+ import android.app.PendingIntent
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.graphics.Bitmap
8
+ import android.os.Build
9
+ import android.os.Bundle
10
+ import android.util.Log
11
+ import android.util.TypedValue
12
+ import android.widget.RemoteViews
13
+ import androidx.core.app.NotificationCompat
14
+ import com.facebook.react.bridge.ReadableMap
15
+ import com.loyalytics.swan.R
16
+ import com.loyalytics.swan.templates.SwanNotificationActionReceiver
17
+ import com.loyalytics.swan.templates.SwanNotificationTemplate
18
+ import com.loyalytics.swan.templates.common.NotificationBitmapCache
19
+ import com.loyalytics.swan.templates.common.NotificationImageLoader
20
+ import com.loyalytics.swan.templates.common.NotificationStateManager
21
+ import org.json.JSONArray
22
+ import org.json.JSONObject
23
+
24
+ class CarouselTemplate : SwanNotificationTemplate {
25
+ companion object {
26
+ private const val TAG = "SwanCarousel"
27
+ const val TYPE = "carousel"
28
+
29
+ // Actions
30
+ const val ACTION_PREV = "PREV"
31
+ const val ACTION_NEXT = "NEXT"
32
+ const val ACTION_CLICK = "CLICK"
33
+ const val ACTION_DISMISS = "DISMISS"
34
+
35
+ // Intent extras
36
+ const val EXTRA_ITEM_INDEX = "itemIndex"
37
+ const val EXTRA_ITEM_ROUTE = "itemRoute"
38
+ const val EXTRA_MESSAGE_ID = "messageId"
39
+ }
40
+
41
+ override fun canHandle(notificationType: String): Boolean = notificationType == TYPE
42
+
43
+ override suspend fun display(context: Context, config: ReadableMap) {
44
+ val messageId = config.getString("messageId") ?: "swan_${System.currentTimeMillis()}"
45
+ val title = config.getString("title") ?: ""
46
+ val body = config.getString("body") ?: ""
47
+ val channelId = config.getString("channelId") ?: "swan_notifications"
48
+ val mode = config.getString("carouselMode") ?: "manual"
49
+ val variant = config.getString("carouselVariant") ?: "standard"
50
+ val interval = if (config.hasKey("carouselInterval")) config.getInt("carouselInterval") else 3000
51
+ val itemsJson = config.getString("carouselItems") ?: "[]"
52
+ val defaultRoute = config.getString("defaultRoute") ?: ""
53
+
54
+ val items = parseItems(itemsJson)
55
+ if (items.isEmpty()) {
56
+ Log.w(TAG, "No carousel items to display")
57
+ return
58
+ }
59
+
60
+ // Download all images concurrently
61
+ val imageUrls = items.map { it.optString("imageUrl", "") }
62
+ val bitmaps = NotificationImageLoader.downloadAll(imageUrls)
63
+
64
+ val notificationId = messageId.hashCode()
65
+
66
+ // Cache bitmaps for navigation
67
+ NotificationBitmapCache.putAll(notificationId, imageUrls, bitmaps)
68
+
69
+ // Save state for BroadcastReceiver
70
+ val state = JSONObject().apply {
71
+ put("items", JSONArray(items.map { it.toString() }))
72
+ put("currentIndex", 0)
73
+ put("messageId", messageId)
74
+ put("title", title)
75
+ put("body", body)
76
+ put("channelId", channelId)
77
+ put("carouselMode", mode)
78
+ put("carouselVariant", variant)
79
+ put("carouselInterval", interval)
80
+ put("defaultRoute", defaultRoute)
81
+ }
82
+ NotificationStateManager.save(context, notificationId, state)
83
+
84
+ if (mode == "auto" && items.size > 1) {
85
+ showAutoCarouselNotification(
86
+ context, notificationId, title, body, channelId,
87
+ items, bitmaps, interval, messageId, defaultRoute
88
+ )
89
+ } else {
90
+ showManualCarouselNotification(
91
+ context, notificationId, title, body, channelId,
92
+ items, bitmaps, 0, variant
93
+ )
94
+ }
95
+ }
96
+
97
+ override fun handleAction(context: Context, action: String, extras: Bundle) {
98
+ val notificationId = extras.getInt(SwanNotificationActionReceiver.EXTRA_NOTIFICATION_ID, -1)
99
+ if (notificationId == -1) {
100
+ Log.w(TAG, "Missing notificationId in action extras")
101
+ return
102
+ }
103
+
104
+ when (action) {
105
+ ACTION_PREV -> navigateCarousel(context, notificationId, -1)
106
+ ACTION_NEXT -> navigateCarousel(context, notificationId, +1)
107
+ ACTION_CLICK -> handleItemClick(context, extras, notificationId)
108
+ ACTION_DISMISS -> {
109
+ NotificationBitmapCache.clear(notificationId)
110
+ NotificationStateManager.remove(context, notificationId)
111
+ }
112
+ }
113
+ }
114
+
115
+ private fun navigateCarousel(context: Context, notificationId: Int, direction: Int) {
116
+ val state = NotificationStateManager.get(context, notificationId) ?: return
117
+ val itemsJsonArray = state.optJSONArray("items") ?: return
118
+ val items = (0 until itemsJsonArray.length()).map { JSONObject(itemsJsonArray.getString(it)) }
119
+ if (items.isEmpty()) return
120
+
121
+ val currentIndex = state.optInt("currentIndex", 0)
122
+ val newIndex = wrapIndex(currentIndex + direction, items.size)
123
+
124
+ // Update state
125
+ state.put("currentIndex", newIndex)
126
+ NotificationStateManager.save(context, notificationId, state)
127
+
128
+ val channelId = state.optString("channelId", "swan_notifications")
129
+ val title = state.optString("title", "")
130
+ val body = state.optString("body", "")
131
+ val variant = state.optString("carouselVariant", "standard")
132
+
133
+ // Load all bitmaps from cache
134
+ val bitmaps = items.map { item ->
135
+ val url = item.optString("imageUrl", "")
136
+ NotificationBitmapCache.get(notificationId, url)
137
+ }
138
+
139
+ showManualCarouselNotification(
140
+ context, notificationId, title, body, channelId,
141
+ items, bitmaps, newIndex, variant
142
+ )
143
+ }
144
+
145
+ private fun handleItemClick(context: Context, extras: Bundle, notificationId: Int) {
146
+ NotificationBitmapCache.clear(notificationId)
147
+
148
+ val messageId = extras.getString(EXTRA_MESSAGE_ID, "")
149
+ val itemRoute = extras.getString(EXTRA_ITEM_ROUTE, "")
150
+ val itemIndex = extras.getInt(EXTRA_ITEM_INDEX, 0)
151
+
152
+ Log.d(TAG, "Carousel item clicked: messageId=$messageId, index=$itemIndex, route=$itemRoute")
153
+
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)
162
+ }
163
+
164
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
165
+ nm.cancel(notificationId)
166
+ NotificationStateManager.remove(context, notificationId)
167
+ }
168
+
169
+ /**
170
+ * Show manual carousel notification using custom RemoteViews.
171
+ * - Standard: ViewFlipper with all images, arrow overlays, per-item info
172
+ * - Filmstrip: 3 ImageViews (left/center/right preview)
173
+ * DecoratedCustomViewStyle on Android 12+ (same as CleverTap).
174
+ */
175
+ private fun showManualCarouselNotification(
176
+ context: Context,
177
+ notificationId: Int,
178
+ title: String,
179
+ body: String,
180
+ channelId: String,
181
+ items: List<JSONObject>,
182
+ bitmaps: List<Bitmap?>,
183
+ currentIndex: Int,
184
+ variant: String
185
+ ) {
186
+ val currentItem = items[currentIndex]
187
+ val itemRoute = currentItem.optString("route", "")
188
+ val state = NotificationStateManager.get(context, notificationId)
189
+ val messageId = state?.optString("messageId", "") ?: ""
190
+
191
+ val expanded: RemoteViews
192
+ val collapsed: RemoteViews
193
+
194
+ if (variant == "filmstrip" && items.size > 1) {
195
+ val prevIndex = wrapIndex(currentIndex - 1, items.size)
196
+ val nextIndex = wrapIndex(currentIndex + 1, items.size)
197
+
198
+ // Filmstrip shows 3 bitmaps at once — scale down to fit RemoteViews ~1MB limit
199
+ // Center (60% width) gets 400px, side previews (20% each) get 200px
200
+ val filmstrip = CarouselFilmstripRemoteViews.build(
201
+ context = context,
202
+ notificationId = notificationId,
203
+ title = title,
204
+ itemTitle = currentItem.optString("title", body),
205
+ itemBody = currentItem.optString("body", ""),
206
+ leftBitmap = bitmaps[prevIndex]?.let { scaleBitmap(it, 200) },
207
+ centerBitmap = bitmaps[currentIndex]?.let { scaleBitmap(it, 400) },
208
+ rightBitmap = bitmaps[nextIndex]?.let { scaleBitmap(it, 200) },
209
+ currentIndex = currentIndex,
210
+ totalItems = items.size,
211
+ messageId = messageId,
212
+ itemRoute = itemRoute
213
+ )
214
+ expanded = filmstrip.expanded
215
+ collapsed = filmstrip.collapsed
216
+ } else {
217
+ // Standard shows 1 bitmap at a time via ViewFlipper — 720px is fine
218
+ val scaledBitmaps = bitmaps.map { bitmap ->
219
+ bitmap?.let { scaleBitmap(it, 720) }
220
+ }
221
+ val standard = CarouselRemoteViews.build(
222
+ context = context,
223
+ notificationId = notificationId,
224
+ title = title,
225
+ body = currentItem.optString("title", body),
226
+ bitmaps = scaledBitmaps,
227
+ currentIndex = currentIndex,
228
+ totalItems = items.size,
229
+ messageId = messageId,
230
+ itemRoute = itemRoute
231
+ )
232
+ expanded = standard.expanded
233
+ collapsed = standard.collapsed
234
+ }
235
+
236
+ applyPreApi31TextPadding(context, expanded, variant)
237
+
238
+ val appIcon = getAppIcon(context)
239
+
240
+ val builder = NotificationCompat.Builder(context, channelId)
241
+ .setSmallIcon(appIcon)
242
+ .setContentTitle(title)
243
+ .setCustomBigContentView(expanded)
244
+ .setCustomContentView(collapsed)
245
+ .setOngoing(false)
246
+ .setAutoCancel(true)
247
+ .setOnlyAlertOnce(true)
248
+ .setDeleteIntent(createActionPendingIntent(
249
+ context, notificationId, ACTION_DISMISS, Bundle()
250
+ ))
251
+
252
+ // DecoratedCustomViewStyle on Android 12+ (same as CleverTap)
253
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
254
+ builder.setStyle(NotificationCompat.DecoratedCustomViewStyle())
255
+ }
256
+
257
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
258
+ nm.notify(notificationId, builder.build())
259
+ }
260
+
261
+ /**
262
+ * Show auto-carousel using ViewFlipper with autoStart.
263
+ */
264
+ private fun showAutoCarouselNotification(
265
+ context: Context,
266
+ notificationId: Int,
267
+ title: String,
268
+ body: String,
269
+ channelId: String,
270
+ items: List<JSONObject>,
271
+ bitmaps: List<Bitmap?>,
272
+ intervalMs: Int,
273
+ messageId: String,
274
+ defaultRoute: String
275
+ ) {
276
+ val route = defaultRoute.ifEmpty {
277
+ items.firstOrNull()?.optString("route", "") ?: ""
278
+ }
279
+
280
+ val scaledBitmaps = bitmaps.map { bitmap ->
281
+ bitmap?.let { scaleBitmap(it, 400) }
282
+ }
283
+
284
+ val views = CarouselAutoRemoteViews.build(
285
+ context = context,
286
+ notificationId = notificationId,
287
+ title = title,
288
+ body = body,
289
+ bitmaps = scaledBitmaps,
290
+ intervalMs = intervalMs,
291
+ messageId = messageId,
292
+ defaultRoute = route
293
+ )
294
+
295
+ applyPreApi31TextPadding(context, views.expanded, "auto")
296
+
297
+ val appIcon = getAppIcon(context)
298
+
299
+ val builder = NotificationCompat.Builder(context, channelId)
300
+ .setSmallIcon(appIcon)
301
+ .setCustomBigContentView(views.expanded)
302
+ .setCustomContentView(views.collapsed)
303
+ .setOngoing(false)
304
+ .setAutoCancel(false)
305
+ .setOnlyAlertOnce(true)
306
+ .setDeleteIntent(createActionPendingIntent(
307
+ context, notificationId, ACTION_DISMISS, Bundle()
308
+ ))
309
+
310
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
311
+ builder.setStyle(NotificationCompat.DecoratedCustomViewStyle())
312
+ }
313
+
314
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
315
+ nm.notify(notificationId, builder.build())
316
+ }
317
+
318
+ internal fun createActionPendingIntent(
319
+ context: Context,
320
+ notificationId: Int,
321
+ action: String,
322
+ extras: Bundle
323
+ ): PendingIntent {
324
+ val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
325
+ this.action = "com.loyalytics.swan.${action}_$notificationId"
326
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, TYPE)
327
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_ACTION, action)
328
+ putExtra(SwanNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
329
+ putExtras(extras)
330
+ }
331
+
332
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
333
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
334
+ } else {
335
+ PendingIntent.FLAG_UPDATE_CURRENT
336
+ }
337
+
338
+ return PendingIntent.getBroadcast(context, "$notificationId$action".hashCode(), intent, flags)
339
+ }
340
+
341
+ private fun scaleBitmap(bitmap: Bitmap, maxDimension: Int): Bitmap {
342
+ val width = bitmap.width
343
+ val height = bitmap.height
344
+ if (width <= maxDimension && height <= maxDimension) return bitmap
345
+ val ratio = minOf(maxDimension.toFloat() / width, maxDimension.toFloat() / height)
346
+ val newWidth = (width * ratio).toInt().coerceAtLeast(1)
347
+ val newHeight = (height * ratio).toInt().coerceAtLeast(1)
348
+ return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
349
+ }
350
+
351
+ private fun getAppIcon(context: Context): Int {
352
+ return try {
353
+ val appInfo = context.applicationInfo
354
+ appInfo.icon.takeIf { it != 0 }
355
+ ?: android.R.drawable.ic_dialog_info
356
+ } catch (e: Exception) {
357
+ android.R.drawable.ic_dialog_info
358
+ }
359
+ }
360
+
361
+ /**
362
+ * On API < 31 (pre-Android 12) there's no DecoratedCustomViewStyle padding,
363
+ * so we add 16dp horizontal padding to text areas programmatically.
364
+ */
365
+ private fun applyPreApi31TextPadding(context: Context, expanded: RemoteViews, variant: String) {
366
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
367
+
368
+ val dm = context.resources.displayMetrics
369
+ val px16 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f, dm).toInt()
370
+ val px8 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, dm).toInt()
371
+ val px4 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, dm).toInt()
372
+ val px2 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, dm).toInt()
373
+
374
+ when (variant) {
375
+ "filmstrip" -> {
376
+ expanded.setViewPadding(R.id.swan_filmstrip_title, px16, 0, px16, px8)
377
+ expanded.setViewPadding(R.id.swan_filmstrip_item_title, px16, 0, px16, 0)
378
+ expanded.setViewPadding(R.id.swan_filmstrip_item_body, px16, 0, px16, 0)
379
+ expanded.setViewPadding(R.id.swan_filmstrip_counter, px16, 0, px16, 0)
380
+ }
381
+ "auto" -> {
382
+ expanded.setViewPadding(R.id.swan_carousel_auto_title, px16, 0, px16, 0)
383
+ expanded.setViewPadding(R.id.swan_carousel_auto_body, px16, 0, px16, 0)
384
+ }
385
+ else -> {
386
+ expanded.setViewPadding(R.id.swan_carousel_header, px16, 0, px16, px8)
387
+ expanded.setViewPadding(R.id.swan_carousel_counter, 0, px2, px16, px4)
388
+ }
389
+ }
390
+ }
391
+
392
+ private fun parseItems(json: String): List<JSONObject> {
393
+ return try {
394
+ val array = JSONArray(json)
395
+ (0 until array.length())
396
+ .map { array.getJSONObject(it) }
397
+ .filter { it.optString("imageUrl", "").isNotEmpty() }
398
+ .take(10)
399
+ } catch (e: Exception) {
400
+ Log.e(TAG, "Failed to parse carousel items", e)
401
+ emptyList()
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Calculate wrapped index for circular navigation.
408
+ */
409
+ internal fun wrapIndex(index: Int, size: Int): Int {
410
+ if (size == 0) return 0
411
+ return ((index % size) + size) % size
412
+ }
@@ -0,0 +1,70 @@
1
+ package com.loyalytics.swan.templates.common
2
+
3
+ import android.graphics.Bitmap
4
+ import android.util.Log
5
+ import java.util.concurrent.ConcurrentHashMap
6
+
7
+ /**
8
+ * In-memory bitmap cache for notification images.
9
+ * Keyed by notificationId → (imageUrl → Bitmap).
10
+ *
11
+ * Pre-populated on first display, served on navigation clicks,
12
+ * cleared on notification dismiss or click-through.
13
+ */
14
+ object NotificationBitmapCache {
15
+ private const val TAG = "SwanBitmapCache"
16
+
17
+ private val cache = ConcurrentHashMap<Int, ConcurrentHashMap<String, Bitmap>>()
18
+
19
+ /**
20
+ * Bulk-cache bitmaps after initial downloadAll.
21
+ * Only caches non-null bitmaps with non-empty URLs.
22
+ */
23
+ fun putAll(notificationId: Int, urls: List<String>, bitmaps: List<Bitmap?>) {
24
+ val map = cache.getOrPut(notificationId) { ConcurrentHashMap() }
25
+ var cached = 0
26
+ urls.forEachIndexed { index, url ->
27
+ val bitmap = bitmaps.getOrNull(index)
28
+ if (url.isNotEmpty() && bitmap != null) {
29
+ map[url] = bitmap
30
+ cached++
31
+ }
32
+ }
33
+ Log.d(TAG, "Cached $cached/${urls.size} bitmaps for notification $notificationId")
34
+ }
35
+
36
+ /**
37
+ * Get a single cached bitmap. Returns null on cache miss.
38
+ */
39
+ fun get(notificationId: Int, url: String): Bitmap? {
40
+ return cache[notificationId]?.get(url)
41
+ }
42
+
43
+ /**
44
+ * Cache a single bitmap (e.g., after a cache-miss download).
45
+ */
46
+ fun put(notificationId: Int, url: String, bitmap: Bitmap) {
47
+ val map = cache.getOrPut(notificationId) { ConcurrentHashMap() }
48
+ map[url] = bitmap
49
+ }
50
+
51
+ /**
52
+ * Get ordered list of bitmaps for the given URLs.
53
+ * Returns null for any cache miss.
54
+ */
55
+ fun getAll(notificationId: Int, urls: List<String>): List<Bitmap?> {
56
+ val map = cache[notificationId] ?: return urls.map { null }
57
+ return urls.map { url -> map[url] }
58
+ }
59
+
60
+ /**
61
+ * Clear all cached bitmaps for a notification (on dismiss/click).
62
+ */
63
+ fun clear(notificationId: Int) {
64
+ val removed = cache.remove(notificationId)
65
+ val count = removed?.size ?: 0
66
+ if (count > 0) {
67
+ Log.d(TAG, "Cleared $count cached bitmaps for notification $notificationId")
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,97 @@
1
+ package com.loyalytics.swan.templates.common
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.util.Log
6
+ import kotlinx.coroutines.Dispatchers
7
+ import kotlinx.coroutines.async
8
+ import kotlinx.coroutines.awaitAll
9
+ import kotlinx.coroutines.coroutineScope
10
+ import kotlinx.coroutines.withContext
11
+ import java.net.HttpURLConnection
12
+ import java.net.URL
13
+
14
+ /**
15
+ * Shared image downloader for notification templates.
16
+ * Downloads and decodes bitmap images from URLs with size limits.
17
+ */
18
+ object NotificationImageLoader {
19
+ private const val TAG = "SwanImageLoader"
20
+ private const val CONNECT_TIMEOUT_MS = 5000
21
+ private const val READ_TIMEOUT_MS = 10000
22
+ private const val MAX_IMAGE_DIMENSION = 500 // px
23
+
24
+ /**
25
+ * Download a single image from a URL.
26
+ * Returns null if download fails.
27
+ */
28
+ suspend fun download(imageUrl: String): Bitmap? = withContext(Dispatchers.IO) {
29
+ try {
30
+ val url = URL(imageUrl)
31
+ val connection = url.openConnection() as HttpURLConnection
32
+ connection.connectTimeout = CONNECT_TIMEOUT_MS
33
+ connection.readTimeout = READ_TIMEOUT_MS
34
+ connection.doInput = true
35
+ connection.connect()
36
+
37
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
38
+ Log.w(TAG, "HTTP ${connection.responseCode} for $imageUrl")
39
+ connection.disconnect()
40
+ return@withContext null
41
+ }
42
+
43
+ val inputStream = connection.inputStream
44
+
45
+ // First pass: get dimensions
46
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
47
+ BitmapFactory.decodeStream(inputStream, null, options)
48
+ inputStream.close()
49
+ connection.disconnect()
50
+
51
+ // Calculate sample size for downscaling
52
+ val sampleSize = calculateSampleSize(
53
+ options.outWidth, options.outHeight, MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION
54
+ )
55
+
56
+ // Second pass: decode with sample size
57
+ val connection2 = url.openConnection() as HttpURLConnection
58
+ connection2.connectTimeout = CONNECT_TIMEOUT_MS
59
+ connection2.readTimeout = READ_TIMEOUT_MS
60
+ connection2.doInput = true
61
+ connection2.connect()
62
+
63
+ val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
64
+ val bitmap = BitmapFactory.decodeStream(connection2.inputStream, null, decodeOptions)
65
+ connection2.disconnect()
66
+
67
+ bitmap
68
+ } catch (e: Exception) {
69
+ Log.e(TAG, "Failed to download image: $imageUrl", e)
70
+ null
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Download multiple images concurrently.
76
+ * Returns list of nullable bitmaps (null for failed downloads).
77
+ */
78
+ suspend fun downloadAll(imageUrls: List<String>): List<Bitmap?> = coroutineScope {
79
+ imageUrls.map { url ->
80
+ async { download(url) }
81
+ }.awaitAll()
82
+ }
83
+
84
+ private fun calculateSampleSize(
85
+ width: Int, height: Int, reqWidth: Int, reqHeight: Int
86
+ ): Int {
87
+ var sampleSize = 1
88
+ if (height > reqHeight || width > reqWidth) {
89
+ val halfHeight = height / 2
90
+ val halfWidth = width / 2
91
+ while ((halfHeight / sampleSize) >= reqHeight && (halfWidth / sampleSize) >= reqWidth) {
92
+ sampleSize *= 2
93
+ }
94
+ }
95
+ return sampleSize
96
+ }
97
+ }
@@ -0,0 +1,85 @@
1
+ package com.loyalytics.swan.templates.common
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.util.Log
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+ import java.util.concurrent.ConcurrentHashMap
9
+
10
+ /**
11
+ * Manages state persistence for interactive notifications (e.g., carousel current index).
12
+ *
13
+ * Uses in-memory cache for fast reads during notification updates,
14
+ * backed by SharedPreferences for process restart survival.
15
+ */
16
+ object NotificationStateManager {
17
+ private const val TAG = "SwanStateManager"
18
+ private const val PREFS_NAME = "swan_notification_state"
19
+
20
+ // In-memory cache for fast reads
21
+ private val memoryCache = ConcurrentHashMap<Int, JSONObject>()
22
+
23
+ private fun getPrefs(context: Context): SharedPreferences {
24
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
25
+ }
26
+
27
+ /**
28
+ * Save state for a notification.
29
+ * @param notificationId The Android notification ID
30
+ * @param state JSON object with template-specific state
31
+ */
32
+ fun save(context: Context, notificationId: Int, state: JSONObject) {
33
+ memoryCache[notificationId] = state
34
+ try {
35
+ getPrefs(context).edit()
36
+ .putString(notificationId.toString(), state.toString())
37
+ .apply()
38
+ } catch (e: Exception) {
39
+ Log.e(TAG, "Failed to persist state for notification $notificationId", e)
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Get state for a notification. Checks memory first, then SharedPrefs.
45
+ */
46
+ fun get(context: Context, notificationId: Int): JSONObject? {
47
+ // Check memory first
48
+ memoryCache[notificationId]?.let { return it }
49
+
50
+ // Fall back to SharedPrefs
51
+ return try {
52
+ val json = getPrefs(context).getString(notificationId.toString(), null)
53
+ if (json != null) {
54
+ val state = JSONObject(json)
55
+ memoryCache[notificationId] = state
56
+ state
57
+ } else {
58
+ null
59
+ }
60
+ } catch (e: Exception) {
61
+ Log.e(TAG, "Failed to read state for notification $notificationId", e)
62
+ null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Remove state for a dismissed notification.
68
+ */
69
+ fun remove(context: Context, notificationId: Int) {
70
+ memoryCache.remove(notificationId)
71
+ try {
72
+ getPrefs(context).edit()
73
+ .remove(notificationId.toString())
74
+ .apply()
75
+ } catch (e: Exception) {
76
+ Log.e(TAG, "Failed to remove state for notification $notificationId", e)
77
+ }
78
+ }
79
+
80
+ /** Visible for testing */
81
+ internal fun clearAll(context: Context) {
82
+ memoryCache.clear()
83
+ getPrefs(context).edit().clear().apply()
84
+ }
85
+ }
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <alpha xmlns:android="http://schemas.android.com/apk/res/android"
3
+ android:fromAlpha="0.0"
4
+ android:toAlpha="1.0"
5
+ android:duration="300"
6
+ android:interpolator="@android:anim/decelerate_interpolator" />
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <alpha xmlns:android="http://schemas.android.com/apk/res/android"
3
+ android:fromAlpha="1.0"
4
+ android:toAlpha="0.0"
5
+ android:duration="300"
6
+ android:interpolator="@android:anim/accelerate_interpolator" />
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <set xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <translate
4
+ android:fromXDelta="100%p"
5
+ android:toXDelta="0"
6
+ android:duration="300"
7
+ android:interpolator="@android:anim/decelerate_interpolator" />
8
+ </set>