diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index 03416e6a6..4ef5616a2 100644 --- a/integration-tests/build.gradle +++ b/integration-tests/build.gradle @@ -1,3 +1,9 @@ +plugins { + id 'com.android.application' + // Add the Google services Gradle plugin + id 'com.google.gms.google-services' +} + apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'jacoco' diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt index d7c8d5e3f..d19bf31fe 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt @@ -33,6 +33,11 @@ abstract class BaseIntegrationTest { // URL handler tracking for tests private val urlHandlerCalled = AtomicBoolean(false) private val lastHandledUrl = AtomicReference(null) + + // Custom action handler tracking for tests + private val customActionHandlerCalled = AtomicBoolean(false) + private val lastHandledAction = AtomicReference(null) + private val lastHandledActionType = AtomicReference(null) @Before open fun setUp() { @@ -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 @@ -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 + } + }) .setUrlHandler { url, context -> // Handle URLs during tests Log.d("BaseIntegrationTest", "URL handler triggered: $url") urlHandlerCalled.set(true) lastHandledUrl.set(url.toString()) - true + false } .build() @@ -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() + } + } + /** * Wait for a condition to be true with timeout */ @@ -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? = null, + callback: ((Boolean) -> Unit)? = null + ) { + testUtils.triggerPushCampaignViaAPI(campaignId, recipientEmail, dataFields, callback) + } + /** * Reset URL handler tracking @@ -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) + } } \ No newline at end of file diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt new file mode 100644 index 000000000..c546dcf4f --- /dev/null +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt @@ -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 + + @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) + } + + 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() { + 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) + } + } +} + diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt index 64f695936..674985665 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt @@ -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 } }) .build() diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/TestConstants.kt b/integration-tests/src/main/java/com/iterable/integration/tests/TestConstants.kt index 80f2464e0..9e84d3760 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/TestConstants.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/TestConstants.kt @@ -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 diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt index 1eeab9cf3..55094cdf0 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt @@ -1,14 +1,44 @@ package com.iterable.integration.tests.activities +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.util.Log +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import com.iterable.integration.tests.R +import com.iterable.integration.tests.TestConstants +import com.iterable.integration.tests.utils.IntegrationTestUtils +import com.iterable.iterableapi.IterableApi class PushNotificationTestActivity : AppCompatActivity() { companion object { private const val TAG = "PushNotificationTest" + private const val TEST_PUSH_CAMPAIGN_ID = TestConstants.TEST_PUSH_CAMPAIGN_ID + } + + private lateinit var statusTextView: TextView + private lateinit var permissionStatusTextView: TextView + private lateinit var triggerCampaignButton: Button + private lateinit var requestPermissionButton: Button + private lateinit var testUtils: IntegrationTestUtils + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + updatePermissionStatus() + if (isGranted) { + Toast.makeText(this, "Notification permission granted", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Notification permission denied. Push notifications may not work.", Toast.LENGTH_LONG).show() + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -17,6 +47,136 @@ class PushNotificationTestActivity : AppCompatActivity() { Log.d(TAG, "Push Notification Test Activity started") - // TODO: Implement push notification test UI and logic + testUtils = IntegrationTestUtils(this) + + initializeViews() + setupButtonListeners() + ensureUserSignedIn() + updatePermissionStatus() + updateStatus("Ready to test push notifications") + } + + override fun onResume() { + super.onResume() + // Update permission status when activity resumes (in case user granted permission) + updatePermissionStatus() + } + + private fun initializeViews() { + statusTextView = findViewById(R.id.status_text) + permissionStatusTextView = findViewById(R.id.permission_status_text) + triggerCampaignButton = findViewById(R.id.btnTriggerPushCampaign) + requestPermissionButton = findViewById(R.id.btnRequestPermission) + } + + private fun setupButtonListeners() { + triggerCampaignButton.setOnClickListener { + triggerPushCampaign() + } + + requestPermissionButton.setOnClickListener { + requestNotificationPermission() + } + } + + private fun ensureUserSignedIn() { + val success = testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL) + if (success) { + Log.d(TAG, "User signed in: ${IterableApi.getInstance().getEmail()}") + } else { + Log.e(TAG, "Failed to sign in user") + updateStatus("Failed to sign in user") + } + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + // For Android 12 and below, check if notifications are enabled + NotificationManagerCompat.from(this).areNotificationsEnabled() + } + } + + private fun updatePermissionStatus() { + val hasPermission = hasNotificationPermission() + val statusText = if (hasPermission) { + "✅ Notification Permission: Granted" + } else { + "❌ Notification Permission: Not Granted" + } + permissionStatusTextView.text = statusText + + // Enable/disable trigger button based on permission + triggerCampaignButton.isEnabled = hasPermission + if (!hasPermission) { + updateStatus("Please grant notification permission to test push notifications") + } + + // Show/hide request permission button based on Android version and current permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionButton.visibility = if (hasPermission) { + android.view.View.GONE + } else { + android.view.View.VISIBLE + } + } else { + // For Android 12 and below, permission is managed in system settings + requestPermissionButton.visibility = if (hasPermission) { + android.view.View.GONE + } else { + android.view.View.VISIBLE + } + requestPermissionButton.text = "Open Notification Settings" + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + // For Android 12 and below, open notification settings + val intent = android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName) + } + startActivity(intent) + } + } + + private fun triggerPushCampaign() { + // Double-check permission before triggering + if (!hasNotificationPermission()) { + updateStatus("❌ Notification permission not granted. Please grant permission first.") + Toast.makeText(this, "Notification permission required", Toast.LENGTH_SHORT).show() + updatePermissionStatus() + return + } + + updateStatus("Triggering push campaign...") + triggerCampaignButton.isEnabled = false + + testUtils.triggerPushCampaignViaAPI(TEST_PUSH_CAMPAIGN_ID, TestConstants.TEST_USER_EMAIL) { success -> + runOnUiThread { + updatePermissionStatus() // Refresh permission status + triggerCampaignButton.isEnabled = hasNotificationPermission() + if (success) { + updateStatus("✅ Push campaign triggered successfully!\nCampaign ID: $TEST_PUSH_CAMPAIGN_ID\nCheck notification drawer in a few seconds.") + Toast.makeText(this@PushNotificationTestActivity, "Push campaign triggered successfully", Toast.LENGTH_LONG).show() + Log.d(TAG, "Push campaign triggered successfully: campaignId=$TEST_PUSH_CAMPAIGN_ID") + } else { + val errorMessage = testUtils.getLastErrorMessage() + updateStatus("❌ Failed to trigger push campaign\n${errorMessage ?: "Unknown error"}") + Toast.makeText(this@PushNotificationTestActivity, "Failed to trigger push campaign", Toast.LENGTH_SHORT).show() + Log.e(TAG, "Failed to trigger push campaign: $errorMessage") + } + } + } + } + + private fun updateStatus(status: String) { + statusTextView.text = "Status: $status" } } \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt b/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt index 7dcf43efa..0120f215e 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt @@ -4,6 +4,7 @@ import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.iterable.integration.tests.utils.IntegrationTestUtils +import com.iterable.iterableapi.IterableFirebaseMessagingService class IntegrationFirebaseMessagingService : FirebaseMessagingService() { @@ -44,10 +45,8 @@ class IntegrationFirebaseMessagingService : FirebaseMessagingService() { Log.d(TAG, "Received regular push notification") // Regular push notification - Iterable SDK will handle display IntegrationTestUtils(this).setPushNotificationReceived(true) + IterableFirebaseMessagingService.handleMessageReceived(this, remoteMessage) } - - // Let the Iterable SDK handle the message - // The SDK will automatically process the message and display notifications } override fun onMessageSent(msgId: String) { diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt b/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt index 7a36f1f64..71d73e449 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt @@ -143,7 +143,7 @@ class IntegrationTestUtils(private val context: Context) { // Store error message for UI display lastErrorMessage = "HTTP ${response.code}: $errorBody" } - + //TODO: Move callback success inside if(success) callback?.invoke(success) } catch (e: Exception) { Log.e(TAG, "Error triggering push campaign via API", e) diff --git a/integration-tests/src/main/res/layout/activity_push_notification_test.xml b/integration-tests/src/main/res/layout/activity_push_notification_test.xml index 8034d9639..0784a1fc5 100644 --- a/integration-tests/src/main/res/layout/activity_push_notification_test.xml +++ b/integration-tests/src/main/res/layout/activity_push_notification_test.xml @@ -1,25 +1,63 @@ - + android:layout_height="match_parent"> - + android:orientation="vertical" + android:padding="16dp"> - + + + + + + + + + - \ No newline at end of file + + \ No newline at end of file