@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.
- package/android/build.gradle +66 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +43 -0
- package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationPackage.kt +16 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationActionReceiver.kt +49 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationTemplate.kt +20 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanTemplateRegistry.kt +47 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +103 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +132 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +129 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +412 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationBitmapCache.kt +70 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationImageLoader.kt +97 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationStateManager.kt +85 -0
- package/android/src/main/res/anim/swan_fade_in.xml +6 -0
- package/android/src/main/res/anim/swan_fade_out.xml +6 -0
- package/android/src/main/res/anim/swan_slide_in_right.xml +8 -0
- package/android/src/main/res/anim/swan_slide_out_left.xml +8 -0
- package/android/src/main/res/drawable/swan_ic_chevron_left.xml +11 -0
- package/android/src/main/res/drawable/swan_ic_chevron_right.xml +11 -0
- package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +51 -0
- package/android/src/main/res/layout/swan_carousel_collapsed.xml +31 -0
- package/android/src/main/res/layout/swan_carousel_expanded.xml +96 -0
- package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +115 -0
- package/android/src/main/res/layout/swan_carousel_flipper_item.xml +7 -0
- package/android/src/test/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplateTest.kt +125 -0
- package/docs/SDK_INDUSTRY_REVIEW_REPORT.md +347 -0
- package/docs/Swan_Push_Notifications.postman_collection.json +330 -0
- package/docs/deep-link-attribution.md +281 -0
- package/ios/SwanNotificationContentExtension/Info.plist +40 -0
- package/ios/SwanNotificationContentExtension/MainInterface.storyboard +19 -0
- package/ios/SwanNotificationContentExtension/NotificationViewController.swift +190 -0
- package/ios/SwanNotificationContentExtension/SwanNotificationContentExtension.entitlements +10 -0
- package/ios/SwanNotificationContentExtension/common/ImageDownloader.swift +32 -0
- package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +336 -0
- package/lib/commonjs/constants/ApiUrls.js.map +1 -1
- package/lib/commonjs/index.js +117 -35
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
- package/lib/commonjs/services/DeviceRegistrationService.js.map +1 -1
- package/lib/commonjs/state/AuthStateMachine.js.map +1 -1
- package/lib/commonjs/state/DeviceStateMachine.js.map +1 -1
- package/lib/commonjs/state/PushStateMachine.js.map +1 -1
- package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
- package/lib/commonjs/utils/Logger.js.map +1 -1
- package/lib/commonjs/utils/SharedCredentialsManager.js +28 -0
- package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/index.js +117 -35
- package/lib/module/index.js.map +1 -1
- package/lib/module/providers/NullPushProvider.js.map +1 -1
- package/lib/module/services/DeviceRegistrationService.js.map +1 -1
- package/lib/module/state/AuthStateMachine.js.map +1 -1
- package/lib/module/state/DeviceStateMachine.js.map +1 -1
- package/lib/module/state/PushStateMachine.js.map +1 -1
- package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
- package/lib/module/utils/Logger.js.map +1 -1
- package/lib/module/utils/SharedCredentialsManager.js +28 -0
- package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/commonjs/src/constants/ApiUrls.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/services/DeviceRegistrationService.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/state/AuthStateMachine.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/state/DeviceStateMachine.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/state/PushStateMachine.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/Logger.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +13 -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/module/src/constants/ApiUrls.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
- package/lib/typescript/module/src/services/DeviceRegistrationService.d.ts.map +1 -1
- package/lib/typescript/module/src/state/AuthStateMachine.d.ts.map +1 -1
- package/lib/typescript/module/src/state/DeviceStateMachine.d.ts.map +1 -1
- package/lib/typescript/module/src/state/PushStateMachine.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/Logger.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +13 -0
- package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
- package/lib/typescript/module/src/version.d.ts +1 -1
- package/package.json +7 -3
- package/react-native.config.json +12 -0
- package/scripts/setup-ios-extension.js +100 -20
- package/scripts/test-carousel-push.js +266 -0
- 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
|
+
}
|
package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationBitmapCache.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationImageLoader.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationStateManager.kt
ADDED
|
@@ -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,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>
|