Skip to content

Commit 6924768

Browse files
authored
feat(push) Action buttons support (#440)
2 parents e04a181 + 5e82562 commit 6924768

4 files changed

Lines changed: 968 additions & 10 deletions

File tree

sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoNotification.kt

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ import androidx.core.app.ActivityCompat
1515
import androidx.core.app.NotificationChannelCompat
1616
import androidx.core.app.NotificationCompat
1717
import androidx.core.app.NotificationManagerCompat
18+
import androidx.core.net.toUri
1819
import com.google.firebase.messaging.RemoteMessage
1920
import com.klaviyo.analytics.linking.DeepLinking
2021
import com.klaviyo.core.Constants
2122
import com.klaviyo.core.Registry
2223
import com.klaviyo.core.utils.activityResolved
24+
import com.klaviyo.pushFcm.KlaviyoRemoteMessage.ActionButton
25+
import com.klaviyo.pushFcm.KlaviyoRemoteMessage.actionButtons
26+
import com.klaviyo.pushFcm.KlaviyoRemoteMessage.appendActionButtonExtras
2327
import com.klaviyo.pushFcm.KlaviyoRemoteMessage.appendKlaviyoExtras
2428
import com.klaviyo.pushFcm.KlaviyoRemoteMessage.body
2529
import com.klaviyo.pushFcm.KlaviyoRemoteMessage.channel_description
@@ -68,7 +72,9 @@ class KlaviyoNotification(private val message: RemoteMessage) {
6872
internal const val NOTIFICATION_PRIORITY = "notification_priority"
6973
internal const val NOTIFICATION_TAG = "notification_tag"
7074
internal const val KEY_VALUE_PAIRS_KEY = Constants.KEY_VALUE_PAIRS
75+
internal const val ACTION_BUTTONS_KEY = "action_buttons"
7176
private const val DOWNLOAD_TIMEOUT_MS = 5_000
77+
private const val ACTION_REQUEST_CODE_OFFSET = 1
7278

7379
/**
7480
* Get an integer ID to associate with a notification or its pending intent
@@ -147,9 +153,10 @@ class KlaviyoNotification(private val message: RemoteMessage) {
147153
* @param context
148154
* @return [Notification.Builder] to display
149155
*/
150-
internal fun buildNotification(context: Context): NotificationCompat.Builder =
151-
NotificationCompat.Builder(context, message.channel_id)
152-
.setContentIntent(makePendingIntent(context))
156+
internal fun buildNotification(context: Context): NotificationCompat.Builder {
157+
val requestCodeBase = generateId()
158+
return NotificationCompat.Builder(context, message.channel_id)
159+
.setContentIntent(makePendingIntent(context, requestCodeBase))
153160
.setSmallIcon(message.getSmallIcon(context))
154161
.also { message.getColor(context)?.let { color -> it.setColor(color) } }
155162
.setContentTitle(message.title)
@@ -159,6 +166,8 @@ class KlaviyoNotification(private val message: RemoteMessage) {
159166
.setNumber(message.notificationCount)
160167
.setPriority(message.notificationPriority)
161168
.setAutoCancel(true)
169+
.addActionButtons(context, requestCodeBase)
170+
}
162171

163172
private fun URL.applyToNotification(builder: NotificationCompat.Builder) {
164173
val executor = Executors.newCachedThreadPool()
@@ -208,10 +217,10 @@ class KlaviyoNotification(private val message: RemoteMessage) {
208217
*
209218
* @return [PendingIntent]
210219
*/
211-
private fun makePendingIntent(context: Context) =
220+
private fun makePendingIntent(context: Context, requestCode: Int) =
212221
PendingIntent.getActivity(
213222
context,
214-
generateId(),
223+
requestCode,
215224
makeOpenedIntent(context),
216225
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT
217226
)
@@ -224,16 +233,114 @@ class KlaviyoNotification(private val message: RemoteMessage) {
224233
private fun makeOpenedIntent(context: Context) = message.deepLink.let { deepLink ->
225234
when {
226235
// If deep link is present, use an ACTION_VIEW intent
227-
deepLink is Uri -> DeepLinking.makeDeepLinkIntent(deepLink, context)
228-
.takeIf { it.activityResolved(context) }
229-
?: DeepLinking.makeLaunchIntent(context).also {
230-
Registry.log.error("Push message contained unsupported deep link: $deepLink")
231-
}
236+
deepLink is Uri -> makeResolvedDeepLinkIntent(
237+
context,
238+
deepLink,
239+
"Push message contained unsupported deep link: $deepLink"
240+
)
232241
// Else, just launch the app
233242
else -> DeepLinking.makeLaunchIntent(context)
234243
}?.apply {
235244
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
236245
appendKlaviyoExtras(message)
237246
}
238247
}
248+
249+
/**
250+
* Parse action buttons from message data and add them to the notification
251+
*
252+
* Expected format (iOS-aligned):
253+
* [{"id":"...", "label":"...", "action":"deep_link|open_app", "url":"..."}]
254+
*
255+
* Supported action types:
256+
* - "deep_link": Opens app with deep link or URL
257+
* - "open_app": Opens app
258+
*
259+
* Note: Icons are not supported on Android (iOS only).
260+
*/
261+
private fun NotificationCompat.Builder.addActionButtons(
262+
context: Context,
263+
requestCodeBase: Int
264+
): NotificationCompat.Builder {
265+
val actionButtons = message.actionButtons ?: return this
266+
267+
// Parser has already validated and limited buttons to MAX_ACTION_BUTTONS
268+
actionButtons.forEachIndexed { index, button ->
269+
// request codes need to be unique, add index + offset to generate unique code
270+
// offset required due to zero index, so body and first button have unique codes
271+
val requestCode = requestCodeBase + index + ACTION_REQUEST_CODE_OFFSET
272+
val action = createButtonAction(context, index, requestCode, button) ?: return@forEachIndexed
273+
addAction(action)
274+
275+
val actionType = when (button) {
276+
is ActionButton.DeepLink -> ActionButton.DISPLAY_NAME_DEEP_LINK
277+
is ActionButton.OpenApp -> ActionButton.DISPLAY_NAME_OPEN_APP
278+
}
279+
val destination = when (button) {
280+
is ActionButton.DeepLink -> " -> ${button.url}"
281+
is ActionButton.OpenApp -> ""
282+
}
283+
Registry.log.verbose(
284+
"Added action button $index: '${button.label}' ($actionType)$destination"
285+
)
286+
}
287+
return this
288+
}
289+
290+
/**
291+
* Create a notification action that either opens the app or navigates to a deep link
292+
*/
293+
private fun createButtonAction(
294+
context: Context,
295+
index: Int,
296+
requestCode: Int,
297+
button: ActionButton
298+
): NotificationCompat.Action? {
299+
val intent = when (button) {
300+
is ActionButton.DeepLink -> {
301+
val uri = button.url.toUri()
302+
makeResolvedDeepLinkIntent(
303+
context,
304+
uri,
305+
"Action button $index contained unsupported deep link: $uri"
306+
)
307+
}
308+
is ActionButton.OpenApp -> {
309+
DeepLinking.makeLaunchIntent(context)
310+
}
311+
}?.apply {
312+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
313+
}?.appendKlaviyoExtras(message)
314+
?.appendActionButtonExtras(button)
315+
316+
if (intent == null) {
317+
Registry.log.warning(
318+
"Action button $index could not be created: no launch intent found for host app"
319+
)
320+
return null
321+
}
322+
323+
val pendingIntent = PendingIntent.getActivity(
324+
context,
325+
requestCode,
326+
intent,
327+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
328+
)
329+
330+
return NotificationCompat.Action(
331+
0, // No icon (icons not supported on Android)
332+
button.label,
333+
pendingIntent
334+
)
335+
}
336+
337+
private fun makeResolvedDeepLinkIntent(
338+
context: Context,
339+
deepLink: Uri,
340+
errorMessage: String
341+
): Intent? = DeepLinking.makeDeepLinkIntent(deepLink, context)
342+
.takeIf { it.activityResolved(context) }
343+
?: DeepLinking.makeLaunchIntent(context).also {
344+
Registry.log.error(errorMessage)
345+
}
239346
}

sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoRemoteMessage.kt

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.klaviyo.core.Registry
2121
import com.klaviyo.core.config.getApplicationInfoCompat
2222
import com.klaviyo.core.config.getManifestInt
2323
import java.net.URL
24+
import org.json.JSONArray
2425
import org.json.JSONObject
2526

2627
/**
@@ -29,6 +30,11 @@ import org.json.JSONObject
2930
*/
3031
object KlaviyoRemoteMessage {
3132

33+
/**
34+
* Maximum number of action buttons supported per notification
35+
*/
36+
private const val MAX_ACTION_BUTTONS = 3
37+
3238
/**
3339
* Append requisite data from a remote message to an intent
3440
* for displaying a notification
@@ -43,6 +49,28 @@ object KlaviyoRemoteMessage {
4349
}
4450
}
4551

52+
/**
53+
* Append action button tracking data to an intent for analytics
54+
*
55+
* This enables tracking which specific button was clicked in $opened_push events
56+
*
57+
* @param button The action button that was clicked
58+
*/
59+
fun Intent.appendActionButtonExtras(button: ActionButton) = apply {
60+
putExtra(PACKAGE_PREFIX + "Button ID", button.id)
61+
putExtra(PACKAGE_PREFIX + "Button Label", button.label)
62+
63+
val actionName = when (button) {
64+
is ActionButton.DeepLink -> ActionButton.DISPLAY_NAME_DEEP_LINK
65+
is ActionButton.OpenApp -> ActionButton.DISPLAY_NAME_OPEN_APP
66+
}
67+
putExtra(PACKAGE_PREFIX + "Button Action", actionName)
68+
69+
if (button is ActionButton.DeepLink) {
70+
putExtra(PACKAGE_PREFIX + "Button Link", button.url)
71+
}
72+
}
73+
4674
/**
4775
* Parse channel ID or fallback on default
4876
*/
@@ -117,6 +145,14 @@ object KlaviyoRemoteMessage {
117145
Registry.log.warning("Error converting string to URL", it)
118146
}.getOrNull()
119147

148+
private fun JSONObject.optNonBlankString(key: String): String? {
149+
// JSONObject.optString returns the literal "null" for JSON null values.
150+
if (isNull(key)) {
151+
return null
152+
}
153+
return optString(key).takeIf { it.isNotBlank() }
154+
}
155+
120156
/**
121157
* Parse [Uri] to sound resource
122158
*/
@@ -156,6 +192,138 @@ object KlaviyoRemoteMessage {
156192
}
157193
}
158194

195+
/**
196+
* Parse action buttons from the iOS-aligned format
197+
*
198+
* Validates and filters buttons to ensure only valid instances are returned.
199+
* Invalid buttons (missing required fields, invalid format) are skipped with warnings.
200+
* Maximum of 3 buttons are supported - additional buttons beyond this limit are ignored.
201+
*
202+
* Expected structure:
203+
* [{"id":"...", "label":"...", "action":"deep_link|open_app", "url":"..."}]
204+
*/
205+
val RemoteMessage.actionButtons: List<ActionButton>?
206+
get() = this.data[KlaviyoNotification.ACTION_BUTTONS_KEY]?.let { jsonString ->
207+
Registry.log.verbose("Parsing action_buttons from: $jsonString")
208+
try {
209+
val jsonArray = JSONArray(jsonString)
210+
val buttons = mutableListOf<ActionButton>()
211+
val buttonCount = jsonArray.length()
212+
Registry.log.verbose("JSON array has $buttonCount buttons")
213+
214+
// Parse buttons until we have MAX_ACTION_BUTTONS valid buttons
215+
for (i in 0 until buttonCount) {
216+
// Stop if we've already collected the maximum number of valid buttons
217+
if (buttons.size >= MAX_ACTION_BUTTONS) {
218+
Registry.log.warning(
219+
"Reached maximum of $MAX_ACTION_BUTTONS valid action buttons, " +
220+
"remaining ${buttonCount - i} button(s) will be ignored."
221+
)
222+
break
223+
}
224+
225+
val jsonObject = jsonArray.optJSONObject(i)
226+
if (jsonObject == null) {
227+
Registry.log.warning(
228+
"Skipping action button $i: invalid JSON object"
229+
)
230+
continue
231+
}
232+
val id = jsonObject.optNonBlankString("id")
233+
val label = jsonObject.optNonBlankString("label")
234+
235+
// Validate required label field
236+
if (id == null || label == null) {
237+
Registry.log.warning(
238+
"Skipping action button $i: missing required field(s) (label, id)"
239+
)
240+
continue
241+
}
242+
243+
val actionType = jsonObject.optString("action", ActionButton.TYPE_OPEN_APP)
244+
245+
// Create appropriate sealed class instance based on action type
246+
when (actionType) {
247+
ActionButton.TYPE_DEEP_LINK -> {
248+
jsonObject.optNonBlankString("url")?.let { url ->
249+
ActionButton.DeepLink(
250+
id = id,
251+
label = label,
252+
url = url
253+
)
254+
} ?: run {
255+
Registry.log.warning(
256+
"Skipping DEEP_LINK action button $i: missing required url"
257+
)
258+
null
259+
}
260+
}
261+
ActionButton.TYPE_OPEN_APP -> {
262+
ActionButton.OpenApp(id = id, label = label)
263+
}
264+
else -> {
265+
// Skip buttons with unsupported or malformed action types
266+
Registry.log.warning(
267+
"Skipping action button $i: unsupported action type '$actionType'"
268+
)
269+
null
270+
}
271+
}?.let { button ->
272+
Registry.log.verbose("Parsed button $i: $button")
273+
buttons.add(button)
274+
}
275+
}
276+
277+
Registry.log.verbose("Successfully parsed ${buttons.size} valid action buttons")
278+
buttons.takeIf { it.isNotEmpty() }
279+
} catch (e: Exception) {
280+
Registry.log.error(
281+
"Klaviyo SDK failed to parse action_buttons JSON: $jsonString",
282+
e
283+
)
284+
null
285+
}
286+
}
287+
288+
/**
289+
* Sealed class representing different types of notification action buttons
290+
*/
291+
sealed class ActionButton {
292+
abstract val id: String
293+
abstract val label: String
294+
295+
/**
296+
* Button that opens the app without navigating to a specific destination
297+
*/
298+
data class OpenApp(
299+
override val id: String,
300+
override val label: String
301+
) : ActionButton()
302+
303+
/**
304+
* Button that opens the app and navigates to a deep link destination
305+
*/
306+
data class DeepLink(
307+
override val id: String,
308+
override val label: String,
309+
val url: String
310+
) : ActionButton()
311+
312+
companion object {
313+
/**
314+
* Serialized type names used in remote message payload
315+
*/
316+
const val TYPE_OPEN_APP = "open_app"
317+
const val TYPE_DEEP_LINK = "deep_link"
318+
319+
/**
320+
* Human-readable display names for analytics
321+
*/
322+
const val DISPLAY_NAME_OPEN_APP = "Open App"
323+
const val DISPLAY_NAME_DEEP_LINK = "Deep Link"
324+
}
325+
}
326+
159327
/**
160328
* Determine the resource ID of the small icon from provided context
161329
*

0 commit comments

Comments
 (0)