diff --git a/.github/workflows/deploy-sdk.yml b/.github/workflows/deploy-sdk.yml index 7410d8353..06e901e68 100644 --- a/.github/workflows/deploy-sdk.yml +++ b/.github/workflows/deploy-sdk.yml @@ -37,11 +37,9 @@ jobs: id: semantic-release with: dry_run: false - # version numbers below can be in many forms: M, M.m, M.m.p + semantic_version: latest extra_plugins: | conventional-changelog-conventionalcommits - @semantic-release/changelog - @semantic-release/git @semantic-release/github @semantic-release/exec env: diff --git a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt index 031bcc9f2..1d2d315d5 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.Bundle import androidx.annotation.ColorInt import androidx.annotation.DrawableRes +import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -217,7 +218,8 @@ internal class CustomerIOPushNotificationHandler( notificationManager.notify(requestCode, notification) } - private fun createIntentForNotificationClick( + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun createIntentForNotificationClick( context: Context, requestCode: Int, payload: CustomerIOParsedPushPayload diff --git a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt index 8d7d13240..a4bbef9c0 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt @@ -1,7 +1,7 @@ package io.customer.messagingpush import io.customer.messagingpush.config.PushClickBehavior -import io.customer.messagingpush.config.PushClickBehavior.ACTIVITY_NO_FLAGS +import io.customer.messagingpush.config.PushClickBehavior.ACTIVITY_PREVENT_RESTART import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback import io.customer.sdk.module.CustomerIOModuleConfig @@ -25,7 +25,7 @@ class MessagingPushModuleConfig private constructor( private var autoTrackPushEvents: Boolean = true private var notificationCallback: CustomerIOPushNotificationCallback? = null private var redirectDeepLinksToOtherApps: Boolean = true - private var pushClickBehavior: PushClickBehavior = ACTIVITY_NO_FLAGS + private var pushClickBehavior: PushClickBehavior = ACTIVITY_PREVENT_RESTART /** * Allows to enable/disable automatic tracking of push events. Auto tracking will generate diff --git a/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt b/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt index 8fb42e27a..89ae5cebf 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/activity/NotificationClickReceiverActivity.kt @@ -3,18 +3,9 @@ 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.PushClickBehavior -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.messagingpush.di.pushMessageProcessor 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 @@ -43,98 +34,23 @@ class NotificationClickReceiverActivity : Activity(), TrackableScreen { } 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") + if (data == null || data.extras == null) { + // This should never happen ideally + logger.error("Intent is null, cannot process notification click") + } else { + val sdkInstance = CustomerIO.instanceOrNull(context = this) + if (sdkInstance == null) { + logger.error("SDK is not initialized, cannot handle notification intent") } else { - processNotificationIntent(payload = payload) + sdkInstance.diGraph.pushMessageProcessor.processNotificationClick( + activityContext = this, + intent = data + ) } - }.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) - // 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) - } - 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.pushClickBehavior}") - - when (moduleConfig.pushClickBehavior) { - PushClickBehavior.RESET_TASK_STACK -> { - val taskStackBuilder = TaskStackBuilder.create(this).apply { - addNextIntentWithParentStack(deepLinkIntent) - } - taskStackBuilder.startActivities() - } - - PushClickBehavior.ACTIVITY_PREVENT_RESTART -> { - deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP - startActivity(deepLinkIntent) - } - - PushClickBehavior.ACTIVITY_NO_FLAGS -> { - startActivity(deepLinkIntent) - } - } - } - companion object { const val NOTIFICATION_PAYLOAD_EXTRA = "CIO_NotificationPayloadExtras" } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt index a515ec2e8..f26c6e9d9 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt @@ -40,6 +40,7 @@ internal val CustomerIOComponent.pushMessageProcessor: PushMessageProcessor PushMessageProcessorImpl( logger = logger, moduleConfig = moduleConfig, + deepLinkUtil = deepLinkUtil, trackRepository = trackRepository ) } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessor.kt b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessor.kt index 2e6c9f16e..759715f8f 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessor.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessor.kt @@ -1,5 +1,6 @@ package io.customer.messagingpush.processor +import android.content.Context import android.content.Intent import com.google.firebase.messaging.RemoteMessage import io.customer.base.internal.InternalCustomerIOApi @@ -45,6 +46,21 @@ interface PushMessageProcessor { */ fun processRemoteMessageDeliveredMetrics(deliveryId: String, deliveryToken: String) + /** + * Executes the necessary actions when a notification is clicked by the user. + * + * This method performs the following tasks: + * 1. Tracks 'opened' metrics for the notification. + * 2. Resolves the deep link, if available in the notification payload, and navigates to the corresponding screen. + * 3. If no deep link is provided, opens the default launcher screen. + * + * This method may only be called from `onCreate` or `onNewIntent` methods of notification handler activity. + * + * @param activityContext context should be from activity as this will be used for launching activity + * @param intent intent received by the activity + */ + fun processNotificationClick(activityContext: Context, intent: Intent) + companion object { // Count of messages stored in memory const val RECENT_MESSAGES_MAX_SIZE = 10 diff --git a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt index c8dee535f..a3ff1e7a6 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt @@ -1,16 +1,25 @@ package io.customer.messagingpush.processor +import android.content.Context import android.content.Intent import androidx.annotation.VisibleForTesting +import androidx.core.app.TaskStackBuilder import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.activity.NotificationClickReceiverActivity +import io.customer.messagingpush.config.PushClickBehavior +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.extensions.parcelable +import io.customer.messagingpush.util.DeepLinkUtil import io.customer.messagingpush.util.PushTrackingUtil import io.customer.sdk.data.request.MetricEvent +import io.customer.sdk.extensions.takeIfNotBlank import io.customer.sdk.repository.TrackRepository import io.customer.sdk.util.Logger internal class PushMessageProcessorImpl( private val logger: Logger, private val moduleConfig: MessagingPushModuleConfig, + private val deepLinkUtil: DeepLinkUtil, private val trackRepository: TrackRepository ) : PushMessageProcessor { @@ -85,4 +94,104 @@ internal class PushMessageProcessorImpl( ) } } + + override fun processNotificationClick(activityContext: Context, intent: Intent) { + kotlin.runCatching { + val payload: CustomerIOParsedPushPayload? = + intent.extras?.parcelable(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA) + if (payload == null) { + logger.error("Payload is null, cannot handle notification intent") + } else { + handleNotificationClickIntent(activityContext, payload) + } + }.onFailure { ex -> + logger.error("Failed to process notification intent: ${ex.message}") + } + } + + private fun handleNotificationClickIntent( + activityContext: Context, + payload: CustomerIOParsedPushPayload + ) { + trackNotificationClickMetrics(payload) + handleNotificationDeepLink(activityContext, payload) + } + + private fun trackNotificationClickMetrics(payload: CustomerIOParsedPushPayload) { + if (moduleConfig.autoTrackPushEvents) { + trackRepository.trackMetric( + deliveryID = payload.cioDeliveryId, + event = MetricEvent.opened, + deviceToken = payload.cioDeliveryToken + ) + } + } + + private fun handleNotificationDeepLink( + activityContext: Context, + payload: CustomerIOParsedPushPayload + ) { + val deepLink = payload.deepLink?.takeIfNotBlank() + + // check if host app overrides the handling of deeplink + val notificationCallback = moduleConfig.notificationCallback + val taskStackFromPayload = notificationCallback?.createTaskStackFromPayload( + context = activityContext, + payload = payload + ) + if (taskStackFromPayload != null) { + logger.info("Notification target overridden by createTaskStackFromPayload, starting new stack for link $deepLink") + taskStackFromPayload.startActivities() + return + } + + // Check if the deep links are handled within the host app + val deepLinkHostAppIntent = deepLink?.let { link -> + deepLinkUtil.createDeepLinkHostAppIntent(context = activityContext, link = link) + } + // Check if the deep links are handled externally only if the host app doesn't handle it + if (deepLinkHostAppIntent == null) { + // Check if the deep links can be opened outside the host app + val deepLinkExternalIntent = deepLink?.let { link -> + deepLinkUtil.createDeepLinkExternalIntent(context = activityContext, link = link) + } + // Check if the deep links should be opened externally + if (deepLinkExternalIntent != null) { + // Open link externally and return + activityContext.startActivity(deepLinkExternalIntent) + return + } + } + + // Get the default intent for the host app + val defaultHostAppIntent = + deepLinkUtil.createDefaultHostAppIntent(context = activityContext) + val deepLinkIntent: Intent = deepLinkHostAppIntent + ?: defaultHostAppIntent + ?: return + deepLinkIntent.putExtra( + NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, + payload + ) + logger.info("Dispatching notification with link $deepLink to intent: $deepLinkIntent with behavior: ${moduleConfig.pushClickBehavior}") + + when (moduleConfig.pushClickBehavior) { + PushClickBehavior.RESET_TASK_STACK -> { + val taskStackBuilder = TaskStackBuilder.create(activityContext).apply { + addNextIntentWithParentStack(deepLinkIntent) + } + taskStackBuilder.startActivities() + } + + PushClickBehavior.ACTIVITY_PREVENT_RESTART -> { + deepLinkIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP + activityContext.startActivity(deepLinkIntent) + } + + PushClickBehavior.ACTIVITY_NO_FLAGS -> { + activityContext.startActivity(deepLinkIntent) + } + } + } } 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 589bbb3d5..fe7067d95 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/util/DeepLinkUtil.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/util/DeepLinkUtil.kt @@ -26,7 +26,7 @@ interface DeepLinkUtil { * @return intent matching the link in traditional Android way; null if no * matching intents found */ - fun createDeepLinkHostAppIntent(context: Context, link: String?): Intent? + fun createDeepLinkHostAppIntent(context: Context, link: String): Intent? /** * Creates intent outside the host app that can open the provided link. @@ -50,7 +50,7 @@ class DeepLinkUtilImpl( return context.packageManager.getLaunchIntentForPackage(context.packageName) } - override fun createDeepLinkHostAppIntent(context: Context, link: String?): Intent? { + override fun createDeepLinkHostAppIntent(context: Context, link: String): Intent? { val intent: Intent? = queryDeepLinksForHostApp(context, Uri.parse(link)) if (intent == null) { logger.info( diff --git a/messagingpush/src/sharedTest/java/io/customer/messagingpush/CustomerIOPushNotificationHandlerTest.kt b/messagingpush/src/sharedTest/java/io/customer/messagingpush/CustomerIOPushNotificationHandlerTest.kt new file mode 100644 index 000000000..3523ad575 --- /dev/null +++ b/messagingpush/src/sharedTest/java/io/customer/messagingpush/CustomerIOPushNotificationHandlerTest.kt @@ -0,0 +1,57 @@ +package io.customer.messagingpush + +import android.os.Bundle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.firebase.messaging.RemoteMessage +import io.customer.commontest.BaseTest +import io.customer.messagingpush.activity.NotificationClickReceiverActivity +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.extensions.parcelable +import io.customer.sdk.extensions.random +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.Shadows + +@RunWith(AndroidJUnit4::class) +internal class CustomerIOPushNotificationHandlerTest : BaseTest() { + + private lateinit var pushNotificationHandler: CustomerIOPushNotificationHandler + private lateinit var pushNotificationPayload: CustomerIOParsedPushPayload + + @Before + override fun setup() { + super.setup() + + val extras = Bundle.EMPTY + pushNotificationHandler = CustomerIOPushNotificationHandler(mock(), RemoteMessage(extras)) + pushNotificationPayload = CustomerIOParsedPushPayload( + extras = extras, + deepLink = String.random, + cioDeliveryId = String.random, + cioDeliveryToken = String.random, + title = String.random, + body = String.random + ) + } + + @Test + fun createIntentForNotificationClick_givenAnyPayload_shouldStartNotificationClickReceiverActivity() { + val actualPendingIntent = pushNotificationHandler.createIntentForNotificationClick( + context, + Int.random(1000, 9999), + pushNotificationPayload + ) + + actualPendingIntent.send() + val nextStartedActivity = Shadows.shadowOf(application).nextStartedActivity + val nextStartedActivityIntent = Shadows.shadowOf(nextStartedActivity) + val nextStartedActivityPayload: CustomerIOParsedPushPayload? = + nextStartedActivity.extras?.parcelable(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA) + + nextStartedActivityIntent.intentClass shouldBeEqualTo NotificationClickReceiverActivity::class.java + nextStartedActivityPayload shouldBeEqualTo pushNotificationPayload + } +} diff --git a/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingConfigTest.kt b/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingConfigTest.kt index 52f279675..b6a1dd30c 100644 --- a/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingConfigTest.kt +++ b/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingConfigTest.kt @@ -2,12 +2,14 @@ package io.customer.messagingpush import androidx.test.ext.junit.runners.AndroidJUnit4 import io.customer.commontest.BaseTest +import io.customer.messagingpush.config.PushClickBehavior import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback import io.customer.messagingpush.di.moduleConfig import io.customer.sdk.CustomerIOConfig import io.customer.sdk.CustomerIOInstance import io.customer.sdk.device.DeviceTokenProvider import io.customer.sdk.module.CustomerIOModule +import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeNull import org.amshove.kluent.shouldBeTrue @@ -53,6 +55,7 @@ internal class ModuleMessagingConfigTest : BaseTest() { moduleConfig.notificationCallback.shouldBeNull() moduleConfig.redirectDeepLinksToOtherApps.shouldBeTrue() + moduleConfig.pushClickBehavior shouldBeEqualTo PushClickBehavior.ACTIVITY_PREVENT_RESTART } @Test @@ -69,6 +72,7 @@ internal class ModuleMessagingConfigTest : BaseTest() { moduleConfig.autoTrackPushEvents.shouldBeTrue() moduleConfig.notificationCallback.shouldBeNull() moduleConfig.redirectDeepLinksToOtherApps.shouldBeTrue() + moduleConfig.pushClickBehavior shouldBeEqualTo PushClickBehavior.ACTIVITY_PREVENT_RESTART } @Test @@ -78,6 +82,7 @@ internal class ModuleMessagingConfigTest : BaseTest() { setAutoTrackPushEvents(false) setNotificationCallback(object : CustomerIOPushNotificationCallback {}) setRedirectDeepLinksToOtherApps(false) + setPushClickBehavior(PushClickBehavior.RESET_TASK_STACK) }.build(), overrideCustomerIO = customerIOMock, overrideDiGraph = di @@ -89,5 +94,6 @@ internal class ModuleMessagingConfigTest : BaseTest() { moduleConfig.autoTrackPushEvents.shouldBeFalse() moduleConfig.notificationCallback.shouldNotBeNull() moduleConfig.redirectDeepLinksToOtherApps.shouldBeFalse() + moduleConfig.pushClickBehavior shouldBeEqualTo PushClickBehavior.RESET_TASK_STACK } } diff --git a/messagingpush/src/sharedTest/java/io/customer/messagingpush/activity/NotificationClickReceiverActivityTest.kt b/messagingpush/src/sharedTest/java/io/customer/messagingpush/activity/NotificationClickReceiverActivityTest.kt new file mode 100644 index 000000000..2f5543cfb --- /dev/null +++ b/messagingpush/src/sharedTest/java/io/customer/messagingpush/activity/NotificationClickReceiverActivityTest.kt @@ -0,0 +1,83 @@ +package io.customer.messagingpush.activity + +import android.content.Intent +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.customer.commontest.BaseIntegrationTest +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.processor.PushMessageProcessor +import io.customer.sdk.CustomerIO +import io.customer.sdk.extensions.random +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions + +@RunWith(AndroidJUnit4::class) +class NotificationClickReceiverActivityTest : BaseIntegrationTest() { + private val pushMessageProcessorMock: PushMessageProcessor = mock() + + @Before + override fun setup() { + super.setup() + + di.overrideDependency(PushMessageProcessor::class.java, pushMessageProcessorMock) + } + + private fun pushActivityExtras(): Bundle { + val payload = CustomerIOParsedPushPayload( + extras = Bundle.EMPTY, + deepLink = null, + cioDeliveryId = String.random, + cioDeliveryToken = String.random, + title = String.random, + body = String.random + ) + + return Bundle().apply { + putParcelable(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, payload) + } + } + + @Test + fun clickNotification_givenValidIntent_expectProcessPush() { + val extras = pushActivityExtras() + val intent = Intent(context, NotificationClickReceiverActivity::class.java) + intent.putExtras(extras) + + val scenario = ActivityScenario.launch(intent) + + verify(pushMessageProcessorMock).processNotificationClick(any(), any()) + scenario.state shouldBeEqualTo Lifecycle.State.DESTROYED + } + + @Test + fun clickNotification_givenSDKNotInitialized_expectDoNoProcessPush() { + val extras = pushActivityExtras() + + val intent = Intent(context, NotificationClickReceiverActivity::class.java) + intent.putExtras(extras) + + CustomerIO.clearInstance() + val scenario = ActivityScenario.launch(intent) + + verifyNoInteractions(pushMessageProcessorMock) + scenario.state shouldBeEqualTo Lifecycle.State.DESTROYED + } + + @Test + fun clickNotification_givenNullIntent_expectDoNoProcessPush() { + val intent = Intent(context, NotificationClickReceiverActivity::class.java) + + val scenario = ActivityScenario.launch(intent) + + verifyNoInteractions(pushMessageProcessorMock) + scenario.state shouldBeEqualTo Lifecycle.State.DESTROYED + } +} diff --git a/messagingpush/src/sharedTest/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt b/messagingpush/src/sharedTest/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt index 893844f2c..197ff70b3 100644 --- a/messagingpush/src/sharedTest/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt +++ b/messagingpush/src/sharedTest/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt @@ -1,12 +1,19 @@ package io.customer.messagingpush.processor import android.content.Intent +import android.net.Uri import android.os.Bundle +import androidx.core.app.TaskStackBuilder import androidx.test.ext.junit.runners.AndroidJUnit4 import io.customer.commontest.BaseTest import io.customer.messagingpush.MessagingPushModuleConfig import io.customer.messagingpush.ModuleMessagingPushFCM -import io.customer.messagingpush.di.moduleConfig +import io.customer.messagingpush.activity.NotificationClickReceiverActivity +import io.customer.messagingpush.config.PushClickBehavior +import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.pushMessageProcessor +import io.customer.messagingpush.util.DeepLinkUtil import io.customer.messagingpush.util.PushTrackingUtil import io.customer.sdk.CustomerIOConfig import io.customer.sdk.CustomerIOInstance @@ -14,27 +21,71 @@ import io.customer.sdk.data.request.MetricEvent import io.customer.sdk.extensions.random import io.customer.sdk.module.CustomerIOModule import io.customer.sdk.repository.TrackRepository +import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldNotBe +import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +import org.robolectric.Shadows @RunWith(AndroidJUnit4::class) class PushMessageProcessorTest : BaseTest() { private val modules = hashMapOf>() private val customerIOMock: CustomerIOInstance = mock() + private val deepLinkUtilMock: DeepLinkUtil = mock() private val trackRepositoryMock: TrackRepository = mock() override fun setupConfig(): CustomerIOConfig = createConfig( modules = modules ) + @Before + override fun setup() { + super.setup() + + di.overrideDependency(DeepLinkUtil::class.java, deepLinkUtilMock) + di.overrideDependency(TrackRepository::class.java, trackRepositoryMock) + } + private fun pushMessageProcessor(): PushMessageProcessorImpl { - return PushMessageProcessorImpl(di.logger, di.moduleConfig, trackRepositoryMock) + return di.pushMessageProcessor as PushMessageProcessorImpl + } + + private fun pushMessagePayload(deepLink: String? = null): CustomerIOParsedPushPayload { + return CustomerIOParsedPushPayload( + extras = Bundle.EMPTY, + deepLink = deepLink, + cioDeliveryId = String.random, + cioDeliveryToken = String.random, + title = String.random, + body = String.random + ) + } + + private fun setupModuleConfig( + pushClickBehavior: PushClickBehavior? = null, + autoTrackPushEvents: Boolean? = null, + notificationCallback: CustomerIOPushNotificationCallback? = null + ) { + modules[ModuleMessagingPushFCM.MODULE_NAME] = ModuleMessagingPushFCM( + overrideCustomerIO = customerIOMock, + overrideDiGraph = di, + moduleConfig = with(MessagingPushModuleConfig.Builder()) { + autoTrackPushEvents?.let { setAutoTrackPushEvents(it) } + notificationCallback?.let { setNotificationCallback(it) } + pushClickBehavior?.let { setPushClickBehavior(it) } + build() + } + ) } @Test @@ -194,4 +245,247 @@ class PushMessageProcessorTest : BaseTest() { givenDeviceToken ) } + + @Test + fun processNotificationClick_givenValidIntent_expectSuccessfulProcessing() { + setupModuleConfig(autoTrackPushEvents = true) + val processor = pushMessageProcessor() + val givenDeepLink = "https://cio.example.com/" + val givenPayload = pushMessagePayload(deepLink = givenDeepLink) + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + verify(trackRepositoryMock).trackMetric( + givenPayload.cioDeliveryId, + MetricEvent.opened, + givenPayload.cioDeliveryToken + ) + verify(deepLinkUtilMock).createDeepLinkHostAppIntent(context, givenDeepLink) + verify(deepLinkUtilMock).createDeepLinkExternalIntent(context, givenDeepLink) + verify(deepLinkUtilMock).createDefaultHostAppIntent(context) + } + + @Test + fun processNotificationClick_givenAutoTrackingDisabled_expectDoNotTrackOpened() { + setupModuleConfig(autoTrackPushEvents = false) + val processor = pushMessageProcessor() + val givenPayload = pushMessagePayload() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + verifyNoInteractions(trackRepositoryMock) + } + + @Test + fun processNotificationClick_givenNoDeepLink_expectOpenLauncherIntent() { + val processor = pushMessageProcessor() + val givenPayload = pushMessagePayload() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + verify(deepLinkUtilMock, never()).createDeepLinkHostAppIntent(any(), any()) + verify(deepLinkUtilMock, never()).createDeepLinkExternalIntent(any(), any()) + verify(deepLinkUtilMock).createDefaultHostAppIntent(any()) + } + + @Test + fun processNotificationClick_givenCallbackWithDeepLink_expectOpenCallbackIntent() { + val notificationCallback: CustomerIOPushNotificationCallback = mock() + whenever(notificationCallback.createTaskStackFromPayload(any(), any())).thenReturn( + TaskStackBuilder.create(context) + ) + val givenPayload = pushMessagePayload(deepLink = "https://cio.example.com/") + + // Make sure that the callback as expected for all behaviors + for (pushClickBehavior in PushClickBehavior.values()) { + setupModuleConfig( + notificationCallback = notificationCallback, + pushClickBehavior = pushClickBehavior + ) + val processor = pushMessageProcessor() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + verifyNoInteractions(deepLinkUtilMock) + } + } + + @Test + fun processNotificationClick_givenCallbackWithoutDeepLink_expectOpenCallbackIntent() { + val notificationCallback: CustomerIOPushNotificationCallback = mock() + whenever(notificationCallback.createTaskStackFromPayload(any(), any())).thenReturn( + TaskStackBuilder.create(context) + ) + val givenPayload = pushMessagePayload() + + // Make sure that the callback as expected for all behaviors + for (pushClickBehavior in PushClickBehavior.values()) { + setupModuleConfig( + notificationCallback = notificationCallback, + pushClickBehavior = pushClickBehavior + ) + val processor = pushMessageProcessor() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + verifyNoInteractions(deepLinkUtilMock) + } + } + + @Test + fun processNotificationClick_givenExternalLink_expectOpenExternalIntent() { + val processor = pushMessageProcessor() + val givenPayload = pushMessagePayload(deepLink = "https://cio.example.com/") + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + whenever(deepLinkUtilMock.createDeepLinkExternalIntent(any(), any())).thenReturn(Intent()) + + processor.processNotificationClick(context, intent) + + verify(deepLinkUtilMock).createDeepLinkHostAppIntent(any(), any()) + verify(deepLinkUtilMock).createDeepLinkExternalIntent(any(), any()) + verify(deepLinkUtilMock, never()).createDefaultHostAppIntent(any()) + } + + @Test + fun processNotificationClick_givenInternalLink_expectOpenInternalIntent() { + val processor = pushMessageProcessor() + val givenPayload = pushMessagePayload(deepLink = "https://cio.example.com/") + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + whenever(deepLinkUtilMock.createDeepLinkExternalIntent(any(), any())).thenReturn(Intent()) + whenever(deepLinkUtilMock.createDeepLinkHostAppIntent(any(), any())).thenReturn(Intent()) + + processor.processNotificationClick(context, intent) + + verify(deepLinkUtilMock).createDeepLinkHostAppIntent(any(), any()) + verify(deepLinkUtilMock, never()).createDeepLinkExternalIntent(any(), any()) + verify(deepLinkUtilMock).createDefaultHostAppIntent(any()) + } + + @Test + fun processNotificationClick_givenPushBehavior_expectResetTaskStack() { + setupModuleConfig( + autoTrackPushEvents = false, + pushClickBehavior = PushClickBehavior.RESET_TASK_STACK + ) + val givenPackageName = "io.customer.example" + val givenDeepLink = "https://cio.example.com/" + whenever(deepLinkUtilMock.createDeepLinkHostAppIntent(any(), any())).thenReturn( + Intent(Intent.ACTION_VIEW, Uri.parse(givenDeepLink)).apply { + setPackage(givenPackageName) + } + ) + val givenPayload = pushMessagePayload(deepLink = givenDeepLink) + val processor = pushMessageProcessor() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + // The intent will be started with the default flags based on the activity launch mode + // Also, we cannot verify the back stack as it is not exposed by the testing framework + val expectedIntentFlags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME + val nextStartedActivity = Shadows.shadowOf(application).nextStartedActivity + nextStartedActivity shouldNotBe null + nextStartedActivity.action shouldBeEqualTo Intent.ACTION_VIEW + nextStartedActivity.dataString shouldBeEqualTo givenDeepLink + nextStartedActivity.flags shouldBeEqualTo expectedIntentFlags + nextStartedActivity.`package` shouldBeEqualTo givenPackageName + } + + @Test + fun processNotificationClick_givenPushBehavior_expectPreventRestart() { + setupModuleConfig( + autoTrackPushEvents = false, + pushClickBehavior = PushClickBehavior.ACTIVITY_PREVENT_RESTART + ) + val givenPackageName = "io.customer.example" + val givenDeepLink = "https://cio.example.com/" + whenever(deepLinkUtilMock.createDeepLinkHostAppIntent(any(), any())).thenReturn( + Intent(Intent.ACTION_VIEW, Uri.parse(givenDeepLink)).apply { + setPackage(givenPackageName) + } + ) + val givenPayload = pushMessagePayload(deepLink = givenDeepLink) + val processor = pushMessageProcessor() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + val expectedIntentFlags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + val nextStartedActivity = Shadows.shadowOf(application).nextStartedActivity + nextStartedActivity shouldNotBe null + nextStartedActivity.action shouldBeEqualTo Intent.ACTION_VIEW + nextStartedActivity.dataString shouldBeEqualTo givenDeepLink + nextStartedActivity.flags shouldBeEqualTo expectedIntentFlags + nextStartedActivity.`package` shouldBeEqualTo givenPackageName + } + + @Ignore( + "Current testing framework does not support verifying the flags. " + + "We'll have to rely on manual testing for this for now." + + "In future, we can use more advanced testing frameworks to verify this" + ) + @Test + fun processNotificationClick_givenPushBehavior_expectNoFlags() { + setupModuleConfig( + autoTrackPushEvents = false, + pushClickBehavior = PushClickBehavior.ACTIVITY_NO_FLAGS + ) + val givenPackageName = "io.customer.example" + val givenDeepLink = "https://cio.example.com/" + whenever(deepLinkUtilMock.createDeepLinkHostAppIntent(any(), any())).thenReturn( + Intent(Intent.ACTION_VIEW, Uri.parse(givenDeepLink)).apply { + setPackage(givenPackageName) + } + ) + val givenPayload = pushMessagePayload(deepLink = givenDeepLink) + val processor = pushMessageProcessor() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + + processor.processNotificationClick(context, intent) + + // The intent will be started with the default flags based on the activity launch mode + val nextStartedActivity = Shadows.shadowOf(application).nextStartedActivity + nextStartedActivity shouldNotBe null + nextStartedActivity.action shouldBeEqualTo Intent.ACTION_VIEW + nextStartedActivity.dataString shouldBeEqualTo givenDeepLink + nextStartedActivity.`package` shouldBeEqualTo givenPackageName + } + + @Test + fun processNotificationClick_givenEmptyIntent_expectNoProcessing() { + setupModuleConfig(autoTrackPushEvents = true) + val processor = pushMessageProcessor() + val intent = Intent() + + processor.processNotificationClick(context, intent) + + verifyNoInteractions(trackRepositoryMock) + verifyNoInteractions(deepLinkUtilMock) + } }