Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new failure meaning conflicts with the rest of the public callback contract.

IterableProjectSwitchResult, IterableProjectSwitchCallback, and the @param below still say warnings only describe teardown/cleanup, neither result is a failure, and the SDK is running on the new project.

An initialization exception can occur before IterableApi.initialize runs - as the new test demonstrates - so those guarantees are no longer true.

Suggest updating all public Javadocs to cover this case and state what callers can safely assume or do after 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
Expand All @@ -1056,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.
* <p>
* 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.
* <p>
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,31 @@ private static ExecutorService createExecutor() {
private static volatile boolean isInitializing = false;
private static volatile boolean isBackgroundInitialized = false;
private static final ConcurrentLinkedQueue<IterableInitializationCallback> pendingCallbacks = new ConcurrentLinkedQueue<>();
private static final ConcurrentLinkedQueue<InitializationResultListener> 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
Expand All @@ -216,26 +241,55 @@ 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;
}

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;
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -302,14 +361,15 @@ 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();
} catch (Exception e) {
IterableLogger.e(TAG, "Exception in initialization callback", e);
}
}
deliverInitResult(resultListener, finalInitSucceeded);

// Call all pending callbacks from concurrent initialization attempts
IterableInitializationCallback pendingCallback;
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@
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}
* when a cleanup step was noisy or no device disable was confirmed for the previous
* 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;

Expand Down
Loading
Loading