diff --git a/.gitignore b/.gitignore index 3826b5eff..ba3c22d54 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ jacoco.exec IDE integration-tests/.idea/ +.claude/ +.mcp.json diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 1f5ed6a38..ae7023ea7 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -913,6 +913,27 @@ public static void setContext(Context context) { IterableActivityMonitor.getInstance().registerLifecycleCallbacks(context); } + /** + * Initialize minimal context for push notification handling when the SDK hasn't been fully initialized. + * This is used internally when processing push actions in the background (e.g., openApp=false scenarios) + * to ensure custom actions can be executed even before IterableApi.initialize() is called. + * + * This method only sets the application context if it hasn't already been set, and does not + * perform full SDK initialization. For full initialization, use {@link #initialize(Context, String, IterableConfig)}. + * + * @param context The context to use for initialization (will use application context) + */ + static void initializeForPush(@Nullable Context context) { + if (context == null) { + IterableLogger.w(TAG, "initializeForPush: context is null"); + return; + } + if (sharedInstance._applicationContext == null) { + sharedInstance._applicationContext = context.getApplicationContext(); + IterableLogger.d(TAG, "initializeForPush: Application context set for background push handling"); + } + } + IterableApi() { config = new IterableConfig.Builder().build(); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index 0aa1e5d17..b28b54511 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -18,7 +18,12 @@ static boolean processPendingAction(Context context) { boolean handled = false; if (pendingAction != null) { handled = executeAction(context, pendingAction); - pendingAction = null; + // Only clear pending action if it was handled. + // This allows the action to be processed later when SDK is fully initialized + // (e.g., when customActionHandler becomes available after initialize() is called). + if (handled) { + pendingAction = null; + } } return handled; } @@ -38,6 +43,12 @@ static void handlePushAction(Context context, Intent intent) { IterableLogger.e(TAG, "handlePushAction: extras == null, can't handle push action"); return; } + + // Initialize minimal context for push handling if SDK hasn't been fully initialized. + // This ensures custom actions can be processed even when the app is in the background + // and the SDK hasn't been initialized yet (e.g., openApp=false scenarios). + IterableApi.initializeForPush(context); + IterableNotificationData notificationData = new IterableNotificationData(intent.getExtras()); String actionIdentifier = intent.getStringExtra(IterableConstants.ITERABLE_DATA_ACTION_IDENTIFIER); IterableAction action = null; diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java index 82a6f20ed..b738cf69a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java @@ -159,5 +159,132 @@ public void testLegacyDeepLinkPayload() throws Exception { assertEquals("https://example.com", capturedAction.getValue().getData()); } + @Test + public void testBackgroundCustomActionWithNonInitializedSDK() throws Exception { + // Reset to simulate SDK not being initialized + IterableTestUtils.resetIterableApi(); + + // Verify context is initially null + assertNull(IterableApi.sharedInstance._applicationContext); + + IterablePushActionReceiver iterablePushActionReceiver = new IterablePushActionReceiver(); + Intent intent = new Intent(IterableConstants.ACTION_PUSH_ACTION); + intent.putExtra(IterableConstants.ITERABLE_DATA_ACTION_IDENTIFIER, "remindMeButton"); + intent.putExtra(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_background_custom_action.json")); + + // Receive push action when SDK is not initialized + iterablePushActionReceiver.onReceive(ApplicationProvider.getApplicationContext(), intent); + + // Verify that context was stored even without SDK initialization + assertNotNull(IterableApi.sharedInstance._applicationContext); + + // Verify that the main app activity was NOT launched (openApp=false) + Application application = ApplicationProvider.getApplicationContext(); + Intent activityIntent = shadowOf(application).peekNextStartedActivity(); + assertNull(activityIntent); + } + + @Test + public void testBackgroundCustomActionProcessedAfterSDKInit() throws Exception { + // Reset to simulate SDK not being initialized + IterableTestUtils.resetIterableApi(); + + IterablePushActionReceiver iterablePushActionReceiver = new IterablePushActionReceiver(); + Intent intent = new Intent(IterableConstants.ACTION_PUSH_ACTION); + intent.putExtra(IterableConstants.ITERABLE_DATA_ACTION_IDENTIFIER, "remindMeButton"); + intent.putExtra(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_background_custom_action.json")); + + // Receive push action when SDK is not initialized (action won't be handled) + iterablePushActionReceiver.onReceive(ApplicationProvider.getApplicationContext(), intent); + + // Reset to real implementation so custom action handler gets invoked + IterableActionRunner.instance = new IterableActionRunner.IterableActionRunnerImpl(); + + // Now initialize SDK with a custom action handler + stubAnyRequestReturningStatusCode(server, 200, "{}"); + final boolean[] handlerCalled = {false}; + IterableTestUtils.createIterableApiNew(builder -> + builder.setCustomActionHandler((action, actionContext) -> { + handlerCalled[0] = true; + assertEquals("snoozeReminder", action.getType()); + return true; + }) + ); + + // Verify that the custom action handler was called during initialization + // (processPendingAction is called in initialize()) + assertEquals(true, handlerCalled[0]); + } + @Test + public void testInitializeForPushSetsContext() throws Exception { + // Reset to simulate SDK not being initialized + IterableTestUtils.resetIterableApi(); + + // Verify context is initially null + assertNull(IterableApi.sharedInstance._applicationContext); + + // Call initializeForPush + IterableApi.initializeForPush(ApplicationProvider.getApplicationContext()); + + // Verify context was set + assertNotNull(IterableApi.sharedInstance._applicationContext); + } + + @Test + public void testInitializeForPushDoesNotOverwriteExistingContext() throws Exception { + // Initialize the SDK normally first + stubAnyRequestReturningStatusCode(server, 200, "{}"); + IterableTestUtils.createIterableApi(); + + // Store reference to the original context + Context originalContext = IterableApi.sharedInstance._applicationContext; + assertNotNull(originalContext); + + // Call initializeForPush - should not overwrite existing context + IterableApi.initializeForPush(ApplicationProvider.getApplicationContext()); + + // Verify context was not changed + assertEquals(originalContext, IterableApi.sharedInstance._applicationContext); + } + + @Test + public void testPreviousPendingActionClearedOnNewPush() throws Exception { + // Reset to simulate SDK not being initialized + IterableTestUtils.resetIterableApi(); + + IterablePushActionReceiver iterablePushActionReceiver = new IterablePushActionReceiver(); + + // Send first push action (won't be handled since SDK not initialized) + Intent firstIntent = new Intent(IterableConstants.ACTION_PUSH_ACTION); + firstIntent.putExtra(IterableConstants.ITERABLE_DATA_ACTION_IDENTIFIER, "remindMeButton"); + firstIntent.putExtra(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_background_custom_action.json")); + iterablePushActionReceiver.onReceive(ApplicationProvider.getApplicationContext(), firstIntent); + + // Send second push action with a different action + Intent secondIntent = new Intent(IterableConstants.ACTION_PUSH_ACTION); + secondIntent.putExtra(IterableConstants.ITERABLE_DATA_ACTION_IDENTIFIER, IterableConstants.ITERABLE_ACTION_DEFAULT); + secondIntent.putExtra(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); + iterablePushActionReceiver.onReceive(ApplicationProvider.getApplicationContext(), secondIntent); + + // Reset to real implementation so custom action handler gets invoked + IterableActionRunner.instance = new IterableActionRunner.IterableActionRunnerImpl(); + + // Now initialize SDK with a custom action handler + stubAnyRequestReturningStatusCode(server, 200, "{}"); + final String[] lastActionType = {null}; + final int[] callCount = {0}; + IterableTestUtils.createIterableApiNew(builder -> + builder.setCustomActionHandler((action, actionContext) -> { + callCount[0]++; + lastActionType[0] = action.getType(); + return true; + }) + ); + + // Verify that only the second action (customAction) was processed, not the first (snoozeReminder) + // The first action should have been cleared when the second push came in + assertEquals(1, callCount[0]); + assertEquals("customAction", lastActionType[0]); + } } diff --git a/iterableapi/src/test/resources/push_payload_background_custom_action.json b/iterableapi/src/test/resources/push_payload_background_custom_action.json new file mode 100644 index 000000000..6cdfeb62f --- /dev/null +++ b/iterableapi/src/test/resources/push_payload_background_custom_action.json @@ -0,0 +1,20 @@ +{ + "campaignId": 5678, + "templateId": 8765, + "messageId": "background123456", + "isGhostPush": false, + "actionButtons": [ + { + "identifier": "remindMeButton", + "title": "Remind me in 15 minutes", + "openApp": false, + "action": { + "type": "snoozeReminder", + "data": "{\"delay\":15}" + } + } + ], + "defaultAction": { + "type": null + } +}