Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions integration-tests/build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
plugins {
id 'com.android.application'
// Add the Google services Gradle plugin
id 'com.google.gms.google-services'
}

Comment on lines +1 to +6
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on following Firebase docs. pretty standard

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'jacoco'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ abstract class BaseIntegrationTest {
// URL handler tracking for tests
private val urlHandlerCalled = AtomicBoolean(false)
private val lastHandledUrl = AtomicReference<String?>(null)

// Custom action handler tracking for tests
private val customActionHandlerCalled = AtomicBoolean(false)
private val lastHandledAction = AtomicReference<com.iterable.iterableapi.IterableAction?>(null)
private val lastHandledActionType = AtomicReference<String?>(null)
Comment on lines +39 to +40
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is like mutual lock which is assigned a value when handlers are called. Used by test to affirm handlers getting called.


@Before
open fun setUp() {
Expand All @@ -41,6 +46,7 @@ abstract class BaseIntegrationTest {

// Reset tracking flags
resetUrlHandlerTracking()
resetCustomActionHandlerTracking()

// Set test mode flag to prevent MainActivity from initializing SDK
// This ensures our test config (with test handlers) is the one used
Expand Down Expand Up @@ -71,17 +77,26 @@ abstract class BaseIntegrationTest {
testUtils.setInAppMessageDisplayed(true)
com.iterable.iterableapi.IterableInAppHandler.InAppResponse.SHOW
}
.setCustomActionHandler { action, context ->
// Handle custom actions during tests
Log.d("BaseIntegrationTest", "Custom action triggered: $action")
true
}
.setCustomActionHandler(object : com.iterable.iterableapi.IterableCustomActionHandler {
override fun handleIterableCustomAction(
action: com.iterable.iterableapi.IterableAction,
actionContext: com.iterable.iterableapi.IterableActionContext
): Boolean {
// Handle custom actions during tests
val actionType = action.getType()
Log.d("BaseIntegrationTest", "Custom action triggered: type=$actionType, action=$action, source=${actionContext.source}")
customActionHandlerCalled.set(true)
lastHandledAction.set(action)
lastHandledActionType.set(actionType)
return false
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

returning false here despite handling the deeplink so taht we can verify if SDK is going to take any action aruond it or not.

}
Comment on lines +80 to +92
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking customActionHandler along with previously tested urlHandler

})
.setUrlHandler { url, context ->
// Handle URLs during tests
Log.d("BaseIntegrationTest", "URL handler triggered: $url")
urlHandlerCalled.set(true)
lastHandledUrl.set(url.toString())
true
false
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

returning false here as I want SDK to take action when google.com is clicked. Returning true indicates that app has handled the link and prevent SDK to take further action. Without this change, it will not open google.com on chrome. It gives opportunity for developer to handle it internally first.

}
.build()

Expand Down Expand Up @@ -110,6 +125,21 @@ abstract class BaseIntegrationTest {
}
}

/**
* Check if notification permission is granted
*/
protected fun hasNotificationPermission(): Boolean {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else {
// For Android 12 and below, notifications are enabled by default
androidx.core.app.NotificationManagerCompat.from(context).areNotificationsEnabled()
}
}

Comment on lines +131 to +142
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification permission logic

/**
* Wait for a condition to be true with timeout
*/
Expand Down Expand Up @@ -141,6 +171,18 @@ abstract class BaseIntegrationTest {
testUtils.triggerCampaignViaAPI(campaignId, recipientEmail, dataFields, callback)
}

/**
* Trigger a push campaign via Iterable API
*/
protected fun triggerPushCampaignViaAPI(
campaignId: Int,
recipientEmail: String = TestConstants.TEST_USER_EMAIL,
dataFields: Map<String, Any>? = null,
callback: ((Boolean) -> Unit)? = null
) {
testUtils.triggerPushCampaignViaAPI(campaignId, recipientEmail, dataFields, callback)
}


/**
* Reset URL handler tracking
Expand All @@ -165,4 +207,36 @@ abstract class BaseIntegrationTest {
urlHandlerCalled.get()
}, timeoutSeconds)
}

/**
* Reset custom action handler tracking
*/
protected fun resetCustomActionHandlerTracking() {
customActionHandlerCalled.set(false)
lastHandledAction.set(null)
lastHandledActionType.set(null)
}

/**
* Get the last action handled by the custom action handler
*/
protected fun getLastHandledAction(): com.iterable.iterableapi.IterableAction? {
return lastHandledAction.get()
}

/**
* Get the last action type handled by the custom action handler
*/
protected fun getLastHandledActionType(): String? {
return lastHandledActionType.get()
}

/**
* Wait for custom action handler to be called
*/
protected fun waitForCustomActionHandler(timeoutSeconds: Long = TIMEOUT_SECONDS): Boolean {
return waitForCondition({
customActionHandlerCalled.get()
}, timeoutSeconds)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package com.iterable.integration.tests

import android.content.Intent
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.By
import com.iterable.iterableapi.IterableApi
import com.iterable.integration.tests.activities.PushNotificationTestActivity
import org.awaitility.Awaitility
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit

@RunWith(AndroidJUnit4::class)
class PushNotificationIntegrationTest : BaseIntegrationTest() {

companion object {
private const val TAG = "PushNotificationIntegrationTest"
private const val TEST_PUSH_CAMPAIGN_ID = TestConstants.TEST_PUSH_CAMPAIGN_ID
}

private lateinit var uiDevice: UiDevice
private lateinit var mainActivityScenario: ActivityScenario<MainActivity>

@Before
override fun setUp() {
uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
super.setUp()

IterableApi.getInstance().inAppManager.setAutoDisplayPaused(true)
IterableApi.getInstance().inAppManager.messages.forEach {
IterableApi.getInstance().inAppManager.removeMessage(it)
}
Comment on lines +39 to +42
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this part similar so that inapps do not interrupt the UI


launchAppAndNavigateToPushNotificationTesting()
}

@After
override fun tearDown() {
super.tearDown()
}

private fun launchAppAndNavigateToPushNotificationTesting() {
Log.d(TAG, "Step 1: Launching MainActivity and navigating to PushNotificationTestActivity")
val mainIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, MainActivity::class.java)
mainActivityScenario = ActivityScenario.launch(mainIntent)

Awaitility.await()
.atMost(5, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.until {
mainActivityScenario.state == Lifecycle.State.RESUMED
}

val pushButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnPushNotifications"))
if (!pushButton.exists()) {
Assert.fail("Push Notifications button not found in MainActivity")
}
pushButton.click()
Thread.sleep(2000)
}

@Test
fun testPushNotificationMVP() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main test function

Assert.assertTrue("User should be signed in", testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL))
Assert.assertTrue("Notification permission should be granted", hasNotificationPermission())

// Test 1: Trigger campaign, minimize app, open notification, verify app opens
Log.d(TAG, "Test 1: Push notification open action")
triggerCampaignAndWait()
uiDevice.pressHome()
Thread.sleep(1000)

uiDevice.openNotification()
Thread.sleep(1000)
val notification1 = findNotification()
Assert.assertNotNull("Notification should be found", notification1)

notification1?.click()
Thread.sleep(2000) // Wait for app to open

// Verify app is in foreground by checking current package name
val isAppInForeground = waitForCondition({
val currentPackage = uiDevice.currentPackageName
currentPackage == "com.iterable.integration.tests"
}, timeoutSeconds = 5)
Assert.assertTrue("App should be in foreground after opening notification", isAppInForeground)
navigateToPushNotificationTestActivity()

// Test 2: Trigger campaign again, tap first action button (Google), verify URL handler
Log.d(TAG, "Test 2: Action button with URL handler")
triggerCampaignAndWait()
uiDevice.pressHome()
Thread.sleep(1000)

uiDevice.openNotification()
Thread.sleep(2000)
val notification2 = findNotification()
Assert.assertNotNull("Notification should be found", notification2)

resetUrlHandlerTracking()
val googleButton = uiDevice.findObject(By.text("Google"))
Assert.assertNotNull("Google button should be found", googleButton)
googleButton?.click()
Thread.sleep(2000)

Assert.assertTrue("URL handler should be called", waitForUrlHandler(timeoutSeconds = 5))
Assert.assertNotNull("Handled URL should not be null", getLastHandledUrl())

// Navigate back to PushNotificationTestActivity for next test (in case action button opened app)
Thread.sleep(1000)
navigateToPushNotificationTestActivity()

// Test 3: Trigger campaign again, tap second action button (Deeplink), verify custom action handler
Log.d(TAG, "Test 3: Action button with custom action handler")
triggerCampaignAndWait()
uiDevice.pressHome()
Thread.sleep(1000)

uiDevice.openNotification()
Thread.sleep(2000)
val notification3 = findNotification()
Assert.assertNotNull("Notification should be found", notification3)

resetCustomActionHandlerTracking()
val deeplinkButton = uiDevice.findObject(By.text("Deeplink"))
Assert.assertNotNull("Deeplink button should be found", deeplinkButton)
deeplinkButton?.click()
Thread.sleep(2000)

Assert.assertTrue("Custom action handler should be called", waitForCustomActionHandler(timeoutSeconds = 5))
Assert.assertNotNull("Action type should not be null", getLastHandledActionType())

// Navigate back to PushNotificationTestActivity (in case action button opened app)
Thread.sleep(1000)
navigateToPushNotificationTestActivity()

// Note: trackPushOpen() is called internally by the SDK when notifications are opened
// It's automatically invoked by IterablePushNotificationUtil.executeAction() which is called
// by the trampoline activity when handling push notification clicks
Log.d(TAG, "Test completed successfully")
}

private fun triggerCampaignAndWait() {
var campaignTriggered = false
val latch = java.util.concurrent.CountDownLatch(1)
triggerPushCampaignViaAPI(TEST_PUSH_CAMPAIGN_ID, TestConstants.TEST_USER_EMAIL, null) { success ->
campaignTriggered = success
latch.countDown()
}
Assert.assertTrue("Campaign trigger should complete", latch.await(10, java.util.concurrent.TimeUnit.SECONDS))
Assert.assertTrue("Campaign should be triggered successfully", campaignTriggered)
Thread.sleep(5000) // Wait for FCM delivery
}

private fun findNotification(): UiObject2? {
val searchTexts = listOf("BCIT", "iterable", "Test", TestConstants.TEST_USER_EMAIL)
for (searchText in searchTexts) {
val notification = uiDevice.findObject(By.textContains(searchText))
if (notification != null) return notification
}

val allNotifications = uiDevice.findObjects(By.res("com.android.systemui:id/notification_text"))
for (notif in allNotifications) {
val text = notif.text ?: ""
if (text.contains("Iterable", ignoreCase = true) || text.contains("iterable", ignoreCase = true)) {
return notif.parent
}
}
return null
}

private fun navigateToPushNotificationTestActivity() {
// Wait a bit for the app to fully open
Thread.sleep(1000)

// Try to find and click the Push Notifications button in MainActivity
val pushButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnPushNotifications"))
if (pushButton.exists()) {
pushButton.click()
Thread.sleep(2000) // Wait for navigation
} else {
// If button not found, try launching the activity directly
val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, PushNotificationTestActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
InstrumentationRegistry.getInstrumentation().targetContext.startActivity(intent)
Thread.sleep(2000)
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,21 @@ class MainActivity : AppCompatActivity() {
val intent = Intent(this@MainActivity, DeepLinkTestActivity::class.java)
intent.putExtra(EXTRA_DEEP_LINK_URL, url.toString())
startActivity(intent)
return true
return false
}
})
.setCustomActionHandler(object : com.iterable.iterableapi.IterableCustomActionHandler {
override fun handleIterableCustomAction(
action: com.iterable.iterableapi.IterableAction,
actionContext: com.iterable.iterableapi.IterableActionContext
): Boolean {
val actionType = action.getType()
// Log action.data()
val actionData = action.getData()
Log.d(TAG, "Custom action received: type=$actionType, data=$actionData")
// You can add custom logic here to handle different action types
// For now, just log and return true to indicate the action was handled
return false
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to BaseIntegration test, it may seem duplicate, but for manual testing, this is where the Iterable initializes vs for Test, BaseIntegrationTest file is where the SDK initializes with all the config

}
})
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ object TestConstants {

// Test campaign IDs - these should be configured in your Iterable project
const val TEST_INAPP_CAMPAIGN_ID = 14332357
const val TEST_PUSH_CAMPAIGN_ID = 14332358
const val TEST_PUSH_CAMPAIGN_ID = 15671239
const val TEST_EMBEDDED_CAMPAIGN_ID = 14332359

// Test placement IDs
Expand Down
Loading
Loading