From ca023bbaf4df8bb979b8d344ff06d0759f460015 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 14:22:30 +0000 Subject: [PATCH 1/8] Fix custom actions not working in background when SDK is not initialized When a push action with openApp=false is received before SDK initialization, the custom action handler was never invoked because getMainActivityContext() returned null. This fix: 1. Stores the application context early in handlePushAction() so getMainActivityContext() returns non-null even before initialize() is called 2. Only clears pendingAction if the action was actually handled, allowing deferred processing when SDK is initialized later with a customActionHandler This enables "snooze" type flows where a push button action with openApp=false can trigger a journey without requiring the full app to open. --- .../IterablePushNotificationUtil.java | 14 ++++- .../IterablePushActionReceiverTest.java | 54 +++++++++++++++++++ ...push_payload_background_custom_action.json | 20 +++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 iterableapi/src/test/resources/push_payload_background_custom_action.json diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index 0aa1e5d17..138d77450 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,13 @@ static void handlePushAction(Context context, Intent intent) { IterableLogger.e(TAG, "handlePushAction: extras == null, can't handle push action"); return; } + + // Store the application context if not already set, so custom actions can be + // processed even when the SDK hasn't been fully initialized (e.g., openApp=false) + if (IterableApi.sharedInstance._applicationContext == null && context != null) { + IterableApi.sharedInstance._applicationContext = context.getApplicationContext(); + } + 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..b58a0c97f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java @@ -159,5 +159,59 @@ 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); + + // 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]); + } + } 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 + } +} From dc855437b0040fd7425d3977f5be08059ce8eb48 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 21 Jan 2026 07:26:53 +0530 Subject: [PATCH 2/8] Fixes --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 2c7470278ebc28a4e47d47bdf69242d2cfbaea27 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 21 Jan 2026 07:55:24 +0530 Subject: [PATCH 3/8] Fixes --- .../com/iterable/iterableapi/IterableApi.java | 21 ++++++ .../IterablePushNotificationUtil.java | 15 +++-- .../IterablePushActionReceiverTest.java | 67 +++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) 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 138d77450..f131149cf 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -44,12 +44,19 @@ static void handlePushAction(Context context, Intent intent) { return; } - // Store the application context if not already set, so custom actions can be - // processed even when the SDK hasn't been fully initialized (e.g., openApp=false) - if (IterableApi.sharedInstance._applicationContext == null && context != null) { - IterableApi.sharedInstance._applicationContext = context.getApplicationContext(); + // Clear any previous pending action that was never processed. + // This prevents residual actions from accumulating if they were never handled + // (e.g., if the SDK was never initialized or the action handler wasn't available). + if (pendingAction != null) { + IterableLogger.d(TAG, "Clearing previous unhandled pending action"); + pendingAction = null; } + // 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 b58a0c97f..1f1a7de4a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java @@ -213,5 +213,72 @@ public void testBackgroundCustomActionProcessedAfterSDKInit() throws Exception { 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); + + // 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]); + } } From edfc5c8b4282b18d3370a4f7731fee61ea45b5e0 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Wed, 21 Jan 2026 08:28:32 +0530 Subject: [PATCH 4/8] Fixes --- .../iterableapi/IterablePushActionReceiverTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java index 1f1a7de4a..b738cf69a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushActionReceiverTest.java @@ -197,6 +197,9 @@ public void testBackgroundCustomActionProcessedAfterSDKInit() throws Exception { // 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}; @@ -264,6 +267,9 @@ public void testPreviousPendingActionClearedOnNewPush() throws Exception { 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}; From dccf1c6792dc942d623e9558634dbd07dc88c084 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 22 Jan 2026 16:19:39 +0530 Subject: [PATCH 5/8] Fixes --- .../iterable/iterableapi/IterablePushNotificationUtil.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index f131149cf..35322131d 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -44,12 +44,8 @@ static void handlePushAction(Context context, Intent intent) { return; } - // Clear any previous pending action that was never processed. - // This prevents residual actions from accumulating if they were never handled - // (e.g., if the SDK was never initialized or the action handler wasn't available). if (pendingAction != null) { - IterableLogger.d(TAG, "Clearing previous unhandled pending action"); - pendingAction = null; + IterableLogger.w(TAG, "There is a previous unhandled pending action...."); } // Initialize minimal context for push handling if SDK hasn't been fully initialized. From 86b403baf8b60196f88417692551a426c94d2d2e Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 22 Jan 2026 16:22:54 +0530 Subject: [PATCH 6/8] Fixes --- .../iterable/iterableapi/IterablePushNotificationUtil.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index 35322131d..b28b54511 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -44,10 +44,6 @@ static void handlePushAction(Context context, Intent intent) { return; } - if (pendingAction != null) { - IterableLogger.w(TAG, "There is a previous unhandled pending action...."); - } - // 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). From 936797961cc4ed95269760fe5e996e8804fd673b Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 29 Jan 2026 16:32:01 +0530 Subject: [PATCH 7/8] Fix flaky testNestedQueueOrExecute_MultipleOverloadChains test The test had a race condition: initializeInBackground() runs actual init on a background thread, which could complete before all 5 API calls were made, causing some operations to execute immediately instead of being queued. Added deterministic test helpers (simulateInitializingState/simulateInitializationComplete) to IterableBackgroundInitializer and replaced Thread.sleep with a polling loop for queue processing. Co-Authored-By: Claude Opus 4.5 --- .../IterableBackgroundInitializer.java | 25 +++++++++++++++++ .../IterableAsyncInitializationTest.java | 28 +++++++++---------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java index 884252383..dbfb432d4 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java @@ -376,6 +376,31 @@ static void notifyInitializationComplete() { callbackManager.notifyInitializationComplete(); } + /** + * Simulate the "initializing" state for testing, without starting actual background init. + * This allows tests to deterministically queue operations without race conditions. + */ + @VisibleForTesting + static void simulateInitializingState() { + synchronized (initLock) { + isInitializing = true; + isBackgroundInitialized = false; + } + } + + /** + * Simulate initialization completion for testing. + * Marks initialization as complete and processes any queued operations. + */ + @VisibleForTesting + static void simulateInitializationComplete() { + synchronized (initLock) { + isBackgroundInitialized = true; + isInitializing = false; + } + operationQueue.processAll(backgroundExecutor); + } + /** * Reset background initialization state - for testing only */ diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java index bd387d64c..15f214ded 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java @@ -1304,15 +1304,9 @@ public void onSDKInitialized() { @Test public void testNestedQueueOrExecute_MultipleOverloadChains() throws InterruptedException { - CountDownLatch initLatch = new CountDownLatch(1); - - // Start background initialization - IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { - @Override - public void onSDKInitialized() { - initLatch.countDown(); - } - }); + // Simulate the initializing state deterministically (no actual background init) + // This avoids race conditions where init completes before operations are called + IterableBackgroundInitializer.simulateInitializingState(); // Call multiple overloaded method chains during initialization // Each overload internally delegates to the full signature @@ -1324,13 +1318,19 @@ public void onSDKInitialized() { // Verify operations are queued int queuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); - assertTrue("Should have queued multiple operations", queuedOps >= 5); + assertTrue("Should have queued multiple operations, but was " + queuedOps, queuedOps >= 5); - // Wait for initialization - assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + // Simulate initialization completion which processes queued operations + IterableBackgroundInitializer.simulateInitializationComplete(); + + // Wait for background executor to process all operations + for (int i = 0; i < 20; i++) { + if (IterableBackgroundInitializer.getQueuedOperationCount() == 0) { + break; + } + Thread.sleep(100); + } - // All operations should be processed - Thread.sleep(200); assertEquals("All operations should be processed", 0, IterableBackgroundInitializer.getQueuedOperationCount()); } From f1d329cc872e20d28721ab79a94cb25dd10aa2f8 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 29 Jan 2026 13:40:12 +0000 Subject: [PATCH 8/8] Process pending actions when app comes to foreground When the app switches to foreground and SDK is initialized, check for any pending custom actions that couldn't be handled previously (e.g., handler returned false or wasn't available when action arrived). --- .../src/main/java/com/iterable/iterableapi/IterableApi.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index ae7023ea7..4acdd3214 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -489,6 +489,9 @@ private void onForeground() { boolean isNotificationEnabled = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_DEVICE_NOTIFICATIONS_ENABLED, false); if (sharedInstance.isInitialized()) { + // Process any pending actions that couldn't be handled when they arrived + IterablePushNotificationUtil.processPendingAction(_applicationContext); + if (sharedInstance.config.autoPushRegistration && hasStoredPermission && (isNotificationEnabled != systemNotificationEnabled)) { sharedInstance.registerForPush(); }