From 7af4d8be144d6a702e51ad57c2f2ef6ca39e21ad Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 16 Sep 2026 16:04:02 +0100 Subject: [PATCH 1/8] IterablePushRegistrationTask as Runnable instead of AsyncTask --- CHANGELOG.md | 1 + .../com/iterable/iterableapi/IterableApi.java | 28 +++---- .../iterableapi/IterableExecutors.java | 20 +++++ .../iterableapi/IterablePushRegistration.java | 20 ++--- .../IterablePushRegistrationTask.java | 17 ++--- .../IterableApiIntegrationTest.java | 37 +++++++++ .../iterable/iterableapi/IterableApiTest.java | 75 +++++++++++-------- .../IterablePushRegistrationTaskTest.java | 46 +++++++++++- 8 files changed, 175 insertions(+), 69 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 945c93e0b..b9930c5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Added `IterableConfig.Builder.setInAppColorScheme()` and `setInAppColorSchemeProvider()` to control the color scheme reported to HTML in-app messages. The provider is evaluated for each new in-app message, allowing apps that keep their theme in Jetpack Compose state to return the current `LIGHT` or `DARK` scheme. ### Fixed +- Push token registration and disable operations now run on an SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue, preventing host app background work from delaying device registration while preserving operation order. - Fixed a regression introduced in 3.6.5 ([SDK-717](https://iterable.atlassian.net/browse/SDK-717)) where `IterableCustomActionHandler.handleIterableCustomAction` was repeatedly fired with a stale action on every app foreground and every `initialize()` call after the first push. The `IterableCustomActionHandler` interface documents its boolean return value as "Reserved for future use", causing most clients (and the React Native SDK wrapper) to return `false`. The SDK incorrectly treated `false` as "action not yet consumed" and kept replaying the pending action. The handler is now treated as consumed once invoked, regardless of return value. Retrying only happens when the handler is `null` (SDK not yet initialized), which is the original SDK-307 intent. Affected versions: **3.6.5–3.10.1**. - HTML in-app messages now follow the host activity's Android light-dark configuration instead of always rendering as if the device were in dark mode. Previously the in-app container always reported `prefers-color-scheme: dark` to the message HTML, so a campaign with an `@media (prefers-color-scheme: dark)` block rendered its dark styles even in light mode. Those campaigns now render their light styles in light mode; campaigns that don't declare dark styles, and rendering in dark mode, are unaffected. - Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index e7f9d9d34..7be4c78ae 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -8,7 +8,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; -import androidx.annotation.VisibleForTesting; import androidx.core.app.NotificationManagerCompat; import com.iterable.iterableapi.util.DeviceInfoUtils; import org.json.JSONException; @@ -56,6 +55,7 @@ public class IterableApi { private IterableAuthManager authManager; private ConcurrentHashMap deviceAttributes = new ConcurrentHashMap<>(); private IterableKeychain keychain; + private final IterablePushRegistration pushRegistration; //region Background Initialization - Delegated to IterableBackgroundInitializer //--------------------------------------------------------------------------------------- @@ -970,26 +970,22 @@ static void initializeForPush(@Nullable Context context) { IterableApi() { config = new IterableConfig.Builder().build(); + pushRegistration = new IterablePushRegistration(); } - - @VisibleForTesting IterableApi(IterableInAppManager inAppManager) { - config = new IterableConfig.Builder().build(); - this.inAppManager = inAppManager; - } - - @VisibleForTesting - IterableApi(IterableInAppManager inAppManager, IterableEmbeddedManager embeddedManager) { - config = new IterableConfig.Builder().build(); + this(); this.inAppManager = inAppManager; - this.embeddedManager = embeddedManager; } - - @VisibleForTesting IterableApi(IterableApiClient apiClient, IterableInAppManager inAppManager) { - config = new IterableConfig.Builder().build(); + this(inAppManager); this.apiClient = apiClient; + } + IterableApi(IterableInAppManager inAppManager, IterableEmbeddedManager embeddedManager, + IterablePushRegistration pushRegistration) { + config = new IterableConfig.Builder().build(); this.inAppManager = inAppManager; + this.embeddedManager = embeddedManager; + this.pushRegistration = Objects.requireNonNull(pushRegistration); } //endregion @@ -1673,7 +1669,7 @@ public void updateUser(@NonNull JSONObject dataFields, Boolean mergeNestedObject public void registerForPush() { if (checkSDKInitialization()) { IterablePushRegistrationData data = new IterablePushRegistrationData(_email, _userId, _authToken, getPushIntegrationName(), IterablePushRegistrationData.PushRegistrationAction.ENABLE); - IterablePushRegistration.executePushRegistrationTask(data); + pushRegistration.executePushRegistrationTask(data); } } @@ -1683,7 +1679,7 @@ public void registerForPush() { public void disablePush() { if (checkSDKInitialization()) { IterablePushRegistrationData data = new IterablePushRegistrationData(_email, _userId, _authToken, getPushIntegrationName(), IterablePushRegistrationData.PushRegistrationAction.DISABLE); - IterablePushRegistration.executePushRegistrationTask(data); + pushRegistration.executePushRegistrationTask(data); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java new file mode 100644 index 000000000..a1a89f9ae --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java @@ -0,0 +1,20 @@ +package com.iterable.iterableapi; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +final class IterableExecutors { + private static final Executor SERIAL_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "IterableSerialExecutor"); + thread.setDaemon(true); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + }); + + private IterableExecutors() { + } + + static Executor serial() { + return SERIAL_EXECUTOR; + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java index 3d10c648b..928c3ddb1 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java @@ -1,19 +1,19 @@ package com.iterable.iterableapi; -import androidx.annotation.VisibleForTesting; +import java.util.concurrent.Executor; -class IterablePushRegistration { +final class IterablePushRegistration { + private final Executor executor; - @VisibleForTesting - static IterablePushRegistrationImpl instance = new IterablePushRegistrationImpl(); + IterablePushRegistration() { + this(IterableExecutors.serial()); + } - static void executePushRegistrationTask(IterablePushRegistrationData data) { - instance.executePushRegistrationTask(data); + IterablePushRegistration(Executor executor) { + this.executor = executor; } - static class IterablePushRegistrationImpl { - void executePushRegistrationTask(IterablePushRegistrationData data) { - new IterablePushRegistrationTask().execute(data); - } + void executePushRegistrationTask(IterablePushRegistrationData data) { + executor.execute(new IterablePushRegistrationTask(data)); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java index 613e646a5..5e0dcc539 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java @@ -1,21 +1,23 @@ package com.iterable.iterableapi; import android.content.Context; -import android.os.AsyncTask; /** * Created by David Truong dt@iterable.com */ -class IterablePushRegistrationTask extends AsyncTask { +class IterablePushRegistrationTask implements Runnable { static final String TAG = "IterablePushRegistration"; - IterablePushRegistrationData iterablePushRegistrationData; + final IterablePushRegistrationData iterablePushRegistrationData; + + IterablePushRegistrationTask(IterablePushRegistrationData iterablePushRegistrationData) { + this.iterablePushRegistrationData = iterablePushRegistrationData; + } /** * Registers or disables the device - * @param params Push registration request data */ - protected Void doInBackground(IterablePushRegistrationData... params) { - iterablePushRegistrationData = params[0]; + @Override + public void run() { if (iterablePushRegistrationData.pushIntegrationName != null) { PushRegistrationObject pushRegistrationObject = getDeviceToken(); if (pushRegistrationObject != null) { @@ -42,7 +44,6 @@ protected Void doInBackground(IterablePushRegistrationData... params) { } else { IterableLogger.e("IterablePush", "iterablePushRegistrationData has not been specified"); } - return null; } /** @@ -108,5 +109,3 @@ static class PushRegistrationObject { } } } - - diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java index e0b06c0d4..ee48be1a4 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java @@ -1,6 +1,7 @@ package com.iterable.iterableapi; import android.content.Context; +import android.os.AsyncTask; import org.json.JSONObject; import org.junit.After; @@ -9,6 +10,7 @@ import org.robolectric.shadows.ShadowPausedAsyncTask; import java.io.IOException; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import okhttp3.mockwebserver.MockResponse; @@ -18,6 +20,7 @@ import static android.os.Looper.getMainLooper; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -76,4 +79,38 @@ public void testDisablePushOnLogout() throws Exception { assertEquals("test@email.com", requestJson.getString(IterableConstants.KEY_EMAIL)); assertEquals(TEST_TOKEN, requestJson.getString(IterableConstants.KEY_TOKEN)); } + + @Test + public void testPushRegistrationDoesNotWaitForHostAsyncTaskSerialExecutor() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(true).build()); + + CountDownLatch hostTaskStarted = new CountDownLatch(1); + CountDownLatch releaseHostTask = new CountDownLatch(1); + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + hostTaskStarted.countDown(); + try { + releaseHostTask.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + try { + assertTrue(hostTaskStarted.await(5, TimeUnit.SECONDS)); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(request); + JSONObject requestJson = new JSONObject(request.getBody().readUtf8()); + assertEquals("/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, request.getPath()); + assertEquals("test@email.com", requestJson.getString(IterableConstants.KEY_EMAIL)); + JSONObject deviceJson = requestJson.getJSONObject(IterableConstants.KEY_DEVICE); + assertEquals(TEST_TOKEN, deviceJson.getString(IterableConstants.KEY_TOKEN)); + } finally { + releaseHostTask.countDown(); + } + } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java index a8ee54116..edbae4237 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java @@ -26,6 +26,7 @@ import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import okhttp3.mockwebserver.MockResponse; @@ -60,23 +61,19 @@ public class IterableApiTest extends BaseTest { private MockWebServer server; private IterableApiClient originalApiClient; private IterableApiClient mockApiClient; - private IterablePushRegistration.IterablePushRegistrationImpl originalPushRegistrationImpl; + private Executor pushRegistrationExecutor; @Before public void setUp() { server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); + pushRegistrationExecutor = mock(Executor.class); reInitIterableApi(); - - originalPushRegistrationImpl = IterablePushRegistration.instance; - IterablePushRegistration.instance = mock(IterablePushRegistration.IterablePushRegistrationImpl.class); } @After public void tearDown() throws IOException { - IterablePushRegistration.instance = originalPushRegistrationImpl; - server.shutdown(); server = null; } @@ -84,8 +81,11 @@ public void tearDown() throws IOException { private void reInitIterableApi() { IterableInAppManager inAppManagerMock = mock(IterableInAppManager.class); IterableEmbeddedManager embeddedManagerMock = mock(IterableEmbeddedManager.class); + IterablePushRegistration pushRegistration = + new IterablePushRegistration(pushRegistrationExecutor); - IterableApi.sharedInstance = new IterableApi(inAppManagerMock, embeddedManagerMock); + IterableApi.sharedInstance = new IterableApi( + inAppManagerMock, embeddedManagerMock, pushRegistration); originalApiClient = IterableApi.sharedInstance.apiClient; mockApiClient = spy(originalApiClient); @@ -264,20 +264,22 @@ public void testSetEmailWithAutomaticPushRegistration() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); // Flush any pending looper callbacks from initialize, then reset mock shadowOf(getMainLooper()).idle(); - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); // Check that setEmail calls registerForPush IterableApi.getInstance().setEmail("test@email.com"); - ArgumentCaptor capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.ENABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); - Mockito.reset(IterablePushRegistration.instance); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.ENABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); + Mockito.reset(pushRegistrationExecutor); // Check that setEmail(null) disables the device IterableApi.getInstance().setEmail(null); - capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.DISABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.DISABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); } @Test @@ -287,27 +289,29 @@ public void testSetEmailWithoutAutomaticPushRegistration() throws Exception { // Check that setEmail doesn't call registerForPush or disablePush IterableApi.getInstance().setEmail("test@email.com"); IterableApi.getInstance().setEmail(null); - verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + verify(pushRegistrationExecutor, never()).execute(any(Runnable.class)); } @Test public void testSetUserIdWithAutomaticPushRegistration() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); // Reset after initialize since it may trigger push registration via background init - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); // Check that setUserId calls registerForPush IterableApi.getInstance().setUserId("userId"); - ArgumentCaptor capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.ENABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); - Mockito.reset(IterablePushRegistration.instance); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.ENABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); + Mockito.reset(pushRegistrationExecutor); // Check that setUserId(null) disables the device IterableApi.getInstance().setUserId(null); - capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.DISABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.DISABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); } @Test @@ -317,25 +321,25 @@ public void testSetUserIdWithoutAutomaticPushRegistration() throws Exception { // Check that setEmail calls registerForPush IterableApi.getInstance().setUserId("userId"); IterableApi.getInstance().setUserId(null); - verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + verify(pushRegistrationExecutor, never()).execute(any(Runnable.class)); } @Test public void testNoAutomaticPushRegistrationOnInit() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); IterableApi.getInstance().setEmail("test@email.com"); - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); reInitIterableApi(); IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); - verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + verify(pushRegistrationExecutor, never()).execute(any(Runnable.class)); } @Test public void testAutomaticPushRegistrationOnInitAndForeground() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); IterableApi.getInstance().setEmail("test@email.com"); - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); reInitIterableApi(); IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); @@ -343,14 +347,23 @@ public void testAutomaticPushRegistrationOnInitAndForeground() throws Exception IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); ActivityController activityController = Robolectric.buildActivity(Activity.class).create().start().resume(); - ArgumentCaptor capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance, atLeastOnce()).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.ENABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); + ArgumentCaptor capturedTask = ArgumentCaptor.forClass(Runnable.class); + verify(pushRegistrationExecutor, atLeastOnce()).execute(capturedTask.capture()); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.ENABLE, + ((IterablePushRegistrationTask) capturedTask.getValue()).iterablePushRegistrationData.pushRegistrationAction + ); activityController.pause().stop().destroy(); IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); } + private IterablePushRegistrationData captureScheduledPushRegistrationData() { + ArgumentCaptor capturedTask = ArgumentCaptor.forClass(Runnable.class); + verify(pushRegistrationExecutor).execute(capturedTask.capture()); + return ((IterablePushRegistrationTask) capturedTask.getValue()).iterablePushRegistrationData; + } + @Test public void testPushRegistrationDeviceFields() throws Exception { server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java index 194ca8546..dbe75e43d 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java @@ -5,14 +5,19 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.robolectric.shadows.ShadowLog; import java.util.HashMap; +import java.util.concurrent.Executor; import okhttp3.mockwebserver.MockWebServer; import static android.os.Looper.getMainLooper; import static com.iterable.iterableapi.IterableTestUtils.stubAnyRequestReturningStatusCode; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.nullable; @@ -52,6 +57,7 @@ public void setUp() throws Exception { IterablePushRegistrationTask.Util.instance = pushRegistrationUtilMock; when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + ShadowLog.clear(); } @After @@ -69,7 +75,7 @@ public void testEnableDevice() throws Exception { IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); IterableApi.getInstance().setDeviceAttribute(DEVICE_ATTRIBUTES_KEY, DEVICE_ATTRIBUTES_VALUE); - new IterablePushRegistrationTask().execute(data); + new IterablePushRegistrationTask(data).run(); deviceAttributes.put(DEVICE_ATTRIBUTES_KEY, DEVICE_ATTRIBUTES_VALUE); shadowOf(getMainLooper()).idle(); @@ -84,9 +90,43 @@ public void testDisableDevice() throws Exception { when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn("testToken"); IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.DISABLE); - new IterablePushRegistrationTask().execute(data); + new IterablePushRegistrationTask(data).run(); shadowOf(getMainLooper()).idle(); verify(apiMock).disableToken(eq(IterableTestUtils.userEmail), isNull(), isNull(), eq(TEST_TOKEN), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); } -} \ No newline at end of file + + @Test + public void testDispatcherUsesConfiguredExecutor() { + Executor executor = mock(Executor.class); + IterablePushRegistration pushRegistration = new IterablePushRegistration(executor); + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); + + pushRegistration.executePushRegistrationTask(data); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(executor).execute(taskCaptor.capture()); + assertTrue(taskCaptor.getValue() instanceof IterablePushRegistrationTask); + } + + @Test + public void testFirebaseTokenFailureIsLoggedAndContained() { + RuntimeException tokenException = new RuntimeException("Unable to fetch token"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenThrow(tokenException); + + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); + new IterablePushRegistrationTask(data).run(); + + verify(apiMock, never()).registerDeviceToken( + any(String.class), + nullable(String.class), + nullable(String.class), + any(String.class), + any(String.class), + anyMap() + ); + assertTrue(ShadowLog.getLogsForTag(IterablePushRegistrationTask.TAG).stream() + .anyMatch(item -> item.msg.contains("Exception while retrieving the device token") + && item.throwable == tokenException)); + } +} From 2410af828cc102c35e9d5e1213558117d0a6e07e Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 09:52:49 +0100 Subject: [PATCH 2/8] [SDK-705] Preserve push registration request ordering --- .../com/iterable/iterableapi/IterableApi.java | 31 ++++--- .../iterableapi/IterableApiClient.java | 41 +++++++- .../IterablePushRegistrationTask.java | 3 +- .../iterableapi/IterableRequestTask.java | 68 +++++++++++++- .../iterableapi/OnlineRequestProcessor.java | 29 +++++- .../IterableApiIntegrationTest.java | 93 ++++++++++++++++++- .../IterablePushRegistrationTaskTest.java | 32 ++++++- 7 files changed, 267 insertions(+), 30 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 7be4c78ae..7bceb6812 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -17,6 +17,7 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; /** * Created by David Truong dt@iterable.com @@ -694,22 +695,26 @@ void setAuthToken(String authToken, boolean bypassAuth) { } protected void registerDeviceToken(final @Nullable String email, final @Nullable String userId, final @Nullable String authToken, final @NonNull String applicationName, final @NonNull String deviceToken, final Map deviceAttributes) { - if (deviceToken != null) { - if (!checkSDKInitialization() && _userIdUnknown == null) { - if (sharedInstance.config.enableUnknownUserActivation) { - unknownUserManager.trackUnknownTokenRegistration(deviceToken); - } - return; + registerDeviceToken(email, userId, authToken, applicationName, deviceToken, deviceAttributes, runnable -> new Thread(runnable).start()); + } + + void registerDeviceToken(final @Nullable String email, final @Nullable String userId, final @Nullable String authToken, final @NonNull String applicationName, final @NonNull String deviceToken, final Map deviceAttributes, Executor executor) { + if (shouldSubmitDeviceTokenRegistration(deviceToken)) { + executor.execute(() -> registerDeviceToken(email, userId, authToken, applicationName, deviceToken, null, deviceAttributes)); + } + } + private boolean shouldSubmitDeviceTokenRegistration(@Nullable String deviceToken) { + if (deviceToken == null) { + return false; + } + if (!checkSDKInitialization() && _userIdUnknown == null) { + if (sharedInstance.config.enableUnknownUserActivation) { + unknownUserManager.trackUnknownTokenRegistration(deviceToken); } - final Thread registrationThread = new Thread(new Runnable() { - public void run() { - registerDeviceToken(email, userId, authToken, applicationName, deviceToken, null, deviceAttributes); - } - }); - registrationThread.start(); + return false; } + return true; } - protected void disableToken(@Nullable String email, @Nullable String userId, @NonNull String token) { disableToken(email, userId, null, token, null, null); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index d0d3ee60d..b1ff5a2ed 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -16,10 +16,14 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; class IterableApiClient { private static final String TAG = "IterableApiClient"; private final @NonNull AuthProvider authProvider; + private final OnlineRequestProcessor pushRegistrationRequestProcessor; + // A newer push action invalidates retries from earlier registration or disable requests. + private final AtomicLong pushRegistrationRequestGeneration = new AtomicLong(); private RequestProcessor requestProcessor; interface AuthProvider { @@ -42,6 +46,8 @@ interface AuthProvider { IterableApiClient(@NonNull AuthProvider authProvider) { this.authProvider = authProvider; + pushRegistrationRequestProcessor = + new OnlineRequestProcessor(IterableExecutors.serial()); } private RequestProcessor getRequestProcessor() { @@ -618,7 +624,13 @@ protected void disableToken(@Nullable String email, @Nullable String userId, @Nu requestJSON.put(IterableConstants.KEY_USER_ID, userId); } - sendPostRequest(IterableConstants.ENDPOINT_DISABLE_DEVICE, requestJSON, authToken, onSuccess, onFailure); + sendPushRegistrationPostRequest( + IterableConstants.ENDPOINT_DISABLE_DEVICE, + requestJSON, + authToken, + onSuccess, + onFailure + ); } catch (JSONException e) { e.printStackTrace(); } @@ -669,7 +681,13 @@ protected void registerDeviceToken(@Nullable String email, @Nullable String user requestJSON.put(IterableConstants.KEY_PREFER_USER_ID, true); } - sendPostRequest(IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, requestJSON, authToken, successHandler, failureHandler); + sendPushRegistrationPostRequest( + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + requestJSON, + authToken, + successHandler, + failureHandler + ); } catch (JSONException e) { IterableLogger.e(TAG, "registerDeviceToken: exception", e); } @@ -768,6 +786,25 @@ void sendPostRequest(@NonNull String resourcePath, @NonNull JSONObject json, @Nu getRequestProcessor().processPostRequest(authProvider.getApiKey(), resourcePath, json, authToken, onSuccess, onFailure); } + private void sendPushRegistrationPostRequest( + @NonNull String resourcePath, + @NonNull JSONObject json, + @Nullable String authToken, + @Nullable IterableHelper.SuccessHandler onSuccess, + @Nullable IterableHelper.FailureHandler onFailure + ) { + long requestGeneration = pushRegistrationRequestGeneration.incrementAndGet(); + pushRegistrationRequestProcessor.processPostRequest( + authProvider.getApiKey(), + resourcePath, + json, + authToken, + onSuccess, + onFailure, + () -> requestGeneration == pushRegistrationRequestGeneration.get() + ); + } + /** * Sends a GET request to Iterable. * Performs network operations on an async thread instead of the main thread. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java index 5e0dcc539..1b69753e5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java @@ -28,7 +28,8 @@ public void run() { iterablePushRegistrationData.authToken, iterablePushRegistrationData.pushIntegrationName, pushRegistrationObject.token, - IterableApi.getInstance().getDeviceAttributes()); + IterableApi.getInstance().getDeviceAttributes(), + Runnable::run); } else if (iterablePushRegistrationData.pushRegistrationAction == IterablePushRegistrationData.PushRegistrationAction.DISABLE) { IterableApi.sharedInstance.disableToken( diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index fb728bfdb..07920f9b0 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -27,6 +27,7 @@ import java.util.Date; import java.util.Iterator; import java.util.Objects; +import java.util.concurrent.Executor; /** * Async task to handle sending data to the Iterable server @@ -47,6 +48,15 @@ class IterableRequestTask extends AsyncTask 0) { iterableApiRequest = params[0]; } + if (iterableApiRequest == null || (retry && !iterableApiRequest.canRetry())) { + return null; + } return executeApiRequest(iterableApiRequest); } @@ -70,8 +83,9 @@ private static void retryRequestWithNewAuthToken(String newAuthToken, IterableAp iterableApiRequest.requestType, newAuthToken, iterableApiRequest.legacyCallback); - IterableRequestTask requestTask = new IterableRequestTask(); - requestTask.execute(request); + request.copyExecutionContextFrom(iterableApiRequest); + IterableRequestTask requestTask = new IterableRequestTask(true); + executeRetry(requestTask, request); } @WorkerThread @@ -367,6 +381,10 @@ private static boolean isSensitive(String key) { @Override protected void onPostExecute(IterableApiResponse response) { + if (response == null || (retry && !iterableApiRequest.canRetry())) { + super.onPostExecute(response); + return; + } if (shouldRetry(response)) { retryRequestWithDelay(); @@ -384,11 +402,14 @@ protected void onPostExecute(IterableApiResponse response) { } private boolean shouldRetry(IterableApiResponse response) { - return !response.success && response.responseCode >= 500 && retryCount <= MAX_RETRY_COUNT; + return iterableApiRequest.canRetry() + && !response.success + && response.responseCode >= 500 + && retryCount <= MAX_RETRY_COUNT; } private void retryRequestWithDelay() { - final IterableRequestTask requestTask = new IterableRequestTask(); + final IterableRequestTask requestTask = new IterableRequestTask(true); requestTask.setRetryCount(retryCount + 1); long delay = (retryCount > 2) ? RETRY_DELAY_MS * retryCount : 0; @@ -396,11 +417,24 @@ private void retryRequestWithDelay() { handler.postDelayed(new Runnable() { @Override public void run() { - requestTask.execute(iterableApiRequest); + executeRetry(requestTask, iterableApiRequest); } }, delay); } + private static void executeRetry(IterableRequestTask requestTask, IterableApiRequest request) { + if (!request.canRetry()) { + return; + } + + Executor retryExecutor = request.getRetryExecutor(); + if (retryExecutor != null) { + requestTask.executeOnExecutor(retryExecutor, request); + } else { + requestTask.execute(request); + } + } + private void handleSuccessResponse(IterableApiResponse response) { if (!Objects.equals(iterableApiRequest.resourcePath, ENDPOINT_GET_REMOTE_CONFIGURATION) && !Objects.equals(iterableApiRequest.resourcePath, ENDPOINT_DISABLE_DEVICE)) { IterableApi.getInstance().getAuthManager().resetFailedAuth(); @@ -462,6 +496,8 @@ class IterableApiRequest { final JSONObject json; final String requestType; final String authToken; + private @Nullable Executor retryExecutor; + private @Nullable IterableRequestRetryState retryState; private ProcessorType processorType = ProcessorType.ONLINE; IterableHelper.IterableActionHandler legacyCallback; @@ -493,6 +529,24 @@ void setProcessorType(ProcessorType processorType) { this.processorType = processorType; } + void setExecutionContext(@Nullable Executor retryExecutor, @Nullable IterableRequestRetryState retryState) { + this.retryExecutor = retryExecutor; + this.retryState = retryState; + } + + void copyExecutionContextFrom(@NonNull IterableApiRequest request) { + setExecutionContext(request.retryExecutor, request.retryState); + } + + @Nullable + Executor getRetryExecutor() { + return retryExecutor; + } + + boolean canRetry() { + return retryState == null || retryState.canRetry(); + } + IterableApiRequest(String apiKey, String baseUrl, String resourcePath, JSONObject json, String requestType, String authToken, IterableHelper.SuccessHandler onSuccess, IterableHelper.FailureHandler onFailure) { this.apiKey = apiKey; this.baseUrl = baseUrl; @@ -567,6 +621,10 @@ static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable String authTok } } +interface IterableRequestRetryState { + boolean canRetry(); +} + class IterableApiResponse { final boolean success; final int responseCode; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java index 013f7f0ad..95fd56ee6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java @@ -10,27 +10,50 @@ import org.json.JSONObject; import java.util.Date; +import java.util.concurrent.Executor; class OnlineRequestProcessor implements RequestProcessor { private static final String TAG = "OnlineRequestProcessor"; + private final @Nullable Executor executor; + + OnlineRequestProcessor() { + this(null); + } + + OnlineRequestProcessor(@Nullable Executor executor) { + this.executor = executor; + } @Override public void processGetRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.IterableActionHandler onCallback) { IterableApiRequest request = new IterableApiRequest(apiKey, resourcePath, addCreatedAtToJson(json), IterableApiRequest.GET, authToken, onCallback); - new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); + executeRequest(request, null); } @Override public void processGetRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { IterableApiRequest request = new IterableApiRequest(apiKey, resourcePath, addCreatedAtToJson(json), IterableApiRequest.GET, authToken, onSuccess, onFailure); - new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); + executeRequest(request, null); } @Override public void processPostRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + processPostRequest(apiKey, resourcePath, json, authToken, onSuccess, onFailure, null); + } + + void processPostRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure, @Nullable IterableRequestRetryState retryState) { IterableApiRequest request = new IterableApiRequest(apiKey, resourcePath, addCreatedAtToJson(json), IterableApiRequest.POST, authToken, onSuccess, onFailure); - new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); + executeRequest(request, retryState); + } + + private void executeRequest(@NonNull IterableApiRequest request, @Nullable IterableRequestRetryState retryState) { + request.setExecutionContext(executor, retryState); + new IterableRequestTask().executeOnExecutor(getExecutor(), request); + } + + private @NonNull Executor getExecutor() { + return executor != null ? executor : AsyncTask.THREAD_POOL_EXECUTOR; } @Override diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java index ee48be1a4..6549c1753 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java @@ -9,8 +9,8 @@ import org.junit.Test; import org.robolectric.shadows.ShadowPausedAsyncTask; -import java.io.IOException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import okhttp3.mockwebserver.MockResponse; @@ -47,7 +47,8 @@ public void setUp() { } @After - public void tearDown() throws IOException { + public void tearDown() throws Exception { + waitForRequestWorkToFinish(); IterablePushRegistrationTask.Util.instance = originalPushRegistrationUtil; server.shutdown(); @@ -81,7 +82,69 @@ public void testDisablePushOnLogout() throws Exception { } @Test - public void testPushRegistrationDoesNotWaitForHostAsyncTaskSerialExecutor() throws Exception { + public void testRegisterForPushFollowedByDisablePushPreservesRequestOrder() throws Exception { + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(false).build()); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableApi.getInstance().registerForPush(); + IterableApi.getInstance().disablePush(); + + RecordedRequest registrationRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(registrationRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + registrationRequest.getPath() + ); + + RecordedRequest disableRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(disableRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, + disableRequest.getPath() + ); + } + + @Test + public void testDisablePushPreventsFailedRegistrationFromRetrying() throws Exception { + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(false).build()); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableApi.getInstance().registerForPush(); + IterableApi.getInstance().disablePush(); + + RecordedRequest registrationRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(registrationRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + registrationRequest.getPath() + ); + + RecordedRequest disableRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(disableRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, + disableRequest.getPath() + ); + + waitForRequestWorkToFinish(); + + assertEquals(2, server.getRequestCount()); + } + + @Test + public void testPushRegistrationRetryDoesNotWaitForHostAsyncTaskSerialExecutor() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500).setBody("{}")); server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); @@ -109,8 +172,32 @@ public void testPushRegistrationDoesNotWaitForHostAsyncTaskSerialExecutor() thro assertEquals("test@email.com", requestJson.getString(IterableConstants.KEY_EMAIL)); JSONObject deviceJson = requestJson.getJSONObject(IterableConstants.KEY_DEVICE); assertEquals(TEST_TOKEN, deviceJson.getString(IterableConstants.KEY_TOKEN)); + + waitForExecutor(IterableExecutors.serial()); + shadowOf(getMainLooper()).idle(); + + RecordedRequest retryRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(retryRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + retryRequest.getPath() + ); } finally { releaseHostTask.countDown(); } } + + private void waitForRequestWorkToFinish() throws InterruptedException { + waitForExecutor(IterableExecutors.serial()); + shadowOf(getMainLooper()).idle(); + waitForExecutor(IterableExecutors.serial()); + waitForExecutor(AsyncTask.SERIAL_EXECUTOR); + shadowOf(getMainLooper()).idle(); + } + + private void waitForExecutor(Executor executor) throws InterruptedException { + CountDownLatch executorDrained = new CountDownLatch(1); + executor.execute(executorDrained::countDown); + assertTrue(executorDrained.await(5, TimeUnit.SECONDS)); + } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java index dbe75e43d..f032f2aaa 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java @@ -10,17 +10,20 @@ import java.util.HashMap; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.mockwebserver.MockWebServer; import static android.os.Looper.getMainLooper; import static com.iterable.iterableapi.IterableTestUtils.stubAnyRequestReturningStatusCode; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -72,14 +75,36 @@ public void tearDown() throws Exception { @Test public void testEnableDevice() throws Exception { when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + AtomicReference registrationSubmissionThread = new AtomicReference<>(); + doAnswer(invocation -> { + registrationSubmissionThread.set(Thread.currentThread()); + return null; + }).when(apiMock).registerDeviceToken( + nullable(String.class), + nullable(String.class), + nullable(String.class), + any(String.class), + any(String.class), + anyMap(), + any(Executor.class) + ); + Thread taskExecutionThread = Thread.currentThread(); IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); IterableApi.getInstance().setDeviceAttribute(DEVICE_ATTRIBUTES_KEY, DEVICE_ATTRIBUTES_VALUE); new IterablePushRegistrationTask(data).run(); deviceAttributes.put(DEVICE_ATTRIBUTES_KEY, DEVICE_ATTRIBUTES_VALUE); - shadowOf(getMainLooper()).idle(); - verify(apiMock).registerDeviceToken(eq(IterableTestUtils.userEmail), nullable(String.class), isNull(), eq(INTEGRATION_NAME), eq(TEST_TOKEN), eq(deviceAttributes)); + assertSame(taskExecutionThread, registrationSubmissionThread.get()); + verify(apiMock).registerDeviceToken( + eq(IterableTestUtils.userEmail), + nullable(String.class), + isNull(), + eq(INTEGRATION_NAME), + eq(TEST_TOKEN), + eq(deviceAttributes), + any(Executor.class) + ); verify(apiMock, never()).disableToken(eq(IterableTestUtils.userEmail), nullable(String.class), nullable(String.class), any(String.class), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); } @@ -123,7 +148,8 @@ public void testFirebaseTokenFailureIsLoggedAndContained() { nullable(String.class), any(String.class), any(String.class), - anyMap() + anyMap(), + any(Executor.class) ); assertTrue(ShadowLog.getLogsForTag(IterablePushRegistrationTask.TAG).stream() .anyMatch(item -> item.msg.contains("Exception while retrieving the device token") From 3b07d4b475da17269d7e68b0de61209d4ccf321d Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 13:34:01 +0100 Subject: [PATCH 3/8] [SDK-705] Isolate push execution lane --- CHANGELOG.md | 2 +- .../java/com/iterable/iterableapi/IterableApiClient.java | 2 +- .../java/com/iterable/iterableapi/IterableExecutors.java | 8 ++++---- .../iterable/iterableapi/IterablePushRegistration.java | 2 +- .../iterable/iterableapi/IterableApiIntegrationTest.java | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9930c5dc..546b10aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Added `IterableConfig.Builder.setInAppColorScheme()` and `setInAppColorSchemeProvider()` to control the color scheme reported to HTML in-app messages. The provider is evaluated for each new in-app message, allowing apps that keep their theme in Jetpack Compose state to return the current `LIGHT` or `DARK` scheme. ### Fixed -- Push token registration and disable operations now run on an SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue, preventing host app background work from delaying device registration while preserving operation order. +- Push token registration and disable operations now run on a dedicated SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue, preventing host app background work from delaying device registration while preserving operation order. - Fixed a regression introduced in 3.6.5 ([SDK-717](https://iterable.atlassian.net/browse/SDK-717)) where `IterableCustomActionHandler.handleIterableCustomAction` was repeatedly fired with a stale action on every app foreground and every `initialize()` call after the first push. The `IterableCustomActionHandler` interface documents its boolean return value as "Reserved for future use", causing most clients (and the React Native SDK wrapper) to return `false`. The SDK incorrectly treated `false` as "action not yet consumed" and kept replaying the pending action. The handler is now treated as consumed once invoked, regardless of return value. Retrying only happens when the handler is `null` (SDK not yet initialized), which is the original SDK-307 intent. Affected versions: **3.6.5–3.10.1**. - HTML in-app messages now follow the host activity's Android light-dark configuration instead of always rendering as if the device were in dark mode. Previously the in-app container always reported `prefers-color-scheme: dark` to the message HTML, so a campaign with an `@media (prefers-color-scheme: dark)` block rendered its dark styles even in light mode. Those campaigns now render their light styles in light mode; campaigns that don't declare dark styles, and rendering in dark mode, are unaffected. - Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index b1ff5a2ed..623b169e1 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -47,7 +47,7 @@ interface AuthProvider { IterableApiClient(@NonNull AuthProvider authProvider) { this.authProvider = authProvider; pushRegistrationRequestProcessor = - new OnlineRequestProcessor(IterableExecutors.serial()); + new OnlineRequestProcessor(IterableExecutors.push()); } private RequestProcessor getRequestProcessor() { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java index a1a89f9ae..fbb69de50 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java @@ -4,8 +4,8 @@ import java.util.concurrent.Executors; final class IterableExecutors { - private static final Executor SERIAL_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "IterableSerialExecutor"); + private static final Executor PUSH_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "IterablePushExecutor"); thread.setDaemon(true); thread.setPriority(Thread.NORM_PRIORITY); return thread; @@ -14,7 +14,7 @@ final class IterableExecutors { private IterableExecutors() { } - static Executor serial() { - return SERIAL_EXECUTOR; + static Executor push() { + return PUSH_EXECUTOR; } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java index 928c3ddb1..822c70937 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java @@ -6,7 +6,7 @@ final class IterablePushRegistration { private final Executor executor; IterablePushRegistration() { - this(IterableExecutors.serial()); + this(IterableExecutors.push()); } IterablePushRegistration(Executor executor) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java index 6549c1753..ae50759e2 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java @@ -173,7 +173,7 @@ public void testPushRegistrationRetryDoesNotWaitForHostAsyncTaskSerialExecutor() JSONObject deviceJson = requestJson.getJSONObject(IterableConstants.KEY_DEVICE); assertEquals(TEST_TOKEN, deviceJson.getString(IterableConstants.KEY_TOKEN)); - waitForExecutor(IterableExecutors.serial()); + waitForExecutor(IterableExecutors.push()); shadowOf(getMainLooper()).idle(); RecordedRequest retryRequest = server.takeRequest(5, TimeUnit.SECONDS); @@ -188,9 +188,9 @@ public void testPushRegistrationRetryDoesNotWaitForHostAsyncTaskSerialExecutor() } private void waitForRequestWorkToFinish() throws InterruptedException { - waitForExecutor(IterableExecutors.serial()); + waitForExecutor(IterableExecutors.push()); shadowOf(getMainLooper()).idle(); - waitForExecutor(IterableExecutors.serial()); + waitForExecutor(IterableExecutors.push()); waitForExecutor(AsyncTask.SERIAL_EXECUTOR); shadowOf(getMainLooper()).idle(); } From 1e3be8c3091b0c3c63e0fa0a1bc41e945ff79cc5 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 16:48:07 +0100 Subject: [PATCH 4/8] [SDK-705] Keep push request retries on SDK executor --- CHANGELOG.md | 2 +- .../iterableapi/IterableApiClient.java | 4 +- .../iterableapi/IterableExecutors.java | 9 + ...rablePushRegistrationRequestProcessor.java | 249 ++++++++++++++++++ .../iterableapi/IterableRequestTask.java | 102 +++---- .../iterableapi/OnlineRequestProcessor.java | 29 +- .../IterableApiIntegrationTest.java | 27 ++ 7 files changed, 325 insertions(+), 97 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 546b10aa5..5a8649dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Added `IterableConfig.Builder.setInAppColorScheme()` and `setInAppColorSchemeProvider()` to control the color scheme reported to HTML in-app messages. The provider is evaluated for each new in-app message, allowing apps that keep their theme in Jetpack Compose state to return the current `LIGHT` or `DARK` scheme. ### Fixed -- Push token registration and disable operations now run on a dedicated SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue, preventing host app background work from delaying device registration while preserving operation order. +- Push token registration and disable operations, including their API requests and retries, now run on a dedicated SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue. This prevents host app background work from delaying device registration, preserves operation order, and keeps client callbacks on the main thread. - Fixed a regression introduced in 3.6.5 ([SDK-717](https://iterable.atlassian.net/browse/SDK-717)) where `IterableCustomActionHandler.handleIterableCustomAction` was repeatedly fired with a stale action on every app foreground and every `initialize()` call after the first push. The `IterableCustomActionHandler` interface documents its boolean return value as "Reserved for future use", causing most clients (and the React Native SDK wrapper) to return `false`. The SDK incorrectly treated `false` as "action not yet consumed" and kept replaying the pending action. The handler is now treated as consumed once invoked, regardless of return value. Retrying only happens when the handler is `null` (SDK not yet initialized), which is the original SDK-307 intent. Affected versions: **3.6.5–3.10.1**. - HTML in-app messages now follow the host activity's Android light-dark configuration instead of always rendering as if the device were in dark mode. Previously the in-app container always reported `prefers-color-scheme: dark` to the message HTML, so a campaign with an `@media (prefers-color-scheme: dark)` block rendered its dark styles even in light mode. Those campaigns now render their light styles in light mode; campaigns that don't declare dark styles, and rendering in dark mode, are unaffected. - Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index 623b169e1..fb331b12c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -21,7 +21,7 @@ class IterableApiClient { private static final String TAG = "IterableApiClient"; private final @NonNull AuthProvider authProvider; - private final OnlineRequestProcessor pushRegistrationRequestProcessor; + private final IterablePushRegistrationRequestProcessor pushRegistrationRequestProcessor; // A newer push action invalidates retries from earlier registration or disable requests. private final AtomicLong pushRegistrationRequestGeneration = new AtomicLong(); private RequestProcessor requestProcessor; @@ -47,7 +47,7 @@ interface AuthProvider { IterableApiClient(@NonNull AuthProvider authProvider) { this.authProvider = authProvider; pushRegistrationRequestProcessor = - new OnlineRequestProcessor(IterableExecutors.push()); + new IterablePushRegistrationRequestProcessor(); } private RequestProcessor getRequestProcessor() { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java index fbb69de50..0a76e5247 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java @@ -1,5 +1,8 @@ package com.iterable.iterableapi; +import android.os.Handler; +import android.os.Looper; + import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -10,6 +13,8 @@ final class IterableExecutors { thread.setPriority(Thread.NORM_PRIORITY); return thread; }); + private static final Executor MAIN_EXECUTOR = + runnable -> new Handler(Looper.getMainLooper()).post(runnable); private IterableExecutors() { } @@ -17,4 +22,8 @@ private IterableExecutors() { static Executor push() { return PUSH_EXECUTOR; } + + static Executor main() { + return MAIN_EXECUTOR; + } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java new file mode 100644 index 000000000..f99f5623c --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java @@ -0,0 +1,249 @@ +package com.iterable.iterableapi; + +import static com.iterable.iterableapi.IterableConstants.ENDPOINT_DISABLE_DEVICE; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Date; +import java.util.Objects; +import java.util.concurrent.Executor; + +final class IterablePushRegistrationRequestProcessor { + private static final String TAG = "IterablePushRegistrationRequestProcessor"; + + interface RetryScheduler { + void schedule(Runnable runnable, long delayMs); + } + + private static final RetryScheduler SDK_RETRY_SCHEDULER = + (runnable, delayMs) -> + new Handler(Looper.getMainLooper()).postDelayed(runnable, delayMs); + + private final Executor requestExecutor; + private final Executor callbackExecutor; + private final RetryScheduler retryScheduler; + + IterablePushRegistrationRequestProcessor() { + this( + IterableExecutors.push(), + IterableExecutors.main(), + SDK_RETRY_SCHEDULER + ); + } + + IterablePushRegistrationRequestProcessor( + Executor requestExecutor, + Executor callbackExecutor, + RetryScheduler retryScheduler + ) { + this.requestExecutor = requestExecutor; + this.callbackExecutor = callbackExecutor; + this.retryScheduler = retryScheduler; + } + + void processPostRequest( + @Nullable String apiKey, + @NonNull String resourcePath, + @NonNull JSONObject json, + @Nullable String authToken, + @Nullable IterableHelper.SuccessHandler onSuccess, + @Nullable IterableHelper.FailureHandler onFailure, + @NonNull PushRegistrationRetryState retryState + ) { + IterableApiRequest request = new IterableApiRequest( + apiKey, + resourcePath, + addCreatedAtToJson(json), + IterableApiRequest.POST, + authToken, + onSuccess, + onFailure + ); + execute(request, retryState, 0, false); + } + + void scheduleRetry( + IterableApiRequest request, + @NonNull PushRegistrationRetryState retryState, + int retryCount, + long delayMs + ) { + retryScheduler.schedule( + () -> execute(request, retryState, retryCount, true), + delayMs + ); + } + + void retryWithNewAuthToken( + String newAuthToken, + IterableApiRequest request, + @NonNull PushRegistrationRetryState retryState + ) { + IterableApiRequest retryRequest = new IterableApiRequest( + request.apiKey, + request.resourcePath, + request.json, + request.requestType, + newAuthToken, + request.legacyCallback + ); + execute(retryRequest, retryState, 0, true); + } + + void deliverResult(Runnable runnable) { + callbackExecutor.execute(runnable); + } + + private void execute( + IterableApiRequest request, + @NonNull PushRegistrationRetryState retryState, + int retryCount, + boolean retry + ) { + requestExecutor.execute(new IterablePushRegistrationRequestTask( + request, + retryState, + retryCount, + retry, + this + )); + } + + private JSONObject addCreatedAtToJson(JSONObject json) { + try { + long createdAt; + if (json.has(IterableConstants.KEY_CREATED_AT)) { + createdAt = Long.parseLong( + json.getString(IterableConstants.KEY_CREATED_AT) + ); + } else { + createdAt = new Date().getTime() / 1000; + } + json.put(IterableConstants.KEY_CREATED_AT, createdAt); + } catch (JSONException e) { + IterableLogger.e( + TAG, + "Could not add createdAt timestamp to json object" + ); + } + return json; + } +} + +final class IterablePushRegistrationRequestTask implements Runnable { + private final IterableApiRequest request; + private final PushRegistrationRetryState retryState; + private final int retryCount; + private final boolean retry; + private final IterablePushRegistrationRequestProcessor requestProcessor; + + IterablePushRegistrationRequestTask( + IterableApiRequest request, + PushRegistrationRetryState retryState, + int retryCount, + boolean retry, + IterablePushRegistrationRequestProcessor requestProcessor + ) { + this.request = request; + this.retryState = retryState; + this.retryCount = retryCount; + this.retry = retry; + this.requestProcessor = requestProcessor; + } + + @Override + public void run() { + if (retry && !retryState.canRetry()) { + return; + } + + IterableApiResponse response = IterableRequestTask.executeApiRequest( + request, + (newAuthToken, originalRequest) -> + requestProcessor.retryWithNewAuthToken( + newAuthToken, + originalRequest, + retryState + ) + ); + requestProcessor.deliverResult(() -> handleResponse(response)); + } + + void handleResponse(IterableApiResponse response) { + if (response == null || (retry && !retryState.canRetry())) { + return; + } + + if (shouldRetry(response)) { + int nextRetryCount = retryCount + 1; + long delayMs = retryCount > 2 + ? IterableRequestTask.RETRY_DELAY_MS * retryCount + : 0; + requestProcessor.scheduleRetry( + request, + retryState, + nextRetryCount, + delayMs + ); + return; + } + + if (response.success) { + handleSuccess(response); + } else { + handleFailure(response); + } + + if (request.legacyCallback != null) { + request.legacyCallback.execute(response.responseBody); + } + } + + private boolean shouldRetry(IterableApiResponse response) { + return retryState.canRetry() + && !response.success + && response.responseCode >= 500 + && retryCount <= IterableRequestTask.MAX_RETRY_COUNT; + } + + private void handleSuccess(IterableApiResponse response) { + if (!Objects.equals(request.resourcePath, ENDPOINT_DISABLE_DEVICE)) { + IterableApi.getInstance().getAuthManager().resetFailedAuth(); + IterableApi.getInstance().getAuthManager().pauseAuthRetries(false); + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); + } + + if (request.successCallback != null) { + request.successCallback.onSuccess(response.responseJson); + } + } + + private void handleFailure(IterableApiResponse response) { + if (request.failureCallback == null) { + return; + } + + JSONObject responseJson = response.responseJson; + if (responseJson != null) { + try { + responseJson.put( + IterableConstants.HTTP_STATUS_CODE, + response.responseCode + ); + } catch (JSONException ignored) { + } + } + request.failureCallback.onFailure(response.errorMessage, responseJson); + } +} + +interface PushRegistrationRetryState { + boolean canRetry(); +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index 07920f9b0..db2822496 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -27,7 +27,6 @@ import java.util.Date; import java.util.Iterator; import java.util.Objects; -import java.util.concurrent.Executor; /** * Async task to handle sending data to the Iterable server @@ -48,15 +47,6 @@ class IterableRequestTask extends AsyncTask 0) { iterableApiRequest = params[0]; } - if (iterableApiRequest == null || (retry && !iterableApiRequest.canRetry())) { - return null; - } return executeApiRequest(iterableApiRequest); } @@ -83,13 +70,23 @@ private static void retryRequestWithNewAuthToken(String newAuthToken, IterableAp iterableApiRequest.requestType, newAuthToken, iterableApiRequest.legacyCallback); - request.copyExecutionContextFrom(iterableApiRequest); - IterableRequestTask requestTask = new IterableRequestTask(true); - executeRetry(requestTask, request); + IterableRequestTask requestTask = new IterableRequestTask(); + requestTask.execute(request); } @WorkerThread static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiRequest) { + return executeApiRequest( + iterableApiRequest, + IterableRequestTask::retryRequestWithNewAuthToken + ); + } + + @WorkerThread + static IterableApiResponse executeApiRequest( + IterableApiRequest iterableApiRequest, + IterableRequestAuthRetryHandler authRetryHandler + ) { IterableApiResponse apiResponse = null; String requestResult = null; @@ -218,7 +215,7 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "JWT Authorization header error"); IterableApi.getInstance().getAuthManager().handleAuthFailure(iterableApiRequest.authToken, getMappedErrorCodeForMessage(jsonResponse)); - handleJwtAuthRetry(iterableApiRequest); + handleJwtAuthRetry(iterableApiRequest, authRetryHandler); } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -278,7 +275,10 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque * is obtained via the AuthTokenReadyListener callback. * For online requests or when autoRetry is disabled, use the existing inline retry. */ - private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { + private static void handleJwtAuthRetry( + IterableApiRequest iterableApiRequest, + IterableRequestAuthRetryHandler authRetryHandler + ) { boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) { IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); @@ -290,7 +290,7 @@ private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { null ); } else { - requestNewAuthTokenAndRetry(iterableApiRequest); + requestNewAuthTokenAndRetry(iterableApiRequest, authRetryHandler); } } @@ -381,10 +381,6 @@ private static boolean isSensitive(String key) { @Override protected void onPostExecute(IterableApiResponse response) { - if (response == null || (retry && !iterableApiRequest.canRetry())) { - super.onPostExecute(response); - return; - } if (shouldRetry(response)) { retryRequestWithDelay(); @@ -402,14 +398,11 @@ protected void onPostExecute(IterableApiResponse response) { } private boolean shouldRetry(IterableApiResponse response) { - return iterableApiRequest.canRetry() - && !response.success - && response.responseCode >= 500 - && retryCount <= MAX_RETRY_COUNT; + return !response.success && response.responseCode >= 500 && retryCount <= MAX_RETRY_COUNT; } private void retryRequestWithDelay() { - final IterableRequestTask requestTask = new IterableRequestTask(true); + final IterableRequestTask requestTask = new IterableRequestTask(); requestTask.setRetryCount(retryCount + 1); long delay = (retryCount > 2) ? RETRY_DELAY_MS * retryCount : 0; @@ -417,24 +410,11 @@ private void retryRequestWithDelay() { handler.postDelayed(new Runnable() { @Override public void run() { - executeRetry(requestTask, iterableApiRequest); + requestTask.execute(iterableApiRequest); } }, delay); } - private static void executeRetry(IterableRequestTask requestTask, IterableApiRequest request) { - if (!request.canRetry()) { - return; - } - - Executor retryExecutor = request.getRetryExecutor(); - if (retryExecutor != null) { - requestTask.executeOnExecutor(retryExecutor, request); - } else { - requestTask.execute(request); - } - } - private void handleSuccessResponse(IterableApiResponse response) { if (!Objects.equals(iterableApiRequest.resourcePath, ENDPOINT_GET_REMOTE_CONFIGURATION) && !Objects.equals(iterableApiRequest.resourcePath, ENDPOINT_DISABLE_DEVICE)) { IterableApi.getInstance().getAuthManager().resetFailedAuth(); @@ -459,7 +439,10 @@ private void handleErrorResponse(IterableApiResponse response) { } } - private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRequest) { + private static void requestNewAuthTokenAndRetry( + IterableApiRequest iterableApiRequest, + IterableRequestAuthRetryHandler authRetryHandler + ) { IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false); long retryInterval = IterableApi.getInstance().getAuthManager().getNextRetryInterval(); IterableApi.getInstance().getAuthManager().scheduleAuthTokenRefresh( @@ -468,7 +451,10 @@ private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRe data -> { try { String newAuthToken = data.getString("newAuthToken"); - retryRequestWithNewAuthToken(newAuthToken, iterableApiRequest); + authRetryHandler.retryWithNewAuthToken( + newAuthToken, + iterableApiRequest + ); } catch (JSONException e) { e.printStackTrace(); } @@ -481,6 +467,10 @@ protected void setRetryCount(int count) { } } +interface IterableRequestAuthRetryHandler { + void retryWithNewAuthToken(String newAuthToken, IterableApiRequest request); +} + /** * Iterable Request object */ @@ -496,8 +486,6 @@ class IterableApiRequest { final JSONObject json; final String requestType; final String authToken; - private @Nullable Executor retryExecutor; - private @Nullable IterableRequestRetryState retryState; private ProcessorType processorType = ProcessorType.ONLINE; IterableHelper.IterableActionHandler legacyCallback; @@ -529,24 +517,6 @@ void setProcessorType(ProcessorType processorType) { this.processorType = processorType; } - void setExecutionContext(@Nullable Executor retryExecutor, @Nullable IterableRequestRetryState retryState) { - this.retryExecutor = retryExecutor; - this.retryState = retryState; - } - - void copyExecutionContextFrom(@NonNull IterableApiRequest request) { - setExecutionContext(request.retryExecutor, request.retryState); - } - - @Nullable - Executor getRetryExecutor() { - return retryExecutor; - } - - boolean canRetry() { - return retryState == null || retryState.canRetry(); - } - IterableApiRequest(String apiKey, String baseUrl, String resourcePath, JSONObject json, String requestType, String authToken, IterableHelper.SuccessHandler onSuccess, IterableHelper.FailureHandler onFailure) { this.apiKey = apiKey; this.baseUrl = baseUrl; @@ -621,10 +591,6 @@ static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable String authTok } } -interface IterableRequestRetryState { - boolean canRetry(); -} - class IterableApiResponse { final boolean success; final int responseCode; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java index 95fd56ee6..013f7f0ad 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java @@ -10,50 +10,27 @@ import org.json.JSONObject; import java.util.Date; -import java.util.concurrent.Executor; class OnlineRequestProcessor implements RequestProcessor { private static final String TAG = "OnlineRequestProcessor"; - private final @Nullable Executor executor; - - OnlineRequestProcessor() { - this(null); - } - - OnlineRequestProcessor(@Nullable Executor executor) { - this.executor = executor; - } @Override public void processGetRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.IterableActionHandler onCallback) { IterableApiRequest request = new IterableApiRequest(apiKey, resourcePath, addCreatedAtToJson(json), IterableApiRequest.GET, authToken, onCallback); - executeRequest(request, null); + new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); } @Override public void processGetRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { IterableApiRequest request = new IterableApiRequest(apiKey, resourcePath, addCreatedAtToJson(json), IterableApiRequest.GET, authToken, onSuccess, onFailure); - executeRequest(request, null); + new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); } @Override public void processPostRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { - processPostRequest(apiKey, resourcePath, json, authToken, onSuccess, onFailure, null); - } - - void processPostRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure, @Nullable IterableRequestRetryState retryState) { IterableApiRequest request = new IterableApiRequest(apiKey, resourcePath, addCreatedAtToJson(json), IterableApiRequest.POST, authToken, onSuccess, onFailure); - executeRequest(request, retryState); - } - - private void executeRequest(@NonNull IterableApiRequest request, @Nullable IterableRequestRetryState retryState) { - request.setExecutionContext(executor, retryState); - new IterableRequestTask().executeOnExecutor(getExecutor(), request); - } - - private @NonNull Executor getExecutor() { - return executor != null ? executor : AsyncTask.THREAD_POOL_EXECUTOR; + new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); } @Override diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java index ae50759e2..83ccd5735 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java @@ -2,6 +2,7 @@ import android.content.Context; import android.os.AsyncTask; +import android.os.Looper; import org.json.JSONObject; import org.junit.After; @@ -12,6 +13,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -20,6 +22,7 @@ import static android.os.Looper.getMainLooper; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -142,6 +145,30 @@ public void testDisablePushPreventsFailedRegistrationFromRetrying() throws Excep assertEquals(2, server.getRequestCount()); } + @Test + public void testPushRegistrationDeliversClientCallbackOnMainThread() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize( + getContext(), + "apiKey", + new IterableConfig.Builder().setAutoPushRegistration(true).build() + ); + AtomicReference callbackLooper = new AtomicReference<>(); + AtomicReference failureReason = new AtomicReference<>(); + + IterableApi.getInstance().setEmail( + "test@email.com", + data -> callbackLooper.set(Looper.myLooper()), + (reason, data) -> failureReason.set(reason) + ); + waitForRequestWorkToFinish(); + + assertEquals(Looper.getMainLooper(), callbackLooper.get()); + assertNull(failureReason.get()); + } + @Test public void testPushRegistrationRetryDoesNotWaitForHostAsyncTaskSerialExecutor() throws Exception { server.enqueue(new MockResponse().setResponseCode(500).setBody("{}")); From 086667c8fa44396fbcbb9e1771436dd9983c8f34 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 17:19:30 +0100 Subject: [PATCH 5/8] [SDK-705] Handle malformed push request timestamps --- ...rablePushRegistrationRequestProcessor.java | 2 +- ...ePushRegistrationRequestProcessorTest.java | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java index f99f5623c..89b3906cc 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java @@ -127,7 +127,7 @@ private JSONObject addCreatedAtToJson(JSONObject json) { createdAt = new Date().getTime() / 1000; } json.put(IterableConstants.KEY_CREATED_AT, createdAt); - } catch (JSONException e) { + } catch (JSONException | NumberFormatException e) { IterableLogger.e( TAG, "Could not add createdAt timestamp to json object" diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java new file mode 100644 index 000000000..7a83f6374 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java @@ -0,0 +1,38 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertNotNull; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +public class IterablePushRegistrationRequestProcessorTest extends BaseTest { + + @Test + public void testMalformedCreatedAtDoesNotPreventPushRequestSubmission() throws JSONException { + AtomicReference submittedRequest = new AtomicReference<>(); + IterablePushRegistrationRequestProcessor processor = + new IterablePushRegistrationRequestProcessor( + submittedRequest::set, + Runnable::run, + (runnable, delayMs) -> { + } + ); + JSONObject requestJson = new JSONObject() + .put(IterableConstants.KEY_CREATED_AT, "not-a-timestamp"); + + processor.processPostRequest( + "apiKey", + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + requestJson, + null, + null, + null, + () -> true + ); + + assertNotNull(submittedRequest.get()); + } +} From 04328b2608b2dd85c7dbd985f17e8dc37b6f8e6c Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 17:31:25 +0100 Subject: [PATCH 6/8] [Tests] Make notification image test deterministic --- .../iterableapi/IterableNotificationTest.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java index a8a65c9f5..4af8f0f77 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java @@ -22,11 +22,13 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.net.URL; import static android.os.Looper.getMainLooper; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; @@ -51,8 +53,6 @@ public class IterableNotificationTest { String itbl_ghost = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":true}"; String itbl1 = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false}"; String itbl2 = "{\"templateId\":2,\"campaignId\":2,\"messageId\":\"22222222222222222222222222222222\",\"isGhostPush\":false}}"; - String itbl_image = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false,\"attachment-url\":\"https://assets.iterable.com/assets/images/logos/itbl-logo-full-gray-800x300.png\"}"; - private Context getContext() { return getApplicationContext(); } @@ -113,8 +113,15 @@ public void testGhostPush() throws Exception { */ @Test public void testNotificationImage() throws Exception { + URL imageResource = getClass().getClassLoader().getResource("350x150.png"); + assertNotNull(imageResource); + JSONObject iterableData = new JSONObject(itbl1); + iterableData.put( + IterableConstants.ITERABLE_DATA_PUSH_IMAGE, + imageResource.toString() + ); Bundle notif = new Bundle(); - notif.putString(IterableConstants.ITERABLE_DATA_KEY, itbl_image); + notif.putString(IterableConstants.ITERABLE_DATA_KEY, iterableData.toString()); notif.putString(IterableConstants.ITERABLE_DATA_BODY, body); IterableNotificationBuilder iterableNotification = postNotification(notif); @@ -341,4 +348,4 @@ private Context iconTestContext(Bundle metaData, String drawableName, int drawab return context; } -} \ No newline at end of file +} From 50030e0650f29ff9fefdb99dce3ac3f7c7d06390 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 17:33:30 +0100 Subject: [PATCH 7/8] [SDK-705] Keep changelog entry under unreleased --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a8649dd8..7918bafa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed +- Push token registration and disable operations, including their API requests and retries, now run on a dedicated SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue. This prevents host app background work from delaying device registration, preserves operation order, and keeps client callbacks on the main thread. + ## [3.11.0] ### Added - `IterableConfig.Builder.setExpiringAuthTokenRefreshPeriod(double)` accepts fractional seconds, matching the iOS, React Native and Flutter SDKs. Previously Android only accepted whole seconds, so a value like `0.5` behaved differently here than on other platforms. The existing `Long` overload is deprecated but still works, so no code changes are required. @@ -11,7 +14,6 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Added `IterableConfig.Builder.setInAppColorScheme()` and `setInAppColorSchemeProvider()` to control the color scheme reported to HTML in-app messages. The provider is evaluated for each new in-app message, allowing apps that keep their theme in Jetpack Compose state to return the current `LIGHT` or `DARK` scheme. ### Fixed -- Push token registration and disable operations, including their API requests and retries, now run on a dedicated SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue. This prevents host app background work from delaying device registration, preserves operation order, and keeps client callbacks on the main thread. - Fixed a regression introduced in 3.6.5 ([SDK-717](https://iterable.atlassian.net/browse/SDK-717)) where `IterableCustomActionHandler.handleIterableCustomAction` was repeatedly fired with a stale action on every app foreground and every `initialize()` call after the first push. The `IterableCustomActionHandler` interface documents its boolean return value as "Reserved for future use", causing most clients (and the React Native SDK wrapper) to return `false`. The SDK incorrectly treated `false` as "action not yet consumed" and kept replaying the pending action. The handler is now treated as consumed once invoked, regardless of return value. Retrying only happens when the handler is `null` (SDK not yet initialized), which is the original SDK-307 intent. Affected versions: **3.6.5–3.10.1**. - HTML in-app messages now follow the host activity's Android light-dark configuration instead of always rendering as if the device were in dark mode. Previously the in-app container always reported `prefers-color-scheme: dark` to the message HTML, so a campaign with an `@media (prefers-color-scheme: dark)` block rendered its dark styles even in light mode. Those campaigns now render their light styles in light mode; campaigns that don't declare dark styles, and rendering in dark mode, are unaffected. - Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes. From fc4b5d704466c8d193f79cebd74b3540fe68c19b Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 25 Sep 2026 10:05:35 +0100 Subject: [PATCH 8/8] [SDK-705] Order manual token registration with push disable --- .../com/iterable/iterableapi/IterableApi.java | 2 +- .../IterableApiIntegrationTest.java | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 7bceb6812..78451b0fd 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -695,7 +695,7 @@ void setAuthToken(String authToken, boolean bypassAuth) { } protected void registerDeviceToken(final @Nullable String email, final @Nullable String userId, final @Nullable String authToken, final @NonNull String applicationName, final @NonNull String deviceToken, final Map deviceAttributes) { - registerDeviceToken(email, userId, authToken, applicationName, deviceToken, deviceAttributes, runnable -> new Thread(runnable).start()); + registerDeviceToken(email, userId, authToken, applicationName, deviceToken, deviceAttributes, IterableExecutors.push()); } void registerDeviceToken(final @Nullable String email, final @Nullable String userId, final @Nullable String authToken, final @NonNull String applicationName, final @NonNull String deviceToken, final Map deviceAttributes, Executor executor) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java index 83ccd5735..e6576b06f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java @@ -112,6 +112,34 @@ public void testRegisterForPushFollowedByDisablePushPreservesRequestOrder() thro ); } + @Test + public void testManualDeviceTokenRegistrationFollowedByDisablePushPreservesRequestOrder() throws Exception { + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(false).build()); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableApi.getInstance().registerDeviceToken(TEST_TOKEN); + IterableApi.getInstance().disablePush(); + + RecordedRequest registrationRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(registrationRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + registrationRequest.getPath() + ); + + RecordedRequest disableRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(disableRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, + disableRequest.getPath() + ); + } + @Test public void testDisablePushPreventsFailedRegistrationFromRetrying() throws Exception { when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345");