From f3564c4876a8a996d3eb4d4569b305e219864817 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Wed, 26 Aug 2026 00:25:55 +0100 Subject: [PATCH] [SDK-673] Add switchProject for runtime project switching Adds IterableApi.switchProject(context, apiKey, config, callback), which moves a running app from one Iterable project to another in place, with no app restart and no state from the previous project leaking into the new one. Aimed at multi-region apps that need to move between a US and an EU project without restarting. The call returns immediately and runs the whole sequence on the background executor: disable the push token on the previous project, reset the in-app, embedded and unknown-user managers, purge the offline queue apart from queued device disables, clear identity and the rest of the previous project's storage, then re-initialize against the new key. The auth manager is rebuilt against the new config and the request processor re-bound to it, which iOS gets for free from its instance swap. Callback contract: IterableProjectSwitchCallback is a single-method interface so a lambda receives the result. true means every teardown step completed cleanly, false means the SDK is on the new project but a cleanup step was noisy or no device disable could be confirmed. false never means the switch failed or was rolled back. An app that does not use push always sees false, which is not an error. Notable details: - The previous project's disable captures that project's API key and its region endpoint when it is initiated, because the FCM token lookup is asynchronous and the live key can change underneath it. Without the captured key the disable lands on the new project; without the endpoint a cross-region switch sends the old key to the new region and is rejected. The switch waits up to 2 seconds for the disable to reach the request layer before swapping. - Offline tasks now persist the endpoint they were created for, so a rehydrated request goes to the region it was built for instead of whichever region is live at flush time. Tasks already on disk keep resolving the old way. - trackPushOpen is not queued behind the switch gate. A push payload carries the sending project's campaignId, templateId and messageId, so replaying it would report it against a project where those IDs do not exist. It runs inline instead. Initialization queueing is unaffected. - The longest track and updateEmail overloads are now queued like their shorter siblings. They were public and ran inline, so mid-switch behaviour depended on which overload the caller happened to use. - Per-project state held on the shared instance is cleared: inbox session ID, push payload, notification data and device attributes. iOS drops all of this when it replaces its instance; Android reuses sharedInstance so it has to be explicit. - The gate check and enqueue are a single atomic step, so a call cannot pass the check just before the gate is raised and then run against a half torn-down SDK. - Recovers if the background executor is shut down when the teardown or drain is submitted, which could happen when switchProject was called from inside a switch callback. checkstyle: FileLength stays suppressed for IterableApi.java only, tracked in SDK-677. --- CHANGELOG.md | 21 + .../com/iterable/iterableapi/IterableApi.java | 260 +++- .../iterableapi/IterableApiClient.java | 51 +- .../iterableapi/IterableAuthManager.java | 6 + .../IterableBackgroundInitializer.java | 449 +++++- .../IterableFirebaseMessagingService.java | 4 + .../IterableInitializationCallback.java | 2 +- .../IterableProjectSwitchCallback.java | 28 + .../iterableapi/IterableProjectSwitcher.java | 356 +++++ .../IterablePushRegistrationData.java | 36 + .../IterablePushRegistrationTask.java | 58 +- .../iterableapi/IterableRequestTask.java | 57 +- .../iterableapi/IterableTaskStorage.java | 5 +- .../iterableapi/OfflineRequestProcessor.java | 27 +- .../iterableapi/OnlineRequestProcessor.java | 9 +- .../iterableapi/RequestProcessor.java | 11 + .../IterableOfflineTaskRegionTest.java | 308 ++++ .../IterablePushRegistrationTaskTest.java | 62 +- ...terableSwitchProjectDisableRegionTest.java | 188 +++ .../IterableSwitchProjectQueueDrainTest.java | 238 +++ .../IterableSwitchProjectTest.java | 1305 +++++++++++++++++ 21 files changed, 3364 insertions(+), 117 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableOfflineTaskRegionTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectDisableRegionTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectQueueDrainTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index aef8d05bf..a2908a90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,31 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Added `IterableApi.switchProject(context, apiKey, config, callback)` for apps that need to move a running install from one Iterable project to another (for example a multi-region app switching between a US and an EU project) without an app restart. The method returns immediately and performs the whole sequence on the SDK's background executor: it disables the push token on the previous project, clears that project's identity from memory and from storage, drops its cached in-app, embedded and unknown-user state along with its activation criteria and push attribution, purges the persisted offline queue, rebuilds the auth manager and the keychain against the new config, and re-initializes with the new API key. SDK calls made between the call and the callback are queued and then run in order against the new project. + - The callback is a new `IterableProjectSwitchCallback`, delivered on the main thread. Its single method `onProjectSwitched(boolean cleanTeardown)` reports whether every cleanup step completed cleanly; `false` means the SDK **is** on the new project but at least one cleanup step was noisy, never that the switch failed. It is a single-method interface so that a lambda receives the result: reusing `IterableInitializationCallback` would not work, because its only abstract method takes no arguments and a lambda would bind to that one and discard the result. `false` is expected in normal operation and is not an error: an app that does not use push, or that has no device token yet, always sees it, because the switch cannot confirm a device disable for the previous project. Either way, carry on and re-identify the user. + - 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. + - Called with an empty or whitespace-only API key, nothing is torn down, the SDK stays on the project it is already on, and the callback reports `false`. Called before any `initialize` the callback also reports `false`, because no teardown ran and no device disable could be confirmed. + - `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 `false`, and the disable that dispatches afterwards still reaches the project it was created for. + - A `true` callback means every teardown step completed at the point it 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. + - Called before any `initialize`, it behaves as `initializeInBackground` and logs a warning. Called with the API key already in use, it is a no-op reporting `true`. Called while an initialization is still in flight, it waits for that initialization and then switches. Called while a switch is already running, the callback is added to that switch instead of starting a second teardown. + - Passing a null `context` or `apiKey` throws `IllegalArgumentException`. Both parameters are `@NonNull`, so a null is a programmer error rather than a runtime condition, and reporting it through the callback would overload the same boolean that means "switched, but noisily". +- 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 - 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. +- Offline requests now persist the API endpoint they were created for, so a queued request that is replayed later is always sent to the region it was created for with the key it was created with. Previously a rehydrated request fell back to whichever data region was live at flush time, which could send a request built for one project to another project's region. Requests already on disk from an earlier SDK version keep resolving their endpoint the old way, so no queued work is lost on upgrade. +- Switching projects no longer discards a queued `users/disableDevice` request. The rest of the offline queue is still dropped, but the device disable is kept so the token is still disabled on the project being left, even when the switch happens offline. This matches the iOS SDK. +- `IterableApi.setEmail` and `IterableApi.setUserId`'s longest overloads (the ones taking an identity resolution and both callbacks) are now queued while the SDK is initializing or switching projects, like every shorter overload already was. An app calling those overloads directly previously bypassed the queue. +- `IterableApi.track`'s and `IterableApi.updateEmail`'s longest overloads are now queued as well, for the same reason. They were public and ran inline while every shorter overload was queued, so behaviour during initialization or a project switch depended on which overload the app happened to call. +- `switchProject` no longer starts a teardown while a background initialization is still in flight, and a callback passed to `initializeInBackground` during a switch is no longer dropped. +- `switchProject` now recovers if the SDK's background executor is shut down at the moment the teardown or the queue drain is submitted, which could happen when an app called `switchProject` again from inside a switch callback. Previously that could either crash the app or leave the SDK permanently unable to execute queued calls. +- The auth token ready listener list no longer accepts the same listener twice, which could make the offline task runner process an auth recovery more than once. +- SDK calls are no longer able to slip past the initialization/switch gate. The gate was read and acted on separately, so a call could pass the check just before a switch raised it and then run against a half torn-down SDK; the check and the queueing are now a single atomic step, as they already are on iOS. ## [3.10.0] ### Added diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 89c70e5a8..a2ce1fcd8 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -32,18 +32,34 @@ public class IterableApi { static volatile IterableApi sharedInstance = new IterableApi(); private static final String TAG = "IterableApi"; + + /** + * Serializes {@link #authManager} between {@link #getAuthManager()}, which builds it lazily from + * {@link #config}, and {@link IterableProjectSwitcher}, which discards and rebuilds it once the + * new project's config is in place. That is the whole guarantee. + * + * It is deliberately not held across the {@link #config} and {@link #_apiKey} swap, which + * {@link #initialize} performs without it, and it does not need to be: an auth manager another + * thread builds from the previous project's config part-way through the swap is thrown away by + * the rebuild, which runs under this lock after the swap has finished. The project-scoped fields + * are volatile instead, so the SDK's own threads ({@code NetworkThread}, the push-registration + * and request {@code AsyncTask}s, the auth manager's executor), none of which the switch gate + * covers, cannot observe a stale value indefinitely. + */ + final Object projectStateLock = new Object(); + Context _applicationContext; // Package-private for background initializer access - IterableConfig config; - String _apiKey; // Package-private for background initializer access - private String _email; - private String _userId; - String _userIdUnknown; - private String _authToken; + volatile IterableConfig config; + volatile String _apiKey; // Package-private for background initializer access + volatile String _email; // Package-private for IterableProjectSwitcher + volatile String _userId; // Package-private for IterableProjectSwitcher + volatile String _userIdUnknown; + volatile String _authToken; // Package-private for IterableProjectSwitcher private boolean _debugMode; private Bundle _payloadData; private IterableNotificationData _notificationData; private String _deviceId; - private boolean _firstForegroundHandled; + volatile boolean _firstForegroundHandled; // Package-private for IterableProjectSwitcher private boolean _autoRetryOnJwtFailure; private IterableHelper.SuccessHandler _setUserSuccessCallbackHandler; private IterableHelper.FailureHandler _setUserFailureCallbackHandler; @@ -51,15 +67,15 @@ public class IterableApi { IterableApiClient apiClient = new IterableApiClient(new IterableApiAuthProvider()); final ApiEndpointClassification apiEndpointClassification = new ApiEndpointClassification(); private static final UnknownUserMerge unknownUserMerge = new UnknownUserMerge(); - private @Nullable UnknownUserManager unknownUserManager; - private @Nullable IterableInAppManager inAppManager; - private @Nullable IterableEmbeddedManager embeddedManager; + volatile @Nullable UnknownUserManager unknownUserManager; // Package-private for IterableProjectSwitcher + private volatile @Nullable IterableInAppManager inAppManager; + private volatile @Nullable IterableEmbeddedManager embeddedManager; private final IterableInAppManager emptyInAppManager = new EmptyInAppManager(); private final IterableEmbeddedManager emptyEmbeddedManager = new EmptyEmbeddedManager(); private String inboxSessionId; - private IterableAuthManager authManager; + volatile IterableAuthManager authManager; // Package-private for IterableProjectSwitcher private ConcurrentHashMap deviceAttributes = new ConcurrentHashMap<>(); - private IterableKeychain keychain; + volatile IterableKeychain keychain; // Package-private for IterableProjectSwitcher //region Background Initialization - Delegated to IterableBackgroundInitializer @@ -82,16 +98,25 @@ private static String maskPII(@Nullable String value) { /** * Helper method to queue operations if background initialization is in progress, - * otherwise execute immediately for backward compatibility + * otherwise execute immediately for backward compatibility. + * + * There is deliberately no gate check here. Reading the gate and then acting on the result is + * check-then-act: a call could pass the check just before {@link IterableApi#switchProject} + * raises the gate and then run against a half torn-down SDK. + * {@link IterableBackgroundInitializer#queueOrExecute} makes the check and the enqueue atomic, + * and still runs the operation outside the lock when the gate is down. */ private void queueOrExecute(Runnable operation, String description) { - // Only queue if background initialization is actively running - if (IterableBackgroundInitializer.isInitializingInBackground()) { - IterableBackgroundInitializer.queueOrExecute(operation, description); - } else { - // Execute immediately for backward compatibility when not using background init - operation.run(); - } + IterableBackgroundInitializer.queueOrExecute(operation, description); + } + + /** + * As {@link #queueOrExecute}, but runs inline rather than queueing when a project switch is in + * progress. For calls carrying identifiers that only exist on the project that produced them, + * which cannot be replayed against a different project. + */ + private void queueOrExecuteUnlessSwitching(Runnable operation, String description) { + IterableBackgroundInitializer.queueOrExecuteUnlessSwitching(operation, description); } void fetchRemoteConfiguration() { @@ -181,10 +206,15 @@ Context getMainActivityContext() { */ @NonNull IterableAuthManager getAuthManager() { - if (authManager == null) { - authManager = new IterableAuthManager(this, config.authHandler, config.retryPolicy, config.expiringAuthTokenRefreshPeriod); + // Locked so a background thread cannot lazily build an auth manager from a config that + // IterableProjectSwitcher is halfway through replacing, which would bind the new project's + // requests to the previous project's IterableAuthHandler for the rest of the process. + synchronized (projectStateLock) { + if (authManager == null) { + authManager = new IterableAuthManager(this, config.authHandler, config.retryPolicy, config.expiringAuthTokenRefreshPeriod); + } + return authManager; } - return authManager; } @Nullable @@ -398,8 +428,24 @@ private String getPushIntegrationName() { } private void logoutPreviousUser() { + logoutPreviousUser(null, null); + } + + /** + * @param disableListener notified once the device disable has been handed off to the request + * layer, i.e. after the FCM token lookup has resolved and the request has + * been built with the API key and region endpoint captured now, or with + * false when there was nothing to send. Only used by + * {@link IterableProjectSwitcher}, which prefers not to swap the project + * until that has happened. + * @param onDisableFailure notified if the disable request itself comes back as a failure + * @return true when a device disable was started, so {@code disableListener} will be notified + */ + boolean logoutPreviousUser(@Nullable IterablePushRegistrationData.DispatchListener disableListener, + @Nullable IterableHelper.FailureHandler onDisableFailure) { + boolean disableStarted = false; if (config.autoPushRegistration && isInitialized()) { - disablePush(); + disableStarted = disablePush(disableListener, onDisableFailure); } // Only reset managers if they're initialized @@ -416,6 +462,7 @@ private void logoutPreviousUser() { if (apiClient != null) { apiClient.onLogout(); } + return disableStarted; } private void onLogin( @@ -538,7 +585,7 @@ boolean checkSDKInitialization() { return true; } - private SharedPreferences getPreferences() { + SharedPreferences getPreferences() { return _applicationContext.getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE); } @@ -716,7 +763,7 @@ public void run() { } protected void disableToken(@Nullable String email, @Nullable String userId, @NonNull String token) { - disableToken(email, userId, null, token, null, null); + disableToken(email, userId, null, null, null, token, null, null); } /** @@ -725,14 +772,19 @@ protected void disableToken(@Nullable String email, @Nullable String userId, @No * @param email User email for whom to disable the device. * @param userId User ID for whom to disable the device. * @param authToken + * @param apiKey API key captured when the disable was initiated, so the request reaches the + * project the token was registered against even if the live key has since been + * replaced by {@link IterableProjectSwitcher}. Null falls back to the live key. + * @param baseUrl Region endpoint captured with {@code apiKey}, so the two cannot be paired across + * projects. Null falls back to the live region. * @param deviceToken The device token */ - protected void disableToken(@Nullable String email, @Nullable String userId, @Nullable String authToken, @NonNull String deviceToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + protected void disableToken(@Nullable String email, @Nullable String userId, @Nullable String authToken, @Nullable String apiKey, @Nullable String baseUrl, @NonNull String deviceToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { if (deviceToken == null) { IterableLogger.d(TAG, "device token not available"); return; } - apiClient.disableToken(email, userId, authToken, deviceToken, onSuccess, onFailure); + apiClient.disableToken(email, userId, authToken, apiKey, baseUrl, deviceToken, onSuccess, onFailure); } /** @@ -924,6 +976,67 @@ public static void initializeInBackground(@NonNull Context context, IterableBackgroundInitializer.initializeInBackground(context, apiKey, config, callback); } + /** + * Moves a running app from one Iterable project to another in place, with no app restart and no + * state from the previous project leaking into the new one. + * + * This returns immediately; teardown and re-initialization run on the SDK's background executor. + * SDK calls made between this call and the callback are queued and drained in FIFO order against + * the new project. + * + * The switch disables the push token on the previous project (with that project's API key and + * region endpoint, even though the FCM token lookup is asynchronous), clears its identity from + * memory and from storage, + * drops its cached in-app / embedded / unknown-user state, its activation criteria and its push + * attribution, and purges its offline queue apart from queued device disables, which are kept so + * they still reach the previous project. It does not re-identify the user: call + * {@link #setEmail(String)} or {@link #setUserId(String)} from the callback. + * + * Called before any initialize, this behaves as + * {@link #initializeInBackground(Context, String, IterableConfig, IterableInitializationCallback)} + * and logs a warning. 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, the callback is registered with that switch instead of + * starting a second teardown. + * + * @param context Application context + * @param apiKey API key of the project to switch to + * @param config Configuration for the new project (can be null, in which case defaults are used) + * @param callback Delivered on the main thread once the SDK is on the new project. + * {@link IterableProjectSwitchCallback#onProjectSwitched(boolean)} receives true + * when every teardown step completed cleanly and false when at least one cleanup + * step was noisy. False never means the switch was rolled back: the SDK is on the + * new project either way, and the right response is to carry on and re-identify + * the user. + *

+ * False is expected in normal operation and is not an error. In particular it is + * what an app that does not use push, or that has no device token yet, will + * always see, because the switch could not confirm a device disable for the + * previous project. It is also reported when the disable request fails, or fails + * to reach the request layer in time. A disable that fails after the callback has + * already been delivered is logged instead. + *

+ * True 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 for later delivery, and the + * callback is not held open for the response. An app that needs certainty about + * the outgoing project's device state should not infer it from this callback. + *

+ * The JWT auth retry budget does not carry over. It is per auth manager instance, + * the switch rebuilds the auth manager against the new config, and identifying a + * user clears it besides, so the new project starts with a full budget. + * @throws IllegalArgumentException if {@code context} or {@code apiKey} is null. Both are + * {@link NonNull}, so a null is a programmer error rather than a + * runtime condition, and reporting it through the callback would + * overload the same boolean that means "switched, but noisily". + */ + public static void switchProject(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableProjectSwitchCallback callback) { + IterableProjectSwitcher.switchProject(context, apiKey, config, callback); + } + /** * Check if SDK initialization is in progress (covers both normal and background initialization) * @return true if initialization is currently running @@ -1054,6 +1167,36 @@ public IterableEmbeddedManager getEmbeddedManagerOrNull() { return embeddedManager; } + /** + * Drops the in-app and embedded managers so {@link #initialize} rebuilds them against the new + * project's config. Used by {@link IterableProjectSwitcher}; the fields stay private because + * Kotlin call sites in this package resolve {@code iterableApi.inAppManager} to the non-null + * {@link #getInAppManager()} accessor, and a package-private field would shadow it. + */ + void clearMessagingManagers() { + inAppManager = null; + embeddedManager = null; + } + + /** + * Clears the per-project state that lives on the shared instance rather than in storage. iOS gets + * this for free because it replaces its SDK instance; Android reuses {@link #sharedInstance}, so + * anything held in a field survives a switch unless it is cleared here. + * + * inboxSessionId is the one that produces cross-project data: a session ID minted under the + * previous project would otherwise be attached to the new project's first in-app tracking call. + * deviceAttributes is app-set rather than project-set, but it is cleared for parity, because iOS + * discards it with the instance and would otherwise report different device attributes than + * Android for the same app after the same switch. Set them again from the callback if they still + * apply to the new project. + */ + void clearProjectScopedInstanceState() { + inboxSessionId = null; + _payloadData = null; + _notificationData = null; + deviceAttributes.clear(); + } + /** * Returns the attribution information ({@link IterableAttributionInfo}) for last push open * or app link click from an email. @@ -1109,6 +1252,15 @@ public void setEmail(@Nullable String email, @Nullable String authToken, @Nullab } public void setEmail(@Nullable String email, @Nullable String authToken, @Nullable IterableIdentityResolution iterableIdentityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { + // Wrapped here too, not just in the shorter overloads: an app calling this overload directly + // would otherwise bypass the initialization and project-switch gate entirely. The shorter + // overloads still delegate here, so a call through them passes the gate twice; the second + // pass runs inline because the gate is down by the time the queue drains. + queueOrExecute(() -> setEmailInternal(email, authToken, iterableIdentityResolution, successHandler, failureHandler), + "setEmail(" + maskPII(email) + ", " + maskPII(authToken) + ", identityResolution, callbacks)"); + } + + private void setEmailInternal(@Nullable String email, @Nullable String authToken, @Nullable IterableIdentityResolution iterableIdentityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { boolean replay = isReplay(iterableIdentityResolution); boolean merge = isMerge(iterableIdentityResolution); @@ -1179,6 +1331,14 @@ public void setUserId(@Nullable String userId, @Nullable String authToken, @Null } public void setUserId(@Nullable String userId, @Nullable String authToken, @Nullable IterableIdentityResolution iterableIdentityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler, boolean isUnknown) { + // Wrapped here too, not just in the shorter overloads: an app calling this overload directly + // would otherwise bypass the initialization and project-switch gate entirely. See setEmail + // for why passing through the gate twice is harmless. + queueOrExecute(() -> setUserIdInternal(userId, authToken, iterableIdentityResolution, successHandler, failureHandler, isUnknown), + "setUserId(" + maskPII(userId) + ", " + maskPII(authToken) + ", identityResolution, callbacks)"); + } + + private void setUserIdInternal(@Nullable String userId, @Nullable String authToken, @Nullable IterableIdentityResolution iterableIdentityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler, boolean isUnknown) { boolean replay = isReplay(iterableIdentityResolution); boolean merge = isMerge(iterableIdentityResolution); @@ -1333,7 +1493,9 @@ public void trackPushOpen(int campaignId, int templateId, @NonNull String messag * @param dataFields */ public void trackPushOpen(int campaignId, int templateId, @NonNull String messageId, boolean appAlreadyRunning, @Nullable JSONObject dataFields) { - queueOrExecute(() -> { + // Not queued behind a project switch: campaignId, templateId and messageId only exist on the + // project that sent the push, so replaying this against the new project misattributes it. + queueOrExecuteUnlessSwitching(() -> { if (messageId == null) { IterableLogger.e(TAG, "messageId is null"); return; @@ -1538,6 +1700,11 @@ public void track(@NonNull String eventName, int campaignId, int templateId) { * @param dataFields */ public void track(@NonNull String eventName, int campaignId, int templateId, @Nullable JSONObject dataFields) { + queueOrExecute(() -> trackInternal(eventName, campaignId, templateId, dataFields), + "track(" + eventName + ", " + campaignId + ", " + templateId + ", dataFields)"); + } + + private void trackInternal(@NonNull String eventName, int campaignId, int templateId, @Nullable JSONObject dataFields) { IterableLogger.printInfo(); if (!checkSDKInitialization() && _userIdUnknown == null) { if (sharedInstance.config.enableUnknownUserActivation) { @@ -1631,6 +1798,11 @@ public void updateEmail(final @NonNull String newEmail, final @Nullable Iterable * @param failureHandler Failure handler. Called when the server call failed. */ public void updateEmail(final @NonNull String newEmail, final @Nullable String authToken, final @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { + queueOrExecute(() -> updateEmailInternal(newEmail, authToken, successHandler, failureHandler), + "updateEmail(" + maskPII(newEmail) + ", " + maskPII(authToken) + ", callbacks)"); + } + + private void updateEmailInternal(final @NonNull String newEmail, final @Nullable String authToken, final @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { if (!checkSDKInitialization()) { IterableLogger.e(TAG, "The Iterable SDK must be initialized with email or userId before " + "calling updateEmail"); @@ -1700,10 +1872,34 @@ public void registerForPush() { * Disables the device from push notifications */ public void disablePush() { - if (checkSDKInitialization()) { - IterablePushRegistrationData data = new IterablePushRegistrationData(_email, _userId, _authToken, getPushIntegrationName(), IterablePushRegistrationData.PushRegistrationAction.DISABLE); - IterablePushRegistration.executePushRegistrationTask(data); + disablePush(null, null); + } + + /** + * @param dispatchListener notified once the disable request has been built and handed to the + * request layer, or with false if there turned out to be nothing to send + * @param onFailure notified if the disable request comes back as a failure + * @return true when a disable was started, so {@code dispatchListener} will be notified + */ + boolean disablePush(@Nullable IterablePushRegistrationData.DispatchListener dispatchListener, + @Nullable IterableHelper.FailureHandler onFailure) { + if (!checkSDKInitialization()) { + return false; } + IterablePushRegistrationData data = new IterablePushRegistrationData(_email, _userId, _authToken, getPushIntegrationName(), IterablePushRegistrationData.PushRegistrationAction.DISABLE); + // Captured here rather than resolved when the request is sent: the FCM token lookup that + // runs first is network-bound, and a project switch can swap _apiKey while it is in flight. + // users/disableDevice is project-scoped on the backend, so a disable that goes out with the + // new project's key leaves the previous project still delivering push to this device. + // The endpoint is captured with the key and never separately: the two only mean anything as a + // pair, and the new project can be in a different data region, in which case a captured key + // sent to the live endpoint is rejected outright. + data.apiKey = _apiKey; + data.baseUrl = IterableRequestTask.getRegionBaseUrl(); + data.dispatchListener = dispatchListener; + data.onFailure = onFailure; + IterablePushRegistration.executePushRegistrationTask(data); + return true; } /** diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index d0d3ee60d..c058c4e13 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -608,7 +608,16 @@ protected void trackPushOpen(int campaignId, int templateId, @NonNull String mes } } - protected void disableToken(@Nullable String email, @Nullable String userId, @Nullable String authToken, @NonNull String deviceToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + /** + * @param apiKey key captured when the disable was initiated, or null to use the live key. + * users/disableDevice is project-scoped on the backend, so a disable that resolves + * its key at send time can land on the wrong project when the key changes while + * the FCM token lookup is in flight. + * @param baseUrl region endpoint captured with {@code apiKey}, or null to use the live region. + * Passed together with the key so the request cannot end up carrying one project's + * key to another project's endpoint. + */ + protected void disableToken(@Nullable String email, @Nullable String userId, @Nullable String authToken, @Nullable String apiKey, @Nullable String baseUrl, @NonNull String deviceToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { JSONObject requestJSON = new JSONObject(); try { requestJSON.put(IterableConstants.KEY_TOKEN, deviceToken); @@ -618,7 +627,7 @@ 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); + sendPostRequest(IterableConstants.ENDPOINT_DISABLE_DEVICE, requestJSON, authToken, apiKey, baseUrl, onSuccess, onFailure); } catch (JSONException e) { e.printStackTrace(); } @@ -765,7 +774,21 @@ void sendPostRequest(@NonNull String resourcePath, @NonNull JSONObject json, @Nu } void sendPostRequest(@NonNull String resourcePath, @NonNull JSONObject json, @Nullable String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { - getRequestProcessor().processPostRequest(authProvider.getApiKey(), resourcePath, json, authToken, onSuccess, onFailure); + sendPostRequest(resourcePath, json, authToken, null, null, onSuccess, onFailure); + } + + /** + * @param apiKeyOverride key captured by the caller, used instead of the live key. Only set by + * callers whose request was built before the live key could change under + * them; null keeps the existing behaviour of resolving it now. + * @param baseUrlOverride region endpoint captured by the same caller at the same moment. Set with + * {@code apiKeyOverride} rather than on its own, because a captured key + * sent to whichever region happens to be live is exactly the mismatch the + * capture exists to prevent; null keeps the existing behaviour. + */ + void sendPostRequest(@NonNull String resourcePath, @NonNull JSONObject json, @Nullable String authToken, @Nullable String apiKeyOverride, @Nullable String baseUrlOverride, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + String apiKey = (apiKeyOverride != null) ? apiKeyOverride : authProvider.getApiKey(); + getRequestProcessor().processPostRequest(apiKey, baseUrlOverride, resourcePath, json, authToken, onSuccess, onFailure); } /** @@ -783,10 +806,30 @@ void sendGetRequest(@NonNull String resourcePath, @NonNull JSONObject json, @Non } void onLogout() { - getRequestProcessor().onLogout(authProvider.getContext()); + purgeOfflineQueue(); authProvider.resetAuth(); } + /** + * Purges the persisted offline queue, preserving queued device disables. Runs synchronously on + * the calling thread, so callers do not have to wait for the purge to finish. + */ + void purgeOfflineQueue() { + getRequestProcessor().onLogout(authProvider.getContext()); + } + + /** + * Re-binds the offline task runner to the current auth manager. Needed after + * {@link IterableApi#switchProject} replaces the auth manager, because the request processor + * (and the task runner it owns) is reused across the switch. + */ + void rebindAuthTokenListener() { + RequestProcessor processor = getRequestProcessor(); + if (processor instanceof OfflineRequestProcessor) { + ((OfflineRequestProcessor) processor).registerAuthTokenListener(); + } + } + void mergeUser(String sourceEmail, String sourceUserId, String destinationEmail, String destinationUserId, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { JSONObject requestJson = new JSONObject(); try { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 3a960e54a..30aa9af8e 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -69,6 +69,12 @@ interface AuthTokenReadyListener { } void addAuthTokenReadyListener(AuthTokenReadyListener listener) { + // Deduped because a listener can be registered twice for the same auth manager: a project + // switch re-binds the task runner after replacing the auth manager, and if the re-init also + // flips offline mode on, the new OfflineRequestProcessor has already registered it. + if (listener == null || authTokenReadyListeners.contains(listener)) { + return; + } authTokenReadyListeners.add(listener); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java index 75cefde9c..14dbea292 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java @@ -15,6 +15,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -31,6 +32,18 @@ class IterableBackgroundInitializer { // Callback manager for initialization completion private static final IterableInitializationCallbackManager callbackManager = new IterableInitializationCallbackManager(); + /** + * Outcome of an attempt to drain the operation queue. + */ + enum DrainResult { + /** A drain task is running; the drain completion will run on the executor thread. */ + STARTED, + /** A drain was already in flight, so this call did nothing. */ + ALREADY_DRAINING, + /** No executor would accept the drain. The queue is still full and nobody will run it. */ + REJECTED + } + /** * Represents a queued operation that should be executed after initialization */ @@ -59,24 +72,66 @@ void enqueue(QueuedOperation operation) { } void processAll(ExecutorService executor) { - if (isProcessing) return; + processAll(executor, null); + } + + /** + * @param onDrained run on the executor thread once every queued operation has executed. Not + * run unless the result is {@link DrainResult#STARTED}. + */ + DrainResult processAll(ExecutorService executor, @Nullable Runnable onDrained) { + if (isProcessing) return DrainResult.ALREADY_DRAINING; isProcessing = true; - executor.execute(() -> { - QueuedOperation operation; - while ((operation = operations.poll()) != null) { - try { - IterableLogger.d(TAG, "Executing queued operation: " + operation.getDescription()); - operation.execute(); - } catch (Exception e) { - IterableLogger.e(TAG, "Failed to execute queued operation", e); + if (submitDrain(executor, onDrained)) { + return DrainResult.STARTED; + } + + // The executor was shut down between being handed to us and execute(). That happens for + // real: the drain task shuts its own executor down as its last act, so a switch started + // from inside a switch callback can land in exactly this window. + IterableLogger.w(TAG, "Background executor rejected the queue drain, retrying on a fresh executor"); + if (submitDrain(replaceIfCurrent(executor), onDrained)) { + return DrainResult.STARTED; + } + + // Nothing will run the drain, so isProcessing has to be released. Left set, it would jam + // the queue for the life of the process and every later drain would be refused. + isProcessing = false; + IterableLogger.e(TAG, "Could not drain the operation queue: both executors rejected it"); + return DrainResult.REJECTED; + } + + /** @return false if {@code executor} rejected the drain */ + private boolean submitDrain(ExecutorService executor, @Nullable Runnable onDrained) { + try { + executor.execute(() -> { + QueuedOperation operation; + while ((operation = operations.poll()) != null) { + try { + IterableLogger.d(TAG, "Executing queued operation: " + operation.getDescription()); + operation.execute(); + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to execute queued operation", e); + } } - } - isProcessing = false; + isProcessing = false; - IterableLogger.d(TAG, "All queued operations processed, shutting down background executor"); - shutdownBackgroundExecutorAsync(executor); - }); + if (onDrained != null) { + try { + onDrained.run(); + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to run queue drain completion", e); + } + } + + IterableLogger.d(TAG, "All queued operations processed, shutting down background executor"); + shutdownBackgroundExecutorAsync(executor); + }); + return true; + } catch (RejectedExecutionException e) { + return false; + } } int size() { @@ -111,6 +166,13 @@ private static ExecutorService createExecutor() { private static volatile boolean isBackgroundInitialized = false; private static final ConcurrentLinkedQueue pendingCallbacks = new ConcurrentLinkedQueue<>(); + /** + * Work that must not run until the in-flight background initialization has finished. Currently + * only {@link IterableApi#switchProject}, which cannot tear the SDK down while an init task is + * still going to mark initialization complete underneath it. + */ + private static final ConcurrentLinkedQueue pendingInitActions = new ConcurrentLinkedQueue<>(); + /** * Initialize the Iterable SDK in the background to avoid ANRs. * This method returns immediately and performs all initialization work on a background thread. @@ -198,13 +260,7 @@ static void initializeInBackground(@NonNull Context context, } // Always mark as completed and call callbacks regardless of success/timeout/failure - synchronized (initLock) { - isBackgroundInitialized = true; - isInitializing = false; - } - - // Process any queued operations - operationQueue.processAll(backgroundExecutor); + markInitializationComplete(); // Notify completion on main thread (always success) final boolean finalInitSucceeded = initSucceeded; @@ -255,7 +311,69 @@ static void initializeInBackground(@NonNull Context context, } }; - backgroundExecutor.execute(initTask); + // Via ensureBackgroundExecutor rather than backgroundExecutor directly: the drain task shuts + // its own executor down when it finishes, so the field can hold a dead executor by the time + // an app initializes again, and a rejection here would throw out of a public API call. + ExecutorService executor; + synchronized (initLock) { + executor = ensureBackgroundExecutor(); + } + executor.execute(initTask); + } + + /** + * Lowers the initialization gate, drains the calls that were queued behind it, and runs anything + * that was waiting for initialization to finish. + * + * Does nothing to the gate while a project switch owns it: the switch raises the same flags and + * {@link #completeProjectSwitch(boolean)} is what lowers them. Without that check an init task + * completing mid-switch would open the gate while the teardown was still running, so calls in + * that window would execute against a half torn-down SDK. + */ + private static void markInitializationComplete() { + boolean switchOwnsGate; + synchronized (initLock) { + switchOwnsGate = isSwitchingProject; + if (!switchOwnsGate) { + isBackgroundInitialized = true; + isInitializing = false; + } + } + + if (switchOwnsGate) { + IterableLogger.d(TAG, "Initialization finished during a project switch; the switch owns the gate"); + return; + } + + operationQueue.processAll(backgroundExecutor); + runPendingInitActions(); + } + + private static void runPendingInitActions() { + Runnable action; + while ((action = pendingInitActions.poll()) != null) { + try { + action.run(); + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to run deferred post-initialization action", e); + } + } + } + + /** + * Defers {@code action} until the in-flight background initialization completes. + * + * @return true if the action was deferred, false if no initialization is in flight and the + * caller should run it itself + */ + static boolean runWhenInitialized(@NonNull Runnable action) { + synchronized (initLock) { + if (!isInitializing || isBackgroundInitialized) { + return false; + } + pendingInitActions.offer(action); + return true; + } } /** @@ -310,6 +428,255 @@ public String getDescription() { }); } + /** + * Queues behind an initialization but never behind a project switch. + * + * A queued operation is replayed once the new project is live, which is right for a call that + * carries no project-scoped identifiers and wrong for one that does. A push open replayed after + * a switch reports the previous project's campaignId, templateId and messageId to the new + * project, where those IDs do not exist. Running it inline instead sends it to whichever project + * is live at the time, which is the previous project for all of the teardown. iOS does not gate + * push handling at all, for the same reason. + * + * Initialization queueing is deliberately left alone: a call made while a background + * initialization is still in flight has no previous project to be misattributed to. + * + * @return true if the operation was queued + */ + static boolean queueOrExecuteUnlessSwitching(QueuedOperation operation) { + boolean switching; + synchronized (initLock) { + if (isInitializing && !isBackgroundInitialized && !isSwitchingProject) { + operationQueue.enqueue(operation); + return true; + } + // Read under the same lock that raises the gate, so the branch cannot be decided on a + // stale value, then log and execute outside it. + switching = isSwitchingProject; + } + if (switching) { + IterableLogger.w(TAG, "switchProject is in progress. Running " + operation.getDescription() + + " against the project that is live now instead of queueing it, because it " + + "carries identifiers that only exist on the project that produced it."); + } + operation.execute(); + return false; + } + + static void queueOrExecuteUnlessSwitching(Runnable runnable, String description) { + queueOrExecuteUnlessSwitching(new QueuedOperation() { + @Override + public void execute() { + runnable.run(); + } + + @Override + public String getDescription() { + return description; + } + }); + } + + + //region Project switching + //--------------------------------------------------------------------------------------- + + private static volatile boolean isSwitchingProject = false; + private static final ConcurrentLinkedQueue switchCallbacks = new ConcurrentLinkedQueue<>(); + + /** + * @return true while {@link IterableApi#switchProject} is tearing down and re-initializing + */ + static boolean isSwitchingProject() { + return isSwitchingProject; + } + + /** + * Raises the switch gate so every SDK call made from now until + * {@link #completeProjectSwitch(boolean)} is queued instead of running against a half + * torn-down SDK, and registers {@code callback} with the switch. + * + * @return true if this call owns the switch, false if a switch was already in progress (the + * callback is registered with the in-flight switch either way) + */ + static boolean beginProjectSwitch(@Nullable IterableProjectSwitchCallback callback) { + synchronized (initLock) { + if (callback != null) { + switchCallbacks.offer(callback); + } + if (isSwitchingProject) { + return false; + } + isSwitchingProject = true; + isInitializing = true; + isBackgroundInitialized = false; + // The new project runs through initialize() again, and notifyInitializationComplete() + // only fires once per initialized flag, so the flag has to be cleared for the second + // initialize() to notify. Subscribers themselves are kept: clearing them would drop a + // subscriber registered while the first initialization was still in flight. + callbackManager.clearInitializedFlag(); + return true; + } + } + + /** + * Lowers the switch gate, drains the calls queued during the switch window FIFO against the new + * project, then delivers every callback registered with this switch on the main thread. + * + * @param cleanTeardown false when a teardown step was noisy. The SDK is on the new project + * either way; this never means the switch failed. + */ + static void completeProjectSwitch(boolean cleanTeardown) { + final List switchCallbacksToNotify = new ArrayList<>(); + final List initCallbacksToNotify = new ArrayList<>(); + final ExecutorService executor; + synchronized (initLock) { + isSwitchingProject = false; + isInitializing = false; + isBackgroundInitialized = true; + IterableProjectSwitchCallback switchCallback; + while ((switchCallback = switchCallbacks.poll()) != null) { + switchCallbacksToNotify.add(switchCallback); + } + // initializeInBackground parks its callback whenever initialization looks in flight, + // which a switch makes true for its whole window. Nothing else drains them, so without + // this they would never fire. + IterableInitializationCallback initCallback; + while ((initCallback = pendingCallbacks.poll()) != null) { + initCallbacksToNotify.add(initCallback); + } + executor = ensureBackgroundExecutor(); + } + + Runnable notifyCallbacks = () -> { + notifySwitchCallbacks(switchCallbacksToNotify, cleanTeardown); + notifyInitializationCallbacks(initCallbacksToNotify); + }; + + DrainResult drainResult = operationQueue.processAll(executor, notifyCallbacks); + if (drainResult == DrainResult.STARTED) { + return; + } + if (drainResult == DrainResult.REJECTED) { + // The gate is already down, so new calls run, but the calls queued during the switch are + // stranded. Report that as a noisy switch rather than silently dropping the callbacks. + notifySwitchCallbacks(switchCallbacksToNotify, false); + notifyInitializationCallbacks(initCallbacksToNotify); + return; + } + notifyCallbacks.run(); + } + + private static void notifySwitchCallbacks(List callbacks, boolean cleanTeardown) { + if (callbacks.isEmpty()) { + return; + } + new Handler(Looper.getMainLooper()).post(() -> { + for (IterableProjectSwitchCallback callback : callbacks) { + try { + callback.onProjectSwitched(cleanTeardown); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in switchProject callback", e); + } + } + }); + } + + /** + * Callbacks that asked about initialization, not about the switch, so they get the plain + * no-argument notification rather than the switch's teardown verdict. + */ + private static void notifyInitializationCallbacks(List callbacks) { + if (callbacks.isEmpty()) { + return; + } + new Handler(Looper.getMainLooper()).post(() -> { + for (IterableInitializationCallback callback : callbacks) { + try { + callback.onSDKInitialized(); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in pending initialization callback", e); + } + } + }); + } + + /** + * Runs project switch teardown and re-initialization off the main thread on the existing + * background executor. + */ + static void executeOnBackgroundExecutor(Runnable task) { + ExecutorService executor; + synchronized (initLock) { + executor = ensureBackgroundExecutor(); + } + executeOn(executor, task); + } + + /** + * Split out from {@link #executeOnBackgroundExecutor(Runnable)} so a test can supply an executor + * that rejects, which is otherwise only reachable through a race. + */ + @VisibleForTesting + static void executeOn(ExecutorService executor, Runnable task) { + try { + executor.execute(task); + return; + } catch (RejectedExecutionException e) { + // The executor was shut down after we picked it up but before execute(), which the drain + // task's own shutdown makes reachable. Retry once on a fresh one. + IterableLogger.w(TAG, "Background executor rejected the project switch, retrying on a fresh executor"); + } + + try { + replaceIfCurrent(executor).execute(task); + } catch (RejectedExecutionException e) { + // Nothing will run the teardown, so the gate would stay raised forever with no callback. + // Lower it and report a failed switch instead. + IterableLogger.e(TAG, "Could not start the project switch: both executors rejected it", e); + completeProjectSwitch(false); + } + } + + /** + * Returns a live executor after {@code rejected} refused a task. Only swaps the shared executor + * when {@code rejected} is the shared one, so a caller-supplied executor cannot take a healthy + * shared executor down with it. + */ + private static ExecutorService replaceIfCurrent(ExecutorService rejected) { + synchronized (initLock) { + if (backgroundExecutor == rejected) { + swapBackgroundExecutor(); + } + return ensureBackgroundExecutor(); + } + } + + /** + * Returns a usable background executor, replacing it first if the previous one was shut down + * after draining the initial queue. Caller must hold {@link #initLock}. + */ + private static ExecutorService ensureBackgroundExecutor() { + if (backgroundExecutor == null || backgroundExecutor.isShutdown()) { + swapBackgroundExecutor(); + } + return backgroundExecutor; + } + + /** + * Swaps in a fresh executor first, then shuts down the old one. This ordering ensures + * shutdownBackgroundExecutorAsync (which may still be pending from the old executor) cannot + * kill the new one. Caller must hold {@link #initLock}. + */ + private static void swapBackgroundExecutor() { + ExecutorService oldExecutor = backgroundExecutor; + backgroundExecutor = createExecutor(); + if (oldExecutor != null && !oldExecutor.isShutdown()) { + oldExecutor.shutdownNow(); + } + } + + //endregion /** * Shutdown the background executor for proper cleanup @@ -392,11 +759,7 @@ static void simulateInitializingState() { */ @VisibleForTesting static void simulateInitializationComplete() { - synchronized (initLock) { - isBackgroundInitialized = true; - isInitializing = false; - } - operationQueue.processAll(backgroundExecutor); + markInitializationComplete(); } /** @@ -407,18 +770,14 @@ static void resetBackgroundInitializationState() { synchronized (initLock) { isInitializing = false; isBackgroundInitialized = false; + isSwitchingProject = false; operationQueue.clear(); pendingCallbacks.clear(); + pendingInitActions.clear(); + switchCallbacks.clear(); callbackManager.reset(); - // Swap in a fresh executor first, then shut down the old one. - // This ensures shutdownBackgroundExecutorAsync (which may still be - // pending from the old executor) cannot kill the new one. - ExecutorService oldExecutor = backgroundExecutor; - backgroundExecutor = createExecutor(); - if (oldExecutor != null && !oldExecutor.isShutdown()) { - oldExecutor.shutdownNow(); - } + swapBackgroundExecutor(); } } @@ -434,6 +793,15 @@ static int getQueuedOperationCount() { return operationQueue.size(); } + /** + * Drains the operation queue on a caller-supplied executor, so a test can supply one that + * rejects. Only reachable through a race otherwise. + */ + @VisibleForTesting + static DrainResult processQueuedOperationsOn(ExecutorService executor) { + return operationQueue.processAll(executor, null); + } + /** * Clear all queued operations (for testing) */ @@ -556,6 +924,17 @@ void reset() { } } + /** + * Allows the next {@link #notifyInitializationComplete()} to fire again, without discarding + * anything that is waiting to be notified. Used when the SDK re-runs initialize() for a new + * project. + */ + void clearInitializedFlag() { + synchronized (initLock) { + isInitialized = false; + } + } + /** * Helper method to ensure callbacks are called on the main thread */ diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java index 8ecb82dc5..a536ac060 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java @@ -45,6 +45,10 @@ public static boolean handleMessageReceived(@NonNull Context context, @NonNull R } IterableLogger.d(TAG, "Message data payload: " + remoteMessage.getData()); + if (IterableBackgroundInitializer.isSwitchingProject()) { + IterableLogger.w(TAG, "Push received while a project switch is in progress. It may have been " + + "sent by the previous project; any resulting SDK calls run against the new project."); + } // Check if message contains a notification payload. if (remoteMessage.getNotification() != null) { IterableLogger.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody()); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java index 402d46378..da3d7a282 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java @@ -6,7 +6,7 @@ * performed in the foreground or background. * * Multiple parties can subscribe to initialization completion using - * {@link IterableApi#addInitializationCallback(IterableInitializationCallback)} + * {@link IterableApi#onSDKInitialized(IterableInitializationCallback)} */ public interface IterableInitializationCallback { /** diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java new file mode 100644 index 000000000..8463ee9aa --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitchCallback.java @@ -0,0 +1,28 @@ +package com.iterable.iterableapi; + +/** + * Callback for {@link IterableApi#switchProject}. + * + * This is a single-method interface so that a lambda receives the teardown result. + * {@link IterableInitializationCallback} cannot serve this purpose: its only abstract method takes + * no arguments, so a lambda would bind to that one and silently discard the result. + */ +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 value of {@code cleanTeardown}. + * + * @param cleanTeardown true when every teardown step completed cleanly. False means at least one + * cleanup step was noisy, or that no device disable was confirmed for the + * previous project. False never means the switch failed or was rolled back, + * so the right response is the same either way: carry on and re-identify + * the user with {@link IterableApi#setEmail(String)} or + * {@link IterableApi#setUserId(String)}. + *

+ * False is expected in normal operation and is not an error. An app that + * does not use push registration, or that has no device token yet, will + * always see false, because the switch could not confirm a device disable + * for the previous project. + */ + void onProjectSwitched(boolean cleanTeardown); +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java new file mode 100644 index 000000000..a49c29808 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java @@ -0,0 +1,356 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Implements {@link IterableApi#switchProject(Context, String, IterableConfig, IterableInitializationCallback)}: + * moving a running app from one Iterable project to another in place. + * + * The eight steps are: guard and validate, raise the switch gate, run the existing logout path, + * purge the persisted offline queue, clear the previous project's identity and project-scoped + * storage, release its managers, re-initialize against the new key and config, then lower the gate, + * drain the calls queued during the window and fire the callbacks. + * + * Kept out of {@link IterableApi} so the switch reads as one sequence, mirroring how the iOS SDK + * organises it in {@code IterableAPI+SwitchProject.swift}. + */ +class IterableProjectSwitcher { + private static final String TAG = "IterableProjectSwitcher"; + + /** + * How long step 3 waits for the device disable to be handed to the request layer before it gives + * up and swaps anyway. The disable has to fetch an FCM token first, which is network-bound and + * untimed, so without a bound a switch could hang for as long as FCM does. + * + * Deliberately well under the 5 second grace period the background executor's shutdown allows + * before it calls {@code shutdownNow()}. A switch started from inside a switch callback runs its + * teardown, including this wait, on that same single-thread executor while the previous drain's + * shutdown is counting down, so equal windows would let {@code shutdownNow()} interrupt this wait. + */ + private static final long DISABLE_DISPATCH_TIMEOUT_MS = 2000; + + /** + * Overridable so a test can drive the timeout path without waiting for it in real time. Raising + * it past the shutdown grace period reintroduces the interrupt above. + */ + @VisibleForTesting + static long disableDispatchTimeoutMs = DISABLE_DISPATCH_TIMEOUT_MS; + + private IterableProjectSwitcher() { } + + static void switchProject(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableProjectSwitchCallback callback) { + // Both parameters are @NonNull, so a null is a programmer error. Reporting it through the + // callback would mean the same false that documents "we switched, but a cleanup step was + // noisy" also has to mean "nothing happened at all". + if (context == null) { + throw new IllegalArgumentException("switchProject: context must not be null"); + } + if (apiKey == null) { + throw new IllegalArgumentException("switchProject: apiKey must not be null"); + } + + // An empty key is a runtime condition rather than a programmer error: a region lookup or a + // remote config can legitimately return one. Tearing down for it would delete the previous + // project's identity and offline queue and leave the SDK initialized against nothing, so + // refuse it and stay put. iOS does the same. + if (apiKey.trim().isEmpty()) { + IterableLogger.e(TAG, "switchProject called with an empty API key. The SDK is left on " + + "the project it is already on."); + deliverSwitchCallback(callback, false); + return; + } + + final IterableApi api = IterableApi.sharedInstance; + + // 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 false, not true. IterableInitializationCallback carries no success signal + // and fires even when initialization times out or throws, so true here would claim a + // clean teardown that was never attempted. False is also what every other path with no + // confirmed device disable reports, and there is no previous project to disable. + // initializeInBackground notifies on the main thread already, so this does not need + // deliverSwitchCallback. + IterableApi.initializeInBackground(context, apiKey, config, + callback == null ? null : () -> callback.onProjectSwitched(false)); + return; + } + + if (apiKey.equals(api._apiKey)) { + IterableLogger.d(TAG, "switchProject called with the API key already in use; nothing to tear down"); + deliverSwitchCallback(callback, true); + return; + } + + // initializeInBackground publishes _apiKey synchronously but finishes its init task later, + // and that task marks initialization complete, which would lower this switch's gate while + // the teardown was still running. Wait for it instead of tearing down underneath it. + if (!IterableBackgroundInitializer.isSwitchingProject() && IterableApi.isSDKInitializing()) { + if (IterableBackgroundInitializer.runWhenInitialized(() -> switchProject(context, apiKey, config, callback))) { + IterableLogger.w(TAG, "switchProject called while initialization was still in flight; " + + "deferring the switch until initialization completes"); + return; + } + } + + // Step 2: raise the switch gate synchronously, so calls made after this method returns are + // queued rather than executed against a half torn-down SDK. + if (!IterableBackgroundInitializer.beginProjectSwitch(callback)) { + IterableLogger.d(TAG, "switchProject: a switch is already in progress; callback registered with it"); + return; + } + + // Steps 3-8 run off the main thread on the existing background executor. + IterableBackgroundInitializer.executeOnBackgroundExecutor(() -> runSwitch(api, context, apiKey, config)); + } + + private static void runSwitch(IterableApi api, Context context, String apiKey, @Nullable IterableConfig config) { + // Counted down once the device disable has been built and handed off, or straight away when + // there was no disable to send. disableConfirmed stays false unless a request actually went + // out, so an app with push disabled, a missing FCM token or a failing disable all report a + // noisy teardown rather than a clean one. + final CountDownLatch disableDispatched = new CountDownLatch(1); + final AtomicBoolean disableConfirmed = new AtomicBoolean(false); + IterablePushRegistrationData.DispatchListener disableListener = dispatched -> { + disableConfirmed.set(dispatched); + disableDispatched.countDown(); + }; + IterableHelper.FailureHandler onDisableFailure = (reason, data) -> { + disableConfirmed.set(false); + IterableLogger.w(TAG, "switchProject: the previous project's device disable failed: " + reason); + }; + + boolean cleanTeardown = tearDown(api, disableListener, onDisableFailure); + + // Waited for so the disable is normally on its way before the swap. It carries both the API + // key and the region endpoint captured when it was initiated, so a timeout here costs + // promptness rather than correctness: a disable that dispatches afterwards still reaches the + // project it was created for. + try { + if (!disableDispatched.await(disableDispatchTimeoutMs, TimeUnit.MILLISECONDS)) { + cleanTeardown = false; + IterableLogger.w(TAG, "switchProject: the previous project's device disable did not dispatch " + + "within " + disableDispatchTimeoutMs + "ms; switching anyway"); + } + } catch (InterruptedException e) { + cleanTeardown = false; + IterableLogger.w(TAG, "switchProject: interrupted while waiting for the device disable to dispatch"); + Thread.currentThread().interrupt(); + } + + if (!disableConfirmed.get()) { + // Expected, not an error, for an app that does not use push or has no token yet. The + // switch itself is unaffected; the callback reports it so an app that does use push can + // tell that the previous project may still have this device registered. + cleanTeardown = false; + IterableLogger.d(TAG, "switchProject: no device disable was confirmed for the previous project"); + } + + // Step 7: re-initialize with the new API key and config. + try { + IterableApi.initialize(context, apiKey, config); + } catch (Exception e) { + cleanTeardown = false; + IterableLogger.e(TAG, "switchProject: re-initialization failed", e); + } + + // initialize() rebuilds the in-app, embedded and unknown-user managers but never the auth + // manager, so it is replaced here: in one place, after config has been swapped, and holding + // the lock getAuthManager() takes, so no other thread can win the race and build one bound + // to the previous project's IterableAuthHandler. + try { + synchronized (api.projectStateLock) { + api.authManager = null; + api.getAuthManager(); + } + } catch (Exception e) { + cleanTeardown = false; + IterableLogger.e(TAG, "switchProject: rebuilding the auth manager was not clean", e); + } + + // The request processor is reused across the switch, so re-bind it to the new auth manager. + try { + api.apiClient.rebindAuthTokenListener(); + } catch (Exception e) { + cleanTeardown = false; + IterableLogger.e(TAG, "switchProject: failed to re-bind the auth token listener", e); + } + + // Step 8: lower the gate, drain queued calls FIFO, then fire the callbacks. + IterableBackgroundInitializer.completeProjectSwitch(cleanTeardown); + } + + /** + * Steps 3-6. Every step is independently guarded: a noisy step is logged and reported through + * the callback, but never stops the swap. + * + * @param disableListener notified once the device disable has reached the request layer, or with + * false when there was no disable to send + * @param onDisableFailure notified if the disable request comes back as a failure + * @return true when every teardown step completed cleanly + */ + private static boolean tearDown(IterableApi api, + IterablePushRegistrationData.DispatchListener disableListener, + IterableHelper.FailureHandler onDisableFailure) { + boolean cleanTeardown = true; + + // Step 3: reuse the existing logout path, so future additions to logout apply here for free. + // This disables the push token on the previous project and resets its managers. + boolean disableStarted = false; + try { + disableStarted = api.logoutPreviousUser(disableListener, onDisableFailure); + } catch (Exception e) { + cleanTeardown = false; + IterableLogger.e(TAG, "switchProject: logout step was not clean", e); + } + if (!disableStarted) { + disableListener.onDispatched(false); + } + + // Step 4: logout above already purges the queue, so this is a repeat. It is kept because a + // logout that threw before reaching apiClient.onLogout() would otherwise carry the previous + // project's queued work into the new project. Queued device disables survive both purges. + try { + api.apiClient.purgeOfflineQueue(); + } catch (Exception e) { + cleanTeardown = false; + IterableLogger.e(TAG, "switchProject: offline queue purge was not clean", e); + } + + // Step 5: clear identity and the rest of the previous project's storage, so + // retrieveEmailAndUserId() during the re-init cannot repopulate its identity and setEmail() + // on the new project cannot replay its events. _deviceId and visitor consent are + // project-agnostic and deliberately left alone. + cleanTeardown &= clearIdentity(api); + cleanTeardown &= clearProjectScopedStorage(api); + + // Step 6: release per-project state so initialize() rebuilds it against the new config. The + // managers are also unregistered from the activity monitor so the discarded instances stop + // reacting to foreground events. authManager is deliberately left in place until config has + // been swapped; see the rebuild in runSwitch. + try { + IterableActivityMonitor activityMonitor = IterableActivityMonitor.getInstance(); + IterableInAppManager inAppManager = api.getInAppManagerOrNull(); + if (inAppManager != null) { + activityMonitor.removeCallback(inAppManager); + } + IterableEmbeddedManager embeddedManager = api.getEmbeddedManagerOrNull(); + if (embeddedManager != null) { + activityMonitor.removeCallback(embeddedManager); + } + if (api.unknownUserManager != null) { + activityMonitor.removeCallback(api.unknownUserManager); + } + if (api.authManager != null) { + activityMonitor.removeCallback(api.authManager); + } + api.clearMessagingManagers(); + api.clearProjectScopedInstanceState(); + api.unknownUserManager = null; + // Dropped so getKeychain() rebuilds it against the new config's keychainEncryption and + // decryptionFailureHandler. Done after clearIdentity, which needs the old one to clear + // values the old encryption settings wrote. + api.keychain = null; + api._firstForegroundHandled = false; + } catch (Exception e) { + cleanTeardown = false; + IterableLogger.e(TAG, "switchProject: releasing per-project state was not clean", e); + } + + return cleanTeardown; + } + + private static boolean clearIdentity(IterableApi api) { + try { + api._email = null; + api._userId = null; + api._userIdUnknown = null; + api._authToken = null; + IterableKeychain keychain = api.getKeychain(); + if (keychain == null) { + IterableLogger.e(TAG, "switchProject: could not clear stored identity, keychain unavailable"); + return false; + } + keychain.saveEmail(null); + keychain.saveUserId(null); + keychain.saveUserIdUnknown(null); + keychain.saveAuthToken(null); + return true; + } catch (Exception e) { + IterableLogger.e(TAG, "switchProject: clearing identity was not clean", e); + return false; + } + } + + /** + * Drops the storage that is scoped to the project being left. The unknown-user event list is the + * damaging one: {@code setEmail} on the new project runs the event replay, which would post + * events collected under the previous project to the new one. Activation criteria and push + * attribution are namespaced per project too, so a stale campaignId would be attached to the + * first track after the switch. + */ + private static boolean clearProjectScopedStorage(IterableApi api) { + boolean clean = true; + + try { + UnknownUserManager unknownUserManager = api.unknownUserManager; + if (unknownUserManager != null) { + unknownUserManager.clearVisitorEventsAndUserData(); + } else { + // Same keys, for the case where the manager was never built. + SharedPreferences.Editor editor = api.getPreferences().edit(); + editor.putString(IterableConstants.SHARED_PREFS_UNKNOWN_SESSIONS, ""); + editor.putString(IterableConstants.SHARED_PREFS_EVENT_LIST_KEY, ""); + editor.putString(IterableConstants.SHARED_PREFS_USER_UPDATE_OBJECT_KEY, ""); + editor.apply(); + } + } catch (Exception e) { + clean = false; + IterableLogger.e(TAG, "switchProject: clearing unknown-user state was not clean", e); + } + + try { + SharedPreferences.Editor editor = api.getPreferences().edit(); + editor.remove(IterableConstants.SHARED_PREFS_CRITERIA); + // matchedCriteriaId is nested inside the unknown-session payload cleared above rather + // than being a key of its own, but remove it too so a build that starts writing it + // separately cannot carry a previous project's criteria id across a switch. + editor.remove(IterableConstants.SHARED_PREFS_CRITERIA_ID); + editor.remove(IterableConstants.SHARED_PREFS_ATTRIBUTION_INFO_KEY + IterableConstants.SHARED_PREFS_OBJECT_SUFFIX); + editor.remove(IterableConstants.SHARED_PREFS_ATTRIBUTION_INFO_KEY + IterableConstants.SHARED_PREFS_EXPIRATION_SUFFIX); + editor.apply(); + } catch (Exception e) { + clean = false; + IterableLogger.e(TAG, "switchProject: clearing criteria and attribution was not clean", e); + } + + return clean; + } + + private static void deliverSwitchCallback(@Nullable IterableProjectSwitchCallback callback, boolean cleanTeardown) { + if (callback == null) { + return; + } + new Handler(Looper.getMainLooper()).post(() -> { + try { + callback.onProjectSwitched(cleanTeardown); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in switchProject callback", e); + } + }); + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationData.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationData.java index 61dd9f1c8..fa331f55a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationData.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationData.java @@ -18,6 +18,42 @@ public enum PushRegistrationAction { String authToken; PushRegistrationAction pushRegistrationAction; + /** + * API key captured when the registration was initiated. Getting the FCM token is network-bound, + * so the live key can change while this task is in flight; a disable that goes out with the wrong + * key hits the wrong project. Null means "use whatever key is live when the request is sent". + */ + String apiKey; + + /** + * Region endpoint captured alongside {@link #apiKey}, for the same reason: the endpoint is + * otherwise resolved from the live config when the request is sent, so a switch to a project in + * a different data region would pair the captured key with the new project's endpoint. Null means + * "use whatever region is live when the request is sent". + */ + String baseUrl; + + /** + * Notified once the registration task has finished handing its request to the request layer, or + * has established that there is nothing to send. + */ + interface DispatchListener { + /** + * @param dispatched true when a request was actually built and handed off. False when there + * was nothing to send: no push integration name, no device token, or the + * token lookup failed. + */ + void onDispatched(boolean dispatched); + } + + DispatchListener dispatchListener; + + /** + * Notified if the request this task dispatches comes back as a failure. Only set by callers that + * need to know, so the usual fire-and-forget registration is unaffected. + */ + IterableHelper.FailureHandler onFailure; + IterablePushRegistrationData(String email, String userId, String pushIntegrationName, String projectNumber, String messagingPlatform, PushRegistrationAction pushRegistrationAction) { this.email = email; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java index 613e646a5..bd21db88c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java @@ -16,31 +16,43 @@ class IterablePushRegistrationTask extends AsyncTask>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"); - String baseUrl = getBaseUrl(); + String baseUrl = getBaseUrl(iterableApiRequest); try { - if (overrideUrl != null && !overrideUrl.isEmpty()) { - baseUrl = overrideUrl; - } if (iterableApiRequest.requestType == IterableApiRequest.GET) { Uri.Builder builder = Uri.parse(baseUrl + iterableApiRequest.resourcePath).buildUpon(); @@ -279,16 +280,35 @@ private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { } } - private static String getBaseUrl() { - IterableConfig config = IterableApi.getInstance().config; - IterableDataRegion dataRegion = config.dataRegion; - String baseUrl = dataRegion.getEndpoint(); - + /** + * Resolves the endpoint for a request, preferring the base URL the request was created with. + * Offline tasks persist their base URL, so a task rehydrated from the database reaches the + * region it was scheduled for rather than whichever region happens to be live now. + * {@link #getBaseUrl()} is only consulted for requests that carry no base URL, which keeps + * behaviour unchanged for tasks persisted by earlier SDK versions. + */ + @VisibleForTesting + static String getBaseUrl(@Nullable IterableApiRequest iterableApiRequest) { if (overrideUrl != null && !overrideUrl.isEmpty()) { - baseUrl = overrideUrl; + return overrideUrl; + } + if (iterableApiRequest != null && iterableApiRequest.baseUrl != null && !iterableApiRequest.baseUrl.isEmpty()) { + return iterableApiRequest.baseUrl; } + return getBaseUrl(); + } - return baseUrl; + private static String getBaseUrl() { + return IterableApi.getInstance().config.dataRegion.getEndpoint(); + } + + /** + * The region endpoint bound to a request when it is created, so it can be persisted alongside + * the API key. Deliberately ignores {@link #overrideUrl} so the debug override stays dynamic + * and is never baked into the offline queue. + */ + static String getRegionBaseUrl() { + return getBaseUrl(); } private static boolean matchesErrorCode(JSONObject jsonResponse, String errorCode) { @@ -451,6 +471,12 @@ class IterableApiRequest { static final String GET = "GET"; static final String POST = "POST"; + /** + * JSON key for the endpoint a persisted task was created for. Absent from tasks written by SDK + * versions that did not persist it yet. + */ + static final String KEY_BASE_URL = "baseUrl"; + final String apiKey; final String baseUrl; final String resourcePath; @@ -527,6 +553,8 @@ public JSONObject toJSONObject() throws JSONException { jsonObject.put("authToken", this.authToken); jsonObject.put("requestType", this.requestType); jsonObject.put("data", this.json); + // Omitted when null so tasks written by earlier SDK versions keep the exact same shape. + jsonObject.putOpt(KEY_BASE_URL, this.baseUrl); return jsonObject; } @@ -554,7 +582,10 @@ static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable String authTok authToken = ""; } JSONObject json = jsonData.getJSONObject("data"); - return new IterableApiRequest(apikey, resourcePath, json, requestType, authToken, onSuccess, onFailure); + // Tasks persisted before baseUrl was part of the schema restore with a null baseUrl and + // fall back to IterableRequestTask.getBaseUrl() at flush time, as they always did. + String baseUrl = jsonData.isNull(KEY_BASE_URL) ? null : jsonData.optString(KEY_BASE_URL, null); + return new IterableApiRequest(apikey, baseUrl, resourcePath, json, requestType, authToken, onSuccess, onFailure); } catch (JSONException e) { IterableLogger.e(TAG, "Failed to create Iterable request from JSON"); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java index 051d6c59a..b0c59e595 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java @@ -332,9 +332,10 @@ ArrayList deleteAllTasks() { /** * Deletes all the entries from the OfflineTask table except those with the given name. - * Task names are the request's resource path, set when the task is scheduled. + * Task names are the request's resource path, set when the task is scheduled. This is how a + * queued device disable survives a logout or a project switch. * - * @param name name of the tasks to preserve + * @param name name of the tasks to preserve, e.g. {@link IterableConstants#ENDPOINT_DISABLE_DEVICE} * @return ids of the deleted tasks, so their parked callbacks can be settled */ @NonNull diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java index 32dee1f82..1d6e1d4bc 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java @@ -60,7 +60,15 @@ class OfflineRequestProcessor implements RequestProcessor { classification); taskScheduler = new TaskScheduler(taskStorage, taskRunner); - // Register task runner as auth token ready listener for JWT auto-retry support + registerAuthTokenListener(); + } + + /** + * Registers the task runner as an auth token ready listener for JWT auto-retry support. + * Called again after {@link IterableApi#switchProject} replaces the auth manager, since the + * request processor itself is reused across the switch. + */ + void registerAuthTokenListener() { try { IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); } catch (Exception e) { @@ -103,7 +111,17 @@ public void processGetRequest(@Nullable String apiKey, @NonNull String resourceP @Override public void processPostRequest(@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, json, IterableApiRequest.POST, authToken, onSuccess, onFailure); + processPostRequest(apiKey, null, resourcePath, json, authToken, onSuccess, onFailure); + } + + @Override + public void processPostRequest(@Nullable String apiKey, @Nullable String baseUrl, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + // Bind the region endpoint alongside the API key so a task that gets persisted here is + // replayed against the project and region it was created for, not the one live at flush time. + // A caller that captured its key ahead of time supplies the endpoint captured with it; the + // live region is only used for requests created here and now. + String requestBaseUrl = (baseUrl != null) ? baseUrl : IterableRequestTask.getRegionBaseUrl(); + IterableApiRequest request = new IterableApiRequest(apiKey, requestBaseUrl, resourcePath, json, IterableApiRequest.POST, authToken, onSuccess, onFailure); if (isRequestOfflineCompatible(request.resourcePath) && healthMonitor.canSchedule()) { request.setProcessorType(IterableApiRequest.ProcessorType.OFFLINE); taskScheduler.scheduleTask(request, onSuccess, onFailure); @@ -114,8 +132,9 @@ public void processPostRequest(@Nullable String apiKey, @NonNull String resource @Override public void onLogout(Context context) { - // A queued disableDevice is the logout itself retrying, so it has to outlive the purge. - // It carries the identity it was created with, so it still targets the outgoing user. + // A queued disableDevice is the logout itself retrying, so it has to outlive the purge. It + // carries the identity and the project it was created with, so it still targets the outgoing + // user, and still reaches the project being left when the purge comes from a project switch. taskScheduler.onTasksPurged(taskStorage.deleteAllTasksExcept(IterableConstants.ENDPOINT_DISABLE_DEVICE)); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java index 013f7f0ad..ee3d7c89c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OnlineRequestProcessor.java @@ -29,7 +29,14 @@ public void processGetRequest(@Nullable String apiKey, @NonNull String resourceP @Override public void processPostRequest(@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.POST, authToken, onSuccess, onFailure); + processPostRequest(apiKey, null, resourcePath, json, authToken, onSuccess, onFailure); + } + + @Override + public void processPostRequest(@Nullable String apiKey, @Nullable String baseUrl, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + // A captured endpoint has to be honoured here too, not only on the offline path: the request + // is executed asynchronously, so the live region can change between here and the send. + IterableApiRequest request = new IterableApiRequest(apiKey, baseUrl, resourcePath, addCreatedAtToJson(json), IterableApiRequest.POST, authToken, onSuccess, onFailure); new IterableRequestTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, request); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/RequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/RequestProcessor.java index 4de818fd6..a30cb0593 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/RequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/RequestProcessor.java @@ -13,5 +13,16 @@ public interface RequestProcessor { void processGetRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure); void processPostRequest(@Nullable String apiKey, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure); + + /** + * @param baseUrl region endpoint the request was created for, or null to resolve it from the live + * config when the request is sent. Set by callers that captured an API key ahead + * of time, so the key and the endpoint stay a matched pair. The default + * implementation ignores it and keeps resolving the endpoint at send time. + */ + default void processPostRequest(@Nullable String apiKey, @Nullable String baseUrl, @NonNull String resourcePath, @NonNull JSONObject json, String authToken, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + processPostRequest(apiKey, resourcePath, json, authToken, onSuccess, onFailure); + } + void onLogout(Context context); } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableOfflineTaskRegionTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableOfflineTaskRegionTest.java new file mode 100644 index 000000000..f817aad3a --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableOfflineTaskRegionTest.java @@ -0,0 +1,308 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import androidx.test.core.app.ApplicationProvider; + +import com.iterable.iterableapi.unit.TestRunner; + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * Covers the per-task credential/endpoint binding of the persisted offline queue, and the queue + * purge semantics that keep queued device disables alive across a logout or a project switch. + */ +@RunWith(TestRunner.class) +public class IterableOfflineTaskRegionTest extends BaseTest { + + private static final String US_ENDPOINT = IterableDataRegion.US.getEndpoint(); + private static final String EU_ENDPOINT = IterableDataRegion.EU.getEndpoint(); + + private String previousOverrideUrl; + + @Before + public void setUp() { + previousOverrideUrl = IterableRequestTask.overrideUrl; + IterableRequestTask.overrideUrl = null; + IterableTestUtils.resetIterableApi(); + } + + @After + public void tearDown() { + IterableRequestTask.overrideUrl = previousOverrideUrl; + } + + @Test + public void testBaseUrlIsPersistedAndRestored() throws Exception { + IterableApiRequest request = new IterableApiRequest("apiKeyA", EU_ENDPOINT, IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, "authTokenA", null, null); + + JSONObject serialized = request.toJSONObject(); + assertEquals(EU_ENDPOINT, serialized.getString(IterableApiRequest.KEY_BASE_URL)); + + IterableApiRequest restored = IterableApiRequest.fromJSON(serialized, null, null); + assertNotNull(restored); + assertEquals("apiKeyA", restored.apiKey); + assertEquals(EU_ENDPOINT, restored.baseUrl); + } + + @Test + public void testRequestWithoutBaseUrlOmitsTheKey() throws Exception { + IterableApiRequest request = new IterableApiRequest("apiKeyA", IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + + assertFalse("baseUrl should not be written when the request carries none", + request.toJSONObject().has(IterableApiRequest.KEY_BASE_URL)); + } + + @Test + public void testTaskPersistedUnderPreviousSchemaFallsBackToLiveConfig() throws Exception { + // A task written by an older SDK version: same JSON shape, no baseUrl key. + JSONObject legacyTaskData = new JSONObject(); + legacyTaskData.put("apiKey", "apiKeyA"); + legacyTaskData.put("resourcePath", IterableConstants.ENDPOINT_TRACK); + legacyTaskData.put("authToken", "authTokenA"); + legacyTaskData.put("requestType", IterableApiRequest.POST); + legacyTaskData.put("data", new JSONObject()); + + IterableApiRequest restored = IterableApiRequest.fromJSON(legacyTaskData, null, null); + assertNotNull(restored); + assertNull("Legacy tasks restore without a baseUrl", restored.baseUrl); + + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyB", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.EU).build()); + + assertEquals("A legacy task resolves its endpoint from the live config, as it always did", + EU_ENDPOINT, IterableRequestTask.getBaseUrl(restored)); + } + + @Test + public void testPersistedBaseUrlWinsOverLiveConfig() { + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyB", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.EU).build()); + + IterableApiRequest taskFromUsProject = new IterableApiRequest("apiKeyA", US_ENDPOINT, IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + + assertEquals("A task queued against US must not be sent to the region that is live now", + US_ENDPOINT, IterableRequestTask.getBaseUrl(taskFromUsProject)); + } + + @Test + public void testOverrideUrlStillWinsOverPersistedBaseUrl() { + IterableRequestTask.overrideUrl = "http://localhost:8080/"; + IterableApiRequest request = new IterableApiRequest("apiKeyA", EU_ENDPOINT, IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + + assertEquals("http://localhost:8080/", IterableRequestTask.getBaseUrl(request)); + } + + @Test + public void testScheduledTaskCarriesTheRegionItWasCreatedFor() { + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyA", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.EU).build()); + + TaskScheduler mockScheduler = mock(TaskScheduler.class); + HealthMonitor mockHealthMonitor = mock(HealthMonitor.class); + when(mockHealthMonitor.canSchedule()).thenReturn(true); + OfflineRequestProcessor processor = new OfflineRequestProcessor(mockScheduler, mock(IterableTaskRunner.class), mock(IterableTaskStorage.class), mockHealthMonitor); + + processor.processPostRequest("apiKeyA", IterableConstants.ENDPOINT_TRACK, new JSONObject(), null, null, null); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IterableApiRequest.class); + verify(mockScheduler).scheduleTask(requestCaptor.capture(), isNull(), isNull()); + assertEquals(EU_ENDPOINT, requestCaptor.getValue().baseUrl); + assertEquals("apiKeyA", requestCaptor.getValue().apiKey); + } + + /** + * The endpoint captured by a caller has to win over the live one, or the capture is pointless: + * pairing a captured key with whichever region is live now is exactly the mismatch it exists to + * prevent. {@code disablePush} is the caller that does this. + */ + @Test + public void testCapturedBaseUrlWinsOverTheLiveRegionWhenSchedulingATask() { + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyB", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.US).build()); + + TaskScheduler mockScheduler = mock(TaskScheduler.class); + HealthMonitor mockHealthMonitor = mock(HealthMonitor.class); + when(mockHealthMonitor.canSchedule()).thenReturn(true); + OfflineRequestProcessor processor = new OfflineRequestProcessor(mockScheduler, mock(IterableTaskRunner.class), mock(IterableTaskStorage.class), mockHealthMonitor); + + processor.processPostRequest("apiKeyA", EU_ENDPOINT, IterableConstants.ENDPOINT_TRACK, new JSONObject(), null, null, null); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IterableApiRequest.class); + verify(mockScheduler).scheduleTask(requestCaptor.capture(), isNull(), isNull()); + assertEquals("apiKeyA", requestCaptor.getValue().apiKey); + assertEquals(EU_ENDPOINT, requestCaptor.getValue().baseUrl); + } + + @Test + public void testOnLogoutPreservesQueuedDeviceDisable() { + IterableTaskStorage mockTaskStorage = mock(IterableTaskStorage.class); + OfflineRequestProcessor processor = new OfflineRequestProcessor(mock(TaskScheduler.class), mock(IterableTaskRunner.class), mockTaskStorage, mock(HealthMonitor.class)); + + processor.onLogout(ApplicationProvider.getApplicationContext()); + + verify(mockTaskStorage).deleteAllTasksExcept(IterableConstants.ENDPOINT_DISABLE_DEVICE); + verify(mockTaskStorage, never()).deleteAllTasks(); + } + + @Test + public void testDeleteAllTasksExceptKeepsOnlyTheDeviceDisable() throws Exception { + IterableTaskStorage taskStorage = IterableTaskStorage.sharedInstance(ApplicationProvider.getApplicationContext()); + taskStorage.deleteAllTasks(); + + IterableApiRequest trackRequest = new IterableApiRequest("apiKeyA", IterableDataRegion.EU.getEndpoint(), IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + IterableApiRequest disableRequest = new IterableApiRequest("apiKeyA", IterableDataRegion.EU.getEndpoint(), IterableConstants.ENDPOINT_DISABLE_DEVICE, new JSONObject(), IterableApiRequest.POST, null, null, null); + + taskStorage.createTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequest.toJSONObject().toString()); + String disableTaskId = taskStorage.createTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, disableRequest.toJSONObject().toString()); + assertEquals(2, taskStorage.getNumberOfTasks()); + + taskStorage.deleteAllTasksExcept(IterableConstants.ENDPOINT_DISABLE_DEVICE); + + ArrayList remaining = taskStorage.getAllTaskIds(); + assertEquals(1, remaining.size()); + assertEquals(disableTaskId, remaining.get(0)); + + // The preserved task still knows which project and region it belongs to. + IterableTask preservedTask = taskStorage.getTask(disableTaskId); + assertNotNull(preservedTask); + IterableApiRequest rehydrated = IterableApiRequest.fromJSON(new JSONObject(preservedTask.data), null, null); + assertNotNull(rehydrated); + assertEquals("apiKeyA", rehydrated.apiKey); + assertEquals(IterableDataRegion.EU.getEndpoint(), rehydrated.baseUrl); + + taskStorage.deleteAllTasks(); + } + + /** + * NAME is nullable in the schema, and SQL three-valued logic makes `name != ?` evaluate to NULL + * rather than true for a null name, hence the `IS NOT` form of the predicate. A null name is not + * reachable through the SDK today, which is why the behaviour needs asserting rather than assuming. + */ + @Test + public void testDeleteAllTasksExceptRemovesRowsWithNoName() { + IterableTaskStorage taskStorage = IterableTaskStorage.sharedInstance(ApplicationProvider.getApplicationContext()); + taskStorage.deleteAllTasks(); + + String unnamedTaskId = taskStorage.createTask(null, IterableTaskType.API, "{}"); + String disableTaskId = taskStorage.createTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, "{}"); + assertEquals(2, taskStorage.getNumberOfTasks()); + + taskStorage.deleteAllTasksExcept(IterableConstants.ENDPOINT_DISABLE_DEVICE); + + ArrayList remaining = taskStorage.getAllTaskIds(); + assertEquals("only the disable is spared; the null-named row must go", 1, remaining.size()); + assertEquals(disableTaskId, remaining.get(0)); + assertNull(taskStorage.getTask(unnamedTaskId)); + + taskStorage.deleteAllTasks(); + } + + @Test + public void testRehydratedTaskNeverMixesTheNewProjectRegionWithTheOldKey() throws Exception { + // Task queued while project A (EU) was live. + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyA", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.EU).build()); + IterableApiRequest queuedRequest = new IterableApiRequest("apiKeyA", IterableRequestTask.getRegionBaseUrl(), IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + String persisted = queuedRequest.toJSONObject().toString(); + + // Project B (US) is live by the time the task is flushed. + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyB", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.US).build()); + assertEquals("The live region must differ from the queued one for this test to mean anything", + US_ENDPOINT, IterableApi.getInstance().config.dataRegion.getEndpoint()); + + IterableApiRequest flushed = IterableApiRequest.fromJSON(new JSONObject(persisted), null, null); + assertNotNull(flushed); + assertEquals("apiKeyA", flushed.apiKey); + assertEquals("The queued key must go to the queued region, not the one that is live now", + EU_ENDPOINT, IterableRequestTask.getBaseUrl(flushed)); + } + + /** + * Every other region test resolves the endpoint through + * {@link IterableRequestTask#getBaseUrl(IterableApiRequest)} directly. This one runs the flush the + * way {@link IterableTaskRunner} does, rehydrating from storage and executing the request, and + * checks it actually lands on the persisted endpoint. + */ + @Test + public void testTaskRunnerFlushesToThePersistedEndpointNotTheLiveRegion() throws Exception { + MockWebServer queuedRegion = new MockWebServer(); + queuedRegion.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200).setBody("{}"); + } + }); + try { + String queuedEndpoint = queuedRegion.url("").toString(); + + IterableTaskStorage taskStorage = IterableTaskStorage.sharedInstance(ApplicationProvider.getApplicationContext()); + taskStorage.deleteAllTasks(); + + IterableApiRequest queuedRequest = new IterableApiRequest("apiKeyA", queuedEndpoint, IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, "authTokenA", null, null); + String taskId = taskStorage.createTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, queuedRequest.toJSONObject().toString()); + + // Project B, a different region, is live by flush time. + IterableApi.initialize(ApplicationProvider.getApplicationContext(), "apiKeyB", + new IterableConfig.Builder().setDataRegion(IterableDataRegion.EU).build()); + + IterableTaskRunner taskRunner = new IterableTaskRunner(taskStorage, + IterableActivityMonitor.getInstance(), + IterableNetworkConnectivityManager.sharedInstance(ApplicationProvider.getApplicationContext()), + mock(HealthMonitor.class)); + IterableTask task = taskStorage.getTask(taskId); + assertNotNull(task); + + // Exactly what IterableTaskRunner.processTask does to a persisted API task. + IterableApiRequest flushed = IterableApiRequest.fromJSON(taskRunner.getTaskDataWithDate(task), null, null, null); + assertNotNull(flushed); + IterableRequestTask.executeApiRequest(flushed); + + RecordedRequest recorded = queuedRegion.takeRequest(5, TimeUnit.SECONDS); + assertNotNull("The flushed task must reach the endpoint it was queued for", recorded); + assertEquals("/" + IterableConstants.ENDPOINT_TRACK, recorded.getPath()); + assertEquals("apiKeyA", recorded.getHeader(IterableConstants.HEADER_API_KEY)); + + taskStorage.deleteAllTasks(); + } finally { + queuedRegion.shutdown(); + } + } + + @Test + public void testScheduleTaskSerializesBaseUrlIntoStorage() throws Exception { + IterableTaskStorage mockTaskStorage = mock(IterableTaskStorage.class); + TaskScheduler scheduler = new TaskScheduler(mockTaskStorage, mock(IterableTaskRunner.class)); + + IterableApiRequest request = new IterableApiRequest("apiKeyA", EU_ENDPOINT, IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + scheduler.scheduleTask(request, null, null); + + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); + verify(mockTaskStorage).createTask(eq(IterableConstants.ENDPOINT_TRACK), any(IterableTaskType.class), dataCaptor.capture()); + assertEquals(EU_ENDPOINT, new JSONObject(dataCaptor.getValue()).getString(IterableApiRequest.KEY_BASE_URL)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java index 194ca8546..47c40d93a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java @@ -7,11 +7,16 @@ import org.junit.Test; import java.util.HashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import okhttp3.mockwebserver.MockWebServer; import static android.os.Looper.getMainLooper; import static com.iterable.iterableapi.IterableTestUtils.stubAnyRequestReturningStatusCode; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; @@ -75,7 +80,7 @@ public void testEnableDevice() throws Exception { shadowOf(getMainLooper()).idle(); verify(apiMock).registerDeviceToken(eq(IterableTestUtils.userEmail), nullable(String.class), isNull(), eq(INTEGRATION_NAME), eq(TEST_TOKEN), eq(deviceAttributes)); - verify(apiMock, never()).disableToken(eq(IterableTestUtils.userEmail), nullable(String.class), nullable(String.class), any(String.class), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); + verify(apiMock, never()).disableToken(eq(IterableTestUtils.userEmail), nullable(String.class), nullable(String.class), nullable(String.class), nullable(String.class), any(String.class), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); } @Test @@ -87,6 +92,59 @@ public void testDisableDevice() throws Exception { new IterablePushRegistrationTask().execute(data); shadowOf(getMainLooper()).idle(); - verify(apiMock).disableToken(eq(IterableTestUtils.userEmail), isNull(), isNull(), eq(TEST_TOKEN), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); + verify(apiMock).disableToken(eq(IterableTestUtils.userEmail), isNull(), isNull(), isNull(), isNull(), eq(TEST_TOKEN), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); + } + + @Test + public void testDisableDeviceCarriesTheCapturedApiKeyAndEndpoint() throws Exception { + stubAnyRequestReturningStatusCode(server, 200, "{}"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.DISABLE); + data.apiKey = "captured_key"; + data.baseUrl = IterableDataRegion.EU.getEndpoint(); + new IterablePushRegistrationTask().execute(data); + shadowOf(getMainLooper()).idle(); + + verify(apiMock).disableToken(eq(IterableTestUtils.userEmail), isNull(), isNull(), eq("captured_key"), eq(IterableDataRegion.EU.getEndpoint()), eq(TEST_TOKEN), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); + } + + @Test + public void testDispatchListenerReportsFalseWithoutADeviceToken() throws Exception { + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(null); + + CountDownLatch notified = new CountDownLatch(1); + AtomicBoolean dispatched = new AtomicBoolean(true); + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.DISABLE); + data.dispatchListener = wasDispatched -> { + dispatched.set(wasDispatched); + notified.countDown(); + }; + new IterablePushRegistrationTask().execute(data); + shadowOf(getMainLooper()).idle(); + + assertTrue("A caller waiting on the hand-off must not be left waiting when there is no token", + notified.await(5, TimeUnit.SECONDS)); + assertFalse("No token means nothing was sent, which the switch reports as a noisy teardown", + dispatched.get()); + } + + @Test + public void testDispatchListenerReportsTrueWhenTheRequestWasHandedOff() throws Exception { + stubAnyRequestReturningStatusCode(server, 200, "{}"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + + CountDownLatch notified = new CountDownLatch(1); + AtomicBoolean dispatched = new AtomicBoolean(false); + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.DISABLE); + data.dispatchListener = wasDispatched -> { + dispatched.set(wasDispatched); + notified.countDown(); + }; + new IterablePushRegistrationTask().execute(data); + shadowOf(getMainLooper()).idle(); + + assertTrue(notified.await(5, TimeUnit.SECONDS)); + assertTrue("A disable that reached the request layer is a clean teardown", dispatched.get()); } } \ No newline at end of file diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectDisableRegionTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectDisableRegionTest.java new file mode 100644 index 000000000..f702f6db6 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectDisableRegionTest.java @@ -0,0 +1,188 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import com.iterable.iterableapi.unit.TestRunner; + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.android.util.concurrent.InlineExecutorService; +import org.robolectric.shadows.ShadowLooper; +import org.robolectric.shadows.ShadowPausedAsyncTask; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * Covers the one case the switch cannot wait out: the previous project's device disable is still + * fetching an FCM token when the dispatch timeout expires, so it is handed to the request layer only + * after the new project's key and region are already live. + * + * {@link IterableSwitchProjectTest} covers the switch itself; this is deliberately a separate class + * because it has to run push registration on a real background thread rather than inline. + */ +@RunWith(TestRunner.class) +public class IterableSwitchProjectDisableRegionTest extends BaseTest { + + private static final String API_KEY_A = "project-a-key"; + private static final String API_KEY_B = "project-b-key"; + private static final String EMAIL_A = "user-a@example.com"; + private static final String TEST_TOKEN = "testToken"; + private static final String EU_ENDPOINT = IterableDataRegion.EU.getEndpoint(); + private static final String US_ENDPOINT = IterableDataRegion.US.getEndpoint(); + + private Context context; + private MockWebServer server; + private IterablePushRegistrationTask.Util.UtilImpl originalPushRegistrationUtil; + private long originalDisableDispatchTimeoutMs; + private ExecutorService pushRegistrationExecutor; + + private final CountDownLatch releaseDeviceToken = new CountDownLatch(1); + private final AtomicBoolean holdDeviceToken = new AtomicBoolean(false); + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + server = new MockWebServer(); + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200).setBody("{}"); + } + }); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + originalDisableDispatchTimeoutMs = IterableProjectSwitcher.disableDispatchTimeoutMs; + // Expire the wait immediately instead of spending the real timeout on every run. The point + // of the test is what happens after it expires, not how long it is. + IterableProjectSwitcher.disableDispatchTimeoutMs = 0; + + originalPushRegistrationUtil = IterablePushRegistrationTask.Util.instance; + IterablePushRegistrationTask.Util.UtilImpl pushRegistrationUtilMock = + mock(IterablePushRegistrationTask.Util.UtilImpl.class); + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenAnswer(invocation -> { + if (holdDeviceToken.get()) { + releaseDeviceToken.await(5, TimeUnit.SECONDS); + } + return TEST_TOKEN; + }); + IterablePushRegistrationTask.Util.instance = pushRegistrationUtilMock; + + // BaseTest runs AsyncTasks inline, which would run the disable's token lookup on the switch's + // own thread and deadlock it against the latch above. + pushRegistrationExecutor = Executors.newCachedThreadPool(); + ShadowPausedAsyncTask.overrideExecutor(pushRegistrationExecutor); + + IterableTestUtils.resetIterableApi(); + } + + @After + public void tearDown() throws Exception { + releaseDeviceToken.countDown(); + IterablePushRegistrationTask.Util.instance = originalPushRegistrationUtil; + IterableProjectSwitcher.disableDispatchTimeoutMs = originalDisableDispatchTimeoutMs; + // The switch gate and the background executor are process-wide statics, so let an in-flight + // switch finish before resetting. + for (int i = 0; i < 100 && IterableBackgroundInitializer.isSwitchingProject(); i++) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + Thread.sleep(20); + } + pushRegistrationExecutor.shutdownNow(); + pushRegistrationExecutor.awaitTermination(5, TimeUnit.SECONDS); + ShadowPausedAsyncTask.overrideExecutor(new InlineExecutorService()); + IterableTestUtils.resetIterableApi(); + IterableRequestTask.overrideUrl = null; + server.shutdown(); + server = null; + } + + @Test + public void testDisableDispatchedAfterTheTimeoutStillCarriesThePreviousProjectsKeyAndEndpoint() throws Exception { + IterableApi.initialize(context, API_KEY_A, new IterableConfig.Builder() + .setKeychainEncryption(false) + .setDataRegion(IterableDataRegion.EU) + .build()); + IterableApi.getInstance().setEmail(EMAIL_A); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + IterableApiClient apiClientSpy = spy(IterableApi.getInstance().apiClient); + IterableApi.getInstance().apiClient = apiClientSpy; + + // From here the FCM token lookup blocks, so the disable cannot dispatch before the switch + // gives up waiting for it. + holdDeviceToken.set(true); + + CountDownLatch switched = new CountDownLatch(1); + AtomicBoolean cleanTeardown = new AtomicBoolean(true); + IterableApi.switchProject(context, API_KEY_B, new IterableConfig.Builder() + .setKeychainEncryption(false) + .setDataRegion(IterableDataRegion.US) + .build(), clean -> { + cleanTeardown.set(clean); + switched.countDown(); + }); + + assertTrue("The switch must not wait for the disable indefinitely", awaitSwitch(switched)); + assertFalse("A disable that did not dispatch in time is a noisy teardown", cleanTeardown.get()); + assertEquals("The new project is live before the disable has gone anywhere", + API_KEY_B, IterableApi.getInstance()._apiKey); + assertEquals(US_ENDPOINT, IterableApi.getInstance().config.dataRegion.getEndpoint()); + + releaseDeviceToken.countDown(); + + // The disable is built now, against a live config that already belongs to project B. It has + // to carry project A's key and project A's endpoint, because either one taken from the live + // config would send a US key to the EU endpoint or leave project A pushing to this device. + verify(apiClientSpy, timeout(5000)).sendPostRequest( + eq(IterableConstants.ENDPOINT_DISABLE_DEVICE), + any(JSONObject.class), + nullable(String.class), + eq(API_KEY_A), + eq(EU_ENDPOINT), + isNull(), + nullable(IterableHelper.FailureHandler.class)); + + assertNotEquals(API_KEY_A, API_KEY_B); + assertNotEquals(EU_ENDPOINT, US_ENDPOINT); + } + + /** Waits for a switch callback, pumping the main looper so the posted callback can run. */ + private boolean awaitSwitch(CountDownLatch latch) throws InterruptedException { + for (int i = 0; i < 100; i++) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + if (latch.await(50, TimeUnit.MILLISECONDS)) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + return true; + } + } + return false; + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectQueueDrainTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectQueueDrainTest.java new file mode 100644 index 000000000..69c660171 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectQueueDrainTest.java @@ -0,0 +1,238 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.os.Looper; + +import androidx.test.core.app.ApplicationProvider; + +import com.iterable.iterableapi.unit.TestRunner; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * The two paths around the switch that {@link IterableSwitchProjectTest} does not reach: a queue + * drain that no executor will accept, and a switch started off the main thread. + */ +@RunWith(TestRunner.class) +public class IterableSwitchProjectQueueDrainTest extends BaseTest { + + private static final String API_KEY_A = "project-a-key"; + private static final String API_KEY_B = "project-b-key"; + + private Context context; + private MockWebServer server; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + server = new MockWebServer(); + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200).setBody("{}"); + } + }); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + IterableTestUtils.resetIterableApi(); + } + + @After + public void tearDown() throws Exception { + for (int i = 0; i < 100 && IterableBackgroundInitializer.isSwitchingProject(); i++) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + Thread.sleep(20); + } + IterableTestUtils.resetIterableApi(); + IterableRequestTask.overrideUrl = null; + server.shutdown(); + server = null; + } + + /** + * Both the caller's executor and the shared one refuse the drain, which the retry on a fresh + * executor normally rules out. The queued calls are stranded, and what must not happen is the + * queue latching itself shut: {@code isProcessing} left set would refuse every later drain for + * the life of the process. + */ + @Test + public void testRejectedDrainReleasesTheQueueInsteadOfLatchingItShut() throws Exception { + ExecutorService rejectingShared = rejectingExecutor(); + ExecutorService originalShared = swapSharedExecutor(rejectingShared); + try { + IterableBackgroundInitializer.simulateInitializingState(); + CountDownLatch executed = new CountDownLatch(1); + IterableBackgroundInitializer.queueOrExecute(executed::countDown, "queued behind the gate"); + assertEquals(1, IterableBackgroundInitializer.getQueuedOperationCount()); + + assertEquals(IterableBackgroundInitializer.DrainResult.REJECTED, + IterableBackgroundInitializer.processQueuedOperationsOn(rejectingExecutor())); + assertEquals("a refused drain must leave the queue intact, not silently drop it", + 1, IterableBackgroundInitializer.getQueuedOperationCount()); + assertFalse(executed.await(100, TimeUnit.MILLISECONDS)); + + swapSharedExecutor(originalShared); + assertEquals("the queue must still be drainable once an executor accepts again", + IterableBackgroundInitializer.DrainResult.STARTED, + IterableBackgroundInitializer.processQueuedOperationsOn(originalShared)); + assertTrue(executed.await(5, TimeUnit.SECONDS)); + } finally { + swapSharedExecutor(originalShared); + IterableBackgroundInitializer.resetBackgroundInitializationState(); + } + } + + /** + * switchProject is documented as callable from anywhere. Its callback is always delivered on the + * main thread, which is the part an app relies on when it re-identifies the user from there. + */ + @Test + public void testSwitchStartedOffTheMainThreadCompletesAndCallsBackOnTheMainThread() throws Exception { + IterableApi.initialize(context, API_KEY_A, config()); + IterableApi.getInstance().setEmail("user-a@example.com"); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + CountDownLatch switched = new CountDownLatch(1); + AtomicReference callbackLooper = new AtomicReference<>(); + Thread caller = new Thread(() -> IterableApi.switchProject(context, API_KEY_B, config(), + cleanTeardown -> { + callbackLooper.set(Looper.myLooper()); + switched.countDown(); + }), "switch-caller"); + caller.start(); + caller.join(5000); + + assertTrue("Callback should fire", awaitSwitch(switched)); + assertEquals(API_KEY_B, IterableApi.getInstance()._apiKey); + assertNotNull(callbackLooper.get()); + assertEquals("the callback contract is main thread regardless of the calling thread", + Looper.getMainLooper(), callbackLooper.get()); + } + + /** + * A push open carries the campaignId, templateId and messageId of the project that sent the + * push. Queueing it behind the switch gate would replay it once the new project is live, so it + * would be reported against a project where those IDs do not exist. It has to run inline. + */ + @Test + public void testPushOpenDuringASwitchRunsInlineInsteadOfBeingReplayedAgainstTheNewProject() throws Exception { + IterableApi.initialize(context, API_KEY_A, config()); + IterableApi.getInstance().setEmail("user-a@example.com"); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + while (server.takeRequest(50, TimeUnit.MILLISECONDS) != null) { /* drain the setup traffic */ } + + assertTrue(IterableBackgroundInitializer.beginProjectSwitch(null)); + try { + IterableApi.getInstance().trackPushOpen(11, 22, "msg_from_project_a", false, null); + + assertEquals("a push open must not be parked for the incoming project", + 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + RecordedRequest pushOpen = takeRequestMatching("trackPushOpen"); + assertNotNull("the push open must be sent rather than dropped", pushOpen); + assertEquals("it must go to the project that sent the push, not the one being switched to", + API_KEY_A, pushOpen.getHeader(IterableConstants.HEADER_API_KEY)); + } finally { + IterableBackgroundInitializer.resetBackgroundInitializationState(); + } + } + + /** + * The switch gate and the initialization gate are the same state on Android, so narrowing one + * risks narrowing the other. A push open made while a background initialization is still in + * flight still has to be queued: there is no previous project for it to be misattributed to. + */ + @Test + public void testPushOpenDuringABackgroundInitializationIsStillQueued() throws Exception { + IterableApi.initialize(context, API_KEY_A, config()); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + IterableBackgroundInitializer.simulateInitializingState(); + try { + IterableApi.getInstance().trackPushOpen(11, 22, "msg", false, null); + + assertEquals("initialization queueing must be left alone", + 1, IterableBackgroundInitializer.getQueuedOperationCount()); + } finally { + IterableBackgroundInitializer.resetBackgroundInitializationState(); + } + } + + private RecordedRequest takeRequestMatching(String pathFragment) throws InterruptedException { + for (int i = 0; i < 20; i++) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + RecordedRequest request = server.takeRequest(100, TimeUnit.MILLISECONDS); + if (request == null) { + continue; + } + if (request.getPath() != null && request.getPath().contains(pathFragment)) { + return request; + } + } + return null; + } + + private IterableConfig config() { + return new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setKeychainEncryption(false) + .build(); + } + + private ExecutorService rejectingExecutor() { + ExecutorService executor = mock(ExecutorService.class); + // Not shut down, so ensureBackgroundExecutor() hands it out rather than replacing it. That is + // the only shape in which a drain can be refused twice: an executor shutting down between + // being handed out and execute(). + when(executor.isShutdown()).thenReturn(false); + doThrow(new RejectedExecutionException("test")).when(executor).execute(any(Runnable.class)); + return executor; + } + + /** + * The shared executor is a private static, and there is no seam for replacing it. Adding one for + * a test that exercises a refusal would put test-only surface on the production class. + */ + private ExecutorService swapSharedExecutor(ExecutorService executor) throws Exception { + Field field = IterableBackgroundInitializer.class.getDeclaredField("backgroundExecutor"); + field.setAccessible(true); + ExecutorService previous = (ExecutorService) field.get(null); + field.set(null, executor); + return previous; + } + + private boolean awaitSwitch(CountDownLatch latch) throws InterruptedException { + for (int i = 0; i < 100; i++) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + if (latch.await(50, TimeUnit.MILLISECONDS)) { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + return true; + } + } + return false; + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java new file mode 100644 index 000000000..2e9237ca6 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableSwitchProjectTest.java @@ -0,0 +1,1305 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.content.Context; +import android.os.Looper; + +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; + +import com.iterable.iterableapi.unit.TestRunner; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * Covers {@link IterableApi#switchProject(Context, String, IterableConfig, IterableProjectSwitchCallback)}: + * the guard cases, the teardown steps, the switch window, and the callback contract. + */ +@RunWith(TestRunner.class) +public class IterableSwitchProjectTest extends BaseTest { + + private static final String API_KEY_A = "project-a-key"; + private static final String API_KEY_B = "project-b-key"; + private static final String EMAIL_A = "user-a@example.com"; + private static final String EMAIL_B = "user-b@example.com"; + private static final String PROJECT_A_EVENT = "projectAOnlyEvent"; + + private Context context; + private MockWebServer server; + private IterablePushRegistrationTask.Util.UtilImpl originalPushRegistrationUtil; + private IterablePushRegistration.IterablePushRegistrationImpl originalPushRegistration; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + server = new MockWebServer(); + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200).setBody("{}"); + } + }); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + originalPushRegistrationUtil = IterablePushRegistrationTask.Util.instance; + originalPushRegistration = IterablePushRegistration.instance; + IterableTestUtils.resetIterableApi(); + } + + @After + public void tearDown() throws Exception { + IterablePushRegistrationTask.Util.instance = originalPushRegistrationUtil; + IterablePushRegistration.instance = originalPushRegistration; + // The switch gate and the background executor are process-wide statics. A switch still in + // flight would re-initialize IterableApi.sharedInstance from another thread part-way through + // the next test, so let it finish before resetting. + for (int i = 0; i < 100 && IterableBackgroundInitializer.isSwitchingProject(); i++) { + drainMainThread(); + Thread.sleep(20); + } + IterableTestUtils.resetIterableApi(); + IterableRequestTask.overrideUrl = null; + server.shutdown(); + server = null; + } + + // ======================================== + // Helpers + // ======================================== + + private IterableConfig configWithoutAuth() { + return new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setKeychainEncryption(false) + .build(); + } + + /** Real-world default: auto push registration on, so logout actually disables the token. */ + private IterableConfig configWithAutoPushRegistration() { + return new IterableConfig.Builder() + .setKeychainEncryption(false) + .build(); + } + + private void initializeProjectA() { + IterableApi.initialize(context, API_KEY_A, configWithoutAuth()); + IterableApi.getInstance().setEmail(EMAIL_A); + drainMainThread(); + } + + private void drainMainThread() { + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + } + + /** Waits for a switch callback, pumping the main looper so the posted callback can run. */ + private boolean awaitSwitch(CountDownLatch latch) throws InterruptedException { + for (int i = 0; i < 100; i++) { + drainMainThread(); + if (latch.await(50, TimeUnit.MILLISECONDS)) { + drainMainThread(); + return true; + } + } + return false; + } + + private boolean firstForegroundHandled() throws Exception { + Field field = IterableApi.class.getDeclaredField("_firstForegroundHandled"); + field.setAccessible(true); + return (boolean) field.get(IterableApi.getInstance()); + } + + private String storedEmail() { + IterableKeychain keychain = IterableApi.getInstance().getKeychain(); + return keychain != null ? keychain.getEmail() : null; + } + + private String storedUserIdUnknown() { + IterableKeychain keychain = IterableApi.getInstance().getKeychain(); + return keychain != null ? keychain.getUserIdUnknown() : null; + } + + private void waitForQueueToDrain() throws InterruptedException { + for (int i = 0; i < 100 && IterableBackgroundInitializer.getQueuedOperationCount() > 0; i++) { + drainMainThread(); + Thread.sleep(20); + } + drainMainThread(); + } + + /** + * Drains the mock server and returns the first request whose path starts with {@code endpoint}, + * or null if none arrives. + */ + private RecordedRequest takeRequestFor(String endpoint) throws InterruptedException { + for (int i = 0; i < 40; i++) { + drainMainThread(); + RecordedRequest recorded = server.takeRequest(100, TimeUnit.MILLISECONDS); + if (recorded == null) { + continue; + } + if (recorded.getPath() != null && recorded.getPath().startsWith("/" + endpoint)) { + return recorded; + } + } + return null; + } + + // ======================================== + // Step 1: guards + // ======================================== + + @Test + public void testSwitchBeforeInitializeBehavesAsInitialize() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference verdict = new AtomicReference<>(); + + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), clean -> { + verdict.set(clean); + latch.countDown(); + }); + + assertTrue("Callback should fire", awaitSwitch(latch)); + assertEquals("The SDK should end up initialized with the requested key", + API_KEY_B, IterableApi.getInstance()._apiKey); + // initializeInBackground's callback fires even when initialization times out or throws, so + // this path cannot honestly claim a clean teardown. It reports false, like every other path + // with no confirmed device disable. + assertEquals("An initialize dressed up as a switch reports false", + Boolean.FALSE, verdict.get()); + } + + @Test + public void testSwitchWithUnchangedApiKeyIsANoOp() throws Exception { + initializeProjectA(); + + IterableInAppManager inAppManagerBefore = IterableApi.getInstance().getInAppManagerOrNull(); + IterableEmbeddedManager embeddedManagerBefore = IterableApi.getInstance().getEmbeddedManagerOrNull(); + IterableAuthManager authManagerBefore = IterableApi.getInstance().getAuthManager(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean cleanTeardown = new AtomicBoolean(false); + IterableApi.switchProject(context, API_KEY_A, configWithoutAuth(), clean -> { + cleanTeardown.set(clean); + latch.countDown(); + }); + + assertTrue("Callback should fire", awaitSwitch(latch)); + assertTrue("A no-op switch reports a clean teardown", cleanTeardown.get()); + assertSame("In-app manager must not be reset", inAppManagerBefore, IterableApi.getInstance().getInAppManagerOrNull()); + assertSame("Embedded manager must not be reset", embeddedManagerBefore, IterableApi.getInstance().getEmbeddedManagerOrNull()); + assertSame("Auth manager must not be reset", authManagerBefore, IterableApi.getInstance().getAuthManager()); + assertEquals("Identity must be untouched", EMAIL_A, IterableApi.getInstance().getEmail()); + } + + @Test + public void testSwitchWhileASwitchIsInProgressQueuesTheCallbackInsteadOfTearingDownAgain() throws Exception { + initializeProjectA(); + + AtomicInteger callbackCount = new AtomicInteger(0); + CountDownLatch bothCallbacks = new CountDownLatch(2); + IterableProjectSwitchCallback first = ignored -> { + callbackCount.incrementAndGet(); + bothCallbacks.countDown(); + }; + IterableProjectSwitchCallback second = ignored -> { + callbackCount.incrementAndGet(); + bothCallbacks.countDown(); + }; + + assertTrue("First caller owns the switch", IterableBackgroundInitializer.beginProjectSwitch(first)); + assertFalse("Second caller must not start a second teardown", IterableBackgroundInitializer.beginProjectSwitch(second)); + assertTrue("Gate should be up", IterableBackgroundInitializer.isSwitchingProject()); + + IterableBackgroundInitializer.completeProjectSwitch(true); + + assertTrue("Both callbacks should fire", awaitSwitch(bothCallbacks)); + assertEquals("Every registered callback fires exactly once", 2, callbackCount.get()); + assertFalse("Gate should be down", IterableBackgroundInitializer.isSwitchingProject()); + } + + // ======================================== + // Teardown correctness + // ======================================== + + @Test + public void testSwitchClearsIdentityInMemoryAndInTheKeychain() throws Exception { + initializeProjectA(); + assertEquals(EMAIL_A, storedEmail()); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertNull("Email must be cleared in memory", IterableApi.getInstance().getEmail()); + assertNull("UserId must be cleared in memory", IterableApi.getInstance().getUserId()); + assertNull("Auth token must be cleared in memory", IterableApi.getInstance().getAuthToken()); + assertNull("Unknown user id must be cleared in memory", IterableApi.getInstance()._userIdUnknown); + assertNull("Email must be cleared in storage so re-init cannot repopulate it", storedEmail()); + assertNull("The stored unknown user id must be cleared too, since storeAuthData writes it", + storedUserIdUnknown()); + assertEquals(API_KEY_B, IterableApi.getInstance()._apiKey); + } + + @Test + public void testRequestAfterSwitchCarriesTheNewProjectKeyAndNotTheOldIdentifier() throws Exception { + initializeProjectA(); + // Drain everything project A queued so the assertions below only see post-switch traffic. + while (server.takeRequest(50, TimeUnit.MILLISECONDS) != null) { /* drain */ } + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + while (server.takeRequest(50, TimeUnit.MILLISECONDS) != null) { /* drain */ } + + IterableApi.getInstance().setEmail(EMAIL_B); + drainMainThread(); + IterableApi.getInstance().track("postSwitchEvent"); + drainMainThread(); + + RecordedRequest trackRequest = null; + for (int i = 0; i < 20; i++) { + RecordedRequest recorded = server.takeRequest(200, TimeUnit.MILLISECONDS); + if (recorded == null) { + break; + } + if (recorded.getPath() != null && recorded.getPath().startsWith("/" + IterableConstants.ENDPOINT_TRACK)) { + trackRequest = recorded; + break; + } + } + + assertNotNull("A track request should reach the server after the switch", trackRequest); + assertEquals("Requests must carry the new project's key", API_KEY_B, trackRequest.getHeader(IterableConstants.HEADER_API_KEY)); + JSONObject body = new JSONObject(trackRequest.getBody().readUtf8()); + assertEquals("Requests must not carry the previous project's identifier", EMAIL_B, body.getString(IterableConstants.KEY_EMAIL)); + } + + @Test + public void testManagersAreDistinctInstancesAndCarryNoPreviousProjectContent() throws Exception { + IterableInAppManager inAppManagerA = mock(IterableInAppManager.class); + IterableEmbeddedManager embeddedManagerA = mock(IterableEmbeddedManager.class); + IterableApi.sharedInstance = new IterableApi(inAppManagerA, embeddedManagerA); + + initializeProjectA(); + UnknownUserManager unknownUserManagerA = IterableApi.getInstance().unknownUserManager; + IterableAuthManager authManagerA = IterableApi.getInstance().getAuthManager(); + // setEmail during setup already went through logout; only count what the switch does. + clearInvocations(inAppManagerA, embeddedManagerA); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + // The existing logout path is what clears the previous project's cached content. + verify(inAppManagerA).reset(); + verify(embeddedManagerA).reset(); + + assertNotSame("In-app manager must be rebuilt", inAppManagerA, IterableApi.getInstance().getInAppManagerOrNull()); + assertNotSame("Embedded manager must be rebuilt", embeddedManagerA, IterableApi.getInstance().getEmbeddedManagerOrNull()); + assertNotSame("Unknown user manager must be rebuilt", unknownUserManagerA, IterableApi.getInstance().unknownUserManager); + assertNotSame("Auth manager must be rebuilt", authManagerA, IterableApi.getInstance().getAuthManager()); + + assertTrue("In-app messages must not carry over from the previous project", + IterableApi.getInstance().getInAppManager().getMessages().isEmpty()); + List embeddedMessages = IterableApi.getInstance().getEmbeddedManager().getMessages(0L); + assertTrue("Embedded messages must not carry over from the previous project", + embeddedMessages == null || embeddedMessages.isEmpty()); + } + + @Test + public void testAuthManagerIsRebuiltWithTheNewAuthHandler() throws Exception { + AtomicInteger handlerACalls = new AtomicInteger(0); + CountDownLatch handlerBCalled = new CountDownLatch(1); + IterableAuthHandler handlerA = new CountingAuthHandler("token-a", handlerACalls, null); + IterableAuthHandler handlerB = new CountingAuthHandler("token-b", new AtomicInteger(0), handlerBCalled); + + IterableApi.initialize(context, API_KEY_A, new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setKeychainEncryption(false) + .setAuthHandler(handlerA) + .build()); + drainMainThread(); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setKeychainEncryption(false) + .setAuthHandler(handlerB) + .build(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertSame("The new config's handler must be the live one", handlerB, IterableApi.getInstance().config.authHandler); + + int callsToHandlerABeforeRequest = handlerACalls.get(); + IterableApi.getInstance().getAuthManager().requestNewAuthToken(false, null); + assertTrue("The new project's auth handler must be the one invoked", handlerBCalled.await(5, TimeUnit.SECONDS)); + assertEquals("The previous project's auth handler must not be invoked again", + callsToHandlerABeforeRequest, handlerACalls.get()); + } + + /** {@link IterableAuthHandler} is not a functional interface, so tests need a small stub. */ + private static class CountingAuthHandler implements IterableAuthHandler { + private final String token; + private final AtomicInteger calls; + private final CountDownLatch called; + + CountingAuthHandler(String token, AtomicInteger calls, CountDownLatch called) { + this.token = token; + this.calls = calls; + this.called = called; + } + + @Override + public String onAuthTokenRequested() { + calls.incrementAndGet(); + if (called != null) { + called.countDown(); + } + return token; + } + + @Override + public void onTokenRegistrationSuccessful(String authToken) { } + + @Override + public void onAuthFailure(AuthFailure authFailure) { } + } + + @Test + public void testFirstForegroundFlagIsResetSoTheNewProjectFetchesRemoteConfiguration() throws Exception { + initializeProjectA(); + Field field = IterableApi.class.getDeclaredField("_firstForegroundHandled"); + field.setAccessible(true); + field.set(IterableApi.getInstance(), true); + assertTrue(firstForegroundHandled()); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertFalse("The new project must get its own first-foreground remote config fetch", firstForegroundHandled()); + } + + @Test + public void testDeviceIdIsProjectAgnosticAndSurvivesTheSwitch() throws Exception { + initializeProjectA(); + // The device id is created lazily, so seed the value the SDK would have stored. + String deviceIdBefore = "0f7c7ac8-project-agnostic-uuid"; + context.getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE) + .edit() + .putString(IterableConstants.SHARED_PREFS_DEVICEID_KEY, deviceIdBefore) + .apply(); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + String deviceIdAfter = context + .getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE) + .getString(IterableConstants.SHARED_PREFS_DEVICEID_KEY, null); + assertEquals("_deviceId must be left alone", deviceIdBefore, deviceIdAfter); + } + + @Test + public void testOfflineQueuePurgeOnSwitchKeepsTheQueuedDeviceDisable() throws Exception { + initializeProjectA(); + IterableApi.getInstance().apiClient.setOfflineProcessingEnabled(true); + + IterableTaskStorage taskStorage = IterableTaskStorage.sharedInstance(context); + taskStorage.deleteAllTasks(); + + IterableApiRequest trackRequest = new IterableApiRequest(API_KEY_A, IterableDataRegion.US.getEndpoint(), IterableConstants.ENDPOINT_TRACK, new JSONObject(), IterableApiRequest.POST, null, null, null); + IterableApiRequest disableRequest = new IterableApiRequest(API_KEY_A, IterableDataRegion.US.getEndpoint(), IterableConstants.ENDPOINT_DISABLE_DEVICE, new JSONObject(), IterableApiRequest.POST, null, null, null); + taskStorage.createTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequest.toJSONObject().toString()); + String disableTaskId = taskStorage.createTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, disableRequest.toJSONObject().toString()); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + List remainingTaskIds = taskStorage.getAllTaskIds(); + assertEquals("Only the device disable survives the switch purge", 1, remainingTaskIds.size()); + assertEquals(disableTaskId, remainingTaskIds.get(0)); + + IterableTask preserved = taskStorage.getTask(disableTaskId); + assertNotNull(preserved); + IterableApiRequest rehydrated = IterableApiRequest.fromJSON(new JSONObject(preserved.data), null, null); + assertNotNull(rehydrated); + assertEquals("The preserved disable must still reach the project it was created for", API_KEY_A, rehydrated.apiKey); + assertEquals("The preserved disable must still reach the region it was created for", + IterableDataRegion.US.getEndpoint(), rehydrated.baseUrl); + + taskStorage.deleteAllTasks(); + IterableApi.getInstance().apiClient.setOfflineProcessingEnabled(false); + } + + // ======================================== + // Switch window + // ======================================== + + @Test + public void testCallsDuringTheSwitchWindowAreQueuedAndDrainedAgainstTheNewProject() { + initializeProjectA(); + + // Raise the gate exactly as switchProject does, so the window is deterministic. + assertTrue(IterableBackgroundInitializer.beginProjectSwitch(null)); + + IterableApi.getInstance().setEmail(EMAIL_B); + IterableApi.getInstance().track("queuedDuringSwitch"); + + List descriptions = IterableBackgroundInitializer.getQueuedOperationDescriptions(); + assertEquals("Both calls must be queued in FIFO order", 2, descriptions.size()); + assertTrue("setEmail should be queued first", descriptions.get(0).startsWith("setEmail(")); + assertTrue("Queued descriptions must mask PII", descriptions.get(0).contains("u***")); + assertFalse("Queued descriptions must not leak the raw email", descriptions.get(0).contains(EMAIL_B)); + assertEquals("track(queuedDuringSwitch)", descriptions.get(1)); + + // Re-initialize on the new project, then drain, mirroring steps 7 and 8. + IterableApi.initialize(context, API_KEY_B, configWithoutAuth()); + IterableBackgroundInitializer.completeProjectSwitch(true); + + for (int i = 0; i < 100 && IterableBackgroundInitializer.getQueuedOperationCount() > 0; i++) { + drainMainThread(); + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + drainMainThread(); + + assertEquals("The queue must drain after the gate is lowered", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + assertEquals("Drained calls run against the new project", API_KEY_B, IterableApi.getInstance()._apiKey); + assertEquals("The queued setEmail must have executed", EMAIL_B, IterableApi.getInstance().getEmail()); + } + + @Test + public void testPublicMethodReturnsBeforeAnyTeardownHappens() throws Exception { + initializeProjectA(); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + + // Asserting on state rather than on wall clock: what matters is that the caller's thread did + // not run the teardown, and that is observable without timing. + assertTrue("The gate must be raised synchronously so calls made after this point are queued", + IterableBackgroundInitializer.isSwitchingProject()); + assertEquals("The API key must not have been swapped on the calling thread", + API_KEY_A, IterableApi.getInstance()._apiKey); + assertEquals("Identity must not have been cleared on the calling thread", + EMAIL_A, IterableApi.getInstance().getEmail()); + + assertTrue("Awaited so the switch does not land in a later test", awaitSwitch(latch)); + } + + // ======================================== + // Callback contract + // ======================================== + + @Test + public void testCallbackIsDeliveredOnTheMainThread() throws Exception { + initializeProjectA(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean onMainThread = new AtomicBoolean(false); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> { + onMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + latch.countDown(); + }); + + assertTrue("Callback should fire", awaitSwitch(latch)); + assertTrue("The switch callback must be delivered on the main thread", onMainThread.get()); + } + + @Test + public void testThrowingTeardownStepStillCompletesTheSwapAndReportsFalse() throws Exception { + initializeProjectA(); + + IterableApiClient throwingApiClient = mock(IterableApiClient.class); + doThrow(new RuntimeException("logout blew up")).when(throwingApiClient).onLogout(); + IterableApi.getInstance().apiClient = throwingApiClient; + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean cleanTeardown = new AtomicBoolean(true); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), clean -> { + cleanTeardown.set(clean); + latch.countDown(); + }); + + assertTrue("Callback should fire", awaitSwitch(latch)); + assertFalse("A noisy teardown step must report false", cleanTeardown.get()); + assertEquals("The swap must still complete", API_KEY_B, IterableApi.getInstance()._apiKey); + assertNull("Identity must still be cleared", IterableApi.getInstance().getEmail()); + verify(throwingApiClient).onLogout(); + } + + @Test + public void testRapidSwitchesRunOneTeardownAndFireEveryCallback() throws Exception { + // The first switch's teardown is parked inside inAppManager.reset() until the second + // switchProject call has been made, so the second call is guaranteed to arrive while the + // first switch is still running rather than racing it. + IterableInAppManager parkedInAppManager = mock(IterableInAppManager.class); + CountDownLatch secondSwitchIssued = new CountDownLatch(1); + AtomicBoolean parkOnReset = new AtomicBoolean(false); + doAnswer(invocation -> { + if (parkOnReset.get()) { + secondSwitchIssued.await(10, TimeUnit.SECONDS); + } + return null; + }).when(parkedInAppManager).reset(); + IterableApi.sharedInstance = new IterableApi(parkedInAppManager, mock(IterableEmbeddedManager.class)); + + initializeProjectA(); + parkOnReset.set(true); + + CountDownLatch bothCallbacks = new CountDownLatch(2); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> bothCallbacks.countDown()); + IterableApi.switchProject(context, "project-c-key", configWithoutAuth(), ignored -> bothCallbacks.countDown()); + secondSwitchIssued.countDown(); + + assertTrue("Both callbacks should fire", awaitSwitch(bothCallbacks)); + assertEquals("Only the first switch tears down; the second only registers its callback", + API_KEY_B, IterableApi.getInstance()._apiKey); + assertFalse("No switch should still be in progress", IterableBackgroundInitializer.isSwitchingProject()); + } + + @Test + public void testSwitchingAToBToALeavesNoResidueFromTheIntermediateProject() throws Exception { + initializeProjectA(); + + CountDownLatch toB = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> toB.countDown()); + assertTrue("Switch to B should complete", awaitSwitch(toB)); + + IterableApi.getInstance().setEmail(EMAIL_B); + drainMainThread(); + IterableInAppManager inAppManagerB = IterableApi.getInstance().getInAppManagerOrNull(); + IterableAuthManager authManagerB = IterableApi.getInstance().getAuthManager(); + + CountDownLatch backToA = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_A, configWithoutAuth(), ignored -> backToA.countDown()); + assertTrue("Switch back to A should complete", awaitSwitch(backToA)); + + assertEquals(API_KEY_A, IterableApi.getInstance()._apiKey); + assertNull("Project B's identity must not survive", IterableApi.getInstance().getEmail()); + assertNull("Project B's identity must not survive in storage", storedEmail()); + assertNotSame("Project B's in-app manager must not survive", inAppManagerB, IterableApi.getInstance().getInAppManagerOrNull()); + assertNotSame("Project B's auth manager must not survive", authManagerB, IterableApi.getInstance().getAuthManager()); + assertTrue("No project B in-app content may survive", IterableApi.getInstance().getInAppManager().getMessages().isEmpty()); + } + + @Test + public void testSwitchWithNullApiKeyThrowsWithoutTearingDown() { + initializeProjectA(); + IterableInAppManager inAppManagerBefore = IterableApi.getInstance().getInAppManagerOrNull(); + + // apiKey is @NonNull, so null is a programmer error. Reporting it through the callback would + // mean the same false that means "switched, but noisily" also means "nothing happened". + assertThrows(IllegalArgumentException.class, + () -> IterableApi.switchProject(context, null, configWithoutAuth(), ignored -> { })); + + assertEquals("The live project must not change", API_KEY_A, IterableApi.getInstance()._apiKey); + assertSame("Nothing may be torn down", inAppManagerBefore, IterableApi.getInstance().getInAppManagerOrNull()); + assertEquals(EMAIL_A, IterableApi.getInstance().getEmail()); + assertFalse("The gate must not be left raised", IterableBackgroundInitializer.isSwitchingProject()); + } + + @Test + public void testSwitchWithNullContextThrowsWithoutTearingDown() { + initializeProjectA(); + + assertThrows(IllegalArgumentException.class, + () -> IterableApi.switchProject(null, API_KEY_B, configWithoutAuth(), ignored -> { })); + + assertEquals(API_KEY_A, IterableApi.getInstance()._apiKey); + assertFalse("The gate must not be left raised", IterableBackgroundInitializer.isSwitchingProject()); + } + + /** + * An empty key is what a failed region lookup or a missing remote config entry produces, so it is + * a runtime condition rather than a programmer error. Tearing down for it would delete the + * previous project's identity and offline queue and leave the SDK initialized against nothing. + */ + @Test + public void testSwitchWithEmptyApiKeyStaysOnTheCurrentProjectAndReportsFalse() throws Exception { + assertBlankApiKeyIsRefused(""); + } + + @Test + public void testSwitchWithWhitespaceOnlyApiKeyStaysOnTheCurrentProjectAndReportsFalse() throws Exception { + assertBlankApiKeyIsRefused(" "); + } + + private void assertBlankApiKeyIsRefused(String blankKey) throws Exception { + initializeProjectA(); + IterableInAppManager inAppManagerBefore = IterableApi.getInstance().getInAppManagerOrNull(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference verdict = new AtomicReference<>(); + IterableApi.switchProject(context, blankKey, configWithoutAuth(), clean -> { + verdict.set(clean); + latch.countDown(); + }); + + assertTrue("Callback should fire for a blank key", awaitSwitch(latch)); + assertEquals("A blank key must report false", Boolean.FALSE, verdict.get()); + assertEquals("The live project must not change", API_KEY_A, IterableApi.getInstance()._apiKey); + assertEquals("Identity must survive", EMAIL_A, IterableApi.getInstance().getEmail()); + assertSame("Nothing may be torn down", inAppManagerBefore, + IterableApi.getInstance().getInAppManagerOrNull()); + assertFalse("The gate must not be left raised", + IterableBackgroundInitializer.isSwitchingProject()); + } + + // ======================================== + // The previous project's device disable (B1) + // ======================================== + + /** + * The FCM token lookup that the disable waits on is network-bound, so the live API key can change + * underneath it. The key has to be the one captured when the disable was initiated. + */ + @Test + public void testDisableCarriesTheKeyCapturedWhenItWasInitiatedNotTheLiveOne() throws Exception { + IterableApi.initialize(context, API_KEY_A, configWithAutoPushRegistration()); + IterableApi.getInstance().setEmail(EMAIL_A); + drainMainThread(); + while (server.takeRequest(50, TimeUnit.MILLISECONDS) != null) { /* drain setup traffic */ } + + // Stand in for the FCM round trip: the key is swapped while the lookup is in flight, exactly + // as steps 4 to 7 of a switch would do. + IterablePushRegistrationTask.Util.instance = new IterablePushRegistrationTask.Util.UtilImpl() { + @Override + String getFirebaseToken() { + IterableApi.getInstance()._apiKey = API_KEY_B; + return "device-token"; + } + + @Override + String getSenderId(Context applicationContext) { + return "12345"; + } + }; + + IterableApi.getInstance().disablePush(); + + RecordedRequest disableRequest = takeRequestFor(IterableConstants.ENDPOINT_DISABLE_DEVICE); + assertNotNull("A disableDevice request should reach the server", disableRequest); + assertEquals("users/disableDevice is project-scoped, so it must carry the key that was live " + + "when the disable was initiated", + API_KEY_A, disableRequest.getHeader(IterableConstants.HEADER_API_KEY)); + } + + /** + * The disable also resolves its data region from the live config at send time, so the swap has to + * wait for the hand-off rather than racing it. + */ + @Test + public void testSwitchSendsThePreviousProjectsDisableBeforeSwappingTheKey() throws Exception { + IterableApi.initialize(context, API_KEY_A, configWithAutoPushRegistration()); + IterableApi.getInstance().setEmail(EMAIL_A); + drainMainThread(); + while (server.takeRequest(50, TimeUnit.MILLISECONDS) != null) { /* drain setup traffic */ } + + // BaseTest replaces the AsyncTask executor with an inline one, so a disable would otherwise + // run synchronously inside step 3 and the ordering would be inherent rather than tested. + // Production runs it on the AsyncTask thread pool, so put it on a real thread here. + IterablePushRegistration.instance = new IterablePushRegistration.IterablePushRegistrationImpl() { + @Override + void executePushRegistrationTask(IterablePushRegistrationData data) { + new Thread(() -> new IterablePushRegistrationTask().doInBackground(data), "push-registration").start(); + } + }; + + AtomicReference apiKeyWhenTokenResolved = new AtomicReference<>(); + IterablePushRegistrationTask.Util.instance = new IterablePushRegistrationTask.Util.UtilImpl() { + @Override + String getFirebaseToken() { + // Stands in for the FCM round trip. Steps 4 to 7 are all local work that finishes in + // single-digit milliseconds, so without the wait they complete inside this window. + try { + Thread.sleep(250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + apiKeyWhenTokenResolved.set(IterableApi.getInstance()._apiKey); + return "device-token"; + } + + @Override + String getSenderId(Context applicationContext) { + return "12345"; + } + }; + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithAutoPushRegistration(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertEquals("The key must not be swapped until the disable has been handed off", + API_KEY_A, apiKeyWhenTokenResolved.get()); + + RecordedRequest disableRequest = takeRequestFor(IterableConstants.ENDPOINT_DISABLE_DEVICE); + assertNotNull("A disableDevice request should reach the server", disableRequest); + assertEquals("The disable must reach the project being left", + API_KEY_A, disableRequest.getHeader(IterableConstants.HEADER_API_KEY)); + assertEquals("The switch must still complete", API_KEY_B, IterableApi.getInstance()._apiKey); + } + + /** + * Matching iOS, which reports false when there was no token to disable. An app without push, or + * one that has no device token yet, always lands here, so false has to be documented as normal. + */ + @Test + public void testSwitchReportsFalseWhenNoDeviceDisableCouldBeConfirmed() throws Exception { + initializeProjectA(); + + assertFalse("A switch with no device disable to send reports a noisy teardown", + switchAndAwaitVerdict(API_KEY_B, configWithoutAuth())); + assertEquals("The switch still completes", API_KEY_B, IterableApi.getInstance()._apiKey); + } + + @Test + public void testSwitchReportsTrueWhenTheDeviceDisableWasSent() throws Exception { + IterableApi.initialize(context, API_KEY_A, configWithAutoPushRegistration()); + IterableApi.getInstance().setEmail(EMAIL_A); + drainMainThread(); + stubFirebaseToken("device-token"); + + assertTrue("A switch that disabled the previous project's token reports a clean teardown", + switchAndAwaitVerdict(API_KEY_B, configWithAutoPushRegistration())); + } + + @Test + public void testSwitchReportsFalseWhenTheDeviceTokenIsUnavailable() throws Exception { + IterableApi.initialize(context, API_KEY_A, configWithAutoPushRegistration()); + IterableApi.getInstance().setEmail(EMAIL_A); + drainMainThread(); + stubFirebaseToken(null); + + assertFalse("A disable that could not be built reports a noisy teardown", + switchAndAwaitVerdict(API_KEY_B, configWithAutoPushRegistration())); + assertEquals("The switch still completes", API_KEY_B, IterableApi.getInstance()._apiKey); + } + + private void stubFirebaseToken(@Nullable String token) { + IterablePushRegistrationTask.Util.instance = new IterablePushRegistrationTask.Util.UtilImpl() { + @Override + String getFirebaseToken() { + return token; + } + + @Override + String getSenderId(Context applicationContext) { + return "12345"; + } + }; + } + + /** @return the cleanTeardown the switch reported */ + private boolean switchAndAwaitVerdict(String apiKey, IterableConfig config) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference verdict = new AtomicReference<>(); + IterableApi.switchProject(context, apiKey, config, cleanTeardown -> { + verdict.set(cleanTeardown); + latch.countDown(); + }); + assertTrue("Callback should fire", awaitSwitch(latch)); + assertNotNull(verdict.get()); + return verdict.get(); + } + + // ======================================== + // Re-entrancy and initialization overlap (B3, B4) + // ======================================== + + /** + * The drain task shuts its own executor down as its last act, so a switch started from inside a + * switch callback lands in the window where the executor is being shut down. That must not jam + * the operation queue or lose the callback. + */ + @Test + public void testSwitchFromInsideItsOwnCallbackStillCompletes() throws Exception { + initializeProjectA(); + + CountDownLatch secondSwitchDone = new CountDownLatch(1); + CountDownLatch firstSwitchDone = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> { + firstSwitchDone.countDown(); + IterableApi.switchProject(context, "project-c-key", configWithoutAuth(), reentrantIgnored -> secondSwitchDone.countDown()); + }); + + assertTrue("The first switch's callback should fire", awaitSwitch(firstSwitchDone)); + assertTrue("The re-entrant switch's callback should fire too", awaitSwitch(secondSwitchDone)); + assertEquals("project-c-key", IterableApi.getInstance()._apiKey); + assertFalse("The gate must be down", IterableBackgroundInitializer.isSwitchingProject()); + + // The queue must still work afterwards, which it does not if isProcessing was left stuck. + IterableApi.getInstance().setEmail(EMAIL_B); + drainMainThread(); + assertEquals("The SDK must still execute calls after a re-entrant switch", + EMAIL_B, IterableApi.getInstance().getEmail()); + assertEquals("Nothing may be left stranded in the operation queue", + 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + /** + * The drain task shuts its own executor down as its last act, so a rejected drain is real. Left + * unhandled it sets isProcessing true and never clears it, so the operation queue never drains + * again for the life of the process and the callbacks polled out of it are lost. + */ + @Test + public void testRejectedQueueDrainRecoversInsteadOfJammingTheQueue() throws Exception { + initializeProjectA(); + + ExecutorService deadExecutor = Executors.newSingleThreadExecutor(); + deadExecutor.shutdownNow(); + + IterableBackgroundInitializer.simulateInitializingState(); + IterableApi.getInstance().track("queuedBeforeTheRejection"); + assertEquals(1, IterableBackgroundInitializer.getQueuedOperationCount()); + + assertEquals("A rejected drain must be retried on a fresh executor, not left to propagate", + IterableBackgroundInitializer.DrainResult.STARTED, + IterableBackgroundInitializer.processQueuedOperationsOn(deadExecutor)); + waitForQueueToDrain(); + assertEquals("The queue must actually drain", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + // The real damage of the original bug: isProcessing left true forever, so nothing drains again. + IterableApi.getInstance().track("queuedAfterTheRejection"); + assertEquals("A later drain must not be refused because isProcessing was left stuck", + IterableBackgroundInitializer.DrainResult.STARTED, + IterableBackgroundInitializer.processQueuedOperationsOn(Executors.newSingleThreadExecutor())); + waitForQueueToDrain(); + assertEquals(0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + /** + * Same window on the other side: a rejected teardown submission would leave the gate raised with + * no callback ever firing, so every later setEmail, setUserId and track is queued forever. + */ + @Test + public void testRejectedTeardownSubmissionStillRunsTheSwitch() throws Exception { + ExecutorService deadExecutor = Executors.newSingleThreadExecutor(); + deadExecutor.shutdownNow(); + + CountDownLatch taskRan = new CountDownLatch(1); + IterableBackgroundInitializer.executeOn(deadExecutor, taskRan::countDown); + + assertTrue("The teardown must be retried on a fresh executor rather than throwing out of " + + "switchProject or stranding the gate", + taskRan.await(5, TimeUnit.SECONDS)); + } + + /** + * initializeInBackground publishes _apiKey synchronously but finishes its init task later, and + * that task marks initialization complete, which would lower a switch's gate mid-teardown. + */ + @Test + public void testSwitchIsDeferredWhileAnInitializationIsStillInFlight() throws Exception { + IterableApi.initialize(context, API_KEY_A, configWithoutAuth()); + IterableApi.getInstance().setEmail(EMAIL_A); + drainMainThread(); + + IterableBackgroundInitializer.simulateInitializingState(); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + + assertFalse("A switch must not start a teardown while initialization is in flight", + IterableBackgroundInitializer.isSwitchingProject()); + assertEquals("Nothing may be swapped yet", API_KEY_A, IterableApi.getInstance()._apiKey); + assertEquals("Identity may not be torn down yet", EMAIL_A, IterableApi.getInstance().getEmail()); + + IterableBackgroundInitializer.simulateInitializationComplete(); + + assertTrue("The deferred switch's callback should fire", awaitSwitch(latch)); + assertEquals("The deferred switch must actually run", API_KEY_B, IterableApi.getInstance()._apiKey); + assertNull("The deferred switch must tear down the previous identity", + IterableApi.getInstance().getEmail()); + } + + /** + * A switch makes initialization look in flight, so initializeInBackground parks its callback in + * pendingCallbacks. Only completeProjectSwitch can drain those. + */ + @Test + public void testInitializationCallbackParkedDuringASwitchStillFires() throws Exception { + initializeProjectA(); + + assertTrue(IterableBackgroundInitializer.beginProjectSwitch(null)); + + CountDownLatch parkedCallback = new CountDownLatch(1); + IterableApi.initializeInBackground(context, API_KEY_B, configWithoutAuth(), parkedCallback::countDown); + + IterableApi.initialize(context, API_KEY_B, configWithoutAuth()); + IterableBackgroundInitializer.completeProjectSwitch(true); + + assertTrue("A callback parked during the switch window must not be dropped", + awaitSwitch(parkedCallback)); + } + + // ======================================== + // Unsynchronised teardown state (B5) + // ======================================== + + /** + * initialize() rebuilds the in-app, embedded and unknown-user managers but never the auth + * manager. It has to be replaced explicitly, after config is swapped, or the next lazy build can + * bind the new project's requests to the previous project's IterableAuthHandler. + */ + @Test + public void testAuthManagerIsRebuiltEagerlyByTheSwitch() throws Exception { + initializeProjectA(); + assertNotNull(IterableApi.getInstance().getAuthManager()); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertNotNull("The switch must leave a live auth manager built from the new config, not a " + + "null field for the next caller to fill in from whatever config it finds", + IterableApi.getInstance().authManager); + } + + /** + * The SDK's own threads are not covered by the switch gate, so the lock the swap holds has to be + * the same one getAuthManager() takes. Otherwise NetworkThread can build an auth manager from a + * config that is halfway through being replaced. + */ + @Test + public void testGetAuthManagerWaitsForTheProjectStateLock() throws Exception { + initializeProjectA(); + IterableApi api = IterableApi.getInstance(); + api.authManager = null; + + CountDownLatch lockHeld = new CountDownLatch(1); + CountDownLatch releaseLock = new CountDownLatch(1); + CountDownLatch managerObtained = new CountDownLatch(1); + + Thread holder = new Thread(() -> { + synchronized (api.projectStateLock) { + lockHeld.countDown(); + try { + releaseLock.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }, "lock-holder"); + holder.start(); + assertTrue(lockHeld.await(5, TimeUnit.SECONDS)); + + Thread reader = new Thread(() -> { + api.getAuthManager(); + managerObtained.countDown(); + }, "auth-manager-reader"); + reader.start(); + + assertFalse("getAuthManager must not build an auth manager while the swap holds the lock", + managerObtained.await(300, TimeUnit.MILLISECONDS)); + + releaseLock.countDown(); + assertTrue("getAuthManager must proceed once the lock is released", + managerObtained.await(5, TimeUnit.SECONDS)); + holder.join(5000); + reader.join(5000); + } + + /** + * The teardown runs on the background executor while the SDK's own threads keep reading these + * fields without taking any lock. Non-volatile writes give those threads no happens-before edge, + * so they can keep seeing the previous project's state indefinitely. + */ + @Test + public void testProjectScopedFieldsAreVolatile() throws Exception { + for (String fieldName : new String[]{"config", "_apiKey", "_email", "_userId", "_userIdUnknown", + "_authToken", "inAppManager", "embeddedManager", "unknownUserManager", "authManager", + "keychain", "_firstForegroundHandled"}) { + Field field = IterableApi.class.getDeclaredField(fieldName); + assertTrue(fieldName + " is replaced by switchProject from the background executor and read " + + "by ungated SDK threads, so it must be volatile", + Modifier.isVolatile(field.getModifiers())); + } + } + + // ======================================== + // Project-scoped storage (B6) + // ======================================== + + /** + * setEmail on the new project runs the unknown-user event replay, which would post events + * collected under the previous project into the new one. + */ + @Test + public void testEventsCollectedUnderThePreviousProjectAreNotReplayedIntoTheNewOne() throws Exception { + IterableConfig unknownUserConfig = new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setKeychainEncryption(false) + .setEnableUnknownUserActivation(true) + .build(); + IterableApi.initialize(context, API_KEY_A, unknownUserConfig); + IterableApi.getInstance().setVisitorUsageTracked(true); + drainMainThread(); + + // Seeded directly: setVisitorUsageTracked clears the list, and this is exactly the shape + // trackUnknownEvent writes. + JSONObject event = new JSONObject(); + event.put(IterableConstants.KEY_EVENT_NAME, PROJECT_A_EVENT); + event.put(IterableConstants.KEY_CREATED_AT, System.currentTimeMillis()); + event.put(IterableConstants.SHARED_PREFS_EVENT_TYPE, IterableConstants.TRACK_EVENT); + JSONArray eventList = new JSONArray(); + eventList.put(event); + context.getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE) + .edit() + .putString(IterableConstants.SHARED_PREFS_EVENT_LIST_KEY, eventList.toString()) + .apply(); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, unknownUserConfig, ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + while (server.takeRequest(50, TimeUnit.MILLISECONDS) != null) { /* drain switch traffic */ } + + IterableApi.getInstance().setEmail(EMAIL_B); + drainMainThread(); + + for (int i = 0; i < 30; i++) { + drainMainThread(); + RecordedRequest recorded = server.takeRequest(100, TimeUnit.MILLISECONDS); + if (recorded == null) { + continue; + } + String body = recorded.getBody().readUtf8(); + assertFalse("The previous project's event must not be replayed into the new project, " + + "path was " + recorded.getPath(), + body.contains(PROJECT_A_EVENT)); + } + + assertEquals("The previous project's event list must not survive the switch", "", + context.getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE) + .getString(IterableConstants.SHARED_PREFS_EVENT_LIST_KEY, "")); + } + + @Test + public void testPreviousProjectsAttributionAndCriteriaAreCleared() throws Exception { + initializeProjectA(); + + IterableApi.getInstance().setAttributionInfo(new IterableAttributionInfo(1234, 5678, "message-a")); + context.getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE) + .edit() + .putString(IterableConstants.SHARED_PREFS_CRITERIA, "[{\"criteriaId\":1}]") + .apply(); + assertNotNull(IterableApi.getInstance().getAttributionInfo()); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertNull("campaignId and templateId are namespaced per project, so the previous project's " + + "attribution must not be attached to the first track after the switch", + IterableApi.getInstance().getAttributionInfo()); + assertEquals("The previous project's activation criteria must not survive", "", + context.getSharedPreferences(IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE) + .getString(IterableConstants.SHARED_PREFS_CRITERIA, "")); + } + + @Test + public void testKeychainIsRebuiltSoTheNewConfigsEncryptionSettingApplies() throws Exception { + initializeProjectA(); + IterableKeychain keychainBefore = IterableApi.getInstance().getKeychain(); + assertNotNull(keychainBefore); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setKeychainEncryption(true) + .build(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertNotSame("The new config's keychainEncryption and decryptionFailureHandler are ignored " + + "for the rest of the process unless the keychain is rebuilt", + keychainBefore, IterableApi.getInstance().getKeychain()); + } + + // ======================================== + // Gate coverage and switch bookkeeping + // ======================================== + + /** + * The long setEmail and setUserId overloads are public, so an app can call them directly. If they + * are not gated, that app bypasses the switch window entirely. + */ + @Test + public void testTheLongSetEmailAndSetUserIdOverloadsAreGated() { + initializeProjectA(); + + assertTrue(IterableBackgroundInitializer.beginProjectSwitch(null)); + + IterableApi.getInstance().setEmail(EMAIL_B, null, null, null, null); + IterableApi.getInstance().setUserId("user-b", null, null, null, null, false); + + assertEquals("Both long overloads must be queued behind the gate, not executed", + 2, IterableBackgroundInitializer.getQueuedOperationCount()); + assertEquals("The previous project's identity must be untouched while the gate is up", + EMAIL_A, IterableApi.getInstance().getEmail()); + + IterableBackgroundInitializer.completeProjectSwitch(true); + } + + /** + * beginProjectSwitch has to let initialize() notify again, but clearing the subscriber list as + * well drops a subscriber registered while the first initialization was in flight. + */ + @Test + public void testRaisingTheSwitchGateKeepsInitializationSubscribers() throws Exception { + // Registered before any initialize has completed, so it is genuinely still pending. That is + // the ordinary case of an app subscribing during Application#onCreate. + CountDownLatch subscriberCalled = new CountDownLatch(1); + IterableApi.onSDKInitialized(subscriberCalled::countDown); + + assertTrue(IterableBackgroundInitializer.beginProjectSwitch(null)); + + IterableApi.initialize(context, API_KEY_B, configWithoutAuth()); + IterableBackgroundInitializer.completeProjectSwitch(true); + + assertTrue("Raising the switch gate must not discard subscribers that are still waiting to be " + + "notified", awaitSwitch(subscriberCalled)); + } + + @Test + public void testAuthTokenReadyListenersAreNotRegisteredTwice() { + initializeProjectA(); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + AtomicInteger notifications = new AtomicInteger(0); + IterableAuthManager.AuthTokenReadyListener listener = notifications::incrementAndGet; + authManager.addAuthTokenReadyListener(listener); + authManager.addAuthTokenReadyListener(listener); + + // Drive the INVALID -> ready transition that notifies listeners. + authManager.setAuthTokenInvalid(); + authManager.setIsLastAuthTokenValid(true); + + assertEquals("A listener registered twice must only be notified once, otherwise the task " + + "runner double-processes every auth recovery after a project switch", + 1, notifications.get()); + } + + @Test + public void testLambdaCallbackReceivesTheTeardownVerdict() throws Exception { + initializeProjectA(); + + // IterableProjectSwitchCallback has to stay a single-method interface. If the verdict ever + // moves onto a type whose only abstract method takes no arguments, a lambda binds to that + // one instead and silently discards the boolean, which this stops compiling. + CountDownLatch latch = new CountDownLatch(1); + AtomicReference verdict = new AtomicReference<>(); + IterableProjectSwitchCallback lambda = clean -> { + verdict.set(clean); + latch.countDown(); + }; + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), lambda); + + assertTrue("A lambda callback must be notified", awaitSwitch(latch)); + assertNotNull("A lambda callback must receive the verdict, not a discarded default", + verdict.get()); + } + + // ======================================== + // Per-project state held on the shared instance + // ======================================== + + /** + * iOS drops all of this when it replaces its SDK instance. Android reuses sharedInstance, so + * anything held in a field survives unless the teardown clears it. inboxSessionId is the one that + * produces cross-project data: it is attached to the new project's in-app tracking calls. + */ + @Test + public void testPerProjectInstanceStateDoesNotSurviveTheSwitch() throws Exception { + initializeProjectA(); + + IterableApi.getInstance().setInboxSessionId("session-from-project-a"); + IterableApi.getInstance().setDeviceAttribute("tenant", "project-a"); + assertNotNull("precondition: the session ID is set", readInboxSessionId()); + assertFalse("precondition: a device attribute is set", + IterableApi.getInstance().getDeviceAttributes().isEmpty()); + + CountDownLatch latch = new CountDownLatch(1); + IterableApi.switchProject(context, API_KEY_B, configWithoutAuth(), ignored -> latch.countDown()); + assertTrue("Callback should fire", awaitSwitch(latch)); + + assertNull("An inbox session from the previous project must not be sent to the new one", + readInboxSessionId()); + assertTrue("Device attributes must not carry over, iOS discards them with the instance", + IterableApi.getInstance().getDeviceAttributes().isEmpty()); + assertNull("The previous project's push payload must not survive", + IterableApi.getInstance().getPayloadData()); + } + + /** + * Every shorter overload of these two is queued, so the longest one has to be too. It was public + * and ran inline, which meant mid-switch behaviour depended on which overload the app happened to + * call. Same defect the branch already fixed for setEmail and setUserId. + */ + @Test + public void testLongestTrackAndUpdateEmailOverloadsAreQueuedLikeTheirShorterSiblings() throws Exception { + initializeProjectA(); + + assertTrue(IterableBackgroundInitializer.beginProjectSwitch(null)); + try { + IterableApi.getInstance().track("event", 11, 22, new JSONObject()); + assertEquals("the longest track overload must not bypass the gate", + 1, IterableBackgroundInitializer.getQueuedOperationCount()); + + IterableApi.getInstance().updateEmail(EMAIL_B, null, null, null); + assertEquals("the longest updateEmail overload must not bypass the gate", + 2, IterableBackgroundInitializer.getQueuedOperationCount()); + } finally { + IterableBackgroundInitializer.resetBackgroundInitializationState(); + } + } + + /** No accessor for it, and adding one purely for a test would put test-only surface on the API. */ + private String readInboxSessionId() throws Exception { + Field field = IterableApi.class.getDeclaredField("inboxSessionId"); + field.setAccessible(true); + return (String) field.get(IterableApi.getInstance()); + } +}