diff --git a/messagingpush/api/messagingpush.api b/messagingpush/api/messagingpush.api index 528131b7f..48c9ac0cf 100644 --- a/messagingpush/api/messagingpush.api +++ b/messagingpush/api/messagingpush.api @@ -26,9 +26,10 @@ public final class io/customer/messagingpush/CustomerIOFirebaseMessagingService$ public final class io/customer/messagingpush/MessagingPushModuleConfig : io/customer/sdk/module/CustomerIOModuleConfig { public static final field Companion Lio/customer/messagingpush/MessagingPushModuleConfig$Companion; - public synthetic fun (ZLio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZLio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;ZLio/customer/messagingpush/config/NotificationClickBehavior;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAutoTrackPushEvents ()Z public final fun getNotificationCallback ()Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback; + public final fun getNotificationOnClickBehavior ()Lio/customer/messagingpush/config/NotificationClickBehavior; public final fun getRedirectDeepLinksToOtherApps ()Z } @@ -38,6 +39,7 @@ public final class io/customer/messagingpush/MessagingPushModuleConfig$Builder : public synthetic fun build ()Lio/customer/sdk/module/CustomerIOModuleConfig; public final fun setAutoTrackPushEvents (Z)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; public final fun setNotificationCallback (Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; + public final fun setNotificationClickBehavior (Lio/customer/messagingpush/config/NotificationClickBehavior;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; public final fun setRedirectDeepLinksToOtherApps (Z)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; } @@ -58,6 +60,25 @@ public final class io/customer/messagingpush/ModuleMessagingPushFCM : io/custome public final class io/customer/messagingpush/ModuleMessagingPushFCM$Companion { } +public final class io/customer/messagingpush/activity/NotificationClickReceiverActivity : android/app/Activity, io/customer/sdk/tracking/TrackableScreen { + public static final field Companion Lio/customer/messagingpush/activity/NotificationClickReceiverActivity$Companion; + public static final field NOTIFICATION_PAYLOAD_EXTRA Ljava/lang/String; + public fun ()V + public final fun getLogger ()Lio/customer/sdk/util/Logger; + public fun getScreenName ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/activity/NotificationClickReceiverActivity$Companion { +} + +public final class io/customer/messagingpush/config/NotificationClickBehavior : java/lang/Enum { + public static final field ACTIVITY_NO_FLAGS Lio/customer/messagingpush/config/NotificationClickBehavior; + public static final field ACTIVITY_PREVENT_RESTART Lio/customer/messagingpush/config/NotificationClickBehavior; + public static final field RESET_TASK_STACK Lio/customer/messagingpush/config/NotificationClickBehavior; + public static fun valueOf (Ljava/lang/String;)Lio/customer/messagingpush/config/NotificationClickBehavior; + public static fun values ()[Lio/customer/messagingpush/config/NotificationClickBehavior; +} + public abstract interface class io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback { public abstract fun createTaskStackFromPayload (Landroid/content/Context;Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;)Landroidx/core/app/TaskStackBuilder; public abstract fun onNotificationComposed (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroidx/core/app/NotificationCompat$Builder;)V diff --git a/messagingpush/src/main/AndroidManifest.xml b/messagingpush/src/main/AndroidManifest.xml index 2f4af990f..e1884a7ad 100644 --- a/messagingpush/src/main/AndroidManifest.xml +++ b/messagingpush/src/main/AndroidManifest.xml @@ -6,6 +6,15 @@ + + + diff --git a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt index 6b5fc374a..5b6a40b7d 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt @@ -16,6 +16,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.TaskStackBuilder import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import io.customer.messagingpush.activity.NotificationClickReceiverActivity import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload import io.customer.messagingpush.di.deepLinkUtil import io.customer.messagingpush.di.moduleConfig @@ -210,11 +211,11 @@ internal class CustomerIOPushNotificationHandler( payload = payload, builder = notificationBuilder ) - createIntentFromLink( + createIntentForNotificationClick( context, requestCode, payload - )?.let { pendingIntent -> + ).let { pendingIntent -> notificationBuilder.setContentIntent(pendingIntent) } @@ -267,6 +268,27 @@ internal class CustomerIOPushNotificationHandler( } } + private fun createIntentForNotificationClick( + context: Context, + requestCode: Int, + payload: CustomerIOParsedPushPayload + ): PendingIntent { + val notifyIntent = Intent(context, NotificationClickReceiverActivity::class.java) + notifyIntent.putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, payload) + // In Android M, you must specify the mutability of each PendingIntent + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getActivity( + context, + requestCode, + notifyIntent, + flags + ) + } + private fun addImage( imageUrl: String, builder: NotificationCompat.Builder, diff --git a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt index 759713e0b..a16cf7029 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt @@ -1,5 +1,7 @@ package io.customer.messagingpush +import io.customer.messagingpush.config.NotificationClickBehavior +import io.customer.messagingpush.config.NotificationClickBehavior.ACTIVITY_NO_FLAGS import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback import io.customer.sdk.module.CustomerIOModuleConfig @@ -14,16 +16,20 @@ import io.customer.sdk.module.CustomerIOModuleConfig * notifications * @property redirectDeepLinksToOtherApps flag to support opening urls from * notification to other native apps or browsers; default true + * @property notificationOnClickBehavior defines the behavior when a notification + * is clicked */ class MessagingPushModuleConfig private constructor( val autoTrackPushEvents: Boolean, val notificationCallback: CustomerIOPushNotificationCallback?, - val redirectDeepLinksToOtherApps: Boolean + val redirectDeepLinksToOtherApps: Boolean, + val notificationOnClickBehavior: NotificationClickBehavior ) : CustomerIOModuleConfig { class Builder : CustomerIOModuleConfig.Builder { private var autoTrackPushEvents: Boolean = true private var notificationCallback: CustomerIOPushNotificationCallback? = null private var redirectDeepLinksToOtherApps: Boolean = true + private var notificationOnClickBehavior: NotificationClickBehavior = ACTIVITY_NO_FLAGS /** * Allows to enable/disable automatic tracking of push events. Auto tracking will generate @@ -62,11 +68,23 @@ class MessagingPushModuleConfig private constructor( return this } + /** + * Defines the behavior when a notification is clicked. + * + * @param notificationOnClickBehavior the behavior when a notification is clicked; default [NotificationClickBehavior.ACTIVITY_PREVENT_RESTART]. + * @see NotificationClickBehavior for more details. + */ + fun setNotificationClickBehavior(notificationOnClickBehavior: NotificationClickBehavior): Builder { + this.notificationOnClickBehavior = notificationOnClickBehavior + return this + } + override fun build(): MessagingPushModuleConfig { return MessagingPushModuleConfig( autoTrackPushEvents = autoTrackPushEvents, notificationCallback = notificationCallback, - redirectDeepLinksToOtherApps = redirectDeepLinksToOtherApps + redirectDeepLinksToOtherApps = redirectDeepLinksToOtherApps, + notificationOnClickBehavior = notificationOnClickBehavior ) } } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt b/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt new file mode 100644 index 000000000..bf24737cb --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt @@ -0,0 +1,148 @@ +package io.customer.messagingpush.activity + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.core.app.TaskStackBuilder +import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.config.NotificationClickBehavior +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.deepLinkUtil +import io.customer.messagingpush.di.moduleConfig +import io.customer.messagingpush.extensions.parcelable +import io.customer.messagingpush.util.DeepLinkUtil +import io.customer.sdk.CustomerIO +import io.customer.sdk.CustomerIOShared +import io.customer.sdk.data.request.MetricEvent +import io.customer.sdk.extensions.takeIfNotBlank +import io.customer.sdk.tracking.TrackableScreen +import io.customer.sdk.util.Logger + +/** + * Activity to handle notification click events. + * + * This activity is launched when a notification is clicked. It tracks opened + * metrics, handles the deep link and opens the desired activity in the host app. + */ +class NotificationClickReceiverActivity : Activity(), TrackableScreen { + val logger: Logger by lazy { CustomerIOShared.instance().diStaticGraph.logger } + + override fun getScreenName(): String? { + // Return null to prevent this screen from being tracked + return null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent(data = intent) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(data = intent) + } + + private fun handleIntent(data: Intent?) { + kotlin.runCatching { + val payload: CustomerIOParsedPushPayload? = + data?.extras?.parcelable(NOTIFICATION_PAYLOAD_EXTRA) + if (payload == null) { + logger.error("Payload is null, cannot handle notification intent") + } else { + processNotificationIntent(payload = payload) + } + }.onFailure { ex -> + logger.error("Failed to process notification intent: ${ex.message}") + } + finish() + } + + private fun processNotificationIntent(payload: CustomerIOParsedPushPayload) { + val sdkInstance = CustomerIO.instanceOrNull(context = this) + if (sdkInstance == null) { + logger.error("SDK is not initialized, cannot handle notification intent") + return + } + + val moduleConfig: MessagingPushModuleConfig = sdkInstance.diGraph.moduleConfig + trackMetrics(moduleConfig, payload) + handleDeepLink(moduleConfig, payload) + } + + private fun trackMetrics( + moduleConfig: MessagingPushModuleConfig, + payload: CustomerIOParsedPushPayload + ) { + if (moduleConfig.autoTrackPushEvents) { + CustomerIO.instance().trackMetric( + payload.cioDeliveryId, + MetricEvent.opened, + payload.cioDeliveryToken + ) + } + } + + private fun handleDeepLink( + moduleConfig: MessagingPushModuleConfig, + payload: CustomerIOParsedPushPayload + ) { + val deepLinkUtil: DeepLinkUtil = CustomerIO.instance().diGraph.deepLinkUtil + val deepLink = payload.deepLink?.takeIfNotBlank() + + // check if host app overrides the handling of deeplink + val notificationCallback = moduleConfig.notificationCallback + val taskStackFromPayload = notificationCallback?.createTaskStackFromPayload(this, payload) + if (taskStackFromPayload != null) { + logger.info("Notification target overridden by createTaskStackFromPayload, starting new stack for link $deepLink") + taskStackFromPayload.startActivities() + return + } + + // Get the default intent for the host app + val defaultHostAppIntent = deepLinkUtil.createDefaultHostAppIntent( + context = this, + contentActionLink = null + ) + // Check if the deep links are handled within the host app + val deepLinkHostAppIntent = deepLink?.let { link -> + deepLinkUtil.createDeepLinkHostAppIntent(context = this, link = link) + } + // Check if the deep links can be opened outside the host app + val deepLinkExternalIntent = deepLink?.let { link -> + deepLinkUtil.createDeepLinkExternalIntent( + context = this, + link = link, + startingFromService = true + ) + } + val deepLinkIntent: Intent = deepLinkHostAppIntent + ?: deepLinkExternalIntent + ?: defaultHostAppIntent + ?: return + deepLinkIntent.putExtra(NOTIFICATION_PAYLOAD_EXTRA, payload) + logger.info("Dispatching notification with link $deepLink to intent: $deepLinkIntent with behavior: ${moduleConfig.notificationOnClickBehavior}") + + when (moduleConfig.notificationOnClickBehavior) { + NotificationClickBehavior.RESET_TASK_STACK -> { + val taskStackBuilder = TaskStackBuilder.create(this).apply { + addNextIntentWithParentStack(deepLinkIntent) + } + taskStackBuilder.startActivities() + } + + NotificationClickBehavior.ACTIVITY_PREVENT_RESTART -> { + deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP + startActivity(deepLinkIntent) + } + + NotificationClickBehavior.ACTIVITY_NO_FLAGS -> { + startActivity(deepLinkIntent) + } + } + } + + companion object { + const val NOTIFICATION_PAYLOAD_EXTRA = "CIO_NotificationPayloadExtras" + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/config/NotificationClickBehavior.kt b/messagingpush/src/main/java/io/customer/messagingpush/config/NotificationClickBehavior.kt new file mode 100644 index 000000000..493b037dc --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/config/NotificationClickBehavior.kt @@ -0,0 +1,38 @@ +package io.customer.messagingpush.config + +/** + * Defines the behaviors for what happens when a notification is clicked. + */ +enum class NotificationClickBehavior { + + /** + * Resets the task stack to include the deep-linked activity 'D'. + * - Example 1: Stack (A -> B -> C) becomes (D) if D is the deep-linked activity. + * - Example 2: Stack (A -> B -> C) changes to (A -> D) if D is the deep-linked activity and A is the root of the task stack provided by callback. + * + * This is similar to Android's "Set up a regular activity PendingIntent." + * For more info, see [Android Documentation](https://developer.android.com/develop/ui/views/notifications/navigation#DirectEntry). + */ + RESET_TASK_STACK, + + /** + * Adds the deep-linked activity 'D' to the existing stack only if it's not already there. + * - Example: Stack (A -> B) becomes (A -> B -> D) if D is the deep-linked activity and not already in the stack. + * - Example: Stack (A -> B -> D) stays as (A -> B -> D) if D is the deep-linked activity and is already in the stack. + * + * The same activity instance will be reused and receive the data in `onNewIntent` if already on top. + * + * This is similar to Android's "Set up a special activity PendingIntent." + * For more info, see [Android Documentation](https://developer.android.com/develop/ui/views/notifications/navigation#ExtendedNotification). + */ + ACTIVITY_PREVENT_RESTART, + + /** + * Starts the deep-linked activity without adding any intent flags. + * - Example: Stack (A -> B) becomes (A -> B -> D) if D is the deep-linked target activity. + * + * This behavior relies on the launch mode or flags specified for the activity in the Android manifest. + * System default behaviors will take over if no flags are mentioned. + */ + ACTIVITY_NO_FLAGS +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/extensions/ApplicationInfoExtensions.kt b/messagingpush/src/main/java/io/customer/messagingpush/extensions/ApplicationInfoExtensions.kt index 6ff1cb703..07a7181b1 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/extensions/ApplicationInfoExtensions.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/extensions/ApplicationInfoExtensions.kt @@ -3,6 +3,7 @@ package io.customer.messagingpush.extensions import android.content.res.Resources import android.os.Build import android.os.Bundle +import android.os.Parcelable private val RESOURCE_ID_NULL: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Resources.ID_NULL else 0 @@ -14,3 +15,14 @@ internal fun Bundle.getMetaDataResource(name: String): Int? { internal fun Bundle.getMetaDataString(name: String): String? { return getString(name, null).takeUnless { value -> value.isNullOrBlank() } } + +inline fun Bundle.parcelable(key: String): T? = when { + // There is a known bug on Android 13 which can throw NPE on newly added getParcelable method + // The issue is fixed for the next major Android release, but can't be back-ported to Android 13 + // The recommended approach is to continue using the older APIs for Android 13 and below + // See following issue for more details + // https://issuetracker.google.com/issues/240585930#comment6 + Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU -> getParcelable(key, T::class.java) + else -> @Suppress("DEPRECATION") + getParcelable(key) as? T +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/util/DeepLinkUtil.kt b/messagingpush/src/main/java/io/customer/messagingpush/util/DeepLinkUtil.kt index a0816b5de..c8365742c 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/util/DeepLinkUtil.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/util/DeepLinkUtil.kt @@ -6,7 +6,6 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import io.customer.messagingpush.MessagingPushModuleConfig -import io.customer.messagingpush.lifecycle.MessagingPushLifecycleCallback import io.customer.sdk.util.Logger interface DeepLinkUtil { @@ -54,32 +53,18 @@ class DeepLinkUtilImpl( private val logger: Logger, private val moduleConfig: MessagingPushModuleConfig ) : DeepLinkUtil { - private val notificationIntentFlags: Int = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - override fun createDefaultHostAppIntent(context: Context, contentActionLink: String?): Intent? { - return context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { - // Add pending link to open outside host app so open tracking metrics are not affected - putExtra(MessagingPushLifecycleCallback.PENDING_CONTENT_ACTION_LINK, contentActionLink) - } + return context.packageManager.getLaunchIntentForPackage(context.packageName) } override fun createDeepLinkHostAppIntent(context: Context, link: String?): Intent? { - if (link.isNullOrBlank()) { - logger.debug("No link received in push notification content") - return null - } - val intent: Intent? = queryDeepLinksForHostApp(context, Uri.parse(link)) - return if (intent != null) { - intent - } else { + if (intent == null) { logger.info( - "No supporting activity found in host app for link received in" + - " push notification $link" + "No supporting activity found in host app for link received in push notification $link" ) - null } + return intent } override fun createDeepLinkExternalIntent( @@ -87,24 +72,19 @@ class DeepLinkUtilImpl( link: String, startingFromService: Boolean ): Intent? { - val linkUri = Uri.parse(link) - var intent: Intent? = null + // check config if the deep link should be opened by any other app or not + if (!moduleConfig.redirectDeepLinksToOtherApps) { + return null + } - if (moduleConfig.redirectDeepLinksToOtherApps) { - intent = queryDeepLinksForThirdPartyApps( - context = context, - uri = linkUri, - startingFromService = startingFromService + val linkUri = Uri.parse(link) + val intent = queryDeepLinksForThirdPartyApps(context = context, uri = linkUri) + if (intent == null) { + logger.info( + "No supporting application found for link received in " + + "push notification: $link" ) - - if (intent == null) { - logger.info( - "No supporting application found for link received in " + - "push notification: $link" - ) - } } - return intent } @@ -118,33 +98,31 @@ class DeepLinkUtilImpl( // check if the deep link is handled within the host app val hostAppIntent = Intent(Intent.ACTION_VIEW, uri) hostAppIntent.setPackage(context.packageName) - - hostAppIntent.flags = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - notificationIntentFlags - } else { - notificationIntentFlags or Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER - } - return hostAppIntent.takeIfResolvable(context.packageManager) } private fun queryDeepLinksForThirdPartyApps( context: Context, - uri: Uri, - startingFromService: Boolean + uri: Uri ): Intent? { // check if the deep link can be opened by any other app val browsableIntent = Intent(Intent.ACTION_VIEW, uri) - if (startingFromService) { - browsableIntent.flags = notificationIntentFlags - } + val packageManager = context.packageManager + val resolveInfoFlag = PackageManager.MATCH_DEFAULT_ONLY - val resolveInfo = context.packageManager.queryIntentActivities(browsableIntent, 0) + val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + browsableIntent, + PackageManager.ResolveInfoFlags.of(resolveInfoFlag.toLong()) + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(browsableIntent, resolveInfoFlag) + } if (resolveInfo.isNotEmpty()) { browsableIntent.setPackage(resolveInfo.first().activityInfo.packageName) } - return browsableIntent.takeIfResolvable(context.packageManager) + return browsableIntent.takeIfResolvable(packageManager) } }