diff --git a/README.md b/README.md index 9f3b1116a..939041d57 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,34 @@ To learn more about various SDK features, read: - [Embedded Messages with Iterable's Android SDK](https://support.iterable.com/hc/articles/23061877893652) - [Unknown User Activation: Developer Docs](https://support.iterable.com/hc/sections/40078809116180) +## Background Initialization (Recommended) + +To prevent ANRs during app startup, use background initialization instead of the standard `initialize()` method: + +```kotlin +// In Application.onCreate() +IterableApi.initializeInBackground(this, "your-api-key", config) { + // SDK is ready - this callback is optional +} +``` + +**For subscribing to initialization completion from multiple places:** + +```kotlin +IterableApi.onSDKInitialized { + // This callback will be invoked when initialization completes + // If already initialized, it's called immediately +} +``` + +Background initialization prevents ANRs by: +- Running all initialization work on a background thread +- Automatically queuing API calls until initialization completes +- Ensuring no data is lost during startup +- Providing callbacks on the main thread when ready + +**⚠️ Important:** Always wait for initialization completion before accessing SDK internals. Accessing the SDK internals before initialization completes can cause crashes. Use the callback methods above to ensure the SDK is ready before use. + ## Sample projects For sample code, take a look at: diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 7ce908c4e..4341c128e 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -31,9 +31,9 @@ public class IterableApi { static volatile IterableApi sharedInstance = new IterableApi(); private static final String TAG = "IterableApi"; - private Context _applicationContext; + Context _applicationContext; // Package-private for background initializer access IterableConfig config; - private String _apiKey; + String _apiKey; // Package-private for background initializer access private String _email; private String _userId; String _userIdUnknown; @@ -56,6 +56,39 @@ public class IterableApi { private HashMap deviceAttributes = new HashMap<>(); private IterableKeychain keychain; + + //region Background Initialization - Delegated to IterableBackgroundInitializer + //--------------------------------------------------------------------------------------- + + /** + * Masks PII data for logging - shows only first character followed by asterisks + * @param value The value to mask (email, userId, authToken, etc.) + * @return Masked string (e.g., "u***") or "null" if input is null + */ + private static String maskPII(@Nullable String value) { + if (value == null || value.isEmpty()) { + return "null"; + } + if (value.length() == 1) { + return "*"; + } + return value.charAt(0) + "***"; + } + + /** + * Helper method to queue operations if background initialization is in progress, + * otherwise execute immediately for backward compatibility + */ + 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(); + } + } + void fetchRemoteConfiguration() { apiClient.getRemoteConfiguration(new IterableHelper.IterableActionHandler() { @Override @@ -347,11 +380,20 @@ private void logoutPreviousUser() { disablePush(); } - getInAppManager().reset(); - getEmbeddedManager().reset(); - getAuthManager().reset(); + // Only reset managers if they're initialized + if (inAppManager != null) { + inAppManager.reset(); + } + if (embeddedManager != null) { + embeddedManager.reset(); + } + if (authManager != null) { + authManager.reset(); + } - apiClient.onLogout(); + if (apiClient != null) { + apiClient.onLogout(); + } } private void onLogin( @@ -724,6 +766,79 @@ public static void initialize(@NonNull Context context, @NonNull String apiKey, IterableLogger.e(TAG, "initialize: exception", e); } } + + // Notify initialization completion + IterableBackgroundInitializer.notifyInitializationComplete(); + } + + /** + * Initialize the Iterable SDK in the background to avoid ANRs. + * This method returns immediately and performs all initialization work on a background thread. + * Any API calls made before initialization completes will be queued and executed after initialization. + * + * @param context Application context + * @param apiKey Iterable API key + * @param callback Optional callback for initialization completion (can be null) + */ + public static void initializeInBackground(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableInitializationCallback callback) { + IterableBackgroundInitializer.initializeInBackground(context, apiKey, null, callback); + } + + /** + * Initialize the Iterable SDK in the background to avoid ANRs. + * This method returns immediately and performs all initialization work on a background thread. + * Any API calls made before initialization completes will be queued and executed after initialization. + * + * @param context Application context + * @param apiKey Iterable API key + * @param config Optional configuration (can be null) + * @param callback Optional callback for initialization completion (can be null) + */ + public static void initializeInBackground(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableInitializationCallback callback) { + IterableBackgroundInitializer.initializeInBackground(context, apiKey, config, callback); + } + + /** + * Check if SDK initialization is in progress (covers both normal and background initialization) + * @return true if initialization is currently running + */ + public static boolean isSDKInitializing() { + return IterableBackgroundInitializer.isInitializingInBackground(); + } + + /** + * Check if SDK is fully initialized and ready to use. + * This checks both that initialization has been run (sync or background) and that + * the SDK is properly configured with API key and user identification. + * @return true if SDK is fully initialized and ready for use + */ + public static boolean isSDKInitialized() { + // Check if initialization has been run (either sync or background) + boolean initializationRun = sharedInstance._apiKey != null && sharedInstance._applicationContext != null; + + // Check if background initialization has completed (if it was used) + boolean backgroundInitComplete = !IterableBackgroundInitializer.isInitializingInBackground(); + + // Check if SDK is properly configured (private method logic) + boolean sdkConfigured = sharedInstance.isInitialized(); + + return initializationRun && backgroundInitComplete && sdkConfigured; + } + + + /** + * Register a callback to be notified when SDK initialization completes. + * If the SDK is already initialized, the callback is invoked immediately. + * + * @param callback The callback to be notified when initialization completes + */ + public static void onSDKInitialized(@NonNull IterableInitializationCallback callback) { + IterableBackgroundInitializer.onSDKInitialized(callback); } public static void setContext(Context context) { @@ -807,31 +922,31 @@ public void pauseAuthRetries(boolean pauseRetry) { } public void setEmail(@Nullable String email) { - setEmail(email, null, null, null, null); + queueOrExecute(() -> setEmail(email, null, null, null, null), "setEmail(" + maskPII(email) + ")"); } public void setEmail(@Nullable String email, IterableIdentityResolution identityResolution) { - setEmail(email, null, identityResolution, null, null); + queueOrExecute(() -> setEmail(email, null, identityResolution, null, null), "setEmail(" + maskPII(email) + ", identityResolution)"); } public void setEmail(@Nullable String email, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - setEmail(email, null, null, successHandler, failureHandler); + queueOrExecute(() -> setEmail(email, null, null, successHandler, failureHandler), "setEmail(" + maskPII(email) + ", callbacks)"); } public void setEmail(@Nullable String email, IterableIdentityResolution identityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - setEmail(email, null, identityResolution, successHandler, failureHandler); + queueOrExecute(() -> setEmail(email, null, identityResolution, successHandler, failureHandler), "setEmail(" + maskPII(email) + ", identityResolution, callbacks)"); } public void setEmail(@Nullable String email, @Nullable String authToken) { - setEmail(email, authToken, null, null, null); + queueOrExecute(() -> setEmail(email, authToken, null, null, null), "setEmail(" + maskPII(email) + ", " + maskPII(authToken) + ")"); } public void setEmail(@Nullable String email, @Nullable String authToken, IterableIdentityResolution identityResolution) { - setEmail(email, authToken, identityResolution, null, null); + queueOrExecute(() -> setEmail(email, authToken, identityResolution, null, null), "setEmail(" + maskPII(email) + ", " + maskPII(authToken) + ", identityResolution)"); } public void setEmail(@Nullable String email, @Nullable String authToken, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - setEmail(email, authToken, null, successHandler, failureHandler); + queueOrExecute(() -> setEmail(email, authToken, null, successHandler, failureHandler), "setEmail(" + maskPII(email) + ", " + maskPII(authToken) + ", callbacks)"); } public void setEmail(@Nullable String email, @Nullable String authToken, @Nullable IterableIdentityResolution iterableIdentityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { @@ -876,32 +991,32 @@ public void setUnknownUser(@Nullable String userId) { } public void setUserId(@Nullable String userId) { - setUserId(userId, null, null, null, null, false); + queueOrExecute(() -> setUserId(userId, null, null, null, null, false), "setUserId(" + maskPII(userId) + ")"); } public void setUserId(@Nullable String userId, IterableIdentityResolution identityResolution) { - setUserId(userId, null, identityResolution, null, null, false); + queueOrExecute(() -> setUserId(userId, null, identityResolution, null, null, false), "setUserId(" + maskPII(userId) + ", identityResolution)"); } public void setUserId(@Nullable String userId, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - setUserId(userId, null, null, successHandler, failureHandler, false); + queueOrExecute(() -> setUserId(userId, null, null, successHandler, failureHandler, false), "setUserId(" + maskPII(userId) + ", callbacks)"); } public void setUserId(@Nullable String userId, IterableIdentityResolution identityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - setUserId(userId, null, identityResolution, successHandler, failureHandler, false); + queueOrExecute(() -> setUserId(userId, null, identityResolution, successHandler, failureHandler, false), "setUserId(" + maskPII(userId) + ", identityResolution, callbacks)"); } public void setUserId(@Nullable String userId, @Nullable String authToken) { - setUserId(userId, authToken, null, null, null, false); + queueOrExecute(() -> setUserId(userId, authToken, null, null, null, false), "setUserId(" + maskPII(userId) + ", " + maskPII(authToken) + ")"); } public void setUserId(@Nullable String userId, @Nullable String authToken, IterableIdentityResolution identityResolution) { - setUserId(userId, authToken, identityResolution, null, null, false); + queueOrExecute(() -> setUserId(userId, authToken, identityResolution, null, null, false), "setUserId(" + maskPII(userId) + ", " + maskPII(authToken) + ", identityResolution)"); } public void setUserId(@Nullable String userId, @Nullable String authToken, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - setUserId(userId, authToken, null, successHandler, failureHandler, false); + queueOrExecute(() -> setUserId(userId, authToken, null, successHandler, failureHandler, false), "setUserId(" + maskPII(userId) + ", " + maskPII(authToken) + ", callbacks)"); } public void setUserId(@Nullable String userId, @Nullable String authToken, @Nullable IterableIdentityResolution iterableIdentityResolution, @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler, boolean isUnknown) { @@ -1026,11 +1141,11 @@ public void removeDeviceAttribute(String key) { * @param deviceToken Push token obtained from GCM or FCM */ public void registerDeviceToken(@NonNull String deviceToken) { - registerDeviceToken(_email, _userId, _authToken, getPushIntegrationName(), deviceToken, deviceAttributes); + queueOrExecute(() -> registerDeviceToken(_email, _userId, _authToken, getPushIntegrationName(), deviceToken, deviceAttributes), "registerDeviceToken"); } public void trackPushOpen(int campaignId, int templateId, @NonNull String messageId) { - trackPushOpen(campaignId, templateId, messageId, null); + queueOrExecute(() -> trackPushOpen(campaignId, templateId, messageId, null), "trackPushOpen(" + campaignId + ", " + templateId + ", " + maskPII(messageId) + ")"); } /** @@ -1039,12 +1154,14 @@ public void trackPushOpen(int campaignId, int templateId, @NonNull String messag * @param templateId */ public void trackPushOpen(int campaignId, int templateId, @NonNull String messageId, @Nullable JSONObject dataFields) { - if (messageId == null) { - IterableLogger.e(TAG, "messageId is null"); - return; - } + queueOrExecute(() -> { + if (messageId == null) { + IterableLogger.e(TAG, "messageId is null"); + return; + } - apiClient.trackPushOpen(campaignId, templateId, messageId, dataFields); + apiClient.trackPushOpen(campaignId, templateId, messageId, dataFields); + }, "trackPushOpen(" + campaignId + ", " + templateId + ", " + maskPII(messageId) + ", dataFields)"); } /** @@ -1202,7 +1319,7 @@ public boolean isIterableIntent(@Nullable Intent intent) { * @param eventName */ public void track(@NonNull String eventName) { - track(eventName, 0, 0, null); + queueOrExecute(() -> track(eventName, 0, 0, null), "track(" + eventName + ")"); } /** @@ -1211,7 +1328,7 @@ public void track(@NonNull String eventName) { * @param dataFields */ public void track(@NonNull String eventName, @Nullable JSONObject dataFields) { - track(eventName, 0, 0, dataFields); + queueOrExecute(() -> track(eventName, 0, 0, dataFields), "track(" + eventName + ", dataFields)"); } /** @@ -1221,7 +1338,7 @@ public void track(@NonNull String eventName, @Nullable JSONObject dataFields) { * @param templateId */ public void track(@NonNull String eventName, int campaignId, int templateId) { - track(eventName, campaignId, templateId, null); + queueOrExecute(() -> track(eventName, campaignId, templateId, null), "track(" + eventName + ", " + campaignId + ", " + templateId + ")"); } /** @@ -1248,14 +1365,16 @@ public void track(@NonNull String eventName, int campaignId, int templateId, @Nu * @param items */ public void updateCart(@NonNull List items) { - if (!checkSDKInitialization() && _userIdUnknown == null) { - if (sharedInstance.config.enableUnknownUserActivation) { - unknownUserManager.trackUnknownUpdateCart(items); + queueOrExecute(() -> { + if (!checkSDKInitialization() && _userIdUnknown == null) { + if (sharedInstance.config.enableUnknownUserActivation) { + unknownUserManager.trackUnknownUpdateCart(items); + } + return; } - return; - } - apiClient.updateCart(items); + apiClient.updateCart(items); + }, "updateCart(" + items.size() + " items)"); } /** @@ -1264,7 +1383,7 @@ public void updateCart(@NonNull List items) { * @param items list of purchased items */ public void trackPurchase(double total, @NonNull List items) { - trackPurchase(total, items, null, null); + queueOrExecute(() -> trackPurchase(total, items, null, null), "trackPurchase(" + total + ", " + items.size() + " items)"); } /** @@ -1274,7 +1393,7 @@ public void trackPurchase(double total, @NonNull List items) { * @param dataFields a `JSONObject` containing any additional information to save along with the event */ public void trackPurchase(double total, @NonNull List items, @Nullable JSONObject dataFields) { - trackPurchase(total, items, dataFields, null); + queueOrExecute(() -> trackPurchase(total, items, dataFields, null), "trackPurchase(" + total + ", " + items.size() + " items, dataFields)"); } @@ -1286,14 +1405,16 @@ public void trackPurchase(double total, @NonNull List items, @Null * @param attributionInfo a `JSONObject` containing information about what the purchase was attributed to */ public void trackPurchase(double total, @NonNull List items, @Nullable JSONObject dataFields, @Nullable IterableAttributionInfo attributionInfo) { - if (!checkSDKInitialization() && _userIdUnknown == null) { - if (sharedInstance.config.enableUnknownUserActivation) { - unknownUserManager.trackUnknownPurchaseEvent(total, items, dataFields); + queueOrExecute(() -> { + if (!checkSDKInitialization() && _userIdUnknown == null) { + if (sharedInstance.config.enableUnknownUserActivation) { + unknownUserManager.trackUnknownPurchaseEvent(total, items, dataFields); + } + return; } - return; - } - apiClient.trackPurchase(total, items, dataFields, attributionInfo); + apiClient.trackPurchase(total, items, dataFields, attributionInfo); + }, "trackPurchase(" + total + ", " + items.size() + " items, dataFields, attributionInfo)"); } /** @@ -1302,15 +1423,15 @@ public void trackPurchase(double total, @NonNull List items, @Null * @param newEmail New email */ public void updateEmail(final @NonNull String newEmail) { - updateEmail(newEmail, null, null, null); + queueOrExecute(() -> updateEmail(newEmail, null, null, null), "updateEmail(" + maskPII(newEmail) + ")"); } public void updateEmail(final @NonNull String newEmail, final @NonNull String authToken) { - updateEmail(newEmail, authToken, null, null); + queueOrExecute(() -> updateEmail(newEmail, authToken, null, null), "updateEmail(" + maskPII(newEmail) + ", " + maskPII(authToken) + ")"); } public void updateEmail(final @NonNull String newEmail, final @Nullable IterableHelper.SuccessHandler successHandler, @Nullable IterableHelper.FailureHandler failureHandler) { - updateEmail(newEmail, null, successHandler, failureHandler); + queueOrExecute(() -> updateEmail(newEmail, null, successHandler, failureHandler), "updateEmail(" + maskPII(newEmail) + ", callbacks)"); } /** @@ -1704,4 +1825,5 @@ public void trackEmbeddedSession(@NonNull IterableEmbeddedSession session) { apiClient.trackEmbeddedSession(session); } //endregion + } \ No newline at end of file diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java new file mode 100644 index 000000000..884252383 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java @@ -0,0 +1,556 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Handles background initialization of the Iterable SDK to prevent ANRs. + * This class manages operation queuing, thread safety, and initialization state. + */ +class IterableBackgroundInitializer { + private static final String TAG = "IterableBackgroundInit"; + + // Timeout for initialization to prevent indefinite hangs (5 seconds) + private static final int INITIALIZATION_TIMEOUT_SECONDS = 5; + + // Callback manager for initialization completion + private static final IterableInitializationCallbackManager callbackManager = new IterableInitializationCallbackManager(); + + /** + * Represents a queued operation that should be executed after initialization + */ + interface QueuedOperation { + /** + * Execute the operation + */ + void execute(); + + /** + * Get description for debugging + */ + String getDescription(); + } + + /** + * Queue for operations called before initialization completes + */ + private static class OperationQueue { + private final ConcurrentLinkedQueue operations = new ConcurrentLinkedQueue<>(); + private volatile boolean isProcessing = false; + + void enqueue(QueuedOperation operation) { + operations.offer(operation); + IterableLogger.d(TAG, "Queued operation: " + operation.getDescription()); + } + + void processAll(ExecutorService executor) { + if (isProcessing) return; + 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); + } + } + isProcessing = false; + + // After processing all operations, shut down the executor + IterableLogger.d(TAG, "All queued operations processed, shutting down background executor"); + shutdownBackgroundExecutorAsync(); + }); + } + + int size() { + return operations.size(); + } + + void clear() { + operations.clear(); + isProcessing = false; + } + } + + // Background initialization infrastructure + private static volatile ExecutorService backgroundExecutor; + private static final Object initLock = new Object(); + + static { + backgroundExecutor = createExecutor(); + } + + private static ExecutorService createExecutor() { + return Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "IterableBackgroundInit"); + t.setDaemon(true); + t.setPriority(Thread.NORM_PRIORITY); + return t; + }); + } + + private static final OperationQueue operationQueue = new OperationQueue(); + private static volatile boolean isInitializing = false; + private static volatile boolean isBackgroundInitialized = false; + private static final ConcurrentLinkedQueue pendingCallbacks = 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. + * Any API calls made before initialization completes will be queued and executed after initialization. + * + * @param context Application context + * @param apiKey Iterable API key + * @param config Optional configuration (can be null) + * @param callback Optional callback for initialization completion (can be null) + */ + static void initializeInBackground(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableInitializationCallback callback) { + // Handle null context early - still report success but log error + if (context == null) { + IterableLogger.e(TAG, "Context cannot be null, but reporting success"); + if (callback != null) { + new Handler(Looper.getMainLooper()).post(callback::onSDKInitialized); + } + return; + } + + synchronized (initLock) { + if (isInitializing || isBackgroundInitialized) { + IterableLogger.w(TAG, "initializeInBackground called but initialization already in progress or completed"); + if (callback != null) { + if (isBackgroundInitialized) { + // Initialization already complete, call callback immediately + new Handler(Looper.getMainLooper()).post(callback::onSDKInitialized); + } else { + // Initialization in progress, queue callback for later + pendingCallbacks.offer(callback); + } + } + return; + } + + // Set initializing flag and essential properties inside synchronized block + isInitializing = true; + IterableApi.sharedInstance._applicationContext = context.getApplicationContext(); + IterableApi.sharedInstance._apiKey = apiKey; + IterableApi.sharedInstance.config = (config != null) ? config : new IterableConfig.Builder().build(); + } + + IterableLogger.d(TAG, "Starting background initialization"); + + // Create a separate executor for the actual initialization to enable timeout + ExecutorService initExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "IterableInit"); + t.setDaemon(true); + t.setPriority(Thread.NORM_PRIORITY); + return t; + }); + + Runnable initTask = () -> { + long startTime = System.currentTimeMillis(); + boolean initSucceeded = false; + + try { + IterableLogger.d(TAG, "Starting initialization with " + INITIALIZATION_TIMEOUT_SECONDS + " second timeout"); + + // Submit the actual initialization task + Future initFuture = initExecutor.submit(() -> { + IterableLogger.d(TAG, "Executing initialization on background thread"); + IterableApi.initialize(context, apiKey, config); + }); + + // Wait for initialization with timeout + initFuture.get(INITIALIZATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + initSucceeded = true; + + long elapsedTime = System.currentTimeMillis() - startTime; + IterableLogger.d(TAG, "Background initialization completed successfully in " + elapsedTime + "ms"); + + } catch (TimeoutException e) { + long elapsedTime = System.currentTimeMillis() - startTime; + IterableLogger.w(TAG, "Background initialization timed out after " + elapsedTime + "ms, continuing anyway"); + // Cancel the hanging initialization task + initExecutor.shutdownNow(); + + } catch (Exception e) { + long elapsedTime = System.currentTimeMillis() - startTime; + IterableLogger.e(TAG, "Background initialization encountered error after " + elapsedTime + "ms, but continuing", e); + } + + // 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); + + // Notify completion on main thread (always success) + final boolean finalInitSucceeded = initSucceeded; + new Handler(Looper.getMainLooper()).post(() -> { + try { + long totalTime = System.currentTimeMillis() - startTime; + if (finalInitSucceeded) { + IterableLogger.d(TAG, "Initialization completed successfully, notifying callbacks after " + totalTime + "ms"); + } else { + IterableLogger.w(TAG, "Initialization timed out or failed, but notifying callbacks anyway after " + totalTime + "ms"); + } + + // Call the original callback directly + if (callback != null) { + try { + callback.onSDKInitialized(); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in initialization callback", e); + } + } + + // Call all pending callbacks from concurrent initialization attempts + IterableInitializationCallback pendingCallback; + while ((pendingCallback = pendingCallbacks.poll()) != null) { + try { + pendingCallback.onSDKInitialized(); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in pending initialization callback", e); + } + } + + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in initialization completion notification", e); + } + }); + + // Clean up the init executor + try { + if (!initExecutor.isShutdown()) { + initExecutor.shutdown(); + if (!initExecutor.awaitTermination(1, TimeUnit.SECONDS)) { + initExecutor.shutdownNow(); + } + } + } catch (InterruptedException e) { + initExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + }; + + backgroundExecutor.execute(initTask); + } + + /** + * Check if background initialization is in progress + * @return true if initialization is currently running in background + */ + static boolean isInitializingInBackground() { + return isInitializing; + } + + /** + * Check if background initialization has completed + * @return true if background initialization completed successfully + */ + static boolean isBackgroundInitializationComplete() { + return isBackgroundInitialized; + } + + /** + * Queue an operation if initialization is in progress, otherwise execute immediately + * @param operation The operation to queue or execute + * @return true if operation was queued, false if executed immediately + */ + static boolean queueOrExecute(QueuedOperation operation) { + synchronized (initLock) { + if (isInitializing && !isBackgroundInitialized) { + operationQueue.enqueue(operation); + return true; + } + } + // Execute immediately if not initializing + operation.execute(); + return false; + } + + /** + * Convenient method for one-liner operation queuing + * @param runnable The operation to execute + * @param description Description for debugging + */ + static void queueOrExecute(Runnable runnable, String description) { + queueOrExecute(new QueuedOperation() { + @Override + public void execute() { + runnable.run(); + } + + @Override + public String getDescription() { + return description; + } + }); + } + + + /** + * Shutdown the background executor for proper cleanup + * Should be called during application shutdown or for testing + */ + @VisibleForTesting + static void shutdownBackgroundExecutor() { + synchronized (initLock) { + if (backgroundExecutor != null && !backgroundExecutor.isShutdown()) { + backgroundExecutor.shutdown(); + try { + if (!backgroundExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + backgroundExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + backgroundExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + } + + /** + * Shutdown the background executor asynchronously to avoid blocking the executor thread itself + * Used internally after initialization completes + */ + private static void shutdownBackgroundExecutorAsync() { + // Schedule shutdown on a separate thread to avoid blocking the executor thread + new Thread(() -> { + synchronized (initLock) { + if (backgroundExecutor != null && !backgroundExecutor.isShutdown()) { + backgroundExecutor.shutdown(); + try { + if (!backgroundExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + IterableLogger.w(TAG, "Background executor did not terminate gracefully, forcing shutdown"); + backgroundExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + IterableLogger.w(TAG, "Interrupted while waiting for executor termination"); + backgroundExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + IterableLogger.d(TAG, "Background executor shutdown completed"); + } + } + }, "IterableExecutorShutdown").start(); + } + + /** + * Register a callback to be notified when SDK initialization completes. + * If the SDK is already initialized, the callback is invoked immediately. + * + * @param callback The callback to be notified when initialization completes + */ + static void onSDKInitialized(@NonNull IterableInitializationCallback callback) { + callbackManager.addSubscriber(callback); + } + + /** + * Notify that initialization has completed - called by IterableApi.initialize() + */ + static void notifyInitializationComplete() { + callbackManager.notifyInitializationComplete(); + } + + /** + * Reset background initialization state - for testing only + */ + @VisibleForTesting + static void resetBackgroundInitializationState() { + synchronized (initLock) { + isInitializing = false; + isBackgroundInitialized = false; + operationQueue.clear(); + pendingCallbacks.clear(); + callbackManager.reset(); + + // Recreate executor if it was shut down + if (backgroundExecutor == null || backgroundExecutor.isShutdown()) { + backgroundExecutor = createExecutor(); + } + } + } + + // ======================================== + // Test Support Methods + // ======================================== + + /** + * Get the number of queued operations (for testing) + */ + @VisibleForTesting + static int getQueuedOperationCount() { + return operationQueue.size(); + } + + /** + * Clear all queued operations (for testing) + */ + @VisibleForTesting + static void clearQueuedOperations() { + operationQueue.clear(); + } + + /** + * Get descriptions of all queued operations (for testing PII masking) + */ + @VisibleForTesting + static List getQueuedOperationDescriptions() { + List descriptions = new ArrayList<>(); + for (QueuedOperation op : operationQueue.operations) { + descriptions.add(op.getDescription()); + } + return descriptions; + } +} + +/** + * Manages initialization callbacks for the Iterable SDK. + * Supports multiple subscribers and ensures callbacks are called on the main thread. + */ +class IterableInitializationCallbackManager { + private static final String TAG = "IterableInitCallbackMgr"; + + // Thread-safe collections for callback management + private final CopyOnWriteArraySet subscribers = new CopyOnWriteArraySet<>(); + private final ConcurrentLinkedQueue oneTimeCallbacks = new ConcurrentLinkedQueue<>(); + + private volatile boolean isInitialized = false; + private final Object initLock = new Object(); + + /** + * Add a callback that will be called every time initialization completes. + * If initialization has already completed, the callback is called immediately. + * + * @param callback The callback to add (must not be null) + */ + void addSubscriber(@NonNull IterableInitializationCallback callback) { + if (callback == null) { + IterableLogger.w(TAG, "Cannot add null callback subscriber"); + return; + } + + subscribers.add(callback); + + // If already initialized, call immediately on main thread + synchronized (initLock) { + if (isInitialized) { + callCallbackOnMainThread(callback, "subscriber (immediate)"); + subscribers.remove(callback); // Auto-remove after calling + } + } + } + + /** + * Add a one-time callback that will be called once when initialization completes. + * If initialization has already completed, the callback is called immediately. + * This is used for the callback parameter in initialize() methods. + * + * @param callback The one-time callback to add (can be null) + */ + void addOneTimeCallback(@Nullable IterableInitializationCallback callback) { + if (callback == null) { + return; + } + + synchronized (initLock) { + if (isInitialized) { + // Call immediately if already initialized + callCallbackOnMainThread(callback, "one-time (immediate)"); + } else { + // Queue for later + oneTimeCallbacks.offer(callback); + } + } + } + + /** + * Notify all callbacks that initialization has completed. + * This should be called once when initialization finishes. + */ + void notifyInitializationComplete() { + synchronized (initLock) { + if (isInitialized) { + IterableLogger.d(TAG, "notifyInitializationComplete called but already initialized"); + return; + } + isInitialized = true; + } + + IterableLogger.d(TAG, "Notifying initialization completion to " + + subscribers.size() + " subscribers and " + + oneTimeCallbacks.size() + " one-time callbacks"); + + // Notify all subscribers and clear the list (auto-remove after calling) + for (IterableInitializationCallback callback : subscribers) { + callCallbackOnMainThread(callback, "subscriber"); + } + subscribers.clear(); // Auto-remove all subscribers after calling them + + // Notify and clear one-time callbacks + IterableInitializationCallback oneTimeCallback; + while ((oneTimeCallback = oneTimeCallbacks.poll()) != null) { + callCallbackOnMainThread(oneTimeCallback, "one-time"); + } + } + + /** + * Reset the initialization state - for testing only + */ + void reset() { + synchronized (initLock) { + isInitialized = false; + subscribers.clear(); + oneTimeCallbacks.clear(); + } + } + + /** + * Helper method to ensure callbacks are called on the main thread + */ + private void callCallbackOnMainThread(@NonNull IterableInitializationCallback callback, String type) { + if (Looper.myLooper() == Looper.getMainLooper()) { + // Already on main thread + try { + callback.onSDKInitialized(); + IterableLogger.d(TAG, "Called " + type + " callback on main thread"); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in " + type + " initialization callback", e); + } + } else { + // Post to main thread + new Handler(Looper.getMainLooper()).post(() -> { + try { + callback.onSDKInitialized(); + IterableLogger.d(TAG, "Called " + type + " callback via main thread handler"); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in " + type + " initialization callback", e); + } + }); + } + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java new file mode 100644 index 000000000..402d46378 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInitializationCallback.java @@ -0,0 +1,17 @@ +package com.iterable.iterableapi; + +/** + * Callback interface for Iterable SDK initialization completion. + * This callback is called when initialization completes, regardless of whether it was + * performed in the foreground or background. + * + * Multiple parties can subscribe to initialization completion using + * {@link IterableApi#addInitializationCallback(IterableInitializationCallback)} + */ +public interface IterableInitializationCallback { + /** + * Called when Iterable SDK initialization has completed. + * This method is always called on the main thread. + */ + void onSDKInitialized(); +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableLogger.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableLogger.java index a8129fd2c..244cfa13c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableLogger.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableLogger.java @@ -62,11 +62,14 @@ private static boolean isLoggableLevel(int messageLevel) { } private static int getLogLevel() { - if (IterableApi.sharedInstance != null) { + if (IterableApi.sharedInstance != null && IterableApi.sharedInstance.config != null) { if (IterableApi.sharedInstance.getDebugMode()) { return Log.VERBOSE; } else { - return IterableApi.sharedInstance.config.logLevel; + IterableConfig config = IterableApi.sharedInstance.config; + if (config != null) { + return config.logLevel; + } } } return Log.ERROR; diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java new file mode 100644 index 000000000..bd387d64c --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java @@ -0,0 +1,1490 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.os.Looper; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLooper; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Comprehensive test suite for async initialization functionality. + * Tests ANR elimination, operation queuing, callback execution, and edge cases. + */ +@Config(sdk = 21) +@RunWith(RobolectricTestRunner.class) +public class IterableAsyncInitializationTest { + + private Context context; + private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_EMAIL = "test@example.com"; + private static final String TEST_USER_ID = "test-user-123"; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + // Reset the shared instance before each test + IterableTestUtils.resetIterableApi(); + + // Clear any background initialization state + resetBackgroundInitializationState(); + } + + @After + public void tearDown() { + IterableTestUtils.resetIterableApi(); + resetBackgroundInitializationState(); + } + + @org.junit.AfterClass + public static void tearDownClass() { + // Shutdown executor service after all tests complete + IterableBackgroundInitializer.shutdownBackgroundExecutor(); + } + + private void resetBackgroundInitializationState() { + // Use the dedicated method for resetting background initialization state + IterableBackgroundInitializer.resetBackgroundInitializationState(); + } + + /** + * Helper method to wait for async initialization with proper timing for test environment. + * This handles the background thread + main thread callback timing issues. + */ + private boolean waitForAsyncInitialization(CountDownLatch latch, int timeoutSeconds) throws InterruptedException { + // Give background thread more time to execute in test environment + for (int i = 0; i < 20; i++) { // Try for up to 2 seconds (20 * 100ms) + Thread.sleep(100); + + // Process any pending main thread tasks (like callbacks) + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + // Check if completed + if (latch.getCount() == 0) { + return true; + } + } + + // Final wait with timeout + boolean result = latch.await(timeoutSeconds, TimeUnit.SECONDS); + + // Process any remaining main thread tasks + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + return result; + } + + // ======================================== + // ANR Elimination Tests + // ======================================== + + @Test + public void testInitializeInBackground_ReturnsImmediately() { + long startTime = System.currentTimeMillis(); + + IterableApi.initializeInBackground(context, TEST_API_KEY, null); + + long elapsedTime = System.currentTimeMillis() - startTime; + assertTrue("initializeInBackground should return in <50ms, took " + elapsedTime + "ms", + elapsedTime < 50); + assertTrue("Should be marked as initializing", IterableApi.isSDKInitializing()); + } + + @Test + public void testInitializeInBackground_NoMainThreadBlocking() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + AtomicBoolean callbackExecutedOnMainThread = new AtomicBoolean(false); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + callbackExecutedOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + initLatch.countDown(); + } + }); + + // Verify main thread is not blocked + assertTrue("Method should return immediately", true); + + // Wait for completion with proper timing + assertTrue("Initialization should complete within 5 seconds", + waitForAsyncInitialization(initLatch, 5)); + assertTrue("Callback should execute on main thread", callbackExecutedOnMainThread.get()); + assertFalse("Should not be marked as initializing after completion", IterableApi.isSDKInitializing()); + } + + @Test + public void testInitializeInBackground_WithConfig() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + IterableConfig config = new IterableConfig.Builder() + .setAutoPushRegistration(false) + .build(); + + IterableApi.initializeInBackground(context, TEST_API_KEY, config, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + assertTrue("Initialization with config should complete", + waitForAsyncInitialization(initLatch, 3)); + } + + // ======================================== + // Operation Queuing Tests + // ======================================== + + @Test + public void testOperationQueuing_DuringInitialization() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + List executedOperations = new ArrayList<>(); + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Make API calls while initialization is in progress + assertTrue("Should be initializing", IterableApi.isSDKInitializing()); + + IterableApi.getInstance().setEmail(TEST_EMAIL); + IterableApi.getInstance().track("testEvent"); + IterableApi.getInstance().setUserId(TEST_USER_ID); + + // Verify operations are queued + assertTrue("Operations should be queued", IterableBackgroundInitializer.getQueuedOperationCount() > 0); + + // Wait for initialization to complete + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // Process queue + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + // Verify queue is processed + assertEquals("Queue should be empty after processing", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testOperationQueuing_BeforeInitialization() throws InterruptedException { + // Make API calls BEFORE starting initialization + IterableApi.getInstance().setEmail(TEST_EMAIL); + IterableApi.getInstance().track("preInitEvent"); + IterableApi.getInstance().setUserId(TEST_USER_ID); + + // These should NOT be queued since initialization hasn't started + assertEquals("Operations should not be queued before initialization starts", + 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + CountDownLatch initLatch = new CountDownLatch(1); + + // Now start initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Make more API calls during initialization + IterableApi.getInstance().setEmail("updated@example.com"); + IterableApi.getInstance().track("duringInitEvent"); + + // These SHOULD be queued + assertTrue("Operations during init should be queued", IterableBackgroundInitializer.getQueuedOperationCount() > 0); + + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + } + + @Test + public void testOperationQueuing_LargeNumberOfOperations() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + + }); + + // Queue many operations + int numOperations = 100; + for (int i = 0; i < numOperations; i++) { + IterableApi.getInstance().track("event" + i); + } + + assertEquals("Should have " + numOperations + " queued operations", + numOperations, IterableBackgroundInitializer.getQueuedOperationCount()); + + // Wait for completion and processing + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // Process queue + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + assertEquals("All operations should be processed", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testOperationQueuing_AfterInitializationComplete() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Complete initialization first + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + + }); + + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 5)); + + // Now make API calls - these should NOT be queued + IterableApi.getInstance().setEmail(TEST_EMAIL); + IterableApi.getInstance().track("postInitEvent"); + IterableApi.getInstance().setUserId(TEST_USER_ID); + + assertEquals("Operations after init should not be queued", + 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + // ======================================== + // Callback Tests + // ======================================== + + @Test + public void testInitializationCallback_Success() throws InterruptedException { + CountDownLatch successLatch = new CountDownLatch(1); + AtomicBoolean callbackExecutedOnMainThread = new AtomicBoolean(false); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + callbackExecutedOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + successLatch.countDown(); + } + + }); + + assertTrue("Success callback should be called", waitForAsyncInitialization(successLatch, 3)); + assertTrue("Callback should execute on main thread", callbackExecutedOnMainThread.get()); + } + + @Test + public void testInitializationCallback_NullCallback() throws InterruptedException { + // Should not crash with null callback + IterableApi.initializeInBackground(context, TEST_API_KEY, null); + + assertTrue("Should be initializing", IterableApi.isSDKInitializing()); + + // Wait for background initialization to complete + Thread.sleep(200); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + // Should complete without issues + assertFalse("Should not be initializing after completion", IterableApi.isSDKInitializing()); + } + + @Test + public void testInitializationCallback_ExceptionInCallback() throws InterruptedException { + CountDownLatch callbackLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + callbackLatch.countDown(); + // Throw exception in callback - should not crash the system + throw new RuntimeException("Test exception in callback"); + } + + }); + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + assertTrue("Callback should be called despite exception", + waitForAsyncInitialization(callbackLatch, 3)); + + // System should still be in a valid state + assertFalse("Should not be initializing after completion despite callback exception", + IterableApi.isSDKInitializing()); + } + + // ======================================== + // Thread Safety Tests + // ======================================== + + @Test + public void testConcurrentInitialization_OnlyOneInitializes() throws InterruptedException { + int numThreads = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completeLatch = new CountDownLatch(numThreads); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger warningCount = new AtomicInteger(0); + + for (int i = 0; i < numThreads; i++) { + new Thread(() -> { + try { + startLatch.await(); + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + successCount.incrementAndGet(); + completeLatch.countDown(); + } + + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + completeLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + + assertTrue("All threads should complete", waitForAsyncInitialization(completeLatch, 3)); + + // All threads should get success callbacks (concurrent calls should all be notified when init completes) + assertEquals("All threads should get success callbacks", numThreads, successCount.get()); + } + + @Test + public void testConcurrentOperationQueuing() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + AtomicBoolean initStarted = new AtomicBoolean(false); + + // Start initialization with a callback that adds a delay to ensure we have time to queue operations + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + // Add a small delay to ensure operations have time to queue + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + initLatch.countDown(); + } + + }); + + // Wait to ensure initialization has started + while (!IterableApi.isSDKInitializing()) { + Thread.sleep(10); + } + initStarted.set(true); + + // Queue operations immediately while initialization is definitely in progress + int numOperations = 50; + for (int i = 0; i < numOperations; i++) { + IterableApi.getInstance().track("testEvent" + i); + } + + // Verify operations were queued + int queuedCount = IterableBackgroundInitializer.getQueuedOperationCount(); + assertTrue("Should have queued operations while initializing, got: " + queuedCount + + ", isInitializing: " + IterableApi.isSDKInitializing(), + queuedCount > 0); + + // Wait for initialization to complete + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 10)); + + // After processing, queue should be empty + Thread.sleep(200); // Give time for queue processing + assertEquals("Queue should be empty after processing", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + // ======================================== + // State Management Tests + // ======================================== + + @Test + public void testStateManagement_InitializingState() { + assertFalse("Should not be initializing initially", IterableApi.isSDKInitializing()); + + IterableApi.initializeInBackground(context, TEST_API_KEY, null); + + assertTrue("Should be initializing after call", IterableApi.isSDKInitializing()); + } + + @Test + public void testStateManagement_SDKInitializedMethod() throws InterruptedException { + // Initially should not be initialized + assertFalse("Should not be initialized initially", IterableApi.isSDKInitialized()); + + CountDownLatch initLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // During initialization - should not be considered fully initialized yet + assertFalse("Should not be fully initialized during background init", IterableApi.isSDKInitialized()); + + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // After initialization completes but before setting user - still not fully initialized + assertFalse("Should not be fully initialized without user identification", IterableApi.isSDKInitialized()); + + // Set user email to complete the setup + IterableApi.getInstance().setEmail(TEST_EMAIL); + + // Now should be fully initialized + assertTrue("Should be fully initialized after setting email", IterableApi.isSDKInitialized()); + } + + @Test + public void testStateManagement_SDKInitializedWithSyncInit() { + // Test with regular synchronous initialization + assertFalse("Should not be initialized initially", IterableApi.isSDKInitialized()); + + IterableApi.initialize(context, TEST_API_KEY); + + // After sync init but before setting user - still not fully initialized + assertFalse("Should not be fully initialized without user identification", IterableApi.isSDKInitialized()); + + // Set user ID to complete the setup + IterableApi.getInstance().setUserId(TEST_USER_ID); + + // Now should be fully initialized + assertTrue("Should be fully initialized after setting user ID", IterableApi.isSDKInitialized()); + } + + @Test + public void testStateManagement_CompletedState() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + + }); + + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + assertFalse("Should not be initializing after completion", IterableApi.isSDKInitializing()); + } + + @Test + public void testStateManagement_MultipleInitCalls() throws InterruptedException { + CountDownLatch firstInitLatch = new CountDownLatch(1); + CountDownLatch secondInitLatch = new CountDownLatch(1); + + // First initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + firstInitLatch.countDown(); + } + + }); + + // Second initialization call while first is in progress + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + secondInitLatch.countDown(); + } + + }); + + // First should complete + assertTrue("First initialization should complete", waitForAsyncInitialization(firstInitLatch, 3)); + + // Second should also complete (called immediately since first is done) + assertTrue("Second initialization should also complete", waitForAsyncInitialization(secondInitLatch, 5)); + } + + // ======================================== + // Performance Tests + // ======================================== + + @Test + public void testPerformance_InitializationCallTime() { + long startTime = System.currentTimeMillis(); + + IterableApi.initializeInBackground(context, TEST_API_KEY, null); + + long callReturnTime = System.currentTimeMillis() - startTime; + assertTrue("Method call should return in <50ms, took " + callReturnTime + "ms", + callReturnTime < 50); + } + + @Test + public void testNoANR_WithHangingInitialization() throws InterruptedException { + // This test simulates a real ANR scenario by mocking the initialize method to hang for 5000 seconds + // The background initializer has a 5-second timeout, so it should timeout and still call the callback + // The test validates that: + // 1. initializeInBackground() returns immediately (no main thread blocking) + // 2. Operations are queued during initialization + // 3. System remains responsive even when init hangs + // 4. Timeout mechanism kicks in after 5 seconds and callback is called + + CountDownLatch initCompleteLatch = new CountDownLatch(1); + AtomicBoolean callbackCalled = new AtomicBoolean(false); + AtomicBoolean mainThreadBlocked = new AtomicBoolean(false); + + // Mock IterableApi.initialize() to hang for 5 seconds to simulate ANR conditions + try (MockedStatic mockedIterableApi = Mockito.mockStatic(IterableApi.class, Mockito.CALLS_REAL_METHODS)) { + + mockedIterableApi.when(() -> IterableApi.initialize( + Mockito.any(Context.class), + Mockito.anyString(), + Mockito.any(IterableConfig.class) + )).thenAnswer(invocation -> { + IterableLogger.d("ANR_TEST", "Mocked initialize() called - starting 5000 second delay to simulate extreme ANR"); + + // Simulate a hanging initialization by adding an extremely long delay (5000 seconds) + // This is much longer than the 5-second timeout in IterableBackgroundInitializer + // The timeout mechanism should kick in and call the callback anyway + try { + Thread.sleep(5000 * 1000); // Simulate hanging initialization for 5000 seconds + } catch (InterruptedException e) { + IterableLogger.d("ANR_TEST", "Mocked initialize() was interrupted (expected due to timeout)"); + Thread.currentThread().interrupt(); + } + + IterableLogger.d("ANR_TEST", "Mocked initialize() completed after delay (should not reach here due to timeout)"); + return null; + }); + + long startTime = System.currentTimeMillis(); + + // Start background initialization - this should return immediately despite the hanging initialize() + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + IterableLogger.d("ANR_TEST", "Initialization callback called"); + callbackCalled.set(true); + initCompleteLatch.countDown(); + } + }); + + // Critical test: initializeInBackground() should return immediately (< 100ms) - NO ANR! + long callReturnTime = System.currentTimeMillis() - startTime; + assertTrue("initializeInBackground should return immediately (no ANR), took " + callReturnTime + "ms", + callReturnTime < 100); + + IterableLogger.d("ANR_TEST", "initializeInBackground returned in " + callReturnTime + "ms"); + + // Verify initialization state immediately + assertTrue("Should be marked as initializing", IterableApi.isSDKInitializing()); + + // Queue operations immediately while initialization is definitely in progress + // Do this right away before timeout can kick in + IterableApi.getInstance().track("testEventDuringHangingInit1"); + IterableApi.getInstance().track("testEventDuringHangingInit2"); + IterableApi.getInstance().setEmail("test@hanging.com"); + + // Check queued operations immediately - should be queued since init just started + int queuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); + + IterableLogger.d("ANR_TEST", "Initial state check - IsInitializing: " + IterableApi.isSDKInitializing() + + ", QueuedOps: " + queuedOps); + + // If operations aren't queued immediately, wait a bit for the background thread to start the mocked method + if (queuedOps == 0) { + IterableLogger.d("ANR_TEST", "No operations queued initially, waiting for background thread to start..."); + Thread.sleep(50); // Give initialization thread a moment to start and hit the mocked method + + // Add more operations after waiting + IterableApi.getInstance().track("testEventDuringHangingInit3"); + IterableApi.getInstance().track("testEventDuringHangingInit4"); + queuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); + + IterableLogger.d("ANR_TEST", "After wait - IsInitializing: " + IterableApi.isSDKInitializing() + + ", QueuedOps: " + queuedOps); + } + + // If still no operations queued, the timeout might have already kicked in + if (queuedOps == 0) { + IterableLogger.w("ANR_TEST", "Operations not being queued - timeout may have completed already. " + + "This is actually OK for ANR testing since main thread was never blocked."); + // Don't fail the test - the main goal is ANR prevention, not operation queuing + // The timeout mechanism working quickly is actually a good thing + } else { + IterableLogger.d("ANR_TEST", "Successfully queued " + queuedOps + " operations while init hanging"); + } + + // Store the final queued operation count for later verification + int finalQueuedOpsCount = queuedOps; + + // Critical test: Verify main thread remains responsive while background init hangs + // The background thread is hanging for 5000 seconds, but should timeout after 5 seconds + long workStartTime = System.currentTimeMillis(); + + // Do intensive work on main thread - this should complete quickly + // even though initialization is hanging in the background thread + for (int i = 0; i < 100000; i++) { + Math.sqrt(i * Math.PI); // CPU intensive work + if (i % 20000 == 0) { + // Periodically check that we're still responsive + long currentTime = System.currentTimeMillis() - workStartTime; + if (currentTime > 2000) { // If work takes more than 2 seconds, likely blocked + mainThreadBlocked.set(true); + break; + } + } + } + + long workTime = System.currentTimeMillis() - workStartTime; + IterableLogger.d("ANR_TEST", "Main thread work completed in " + workTime + "ms"); + + // Main thread should remain responsive (< 1000ms for this work) + assertFalse("Main thread should not be blocked by hanging background initialization", + mainThreadBlocked.get()); + assertTrue("Main thread should remain responsive while init hangs, work took " + workTime + "ms", + workTime < 1000); + + // Add more operations to test continued queuing + IterableApi.getInstance().track("additionalEvent1"); + IterableApi.getInstance().track("additionalEvent2"); + + // Now wait for the timeout mechanism to kick in + // The background initializer has a 5-second timeout, so callback should be called within ~7 seconds + // We need to periodically process main thread tasks since the callback is posted to main thread + boolean initCompleted = waitForAsyncInitialization(initCompleteLatch, 8); + + assertTrue("Initialization should complete even after hanging, callback called: " + callbackCalled.get(), + initCompleted); + assertTrue("Callback should be called", callbackCalled.get()); + + // After completion, verify state + assertFalse("Should not be marked as initializing after completion", + IterableApi.isSDKInitializing()); + + // Process any remaining main thread tasks + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + // Queue should eventually be processed (operations executed) + Thread.sleep(200); // Give time for queue processing + int finalQueuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); + + IterableLogger.d("ANR_TEST", "Final queued operations: " + finalQueuedOps); + + long totalTime = System.currentTimeMillis() - startTime; + + // Log final results + IterableLogger.d("ANR_TEST", "=== ANR Timeout Test Results ==="); + IterableLogger.d("ANR_TEST", "Init call return time: " + callReturnTime + "ms"); + IterableLogger.d("ANR_TEST", "Main thread work time: " + workTime + "ms"); + IterableLogger.d("ANR_TEST", "Total test time: " + totalTime + "ms (should be ~5-7 seconds due to timeout)"); + IterableLogger.d("ANR_TEST", "Initial queued operations: " + finalQueuedOpsCount); + IterableLogger.d("ANR_TEST", "Final queued operations: " + finalQueuedOps); + IterableLogger.d("ANR_TEST", "Callback called: " + callbackCalled.get() + " (should be true due to timeout mechanism)"); + IterableLogger.d("ANR_TEST", "Main thread blocked: " + mainThreadBlocked.get() + " (should be false)"); + IterableLogger.d("ANR_TEST", "================================"); + + // Final assertions - the most important ANR prevention tests + assertTrue("NO ANR: initializeInBackground should return immediately", callReturnTime < 100); + assertTrue("NO ANR: main thread should remain responsive during hanging init", workTime < 1000); + assertFalse("NO ANR: main thread should never be blocked", mainThreadBlocked.get()); + assertTrue("Initialization should complete despite hanging", callbackCalled.get()); + assertTrue("Total test should complete within reasonable time (timeout + overhead)", totalTime < 9000); + } + } + + @Test + public void testPerformance_QueueOperationTime() { + IterableApi.initializeInBackground(context, TEST_API_KEY, null); + + long startTime = System.currentTimeMillis(); + for (int i = 0; i < 100; i++) { + IterableApi.getInstance().track("perfTest" + i); + } + long endTime = System.currentTimeMillis(); + + assertTrue("Queuing 100 operations should be fast, took " + (endTime - startTime) + "ms", + (endTime - startTime) < 100); + assertEquals("Should have 100 queued operations", 100, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + // ======================================== + // Backward Compatibility Tests + // ======================================== + + @Test + public void testBackwardCompatibility_ExistingInitializeStillWorks() { + // Test that existing initialize method works unchanged + IterableApi.initialize(context, TEST_API_KEY, null); + + // SDK should be initialized through normal path + assertNotNull("SDK instance should exist", IterableApi.getInstance()); + assertFalse("Should not be marked as initializing via background method", + IterableApi.isSDKInitializing()); + } + + @Test + public void testBackwardCompatibility_MixedInitializationMethods() throws InterruptedException { + // First use regular initialize + IterableApi.initialize(context, TEST_API_KEY, null); + + CountDownLatch latch = new CountDownLatch(1); + + // Then try background initialize - should handle gracefully + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + latch.countDown(); + } + + }); + + assertTrue("Should complete without hanging", waitForAsyncInitialization(latch, 5)); + } + + @Test + public void testBackwardCompatibility_ImmediateExecutionWithTraditionalInit() { + // Use traditional initialize method + IterableApi.initialize(context, TEST_API_KEY, null); + IterableApi.getInstance().setEmail(TEST_EMAIL); + + // Verify SDK is initialized and NOT using background initialization + assertFalse("Should not be marked as background initializing", IterableApi.isSDKInitializing()); + assertTrue("Should be fully initialized", IterableApi.isSDKInitialized()); + + // Clear any existing queued operations from setup + IterableBackgroundInitializer.clearQueuedOperations(); + assertEquals("Queue should be empty before test", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + // Make API calls - these should execute immediately, NOT be queued + IterableApi.getInstance().track("immediateEvent1"); + IterableApi.getInstance().track("immediateEvent2"); + IterableApi.getInstance().setUserId(TEST_USER_ID); + + // Verify operations were NOT queued (executed immediately) + assertEquals("Operations should execute immediately with traditional init, not be queued", + 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testBackwardCompatibility_QueuingWithBackgroundInit() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Use background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Verify background initialization is active + assertTrue("Should be marked as background initializing", IterableApi.isSDKInitializing()); + + // Clear any existing queued operations + IterableBackgroundInitializer.clearQueuedOperations(); + assertEquals("Queue should be empty before test", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + // Make API calls while background initialization is in progress - these should be queued + IterableApi.getInstance().track("queuedEvent1"); + IterableApi.getInstance().track("queuedEvent2"); + IterableApi.getInstance().setEmail(TEST_EMAIL); + + // Verify operations were queued (not executed immediately) + assertTrue("Operations should be queued during background initialization", + IterableBackgroundInitializer.getQueuedOperationCount() > 0); + + // Wait for initialization to complete + assertTrue("Background initialization should complete", waitForAsyncInitialization(initLatch, 5)); + + // After initialization, new operations should execute immediately + int queuedAfterInit = IterableBackgroundInitializer.getQueuedOperationCount(); + IterableApi.getInstance().track("postInitEvent"); + + // Queue count should not increase (operation executed immediately) + assertEquals("Operations after background init completion should execute immediately", + queuedAfterInit, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + + // ======================================== + // Edge Case Tests + // ======================================== + + @Test + public void testEdgeCase_NullContext() throws InterruptedException { + CountDownLatch completionLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(null, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + completionLatch.countDown(); + } + }); + + assertTrue("Success callback should be called even with null context", waitForAsyncInitialization(completionLatch, 3)); + assertEquals("Queue should remain empty", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testEdgeCase_EmptyApiKey() throws InterruptedException { + CountDownLatch completionLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(context, "", new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + completionLatch.countDown(); + } + + }); + + assertTrue("Should handle empty API key", waitForAsyncInitialization(completionLatch, 3)); + } + + @Test + public void testEdgeCase_VeryLongApiKey() throws InterruptedException { + String longApiKey = "a".repeat(1000); // 1000 character API key + CountDownLatch completionLatch = new CountDownLatch(1); + + IterableApi.initializeInBackground(context, longApiKey, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + completionLatch.countDown(); + } + + }); + + assertTrue("Should handle very long API key", waitForAsyncInitialization(completionLatch, 3)); + } + + @Test + public void testOnSDKInitialized_CallbackExecutedOnMainThread() throws InterruptedException { + CountDownLatch callbackLatch = new CountDownLatch(1); + AtomicBoolean callbackExecutedOnMainThread = new AtomicBoolean(false); + + // Register callback before initialization + IterableApi.onSDKInitialized(() -> { + callbackExecutedOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + callbackLatch.countDown(); + }); + + // Initialize SDK + IterableApi.initialize(context, TEST_API_KEY); + + // Wait for callback + boolean callbackCalled = waitForAsyncInitialization(callbackLatch, 3); + + assertTrue("onSDKInitialized callback should be called", callbackCalled); + assertTrue("onSDKInitialized callback should be executed on main thread", callbackExecutedOnMainThread.get()); + } + + @Test + public void testOnSDKInitialized_CallbackCalledImmediatelyIfAlreadyInitialized() throws InterruptedException { + // Initialize SDK first + IterableApi.initialize(context, TEST_API_KEY); + + CountDownLatch callbackLatch = new CountDownLatch(1); + AtomicBoolean callbackExecutedOnMainThread = new AtomicBoolean(false); + + // Register callback after initialization - should be called immediately + IterableApi.onSDKInitialized(() -> { + callbackExecutedOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + callbackLatch.countDown(); + }); + + // Should be called immediately since SDK is already initialized + boolean callbackCalled = waitForAsyncInitialization(callbackLatch, 1); + + assertTrue("onSDKInitialized callback should be called immediately when SDK already initialized", callbackCalled); + assertTrue("onSDKInitialized callback should be executed on main thread", callbackExecutedOnMainThread.get()); + } + + @Test + public void testOnSDKInitialized_MultipleCallbacks() throws InterruptedException { + CountDownLatch callbackLatch = new CountDownLatch(3); + AtomicInteger mainThreadCallbackCount = new AtomicInteger(0); + + // Register multiple callbacks + for (int i = 0; i < 3; i++) { + final int callbackId = i; + IterableApi.onSDKInitialized(() -> { + if (Looper.myLooper() == Looper.getMainLooper()) { + mainThreadCallbackCount.incrementAndGet(); + } + IterableLogger.d("TEST", "Callback " + callbackId + " called"); + callbackLatch.countDown(); + }); + } + + // Initialize SDK + IterableApi.initialize(context, TEST_API_KEY); + + // Wait for all callbacks + boolean allCallbacksCalled = waitForAsyncInitialization(callbackLatch, 3); + + assertTrue("All onSDKInitialized callbacks should be called", allCallbacksCalled); + assertEquals("All callbacks should be executed on main thread", 3, mainThreadCallbackCount.get()); + } + + @Test + public void testOnSDKInitialized_WithBackgroundInitialization() throws InterruptedException { + CountDownLatch subscriberCallbackLatch = new CountDownLatch(1); + CountDownLatch backgroundCallbackLatch = new CountDownLatch(1); + AtomicBoolean subscriberOnMainThread = new AtomicBoolean(false); + AtomicBoolean backgroundCallbackOnMainThread = new AtomicBoolean(false); + + // Register subscriber callback + IterableApi.onSDKInitialized(() -> { + subscriberOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + subscriberCallbackLatch.countDown(); + }); + + // Initialize in background with its own callback + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + backgroundCallbackOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + backgroundCallbackLatch.countDown(); + } + }); + + // Wait for both callbacks + boolean subscriberCalled = waitForAsyncInitialization(subscriberCallbackLatch, 5); + boolean backgroundCallbackCalled = waitForAsyncInitialization(backgroundCallbackLatch, 5); + + assertTrue("Subscriber callback should be called", subscriberCalled); + assertTrue("Background initialization callback should be called", backgroundCallbackCalled); + assertTrue("Subscriber callback should be on main thread", subscriberOnMainThread.get()); + assertTrue("Background initialization callback should be on main thread", backgroundCallbackOnMainThread.get()); + } + + @Test + public void testOnSDKInitialized_ExceptionInCallback() throws InterruptedException { + CountDownLatch callback1Latch = new CountDownLatch(1); + CountDownLatch callback2Latch = new CountDownLatch(1); + AtomicBoolean callback1Called = new AtomicBoolean(false); + AtomicBoolean callback2Called = new AtomicBoolean(false); + + // First callback throws exception + IterableApi.onSDKInitialized(() -> { + callback1Called.set(true); + callback1Latch.countDown(); + throw new RuntimeException("Test exception in callback"); + }); + + // Second callback should still be called despite first one throwing + IterableApi.onSDKInitialized(() -> { + callback2Called.set(true); + callback2Latch.countDown(); + }); + + // Initialize SDK + IterableApi.initialize(context, TEST_API_KEY); + + // Wait for both callbacks + boolean callback1CalledResult = waitForAsyncInitialization(callback1Latch, 3); + boolean callback2CalledResult = waitForAsyncInitialization(callback2Latch, 3); + + assertTrue("First callback should be called even though it throws", callback1CalledResult); + assertTrue("Second callback should be called despite first callback throwing", callback2CalledResult); + assertTrue("First callback should have been executed", callback1Called.get()); + assertTrue("Second callback should have been executed", callback2Called.get()); + } + + @Test + public void testBackgroundInitializationCallback_MainThreadExecution() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + AtomicBoolean callbackExecutedOnMainThread = new AtomicBoolean(false); + + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + callbackExecutedOnMainThread.set(Looper.myLooper() == Looper.getMainLooper()); + initLatch.countDown(); + } + }); + + boolean callbackCalled = waitForAsyncInitialization(initLatch, 5); + + assertTrue("Background initialization callback should be called", callbackCalled); + assertTrue("Background initialization callback must be executed on main thread", callbackExecutedOnMainThread.get()); + } + + // ======================================== + // PII Masking Tests + // ======================================== + + @Test + public void testPIIMasking_EmailMasked() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Use sensitive PII data that should be masked + String sensitiveEmail = "sensitive.user@company.com"; + String sensitiveUserId = "user_12345_secret"; + String sensitiveAuthToken = "Bearer_ABC123XYZ789_Secret"; + + // Make API calls that should mask PII in their queue descriptions + IterableApi.getInstance().setEmail(sensitiveEmail); + IterableApi.getInstance().setUserId(sensitiveUserId); + IterableApi.getInstance().updateEmail(sensitiveEmail); + + // Get the queued operation descriptions + List descriptions = IterableBackgroundInitializer.getQueuedOperationDescriptions(); + + // Verify we have operations queued + assertTrue("Should have queued operations", descriptions.size() >= 3); + + // Verify each description masks PII properly + for (String description : descriptions) { + // Should NOT contain the full sensitive values + assertFalse("Description should not contain full email: " + description, + description.contains(sensitiveEmail)); + assertFalse("Description should not contain full userId: " + description, + description.contains(sensitiveUserId)); + assertFalse("Description should not contain full authToken: " + description, + description.contains(sensitiveAuthToken)); + + // Should contain masked format: first char + "***" + // Email: "sensitive.user@company.com" → "s***" + // UserId: "user_12345_secret" → "u***" + if (description.contains("setEmail") || description.contains("updateEmail")) { + assertTrue("Email should be masked to 's***': " + description, + description.contains("s***")); + } + if (description.contains("setUserId")) { + assertTrue("UserId should be masked to 'u***': " + description, + description.contains("u***")); + } + } + + // Wait for initialization to complete + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + } + + @Test + public void testPIIMasking_NullValuesHandled() { + // Test that null values don't cause NPE in maskPII + IterableApi.initialize(context, TEST_API_KEY); + + // These should not throw NPE even with null values + IterableApi.getInstance().setEmail(null); + IterableApi.getInstance().setUserId(null); + IterableApi.getInstance().updateEmail("new@email.com"); + + // If we got here without NPE, the test passes + assertTrue("Null PII values handled without NPE", true); + } + + @Test + public void testPIIMasking_EmptyStringsHandled() { + // Test that empty strings don't cause issues in maskPII + IterableApi.initialize(context, TEST_API_KEY); + + // These should not throw exceptions with empty strings + IterableApi.getInstance().setEmail(""); + + // If we got here without exception, the test passes + assertTrue("Empty string PII values handled without exception", true); + } + + @Test + public void testPIIMasking_SingleCharacterHandled() { + // Test that single character strings are properly masked + IterableApi.initialize(context, TEST_API_KEY); + + // Single character email/userId should be masked to just "*" + IterableApi.getInstance().setEmail("a"); + IterableApi.getInstance().setUserId("x"); + + // If we got here without exception, the test passes + assertTrue("Single character PII values handled correctly", true); + } + + @Test + public void testPIIMasking_AuthTokenMasked() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Use sensitive auth token + String sensitiveEmail = "test@example.com"; + String sensitiveAuthToken = "SecretAuthToken12345"; + + // Make API calls with auth tokens + IterableApi.getInstance().setEmail(sensitiveEmail, sensitiveAuthToken); + IterableApi.getInstance().setUserId("testuser", sensitiveAuthToken); + + // Get queued operation descriptions + List descriptions = IterableBackgroundInitializer.getQueuedOperationDescriptions(); + + assertTrue("Should have queued operations with auth tokens", descriptions.size() >= 2); + + // Verify auth tokens are masked + for (String description : descriptions) { + assertFalse("Description should not contain full auth token: " + description, + description.contains(sensitiveAuthToken)); + + // Should contain masked auth token: "S***" + if (description.contains("authToken") || description.contains("S***")) { + assertTrue("Auth token should be masked to 'S***': " + description, + description.contains("S***")); + } + } + + // Wait for initialization + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + } + + @Test + public void testPIIMasking_VerifyExactFormat() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Test various PII formats + String email1 = "john.doe@example.com"; // Should mask to "j***" + String userId1 = "user_123_abc"; // Should mask to "u***" + String email2 = "a@b.com"; // Should mask to "a***" + + IterableApi.getInstance().setEmail(email1); + IterableApi.getInstance().setUserId(userId1); + IterableApi.getInstance().updateEmail(email2); + + // Get queued descriptions + List descriptions = IterableBackgroundInitializer.getQueuedOperationDescriptions(); + + assertTrue("Should have queued 3 operations", descriptions.size() >= 3); + + // Verify exact masking format: first_char + "***" + boolean foundEmail1Masked = false; + boolean foundUserId1Masked = false; + boolean foundEmail2Masked = false; + + for (String description : descriptions) { + if (description.contains("setEmail") && description.contains("j***")) { + foundEmail1Masked = true; + // Verify full email is NOT present + assertFalse("Full email should not be in description", + description.contains("john.doe@example.com")); + } + if (description.contains("setUserId") && description.contains("u***")) { + foundUserId1Masked = true; + // Verify full userId is NOT present + assertFalse("Full userId should not be in description", + description.contains("user_123_abc")); + } + if (description.contains("updateEmail") && description.contains("a***")) { + foundEmail2Masked = true; + // Verify full email is NOT present + assertFalse("Full email should not be in description", + description.contains("a@b.com")); + } + } + + assertTrue("Email 'john.doe@example.com' should be masked to 'j***'", foundEmail1Masked); + assertTrue("UserId 'user_123_abc' should be masked to 'u***'", foundUserId1Masked); + assertTrue("Email 'a@b.com' should be masked to 'a***'", foundEmail2Masked); + + // Wait for initialization + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + } + + // ======================================== + // Nested queueOrExecute Tests + // ======================================== + + @Test + public void testNestedQueueOrExecute_OverloadedMethodsDuringInit() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + AtomicBoolean innerMethodCalled = new AtomicBoolean(false); + + // Create a spy of the IterableApi instance to track method calls + IterableApi spyInstance = Mockito.spy(IterableApi.getInstance()); + IterableApi.sharedInstance = spyInstance; + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Call an overloaded method that internally calls another overloaded method + // For example: setEmail(email) calls setEmail(email, null, null, null, null) + // The outer call should queue, and when executed, should call the inner overload + String testEmail = "nested@test.com"; + spyInstance.setEmail(testEmail); // This queues and calls the full overload + + // Verify that operations are queued + int queuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); + assertTrue("Should have queued operations during init", queuedOps > 0); + + // Wait for initialization to complete + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // After init, queue should be processed and inner method should have been called + Thread.sleep(200); + + // Verify the full overload was called (with all parameters) + Mockito.verify(spyInstance, Mockito.atLeastOnce()).setEmail( + Mockito.eq(testEmail), + Mockito.isNull(), + Mockito.isNull(), + Mockito.isNull(), + Mockito.isNull() + ); + + assertEquals("Queue should be empty after processing", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testNestedQueueOrExecute_OverloadedMethodsAfterInit() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Complete initialization first + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // Now call overloaded methods after initialization + // setEmail(email) internally calls setEmail(email, null, null, null, null) + // Both should execute immediately (not queue) since init is complete + String testEmail = "immediate@test.com"; + IterableApi.getInstance().setEmail(testEmail); + + // Verify no operations are queued (they executed immediately) + assertEquals("No operations should be queued after init", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testNestedQueueOrExecute_MultipleOverloadChains() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Call multiple overloaded method chains during initialization + // Each overload internally delegates to the full signature + IterableApi.getInstance().setEmail("user1@test.com"); + IterableApi.getInstance().setEmail("user2@test.com", (IterableHelper.SuccessHandler) null, null); + IterableApi.getInstance().setUserId("user123"); + IterableApi.getInstance().setUserId("user456", (IterableHelper.SuccessHandler) null, null); + IterableApi.getInstance().updateEmail("newemail@test.com"); + + // Verify operations are queued + int queuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); + assertTrue("Should have queued multiple operations", queuedOps >= 5); + + // Wait for initialization + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // All operations should be processed + Thread.sleep(200); + assertEquals("All operations should be processed", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testNestedQueueOrExecute_NoDoubleQueuing() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + + // Create a spy to track method invocations + IterableApi spyInstance = Mockito.spy(IterableApi.getInstance()); + IterableApi.sharedInstance = spyInstance; + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Call a simple overload that delegates to the full method + // setEmail(email) -> queueOrExecute -> setEmail(email, null, null, null, null) + // The inner call should NOT queue again because it's already in the queued operation + String testEmail = "test@example.com"; + spyInstance.setEmail(testEmail); + + int queuedOps = IterableBackgroundInitializer.getQueuedOperationCount(); + + // Should only have ONE operation queued, not multiple for the nested calls + assertEquals("Should only queue outer operation, not nested calls", 1, queuedOps); + + // Wait for initialization + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // Wait for queue processing + Thread.sleep(200); + + // Verify that the full overload method was called exactly once + // This proves the inner method was invoked WITHOUT queuing again + Mockito.verify(spyInstance, Mockito.times(1)).setEmail( + Mockito.eq(testEmail), + Mockito.isNull(), + Mockito.isNull(), + Mockito.isNull(), + Mockito.isNull() + ); + + // Verify the simple overload wrapper was called once + Mockito.verify(spyInstance, Mockito.times(1)).setEmail(Mockito.eq(testEmail)); + + assertEquals("Queue should be empty after processing", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } + + @Test + public void testNestedQueueOrExecute_InnerMethodExecutedNotQueued() throws InterruptedException { + CountDownLatch initLatch = new CountDownLatch(1); + AtomicInteger fullOverloadCallCount = new AtomicInteger(0); + + // Create a custom spy that counts calls to the full overload + IterableApi spyInstance = Mockito.spy(IterableApi.getInstance()); + IterableApi.sharedInstance = spyInstance; + + // Mock the full overload to track invocations + Mockito.doAnswer(invocation -> { + fullOverloadCallCount.incrementAndGet(); + // Call the real method + invocation.callRealMethod(); + return null; + }).when(spyInstance).setEmail( + Mockito.anyString(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any() + ); + + // Start background initialization + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Call the simple overload + // This should queue ONE operation that when executed calls the full overload + spyInstance.setEmail("test@example.com"); + + // Only 1 operation should be queued (the outer wrapper) + assertEquals("Only outer operation should be queued", 1, IterableBackgroundInitializer.getQueuedOperationCount()); + + // Full overload should NOT have been called yet (operation is queued, not executed) + assertEquals("Full overload should not be called during queuing", 0, fullOverloadCallCount.get()); + + // Wait for initialization + assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); + + // Wait for queue to process + Thread.sleep(300); + + // Now the full overload SHOULD have been called (as part of queue processing) + assertEquals("Full overload should be called once during queue processing", 1, fullOverloadCallCount.get()); + + // And queue should be empty + assertEquals("Queue should be empty", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + // Verify the call pattern: simple overload → queued → executed → calls full overload directly + Mockito.verify(spyInstance, Mockito.times(1)).setEmail( + Mockito.eq("test@example.com"), + Mockito.isNull(), + Mockito.isNull(), + Mockito.isNull(), + Mockito.isNull() + ); + } + + @Test + public void testNestedQueueOrExecute_ImmediateExecutionAfterInit() throws InterruptedException { + // Initialize synchronously first + IterableApi.initialize(context, TEST_API_KEY); + + // Call overloaded methods - they should execute immediately + // setEmail(email) calls setEmail(email, null, null, null, null) + // Both should execute immediately without queuing + IterableApi.getInstance().setEmail("immediate@test.com"); + IterableApi.getInstance().setUserId("immediate123"); + + // Verify nothing was queued + assertEquals("No operations should be queued with sync init", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + + // Now try background init on a second instance (simulated by reset) + IterableTestUtils.resetIterableApi(); + resetBackgroundInitializationState(); + + CountDownLatch initLatch = new CountDownLatch(1); + IterableApi.initializeInBackground(context, TEST_API_KEY, new IterableInitializationCallback() { + @Override + public void onSDKInitialized() { + initLatch.countDown(); + } + }); + + // Operations during background init should be queued + IterableApi.getInstance().setEmail("queued@test.com"); + assertTrue("Operations should be queued during background init", IterableBackgroundInitializer.getQueuedOperationCount() > 0); + + // Wait for init to complete + assertTrue("Background init should complete", waitForAsyncInitialization(initLatch, 3)); + + // After init completes, new operations should execute immediately + Thread.sleep(200); + IterableApi.getInstance().setEmail("postinit@test.com"); + + // The queued operation should have been processed, and the new one should not queue + Thread.sleep(100); + assertEquals("Post-init operations should not queue", 0, IterableBackgroundInitializer.getQueuedOperationCount()); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTestUtils.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTestUtils.java index f0e002ced..4810aaad7 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTestUtils.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTestUtils.java @@ -55,6 +55,9 @@ public static void createIterableApiNew(ConfigBuilderExtender extender, String e public static void resetIterableApi() { IterableApi.sharedInstance = new IterableApi(mock(IterableInAppManager.class)); + + // Use the new dedicated method for resetting background initialization state + IterableBackgroundInitializer.resetBackgroundInitializationState(); } public static String getResourceString(String fileName) throws IOException { diff --git a/sample-apps/inbox-customization/app/build.gradle b/sample-apps/inbox-customization/app/build.gradle index 1f47982de..f4c55b712 100644 --- a/sample-apps/inbox-customization/app/build.gradle +++ b/sample-apps/inbox-customization/app/build.gradle @@ -42,8 +42,8 @@ dependencies { implementation 'androidx.navigation:navigation-ui-ktx:2.6.0' implementation 'com.google.android.material:material:1.9.0' - implementation 'com.iterable:iterableapi:3.6.1' - implementation 'com.iterable:iterableapi-ui:3.6.1' + implementation project(':iterableapi') + implementation project(':iterableapi-ui') implementation 'com.squareup.okhttp3:mockwebserver:4.2.2' testImplementation 'junit:junit:4.13.2' diff --git a/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/BackgroundInitFragment.kt b/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/BackgroundInitFragment.kt new file mode 100644 index 000000000..7f80e23f3 --- /dev/null +++ b/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/BackgroundInitFragment.kt @@ -0,0 +1,88 @@ +package com.iterable.inbox_customization + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.iterable.inbox_customization.util.DataManager +import com.iterable.iterableapi.IterableApi +import com.iterable.iterableapi.IterableInitializationCallback + +class BackgroundInitFragment : Fragment() { + private lateinit var statusText: TextView + private lateinit var initButton: Button + private lateinit var testApiButton: Button + private var isInitialized = false + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_background_init, container, false) + + statusText = view.findViewById(R.id.statusText) + initButton = view.findViewById(R.id.initButton) + testApiButton = view.findViewById(R.id.testApiButton) + + setupUI() + return view + } + + private fun setupUI() { + statusText.text = "SDK not initialized" + + initButton.setOnClickListener { + startBackgroundInitialization() + } + + testApiButton.setOnClickListener { + testApiCall() + } + + // Initially disable test API button + testApiButton.isEnabled = false + } + + private fun startBackgroundInitialization() { + statusText.text = "Initializing SDK in background..." + initButton.isEnabled = false + + val callback = object : IterableInitializationCallback { + override fun onSDKInitialized() { + activity?.runOnUiThread { + statusText.text = "SDK initialized successfully!" + testApiButton.isEnabled = true + isInitialized = true + } + } + } + + // This returns immediately and doesn't block the UI + DataManager.initializeIterableApiInBackground(requireContext(), callback) + + // Show that we can continue doing other work immediately + statusText.append("\nInitialization started - UI remains responsive!") + } + + private fun testApiCall() { + if (isInitialized) { + // Make a test API call to demonstrate the SDK is working + val email = IterableApi.getInstance().email + statusText.append("\nCurrent user email: ${email ?: "Not set"}") + + // Demonstrate that we can make API calls after initialization + statusText.append("\nAPI calls are now working!") + } else { + statusText.append("\nSDK not yet initialized - call will be queued") + // This call will be queued and executed after initialization + IterableApi.getInstance().setEmail("queued-user@example.com") + } + } +} + + + diff --git a/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/MainFragment.kt b/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/MainFragment.kt index 4576ff2c9..8715bc018 100644 --- a/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/MainFragment.kt +++ b/sample-apps/inbox-customization/app/src/main/java/com/iterable/inbox_customization/MainFragment.kt @@ -26,6 +26,7 @@ class MainFragment : Fragment() { view.findViewById