From d46578a16ee9efe51fd0f8af81c85347655ece80 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Tue, 6 Oct 2026 13:59:14 +0100 Subject: [PATCH 1/2] [SDK-758] Report a failed pre-init switch the same way iOS does switchProject before initialization always reported a clean switch, even when initialization failed. It now forwards that result, and a successful cold start still reports SWITCHED_CLEANLY. --- CHANGELOG.md | 3 +- .../com/iterable/iterableapi/IterableApi.java | 9 +- .../IterableBackgroundInitializer.java | 84 +++++++++++++++++-- .../iterableapi/IterableProjectSwitcher.java | 24 +++--- .../IterableSwitchProjectTest.java | 24 ++++++ 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af0fbab2..2b7f5b782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Calls made between `switchProject` and its callback are queued and replayed against the new project, except for `trackPushOpen`. A push payload carries the campaignId, templateId and messageId of the project that sent it, so replaying it would report it against a project where those IDs do not exist; it runs inline instead, against whichever project is live at the time. Queueing during ordinary background initialization is unchanged. - Per-project state held on the shared instance is cleared as part of the switch: the inbox session ID, the stored push payload and notification data, and any device attributes set with `setDeviceAttribute`. The device ID and visitor consent are project-agnostic and are deliberately kept. Set device attributes again from the callback if they still apply to the new project. - Added `IterableProject`, which pairs a project's API key with the `IterableConfig` to run it with. `switchProject` takes one of these rather than a loose key and config, because `IterableConfig` carries the data region and the `IterableAuthHandler`: supplying the two halves separately allows one project's key to be paired with another project's region or auth handler, and that mismatch produces an authentication failure, or a request sent to the wrong region, that looks unrelated to the project it came from. `IterableProject` also rejects a null or blank API key at construction with `IllegalArgumentException`, so an unusable project cannot be built and a blank key can no longer reach a teardown. - - Called before any `initialize` the callback reports `SWITCHED_CLEANLY`, matching iOS. There is no previous project, so no teardown step could have been noisy and there was no device to disable. Note that `initializeInBackground` reports completion whether or not initialization succeeded, so on Android this result does not distinguish a failed initialization; iOS does. + - Called before any `initialize` the callback reports `SWITCHED_CLEANLY` when that initialization succeeds, matching iOS. There is no previous project, so no teardown step could have been noisy and there was no device to disable. If the initialization itself fails or times out, the callback reports `SWITCHED_WITH_WARNINGS`, which is also what iOS reports when `start()` fails. The SDK is still left pointed at the requested project. `IterableInitializationCallback` is unchanged and still only means that initialization finished. - `switchProject` does not re-identify the user. Call `setEmail`/`setUserId` from the callback. The JWT auth retry budget does not carry over: it is per auth manager instance, the switch rebuilds the auth manager, and identifying a user clears it besides. - The disable for the previous project captures that project's API key **and** its region endpoint when it is initiated, and the switch waits (up to 2 seconds) for the disable to be handed to the request layer before swapping. `users/disableDevice` is project-scoped on the backend, so without the captured key the disable would usually land on the new project instead, leaving the previous project still delivering push to the device; without the captured endpoint a switch between data regions would send the previous project's key to the new project's region, which is rejected. If the wait times out the switch still completes and the callback reports `SWITCHED_WITH_WARNINGS`, and the disable that dispatches afterwards still reaches the project it was created for. - `SWITCHED_CLEANLY` means every teardown step completed at the point the callback fired. It is not a guarantee that the device disable reached the network: the disable is handed to the request layer, which may queue it, and the callback is not held open for the response. This matches the iOS SDK. An app that needs certainty about the outgoing project's device state should not infer it from this callback. @@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - The SDK now logs a warning when a push is received while a project switch is in progress, so a push that was sent by the previous project can be recognised in logs. ### Fixed +- `switchProject` before the SDK has been initialized now reports `SWITCHED_WITH_WARNINGS` when that initialization fails or times out, matching iOS, which already reports `.switchedWithWarnings` when `start()` fails. A successful cold start still reports `SWITCHED_CLEANLY`. The public initialization callback is unchanged. - Restored offline support for `disablePush()`. When offline mode is enabled, a `users/disableDevice` request made while the network is unavailable is once again persisted and retried instead of being dropped. This behaviour shipped in 3.5.16 and regressed in 3.7.0, leaving Android as the only SDK that silently lost a device disable when the network was down. A queued `disableDevice` is now also preserved across logout (`setEmail`/`setUserId` to a different user), so the disable still reaches the user it was created for. - `users/registerDeviceToken` is now queued in offline mode as well, matching the iOS SDK. Push registration made while the network is unavailable is retried instead of being lost, and because the offline queue drains in `scheduledAt` order, a logout-then-login sequence replays as disable-then-register and leaves the device enabled. Note that the offline queue only drains while the app is in the foreground, so a Firebase token refresh received in the background is now sent on the next foreground rather than immediately. - A queued request that is discarded before it can be sent now calls its failure handler instead of never calling back at all. This matters most for the completion handlers passed to `setEmail`/`setUserId`: they travel with the queued `users/registerDeviceToken`, so logging in as a different user used to strand them, and an app that dismisses a login spinner in that callback would wait forever. The failure reason states that the request was discarded because the user logged out. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 98cee6824..4dd8dcdf2 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -1027,10 +1027,11 @@ public static void initializeInBackground(@NonNull Context context, * * Called before any initialize, this behaves as * {@link #initializeInBackground(Context, String, IterableConfig, IterableInitializationCallback)} - * and logs a warning, reporting {@link IterableProjectSwitchResult#SWITCHED_CLEANLY} once it has - * started: there is no previous project, so no teardown step could have been noisy. Called with - * the API key already in use, it is a no-op. Called while an initialization is still in flight, - * it waits for that initialization and then switches. + * and logs a warning. It reports {@link IterableProjectSwitchResult#SWITCHED_CLEANLY} when that + * initialization succeeds, and {@link IterableProjectSwitchResult#SWITCHED_WITH_WARNINGS} when it + * fails or times out. There is no previous project, so a clean start has no teardown step that + * could have been noisy. Called with the API key already in use, it is a no-op. Called while an + * initialization is still in flight, it waits for that initialization and then switches. * * Called while a switch is already in progress, what happens depends on where this request is * headed. Asking for the project that switch is already going to registers the callback with it diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java index dfbb96311..9cc365f0c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java @@ -194,6 +194,31 @@ private static ExecutorService createExecutor() { private static volatile boolean isInitializing = false; private static volatile boolean isBackgroundInitialized = false; private static final ConcurrentLinkedQueue pendingCallbacks = new ConcurrentLinkedQueue<>(); + private static final ConcurrentLinkedQueue pendingInitResults = new ConcurrentLinkedQueue<>(); + + /** + * Test-only. The next {@link #initializeInBackground} run fails the way a timed-out or thrown + * initialization does, so {@code switchProject} before init can be asserted against that result. + * Cleared as soon as it is consumed. + */ + @VisibleForTesting + static volatile boolean failNextInitialization; + + /** Receives whether background initialization finished without a timeout or an exception. */ + interface InitializationResultListener { + void onInitializationFinished(boolean succeeded); + } + + private static void deliverInitResult(@Nullable InitializationResultListener listener, boolean succeeded) { + if (listener == null) { + return; + } + try { + listener.onInitializationFinished(succeeded); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in initialization result listener", e); + } + } /** * Work that must not run until the in-flight background initialization has finished. Currently @@ -216,11 +241,32 @@ static void initializeInBackground(@NonNull Context context, @NonNull String apiKey, @Nullable IterableConfig config, @Nullable IterableInitializationCallback callback) { + initializeInBackground(context, apiKey, config, callback, null); + } + + /** + * Same as {@link #initializeInBackground(Context, String, IterableConfig, IterableInitializationCallback)}, + * plus a result the public callback cannot carry. {@code IterableInitializationCallback} has no + * success argument, and giving it one would change every app that implements it. + * {@code switchProject} before initialization needs the result so a failed start is + * {@code SWITCHED_WITH_WARNINGS} on Android the way a failed {@code start()} is + * {@code .switchedWithWarnings} on iOS. The public callback still fires either way. + */ + static void initializeInBackground(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableInitializationCallback callback, + @Nullable InitializationResultListener resultListener) { // Handle null context early - still report success but log error if (context == null) { IterableLogger.e(TAG, "Context cannot be null, but reporting success"); - if (callback != null) { - new Handler(Looper.getMainLooper()).post(callback::onSDKInitialized); + if (callback != null || resultListener != null) { + new Handler(Looper.getMainLooper()).post(() -> { + if (callback != null) { + callback.onSDKInitialized(); + } + deliverInitResult(resultListener, true); + }); } return; } @@ -228,14 +274,22 @@ static void initializeInBackground(@NonNull Context context, synchronized (initLock) { if (isInitializing || isBackgroundInitialized) { IterableLogger.w(TAG, "initializeInBackground called but initialization already in progress or completed"); - if (callback != null) { - if (isBackgroundInitialized) { - // Initialization already complete, call callback immediately - new Handler(Looper.getMainLooper()).post(callback::onSDKInitialized); - } else { - // Initialization in progress, queue callback for later + if (isBackgroundInitialized) { + if (callback != null || resultListener != null) { + new Handler(Looper.getMainLooper()).post(() -> { + if (callback != null) { + callback.onSDKInitialized(); + } + deliverInitResult(resultListener, true); + }); + } + } else { + if (callback != null) { pendingCallbacks.offer(callback); } + if (resultListener != null) { + pendingInitResults.offer(resultListener); + } } return; } @@ -264,6 +318,11 @@ static void initializeInBackground(@NonNull Context context, try { IterableLogger.d(TAG, "Starting initialization with " + INITIALIZATION_TIMEOUT_SECONDS + " second timeout"); + if (failNextInitialization) { + failNextInitialization = false; + throw new IllegalStateException("background initialization failed"); + } + // Submit the actual initialization task Future initFuture = initExecutor.submit(() -> { IterableLogger.d(TAG, "Executing initialization on background thread"); @@ -302,7 +361,7 @@ static void initializeInBackground(@NonNull Context context, IterableLogger.w(TAG, "Initialization timed out or failed, but notifying callbacks anyway after " + totalTime + "ms"); } - // Call the original callback directly + // Call the original callback directly. It still means "finished", not "succeeded". if (callback != null) { try { callback.onSDKInitialized(); @@ -310,6 +369,7 @@ static void initializeInBackground(@NonNull Context context, IterableLogger.e(TAG, "Exception in initialization callback", e); } } + deliverInitResult(resultListener, finalInitSucceeded); // Call all pending callbacks from concurrent initialization attempts IterableInitializationCallback pendingCallback; @@ -320,6 +380,10 @@ static void initializeInBackground(@NonNull Context context, IterableLogger.e(TAG, "Exception in pending initialization callback", e); } } + InitializationResultListener pendingResult; + while ((pendingResult = pendingInitResults.poll()) != null) { + deliverInitResult(pendingResult, finalInitSucceeded); + } } catch (Exception e) { IterableLogger.e(TAG, "Exception in initialization completion notification", e); @@ -977,7 +1041,9 @@ static void resetBackgroundInitializationState() { inFlightSwitchApiKey = null; operationQueue.clear(); pendingCallbacks.clear(); + pendingInitResults.clear(); pendingInitActions.clear(); + failNextInitialization = false; switchCallbacks.clear(); pendingSwitches.clear(); callbackManager.reset(); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java index 89fb84335..4ef3814c8 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java @@ -95,16 +95,20 @@ static void switchProject(@NonNull Context context, // Step 1: guard and validate. if (api._apiKey == null || api._applicationContext == null) { IterableLogger.w(TAG, "switchProject called before the SDK was initialized; initializing instead"); - // Reported as clean, matching iOS. There is no previous project, so there is no - // teardown step that could have been noisy and no device to disable: warnings would - // point at a warning that does not exist, and an app that uses switchProject as its - // entry point would see one on every cold start. - // initializeInBackground notifies on the main thread already, so this does not need - // deliverSwitchCallback. - IterableApi.initializeInBackground(context, apiKey, config, - callback == null ? null - : () -> callback.onProjectSwitched(IterableProjectSwitchResult.SWITCHED_CLEANLY)); - releaseInheritedGate(resumingGate, true); + // No previous project, so a clean start has nothing to warn about. A start that fails + // is the one case that does: iOS forwards start()'s result, and this does the same + // with initializeInBackground's. The public init callback cannot carry that, so the + // result listener is what switchProject reads. It runs on the main thread already. + IterableBackgroundInitializer.initializeInBackground(context, apiKey, config, null, succeeded -> { + if (callback != null) { + try { + callback.onProjectSwitched(IterableProjectSwitchResult.from(succeeded)); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in switchProject callback", e); + } + } + releaseInheritedGate(resumingGate, succeeded); + }); return; } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java index bf1480151..6e01806d1 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java @@ -231,6 +231,30 @@ public void testSwitchBeforeInitializeBehavesAsInitialize() throws Exception { IterableProjectSwitchResult.SWITCHED_CLEANLY, verdict.get()); } + /** + * The failure half of the pre-init contract. iOS reports switchedWithWarnings when start() + * fails, and Android has to report the same result when background initialization fails. + * A successful cold start stays SWITCHED_CLEANLY, covered above. + */ + @Test + public void testSwitchBeforeInitializeReportsWarningsWhenInitializationFails() throws Exception { + IterableBackgroundInitializer.failNextInitialization = true; + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference verdict = new AtomicReference<>(); + switchTo(context, API_KEY_B, configWithoutAuth(), result -> { + verdict.set(result); + latch.countDown(); + }); + + assertTrue("Callback should fire", awaitSwitch(latch)); + assertEquals("A failed cold start is a noisy switch, matching iOS", + IterableProjectSwitchResult.SWITCHED_WITH_WARNINGS, verdict.get()); + assertEquals("The SDK is still pointed at the requested project", + API_KEY_B, IterableApi.getInstance()._apiKey); + assertFalse(IterableBackgroundInitializer.failNextInitialization); + } + @Test public void testSwitchWithUnchangedApiKeyIsANoOp() throws Exception { initializeProjectA(); From 3efd425c824c0a157857eac7bd8e20ed18ca6b13 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Mon, 19 Oct 2026 16:15:22 +0100 Subject: [PATCH 2/2] Document what a failed pre-init switchProject leaves behind SWITCHED_WITH_WARNINGS can now mean the first initialization failed or timed out, not only a noisy teardown. Update the IterableProjectSwitchResult, IterableProjectSwitchCallback and switchProject Javadocs to cover that case: the SDK holds the requested key and config but may not be fully initialized, and a same-project retry is a no-op rather than a second initialization. The failure test now locks in that retry behavior. --- .../com/iterable/iterableapi/IterableApi.java | 7 +++++++ .../IterableProjectSwitchCallback.java | 7 ++++++- .../iterableapi/IterableProjectSwitchResult.java | 9 +++++++++ .../iterableapi/IterableSwitchProjectTest.java | 15 +++++++++++++++ 4 files changed, 37 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 4dd8dcdf2..9044233c5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -1057,6 +1057,13 @@ public static void initializeInBackground(@NonNull Context context, * fails to reach the request layer in time. A disable that fails after the * callback has already been delivered is logged instead. *

+ * Called before the SDK was initialized, there is no previous project and no + * teardown. SWITCHED_WITH_WARNINGS then means that initialization failed or timed + * out: the SDK holds the requested API key and config, but its initialization may + * not have completed, so later SDK calls may not take effect. Calling this again + * with the same project does not retry it, because that is a no-op reporting + * SWITCHED_CLEANLY. + *

* SWITCHED_CLEANLY means every teardown step completed at the point the callback * fired. It is * not a guarantee that the device disable reached the network: the disable is diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java index 61a5bfe5a..e2d4700ea 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java @@ -12,7 +12,9 @@ public interface IterableProjectSwitchCallback { /** * Called on the main thread once the SDK is running on the new project. The SDK is on the new - * project by the time this runs, whatever the result says. + * project by the time this runs, whatever the result says. If this was the SDK's first + * initialization and it failed, the SDK may not be fully initialized (see + * {@link IterableProjectSwitchResult#SWITCHED_WITH_WARNINGS}). * * @param result {@link IterableProjectSwitchResult#SWITCHED_CLEANLY} when every teardown step * completed cleanly, {@link IterableProjectSwitchResult#SWITCHED_WITH_WARNINGS} @@ -20,6 +22,9 @@ public interface IterableProjectSwitchCallback { * project. Neither is a failure and neither was rolled back, so the response to * both is the same: carry on and re-identify the user with * {@link IterableApi#setEmail(String)} or {@link IterableApi#setUserId(String)}. + * Called before the SDK was initialized, there is no teardown, and + * {@link IterableProjectSwitchResult#SWITCHED_WITH_WARNINGS} instead means that + * initialization failed or timed out. */ void onProjectSwitched(@NonNull IterableProjectSwitchResult result); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchResult.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchResult.java index 57f83ec55..cec3f3e83 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchResult.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchResult.java @@ -6,6 +6,9 @@ * Both values mean the SDK is on the new project. They differ only in whether leaving the previous * project was tidy. Neither is a failure, and neither is a reason to retry the switch or to show the * user an error: the response to both is the same, carry on and re-identify the user. + * + * The exception is a switch made before the SDK was initialized. There is no previous project, so + * there {@link #SWITCHED_WITH_WARNINGS} means the initialization itself failed or timed out. */ public enum IterableProjectSwitchResult { /** Every teardown step completed cleanly at the point the callback fired. */ @@ -18,6 +21,12 @@ public enum IterableProjectSwitchResult { * This is expected in normal operation rather than exceptional. An app that does not use push * registration, or that has no device token yet, will always see it, because there was no device * disable to confirm. Check the logs for the specific step if you need to know which one it was. + * + * Called before the SDK was initialized, there is no previous project to leave, and this instead + * means that initialization failed or timed out. The SDK holds the requested API key and config, + * but its initialization may not have completed, so later SDK calls may not take effect. Check + * the logs. Calling {@code switchProject} again with the same project does not retry it: that is + * a no-op reporting {@link #SWITCHED_CLEANLY}. */ SWITCHED_WITH_WARNINGS; diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java index 6e01806d1..3399bab42 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java @@ -253,6 +253,21 @@ public void testSwitchBeforeInitializeReportsWarningsWhenInitializationFails() t assertEquals("The SDK is still pointed at the requested project", API_KEY_B, IterableApi.getInstance()._apiKey); assertFalse(IterableBackgroundInitializer.failNextInitialization); + + // Armed again so a second initialization would be caught by the flag being consumed. + IterableBackgroundInitializer.failNextInitialization = true; + CountDownLatch retryLatch = new CountDownLatch(1); + AtomicReference retryVerdict = new AtomicReference<>(); + switchTo(context, API_KEY_B, configWithoutAuth(), result -> { + retryVerdict.set(result); + retryLatch.countDown(); + }); + + assertTrue("Callback should fire", awaitSwitch(retryLatch)); + assertEquals("A same-project retry is a no-op reporting a clean switch", + IterableProjectSwitchResult.SWITCHED_CLEANLY, retryVerdict.get()); + assertTrue("A same-project retry must not run a second initialization", + IterableBackgroundInitializer.failNextInitialization); } @Test