@@ -15,11 +15,15 @@ import androidx.core.app.ActivityCompat
1515import androidx.core.app.NotificationChannelCompat
1616import androidx.core.app.NotificationCompat
1717import androidx.core.app.NotificationManagerCompat
18+ import androidx.core.net.toUri
1819import com.google.firebase.messaging.RemoteMessage
1920import com.klaviyo.analytics.linking.DeepLinking
2021import com.klaviyo.core.Constants
2122import com.klaviyo.core.Registry
2223import 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
2327import com.klaviyo.pushFcm.KlaviyoRemoteMessage.appendKlaviyoExtras
2428import com.klaviyo.pushFcm.KlaviyoRemoteMessage.body
2529import 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}
0 commit comments