Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,5 @@ jacoco.exec

IDE
integration-tests/.idea/
.claude/
.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link

@franco-zalamena-iterable franco-zalamena-iterable Jan 20, 2026

Choose a reason for hiding this comment

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

What happens if it does not manage to execute the action? Can't this pendingAction become residual?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch! I've updated the code to clear any previous unhandled pending action when a new push action comes in. This prevents residual actions from accumulating if they were never handled. The logic now clears pendingAction at the start of handlePushAction() before setting up the new action.

Choose a reason for hiding this comment

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

as stated in the other comment, that should be defined by code here, if we should have a pendingAction after not being able to handle it that is fine, but i feel like if this was not handled here it won't be in the next time if nothing changes, so we can just be calling processPendingAction indefinitely with nothing changing

}
}
return handled;
}
Expand All @@ -38,6 +43,20 @@ static void handlePushAction(Context context, Intent intent) {
IterableLogger.e(TAG, "handlePushAction: extras == null, can't handle push action");
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;

Choose a reason for hiding this comment

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

Is this necessary? On line 97 we are already setting the pending action to something else. Is there a place this can be used accidentally that i am missing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

not really necessary not it was just responding to your previous comment and being extra safe for avoiding future bugs

Choose a reason for hiding this comment

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

yeah, but i feel like this is just adding code that virtually does nothing. My comment was more regarding the pending action that would be as left over on that method, if we are just not processing it and that is the correct behavior that's fine, but if we should do something to the pending action if it was not handled that should be clear

}

// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading